From abcba83b7563dfb0033f1d0bbfce516809cb947a Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 29 May 2026 15:11:43 -0700 Subject: [PATCH] Enhance test discovery payload handling with compact/expansion on paths in payload --- python_files/tests/pytestadapter/helpers.py | 65 ++++++-- .../tests/pytestadapter/test_discovery.py | 132 +++++++++++++++- python_files/vscode_pytest/__init__.py | 109 +++++++++---- .../common/testDiscoveryHandler.ts | 76 ++++++++- .../testing/testController/common/types.ts | 5 +- .../common/testDiscoveryHandler.unit.test.ts | 144 +++++++++++++++++- 6 files changed, 488 insertions(+), 43 deletions(-) diff --git a/python_files/tests/pytestadapter/helpers.py b/python_files/tests/pytestadapter/helpers.py index 03f1187149df..7cc5e678bd61 100644 --- a/python_files/tests/pytestadapter/helpers.py +++ b/python_files/tests/pytestadapter/helpers.py @@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple if sys.platform == "win32": - from namedpipe import NPopen + from namedpipe import NPopen # pylint: disable=import-error # cspell: disable-line script_dir = pathlib.Path(__file__).parent.parent.parent @@ -54,7 +54,7 @@ def create_symlink(root: pathlib.Path, target_ext: str, destination_ext: str): print("destination already exists", destination) try: destination.symlink_to(target) - except Exception as e: + except OSError as e: print("error occurred when attempting to create a symlink", e) yield target, destination finally: @@ -82,12 +82,57 @@ def process_data_received(data: str) -> List[Dict[str, Any]]: elif json_data["jsonrpc"] != "2.0": raise ValueError("Invalid JSON-RPC version received, not version 2.0") else: - json_messages.append(json_data["params"]) + json_messages.append(expand_compact_discovery_payload(json_data["params"])) return json_messages # return the list of json messages -def parse_rpc_message(data: str) -> Tuple[Dict[str, str], str]: +def expand_path(path_value: str, path_base: str) -> str: + if not path_base or pathlib.Path(path_value).is_absolute(): + return path_value + if path_value == ".": + return path_base + return os.fspath(pathlib.Path(path_base, path_value)) + + +def expand_test_id(test_id: str, id_base: str) -> str: + test_path, separator, selector = test_id.partition("::") + expanded_test_path = expand_path(test_path, id_base) + return f"{expanded_test_path}{separator}{selector}" if separator else expanded_test_path + + +def expand_compact_discovery_node( + test_node: Dict[str, Any] | None, path_base: str, id_base: str +) -> Dict[str, Any] | None: + if test_node is None: + return None + expanded_node = dict(test_node) + if "path" in expanded_node: + expanded_node["path"] = expand_path(expanded_node["path"], path_base) + if "id_" in expanded_node: + expanded_node["id_"] = expand_test_id(expanded_node["id_"], id_base) + if "runID" in expanded_node: + expanded_node["runID"] = expand_test_id(expanded_node["runID"], id_base) + if "children" in expanded_node: + expanded_node["children"] = [ + expand_compact_discovery_node(child, path_base, id_base) + for child in expanded_node["children"] + ] + return expanded_node + + +def expand_compact_discovery_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + path_base = payload.get("pathBase") + if not path_base or "tests" not in payload or payload["tests"] is None: + return payload + + expanded_payload = dict(payload) + id_base = payload.get("idBase", path_base) + expanded_payload["tests"] = expand_compact_discovery_node(payload["tests"], path_base, id_base) + return expanded_payload + + +def parse_rpc_message(data: str) -> Tuple[Dict[str, Any], str]: """Process the JSON data which comes from the server. A single rpc payload is in the format: @@ -122,7 +167,7 @@ def parse_rpc_message(data: str) -> Tuple[Dict[str, str], str]: line: str = str_stream.readline(length) try: # try to parse the json, if successful it is single payload so return with remaining data - json_data: dict[str, str] = json.loads(line) + json_data: dict[str, Any] = json.loads(line) return json_data, str_stream.read() except json.JSONDecodeError: print("json decode error") @@ -131,7 +176,7 @@ def parse_rpc_message(data: str) -> Tuple[Dict[str, str], str]: def _listen_on_fifo(pipe_name: str, result: List[str], completed: threading.Event): # Open the FIFO for reading fifo_path = pathlib.Path(pipe_name) - with fifo_path.open() as fifo: + with fifo_path.open(encoding="utf-8") as fifo: print("Waiting for data...") while True: if completed.is_set(): @@ -198,7 +243,7 @@ def _listen_on_pipe_new(listener, result: List[str], completed: threading.Event) def _run_test_code(proc_args: List[str], proc_env, proc_cwd: str, completed: threading.Event): - result = subprocess.run(proc_args, env=proc_env, cwd=proc_cwd) + result = subprocess.run(proc_args, env=proc_env, cwd=proc_cwd, check=False) completed.set() return result @@ -257,7 +302,7 @@ def runner_with_cwd_env( ) env_add.update({"RUN_TEST_IDS_PIPE": test_ids_pipe}) test_ids_arr = after_ids - with open(test_ids_pipe, "w") as f: # noqa: PTH123 + with open(test_ids_pipe, "w", encoding="utf-8") as f: # noqa: PTH123 f.write("\n".join(test_ids_arr)) else: 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: test_file_path: The path to the test file where the test is located. """ test_file_unique_id: str = "test_marker--" + test_name.split("[")[0] - with open(test_file_path) as f: # noqa: PTH123 + with open(test_file_path, encoding="utf-8") as f: # noqa: PTH123 for i, line in enumerate(f): if test_file_unique_id in line: return str(i + 1) @@ -395,7 +440,7 @@ def find_class_line_number(class_name: str, test_file_path) -> str: test_file_path: The path to the test file where the class is located. """ # Look for the class definition line (or function for pytest-describe) - with open(test_file_path) as f: # noqa: PTH123 + with open(test_file_path, encoding="utf-8") as f: # noqa: PTH123 for i, line in enumerate(f): # Match "class ClassName" or "class ClassName(" or "class ClassName:" # Also match "def ClassName(" for pytest-describe blocks diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index cf777399fed9..247a625e7253 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -2,16 +2,146 @@ # Licensed under the MIT License. import json import os +import pathlib import sys -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast import pytest +import vscode_pytest from tests.tree_comparison_helper import is_same_tree from . import expected_discovery_test_output, helpers +def test_compact_discovery_payload_keeps_absolute_tree_until_return(tmp_path, monkeypatch): + monkeypatch.setattr(vscode_pytest, "ERRORS", []) + base_path = tmp_path / "workspace" + test_file = base_path / "tests" / "test_sample.py" + absolute_test_id = f"{os.fspath(test_file)}::test_case[param]" + session_node: vscode_pytest.TestNode = { + "name": "workspace", + "path": base_path, + "type_": "folder", + "id_": os.fspath(base_path), + "children": [ + { + "name": "test_sample.py", + "path": test_file, + "type_": "file", + "id_": os.fspath(test_file), + "children": [ + { + "name": "test_case[param]", + "path": test_file, + "type_": "test", + "id_": absolute_test_id, + "runID": absolute_test_id, + "lineno": "7", + } + ], + } + ], + } + + payload = vscode_pytest.create_compact_discovery_payload(os.fspath(base_path), session_node) + + assert session_node["path"] == base_path + file_node = cast("vscode_pytest.TestNode", session_node["children"][0]) + assert file_node is not None + assert file_node["path"] == test_file + test_node = cast("vscode_pytest.TestItem", file_node["children"][0]) + assert test_node is not None + assert test_node["id_"] == absolute_test_id + + assert payload["pathBase"] == os.fspath(base_path) + assert payload["idBase"] == os.fspath(base_path) + assert payload["tests"] is not None + compact_tests = cast("Dict[str, Any]", payload["tests"]) + assert compact_tests["path"] == "." + assert compact_tests["id_"] == "." + compact_file_node = cast("Dict[str, Any]", compact_tests["children"][0]) + assert compact_file_node["path"] == os.fspath(pathlib.Path("tests", "test_sample.py")) + assert compact_file_node["id_"] == os.fspath(pathlib.Path("tests", "test_sample.py")) + compact_test_node = cast("Dict[str, Any]", compact_file_node["children"][0]) + assert compact_test_node["path"] == os.fspath(pathlib.Path("tests", "test_sample.py")) + assert ( + compact_test_node["id_"] + == os.fspath(pathlib.Path("tests", "test_sample.py")) + "::test_case[param]" + ) + assert ( + compact_test_node["runID"] + == os.fspath(pathlib.Path("tests", "test_sample.py")) + "::test_case[param]" + ) + + +def test_compact_discovery_payload_keeps_paths_outside_base_absolute(tmp_path): + base_path = tmp_path / "workspace" + external_file = tmp_path / "external" / "test_external.py" + + assert vscode_pytest.compact_path(external_file, base_path) == os.fspath(external_file) + assert ( + vscode_pytest.compact_test_id(f"{os.fspath(external_file)}::test_external", base_path) + == f"{os.fspath(external_file)}::test_external" + ) + + +def test_compact_discovery_payload_expands_after_rpc_parsing(tmp_path): + base_path = os.fspath(tmp_path / "workspace") + payload = { + "cwd": base_path, + "status": "success", + "payloadVersion": 2, + "pathBase": base_path, + "idBase": base_path, + "tests": { + "name": "workspace", + "path": ".", + "type_": "folder", + "id_": ".", + "children": [ + { + "name": "test_sample.py", + "path": "tests/test_sample.py", + "type_": "file", + "id_": "tests/test_sample.py", + "children": [ + { + "name": "test_case[param]", + "path": "tests/test_sample.py", + "type_": "test", + "id_": "tests/test_sample.py::test_case[param]", + "runID": "tests/test_sample.py::test_case[param]", + "lineno": "7", + } + ], + } + ], + }, + "error": [], + } + body = json.dumps({"jsonrpc": "2.0", "params": payload}) + framed_message = f"content-length: {len(body)}\r\ncontent-type: application/json\r\n\r\n{body}" + chunked_message = "".join( + [framed_message[:13], framed_message[13:97], framed_message[97:]] + ) + + parsed_payload = helpers.process_data_received(chunked_message)[0] + + assert parsed_payload["tests"]["path"] == base_path + parsed_file_node = parsed_payload["tests"]["children"][0] + assert parsed_file_node["path"] == os.fspath(pathlib.Path(base_path, "tests/test_sample.py")) + parsed_test_node = parsed_file_node["children"][0] + assert ( + parsed_test_node["id_"] + == os.fspath(pathlib.Path(base_path, "tests/test_sample.py")) + "::test_case[param]" + ) + assert ( + parsed_test_node["runID"] + == os.fspath(pathlib.Path(base_path, "tests/test_sample.py")) + "::test_case[param]" + ) + + def test_import_error(): """Test pytest discovery on a file that has a pytest marker but does not import pytest. diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index be4e3daaa843..9c67a680f6ce 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -28,10 +28,12 @@ from typing_extensions import NotRequired USES_PYTEST_DESCRIBE = False +DescribeBlock: Any = None with contextlib.suppress(ImportError): - from pytest_describe.plugin import DescribeBlock + from pytest_describe.plugin import DescribeBlock as ImportedDescribeBlock + DescribeBlock = ImportedDescribeBlock USES_PYTEST_DESCRIBE = True @@ -710,10 +712,11 @@ def build_test_tree(session: pytest.Session) -> TestNode: test_node = process_parameterized_test( test_case, test_node, function_nodes_dict, file_nodes_dict ) - if isinstance(test_case.parent, pytest.Class) or ( - USES_PYTEST_DESCRIBE and isinstance(test_case.parent, DescribeBlock) + parent = test_case.parent + if isinstance(parent, pytest.Class) or ( + USES_PYTEST_DESCRIBE and isinstance(parent, DescribeBlock) ): - case_iter = test_case.parent + case_iter: Any = parent node_child_iter = test_node test_class_node: TestNode | None = None while isinstance(case_iter, pytest.Class) or ( @@ -862,7 +865,7 @@ def create_session_node(session: pytest.Session) -> TestNode: } -def create_class_node(class_module: pytest.Class | DescribeBlock) -> TestNode: +def create_class_node(class_module: Any) -> TestNode: """Creates a class node from a pytest class object. Keyword arguments: @@ -950,6 +953,14 @@ class DiscoveryPayloadDict(TypedDict): error: list[str] | None +class CompactDiscoveryPayloadDict(DiscoveryPayloadDict): + """A compact discovery payload with paths relative to shared bases.""" + + payloadVersion: int + pathBase: str + idBase: str + + class ExecutionPayloadDict(Dict): """A dictionary that is used to send a execution post request to the server.""" @@ -986,6 +997,70 @@ def cached_fsdecode(path: pathlib.Path) -> str: return _path_to_str_cache[path] +def compact_path(path: pathlib.Path | str, path_base: pathlib.Path) -> str: + """Return path relative to path_base when possible without resolving symlinks.""" + current_path = pathlib.Path(path) + if not current_path.is_absolute(): + return os.fspath(current_path) + + try: + relative_path = current_path.relative_to(path_base) + except ValueError: + return os.fspath(current_path) + + relative_path_str = os.fspath(relative_path) + return relative_path_str or "." + + +def compact_test_id(test_id: str, id_base: pathlib.Path) -> str: + """Compact the path prefix in a pytest node id while preserving pytest selectors.""" + test_path, separator, selector = test_id.partition("::") + compact_test_path = compact_path(test_path, id_base) + return f"{compact_test_path}{separator}{selector}" if separator else compact_test_path + + +def compact_test_node( + test_node: TestNode | TestItem | None, + path_base: pathlib.Path, + id_base: pathlib.Path, +) -> dict[str, Any] | None: + """Create a compact copy of a discovery node for JSON serialization.""" + if test_node is None: + return None + + compact_node: dict[str, Any] = {} + for key, value in test_node.items(): + if key == "path": + compact_node[key] = compact_path(cast("pathlib.Path", value), path_base) + elif key in {"id_", "runID"}: + compact_node[key] = compact_test_id(cast("str", value), id_base) + elif key == "children": + compact_node[key] = [ + compact_test_node(child, path_base, id_base) + for child in cast("list[TestNode | TestItem | None]", value) + ] + else: + compact_node[key] = value + return compact_node + + +def create_compact_discovery_payload( + cwd: str, session_node: TestNode +) -> CompactDiscoveryPayloadDict: + """Create the compact wire payload after discovery has fully resolved the tree.""" + path_base = pathlib.Path(session_node["path"]) + id_base = path_base + return CompactDiscoveryPayloadDict( + cwd=cwd, + status="success" if not ERRORS else "error", + tests=cast("TestNode", compact_test_node(session_node, path_base, id_base)), + error=ERRORS, + payloadVersion=2, + pathBase=os.fspath(path_base), + idBase=os.fspath(id_base), + ) + + def get_node_path( node: pytest.Session | pytest.Item @@ -1084,36 +1159,18 @@ def send_discovery_message(cwd: str, session_node: TestNode) -> None: cwd (str): Current working directory. session_node (TestNode): Node information of the test session. """ - payload: DiscoveryPayloadDict = { - "cwd": cwd, - "status": "success" if not ERRORS else "error", - "tests": session_node, - "error": [], - } - if ERRORS is not None: - payload["error"] = ERRORS - send_message(payload, cls_encoder=PathEncoder) - - -class PathEncoder(json.JSONEncoder): - """A custom JSON encoder that encodes pathlib.Path objects as strings.""" - - def default(self, o): - if isinstance(o, pathlib.Path): - return os.fspath(o) - return super().default(o) + payload = create_compact_discovery_payload(cwd, session_node) + send_message(payload) def send_message( payload: ExecutionPayloadDict | DiscoveryPayloadDict | CoveragePayloadDict, - cls_encoder=None, ): """ Sends a post request to the server. Keyword arguments: payload -- the payload data to be sent. - cls_encoder -- a custom encoder if needed. """ if not TEST_RUN_PIPE: error_msg = ( @@ -1146,7 +1203,7 @@ def send_message( "jsonrpc": "2.0", "params": payload, } - data = json.dumps(rpc, cls=cls_encoder) + data = json.dumps(rpc) try: if __writer: request = ( diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index 3f70e6b68594..42ed6594245b 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -3,7 +3,7 @@ import { CancellationToken, TestController, Uri, MarkdownString } from 'vscode'; import * as util from 'util'; -import { DiscoveredTestPayload } from './types'; +import { DiscoveredTestItem, DiscoveredTestNode, DiscoveredTestPayload } from './types'; import { TestProvider } from '../../types'; import { traceError, traceWarn } from '../../../logging'; import { Testing } from '../../../common/utils/localize'; @@ -12,6 +12,74 @@ import { buildErrorNodeOptions, populateTestTree } from './utils'; import { TestItemIndex } from './testItemIndex'; import { PROJECT_ID_SEPARATOR } from './projectUtils'; +function isAbsolutePath(value: string): boolean { + return /^([a-zA-Z]:[\\/]|\\\\)/.test(value) || value.startsWith('/'); +} + +function joinWithBase(base: string, relativePath: string): string { + if (!base || isAbsolutePath(relativePath)) { + return relativePath; + } + if (relativePath === '.') { + return base; + } + + const separator = base.includes('\\') ? '\\' : '/'; + const trimmedBase = base.replace(/[\\/]+$/, ''); + const trimmedRelativePath = relativePath.replace(/^[\\/]+/, ''); + return `${trimmedBase}${separator}${trimmedRelativePath}`; +} + +function expandTestId(testId: string, idBase: string): string { + const separatorIndex = testId.indexOf('::'); + if (separatorIndex === -1) { + return joinWithBase(idBase, testId); + } + + const testPath = testId.slice(0, separatorIndex); + const testSelector = testId.slice(separatorIndex); + return `${joinWithBase(idBase, testPath)}${testSelector}`; +} + +function expandDiscoveryNode( + testNode: DiscoveredTestNode | DiscoveredTestItem, + pathBase: string, + idBase: string, +): DiscoveredTestNode | DiscoveredTestItem { + const expandedNode = { + ...testNode, + path: joinWithBase(pathBase, testNode.path), + id_: expandTestId(testNode.id_, idBase), + }; + + if ('runID' in expandedNode) { + return { + ...expandedNode, + runID: expandTestId(expandedNode.runID, idBase), + }; + } + + return { + ...expandedNode, + children: expandedNode.children.map((child) => expandDiscoveryNode(child, pathBase, idBase)), + }; +} + +export function expandCompactDiscoveryPayload(payload: DiscoveredTestPayload): DiscoveredTestPayload { + if (!payload.pathBase || !payload.tests) { + return payload; + } + + return { + ...payload, + tests: expandDiscoveryNode( + payload.tests, + payload.pathBase, + payload.idBase ?? payload.pathBase, + ) as DiscoveredTestNode, + }; +} + /** * Stateless handler for processing discovery payloads and building/updating the TestItem tree. * This handler is shared across all workspaces and contains no instance state. @@ -37,7 +105,7 @@ export class TestDiscoveryHandler { } const workspacePath = workspaceUri.fsPath; - const rawTestData = payload as DiscoveredTestPayload; + const rawTestData = expandCompactDiscoveryPayload(payload as DiscoveredTestPayload); // Check if there were any errors in the discovery process. if (rawTestData.status === 'error') { @@ -60,6 +128,10 @@ export class TestDiscoveryHandler { // If the test root for this folder exists: Workspace refresh, update its children. // Otherwise, it is a freshly discovered workspace, and we need to create a new test root and populate the test tree. // Note: populateTestTree will call testItemIndex.registerTestItem() for each discovered test + if (rawTestData.tests === null) { + return; + } + populateTestTree( testController, rawTestData.tests, diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 017c41cf3d97..30eeb40a3be9 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -210,9 +210,12 @@ export type DiscoveredTestNode = DiscoveredTestCommon & { export type DiscoveredTestPayload = { cwd: string; - tests?: DiscoveredTestNode; + tests?: DiscoveredTestNode | null; status: 'success' | 'error'; error?: string[]; + payloadVersion?: number; + pathBase?: string; + idBase?: string; }; export type CoveragePayload = { diff --git a/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts b/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts index 458e3d984405..0ce1ad9b25fb 100644 --- a/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts +++ b/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts @@ -5,7 +5,10 @@ import { TestController, TestItem, Uri, CancellationToken, TestItemCollection } import * as typemoq from 'typemoq'; import * as assert from 'assert'; import * as sinon from 'sinon'; -import { TestDiscoveryHandler } from '../../../../client/testing/testController/common/testDiscoveryHandler'; +import { + expandCompactDiscoveryPayload, + TestDiscoveryHandler, +} from '../../../../client/testing/testController/common/testDiscoveryHandler'; import { TestItemIndex } from '../../../../client/testing/testController/common/testItemIndex'; import { DiscoveredTestPayload, DiscoveredTestNode } from '../../../../client/testing/testController/common/types'; import { TestProvider } from '../../../../client/testing/types'; @@ -106,6 +109,142 @@ suite('TestDiscoveryHandler', () => { ); }); + test('should expand compact discovery payload before populating', () => { + const payload: DiscoveredTestPayload = { + cwd: '/foo/bar', + status: 'success', + payloadVersion: 2, + pathBase: '/foo/bar', + idBase: '/foo/bar', + tests: { + path: '.', + name: 'bar', + type_: 'folder', + id_: '.', + children: [ + { + path: 'tests/test_sample.py', + name: 'test_sample.py', + type_: 'file', + id_: 'tests/test_sample.py', + children: [ + { + path: 'tests/test_sample.py', + name: 'test_case[param]', + type_: 'test', + id_: 'tests/test_sample.py::test_case[param]', + runID: 'tests/test_sample.py::test_case[param]', + lineno: '7', + }, + ], + }, + ], + }, + }; + + const populateTestTreeStub = sinon.stub(utils, 'populateTestTree'); + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + const expandedTests = populateTestTreeStub.getCall(0).args[1] as DiscoveredTestNode; + assert.strictEqual(expandedTests.path, '/foo/bar'); + assert.strictEqual(expandedTests.id_, '/foo/bar'); + assert.strictEqual(expandedTests.children[0].path, '/foo/bar/tests/test_sample.py'); + assert.strictEqual(expandedTests.children[0].id_, '/foo/bar/tests/test_sample.py'); + + const fileNode = expandedTests.children[0] as DiscoveredTestNode; + const testNode = fileNode.children[0] as any; + assert.strictEqual(testNode.path, '/foo/bar/tests/test_sample.py'); + assert.strictEqual(testNode.id_, '/foo/bar/tests/test_sample.py::test_case[param]'); + assert.strictEqual(testNode.runID, '/foo/bar/tests/test_sample.py::test_case[param]'); + }); + + test('should leave absolute paths in compact payloads unchanged', () => { + const payload: DiscoveredTestPayload = { + cwd: '/foo/bar', + status: 'success', + payloadVersion: 2, + pathBase: '/foo/bar', + idBase: '/foo/bar', + tests: { + path: '/external/project', + name: 'project', + type_: 'folder', + id_: '/external/project', + children: [ + { + path: '/external/project/test_sample.py', + name: 'test_case', + type_: 'test', + id_: '/external/project/test_sample.py::test_case', + runID: '/external/project/test_sample.py::test_case', + lineno: '3', + }, + ], + }, + }; + + const expandedPayload = expandCompactDiscoveryPayload(payload); + assert.strictEqual(expandedPayload.tests?.path, '/external/project'); + assert.strictEqual(expandedPayload.tests?.id_, '/external/project'); + const testNode = expandedPayload.tests?.children[0] as any; + assert.strictEqual(testNode.path, '/external/project/test_sample.py'); + assert.strictEqual(testNode.id_, '/external/project/test_sample.py::test_case'); + assert.strictEqual(testNode.runID, '/external/project/test_sample.py::test_case'); + }); + + test('should not expand or populate null tests in error payloads', () => { + const payload: DiscoveredTestPayload = { + cwd: '/foo/bar', + status: 'error', + payloadVersion: 2, + pathBase: '/foo/bar', + idBase: '/foo/bar', + tests: null, + error: ['Discovery stopped before tests were available'], + }; + + const populateTestTreeStub = sinon.stub(utils, 'populateTestTree'); + const createErrorNodeSpy = sinon.spy(discoveryHandler, 'createErrorNode'); + const mockErrorItem = ({ + id: 'error_id', + error: null, + canResolveChildren: false, + tags: [], + } as unknown) as TestItem; + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockErrorItem); + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + assert.ok(createErrorNodeSpy.calledOnce); + assert.ok(populateTestTreeStub.notCalled); + testItemIndexMock.verify((x) => x.clear(), typemoq.Times.once()); + }); + test('should clear index before populating', () => { const tests: DiscoveredTestNode = { path: '/foo/bar', @@ -329,8 +468,7 @@ suite('TestDiscoveryHandler', () => { cancelationToken, ); - // Should still call populateTestTree with null - assert.ok(populateTestTreeStub.calledOnce); + assert.ok(populateTestTreeStub.notCalled); testItemIndexMock.verify((x) => x.clear(), typemoq.Times.once()); }); });