Skip to content

Commit 5389068

Browse files
Enhance test discovery payload handling with compact/expansion on paths in payload (#25982)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 03f6e24 commit 5389068

7 files changed

Lines changed: 496 additions & 66 deletions

File tree

python_files/tests/pytestadapter/helpers.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from typing import Any, Dict, List, Optional, Tuple
1616

1717
if sys.platform == "win32":
18-
from namedpipe import NPopen
18+
from namedpipe import NPopen # pylint: disable=import-error # cspell: disable-line
1919

2020

2121
script_dir = pathlib.Path(__file__).parent.parent.parent
@@ -54,7 +54,7 @@ def create_symlink(root: pathlib.Path, target_ext: str, destination_ext: str):
5454
print("destination already exists", destination)
5555
try:
5656
destination.symlink_to(target)
57-
except Exception as e:
57+
except OSError as e:
5858
print("error occurred when attempting to create a symlink", e)
5959
yield target, destination
6060
finally:
@@ -82,12 +82,57 @@ def process_data_received(data: str) -> List[Dict[str, Any]]:
8282
elif json_data["jsonrpc"] != "2.0":
8383
raise ValueError("Invalid JSON-RPC version received, not version 2.0")
8484
else:
85-
json_messages.append(json_data["params"])
85+
json_messages.append(expand_compact_discovery_payload(json_data["params"]))
8686

8787
return json_messages # return the list of json messages
8888

8989

90-
def parse_rpc_message(data: str) -> Tuple[Dict[str, str], str]:
90+
def expand_path(path_value: str, path_base: str) -> str:
91+
if not path_base or pathlib.Path(path_value).is_absolute():
92+
return path_value
93+
if path_value == ".":
94+
return path_base
95+
return os.fspath(pathlib.Path(path_base, path_value))
96+
97+
98+
def expand_test_id(test_id: str, id_base: str) -> str:
99+
test_path, separator, selector = test_id.partition("::")
100+
expanded_test_path = expand_path(test_path, id_base)
101+
return f"{expanded_test_path}{separator}{selector}" if separator else expanded_test_path
102+
103+
104+
def expand_compact_discovery_node(
105+
test_node: Dict[str, Any] | None, path_base: str, id_base: str
106+
) -> Dict[str, Any] | None:
107+
if test_node is None:
108+
return None
109+
expanded_node = dict(test_node)
110+
if "path" in expanded_node:
111+
expanded_node["path"] = expand_path(expanded_node["path"], path_base)
112+
if "id_" in expanded_node:
113+
expanded_node["id_"] = expand_test_id(expanded_node["id_"], id_base)
114+
if "runID" in expanded_node:
115+
expanded_node["runID"] = expand_test_id(expanded_node["runID"], id_base)
116+
if "children" in expanded_node:
117+
expanded_node["children"] = [
118+
expand_compact_discovery_node(child, path_base, id_base)
119+
for child in expanded_node["children"]
120+
]
121+
return expanded_node
122+
123+
124+
def expand_compact_discovery_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
125+
path_base = payload.get("pathBase")
126+
if not path_base or "tests" not in payload or payload["tests"] is None:
127+
return payload
128+
129+
expanded_payload = dict(payload)
130+
id_base = payload.get("idBase", path_base)
131+
expanded_payload["tests"] = expand_compact_discovery_node(payload["tests"], path_base, id_base)
132+
return expanded_payload
133+
134+
135+
def parse_rpc_message(data: str) -> Tuple[Dict[str, Any], str]:
91136
"""Process the JSON data which comes from the server.
92137
93138
A single rpc payload is in the format:
@@ -122,7 +167,7 @@ def parse_rpc_message(data: str) -> Tuple[Dict[str, str], str]:
122167
line: str = str_stream.readline(length)
123168
try:
124169
# try to parse the json, if successful it is single payload so return with remaining data
125-
json_data: dict[str, str] = json.loads(line)
170+
json_data: dict[str, Any] = json.loads(line)
126171
return json_data, str_stream.read()
127172
except json.JSONDecodeError:
128173
print("json decode error")
@@ -131,7 +176,7 @@ def parse_rpc_message(data: str) -> Tuple[Dict[str, str], str]:
131176
def _listen_on_fifo(pipe_name: str, result: List[str], completed: threading.Event):
132177
# Open the FIFO for reading
133178
fifo_path = pathlib.Path(pipe_name)
134-
with fifo_path.open() as fifo:
179+
with fifo_path.open(encoding="utf-8") as fifo:
135180
print("Waiting for data...")
136181
while True:
137182
if completed.is_set():
@@ -198,7 +243,7 @@ def _listen_on_pipe_new(listener, result: List[str], completed: threading.Event)
198243

199244

200245
def _run_test_code(proc_args: List[str], proc_env, proc_cwd: str, completed: threading.Event):
201-
result = subprocess.run(proc_args, env=proc_env, cwd=proc_cwd)
246+
result = subprocess.run(proc_args, env=proc_env, cwd=proc_cwd, check=False)
202247
completed.set()
203248
return result
204249

@@ -257,7 +302,7 @@ def runner_with_cwd_env(
257302
)
258303
env_add.update({"RUN_TEST_IDS_PIPE": test_ids_pipe})
259304
test_ids_arr = after_ids
260-
with open(test_ids_pipe, "w") as f: # noqa: PTH123
305+
with open(test_ids_pipe, "w", encoding="utf-8") as f: # noqa: PTH123
261306
f.write("\n".join(test_ids_arr))
262307
else:
263308
process_args = [sys.executable, "-m", "pytest", "-p", "vscode_pytest", "-s", *args]
@@ -379,7 +424,7 @@ def find_test_line_number(test_name: str, test_file_path) -> str:
379424
test_file_path: The path to the test file where the test is located.
380425
"""
381426
test_file_unique_id: str = "test_marker--" + test_name.split("[")[0]
382-
with open(test_file_path) as f: # noqa: PTH123
427+
with open(test_file_path, encoding="utf-8") as f: # noqa: PTH123
383428
for i, line in enumerate(f):
384429
if test_file_unique_id in line:
385430
return str(i + 1)
@@ -395,7 +440,7 @@ def find_class_line_number(class_name: str, test_file_path) -> str:
395440
test_file_path: The path to the test file where the class is located.
396441
"""
397442
# Look for the class definition line (or function for pytest-describe)
398-
with open(test_file_path) as f: # noqa: PTH123
443+
with open(test_file_path, encoding="utf-8") as f: # noqa: PTH123
399444
for i, line in enumerate(f):
400445
# Match "class ClassName" or "class ClassName(" or "class ClassName:"
401446
# Also match "def ClassName(" for pytest-describe blocks

python_files/tests/pytestadapter/test_discovery.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,147 @@
22
# Licensed under the MIT License.
33
import json
44
import os
5+
import pathlib
56
import sys
6-
from typing import Any, Dict, List, Optional
7+
from typing import Any, Dict, List, Optional, cast
78

89
import pytest
910

11+
import vscode_pytest
1012
from tests.tree_comparison_helper import is_same_tree
1113

1214
from . import expected_discovery_test_output, helpers
1315

1416

17+
def test_compact_discovery_payload_keeps_absolute_tree_until_return(tmp_path, monkeypatch):
18+
monkeypatch.setattr(vscode_pytest, "ERRORS", [])
19+
base_path = tmp_path / "workspace"
20+
test_file = base_path / "tests" / "test_sample.py"
21+
absolute_test_id = f"{os.fspath(test_file)}::test_case[param]"
22+
session_node = cast(
23+
"vscode_pytest.TestNode",
24+
{
25+
"name": "workspace",
26+
"path": base_path,
27+
"type_": "folder",
28+
"id_": os.fspath(base_path),
29+
"children": [
30+
{
31+
"name": "test_sample.py",
32+
"path": test_file,
33+
"type_": "file",
34+
"id_": os.fspath(test_file),
35+
"children": [
36+
{
37+
"name": "test_case[param]",
38+
"path": test_file,
39+
"type_": "test",
40+
"id_": absolute_test_id,
41+
"runID": absolute_test_id,
42+
"lineno": "7",
43+
}
44+
],
45+
}
46+
],
47+
},
48+
)
49+
50+
payload = vscode_pytest.create_compact_discovery_payload(os.fspath(base_path), session_node)
51+
52+
assert session_node["path"] == base_path
53+
file_node = cast("vscode_pytest.TestNode", cast("List[Any]", session_node["children"])[0])
54+
assert file_node is not None
55+
assert file_node["path"] == test_file
56+
test_node = cast("vscode_pytest.TestItem", cast("List[Any]", file_node["children"])[0])
57+
assert test_node is not None
58+
assert test_node["id_"] == absolute_test_id
59+
60+
assert payload["pathBase"] == os.fspath(base_path)
61+
assert payload["idBase"] == os.fspath(base_path)
62+
assert payload["tests"] is not None
63+
compact_tests = cast("Dict[str, Any]", payload["tests"])
64+
assert compact_tests["path"] == "."
65+
assert compact_tests["id_"] == "."
66+
compact_file_node = cast("Dict[str, Any]", compact_tests["children"][0])
67+
assert compact_file_node["path"] == os.fspath(pathlib.Path("tests", "test_sample.py"))
68+
assert compact_file_node["id_"] == os.fspath(pathlib.Path("tests", "test_sample.py"))
69+
compact_test_node = cast("Dict[str, Any]", compact_file_node["children"][0])
70+
assert compact_test_node["path"] == os.fspath(pathlib.Path("tests", "test_sample.py"))
71+
assert (
72+
compact_test_node["id_"]
73+
== os.fspath(pathlib.Path("tests", "test_sample.py")) + "::test_case[param]"
74+
)
75+
assert (
76+
compact_test_node["runID"]
77+
== os.fspath(pathlib.Path("tests", "test_sample.py")) + "::test_case[param]"
78+
)
79+
80+
81+
def test_compact_discovery_payload_keeps_paths_outside_base_absolute(tmp_path):
82+
base_path = tmp_path / "workspace"
83+
external_file = tmp_path / "external" / "test_external.py"
84+
85+
assert vscode_pytest.compact_path(external_file, base_path) == os.fspath(external_file)
86+
assert (
87+
vscode_pytest.compact_test_id(f"{os.fspath(external_file)}::test_external", base_path)
88+
== f"{os.fspath(external_file)}::test_external"
89+
)
90+
91+
92+
def test_compact_discovery_payload_expands_after_rpc_parsing(tmp_path):
93+
base_path = os.fspath(tmp_path / "workspace")
94+
payload = {
95+
"cwd": base_path,
96+
"status": "success",
97+
"payloadVersion": 2,
98+
"pathBase": base_path,
99+
"idBase": base_path,
100+
"tests": {
101+
"name": "workspace",
102+
"path": ".",
103+
"type_": "folder",
104+
"id_": ".",
105+
"children": [
106+
{
107+
"name": "test_sample.py",
108+
"path": "tests/test_sample.py",
109+
"type_": "file",
110+
"id_": "tests/test_sample.py",
111+
"children": [
112+
{
113+
"name": "test_case[param]",
114+
"path": "tests/test_sample.py",
115+
"type_": "test",
116+
"id_": "tests/test_sample.py::test_case[param]",
117+
"runID": "tests/test_sample.py::test_case[param]",
118+
"lineno": "7",
119+
}
120+
],
121+
}
122+
],
123+
},
124+
"error": [],
125+
}
126+
body = json.dumps({"jsonrpc": "2.0", "params": payload})
127+
framed_message = f"content-length: {len(body)}\r\ncontent-type: application/json\r\n\r\n{body}"
128+
chunked_message = "".join([framed_message[:13], framed_message[13:97], framed_message[97:]])
129+
130+
parsed_payload = helpers.process_data_received(chunked_message)[0]
131+
132+
assert parsed_payload["tests"]["path"] == base_path
133+
parsed_file_node = parsed_payload["tests"]["children"][0]
134+
assert parsed_file_node["path"] == os.fspath(pathlib.Path(base_path, "tests/test_sample.py"))
135+
parsed_test_node = parsed_file_node["children"][0]
136+
assert (
137+
parsed_test_node["id_"]
138+
== os.fspath(pathlib.Path(base_path, "tests/test_sample.py")) + "::test_case[param]"
139+
)
140+
assert (
141+
parsed_test_node["runID"]
142+
== os.fspath(pathlib.Path(base_path, "tests/test_sample.py")) + "::test_case[param]"
143+
)
144+
145+
15146
def test_import_error():
16147
"""Test pytest discovery on a file that has a pytest marker but does not import pytest.
17148

0 commit comments

Comments
 (0)