From ea372d8142288a521984c4cbcd95d44ad39a8444 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:09:26 +0000 Subject: [PATCH 1/7] Initial plan From cf2e46851cd3fd73d428cc2391eb02da96743078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:32:41 +0000 Subject: [PATCH 2/7] Add lineno to class nodes for pytest discovery - Modified create_class_node() to extract and include line number using inspect.getsourcelines() - Updated TypeScript types to allow optional lineno on DiscoveredTestNode - Modified populateTestTree() to handle lineno for class nodes (not just test items) - Added find_class_line_number() helper function for test expectations - Updated all test expectations to include lineno for class nodes - Added 'function' to DiscoveredTestType enum This enables TestClass items to show the green arrow and be runnable in VS Code's Test Explorer. Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../expected_discovery_test_output.py | 58 ++++++++++++++++++- python_files/tests/pytestadapter/helpers.py | 20 +++++++ python_files/vscode_pytest/__init__.py | 13 +++++ .../testing/testController/common/types.ts | 3 +- .../testing/testController/common/utils.ts | 15 +++++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index e00db5d660a3..0641529c0d98 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1,6 +1,11 @@ import os -from .helpers import TEST_DATA_PATH, find_test_line_number, get_absolute_test_id +from .helpers import ( + TEST_DATA_PATH, + find_class_line_number, + find_test_line_number, + get_absolute_test_id, +) # This file contains the expected output dictionaries for tests discovery and is used in test_discovery.py. @@ -95,6 +100,9 @@ "unittest_pytest_same_file.py::TestExample", unit_pytest_same_file_path, ), + "lineno": find_class_line_number( + "TestExample", unit_pytest_same_file_path + ), }, { "name": "test_true_pytest", @@ -207,6 +215,9 @@ "unittest_folder/test_add.py::TestAddFunction", test_add_path, ), + "lineno": find_class_line_number( + "TestAddFunction", test_add_path + ), }, { "name": "TestDuplicateFunction", @@ -235,6 +246,9 @@ "unittest_folder/test_add.py::TestDuplicateFunction", test_add_path, ), + "lineno": find_class_line_number( + "TestDuplicateFunction", test_add_path + ), }, ], }, @@ -288,6 +302,9 @@ "unittest_folder/test_subtract.py::TestSubtractFunction", test_subtract_path, ), + "lineno": find_class_line_number( + "TestSubtractFunction", test_subtract_path + ), }, { "name": "TestDuplicateFunction", @@ -316,6 +333,9 @@ "unittest_folder/test_subtract.py::TestDuplicateFunction", test_subtract_path, ), + "lineno": find_class_line_number( + "TestDuplicateFunction", test_subtract_path + ), }, ], }, @@ -553,6 +573,9 @@ "parametrize_tests.py::TestClass", parameterize_tests_path, ), + "lineno": find_class_line_number( + "TestClass", parameterize_tests_path + ), "children": [ { "name": "test_adding", @@ -929,6 +952,9 @@ "test_multi_class_nest.py::TestFirstClass", TEST_MULTI_CLASS_NEST_PATH, ), + "lineno": find_class_line_number( + "TestFirstClass", TEST_MULTI_CLASS_NEST_PATH + ), "children": [ { "name": "TestSecondClass", @@ -938,6 +964,9 @@ "test_multi_class_nest.py::TestFirstClass::TestSecondClass", TEST_MULTI_CLASS_NEST_PATH, ), + "lineno": find_class_line_number( + "TestSecondClass", TEST_MULTI_CLASS_NEST_PATH + ), "children": [ { "name": "test_second", @@ -982,6 +1011,9 @@ "test_multi_class_nest.py::TestFirstClass::TestSecondClass2", TEST_MULTI_CLASS_NEST_PATH, ), + "lineno": find_class_line_number( + "TestSecondClass2", TEST_MULTI_CLASS_NEST_PATH + ), "children": [ { "name": "test_second2", @@ -1227,6 +1259,9 @@ "same_function_new_class_param.py::TestNotEmpty", TEST_DATA_PATH / "same_function_new_class_param.py", ), + "lineno": find_class_line_number( + "TestNotEmpty", TEST_DATA_PATH / "same_function_new_class_param.py" + ), }, { "name": "TestEmpty", @@ -1298,6 +1333,9 @@ "same_function_new_class_param.py::TestEmpty", TEST_DATA_PATH / "same_function_new_class_param.py", ), + "lineno": find_class_line_number( + "TestEmpty", TEST_DATA_PATH / "same_function_new_class_param.py" + ), }, ], } @@ -1371,6 +1409,9 @@ "test_param_span_class.py::TestClass1", TEST_DATA_PATH / "test_param_span_class.py", ), + "lineno": find_class_line_number( + "TestClass1", TEST_DATA_PATH / "test_param_span_class.py" + ), }, { "name": "TestClass2", @@ -1427,6 +1468,9 @@ "test_param_span_class.py::TestClass2", TEST_DATA_PATH / "test_param_span_class.py", ), + "lineno": find_class_line_number( + "TestClass2", TEST_DATA_PATH / "test_param_span_class.py" + ), }, ], } @@ -1503,6 +1547,9 @@ "pytest_describe_plugin/describe_only.py::describe_A", describe_only_path, ), + "lineno": find_class_line_number( + "describe_A", describe_only_path + ), } ], } @@ -1586,6 +1633,9 @@ "pytest_describe_plugin/nested_describe.py::describe_list::describe_append", nested_describe_path, ), + "lineno": find_class_line_number( + "describe_append", nested_describe_path + ), }, { "name": "describe_remove", @@ -1614,12 +1664,18 @@ "pytest_describe_plugin/nested_describe.py::describe_list::describe_remove", nested_describe_path, ), + "lineno": find_class_line_number( + "describe_remove", nested_describe_path + ), }, ], "id_": get_absolute_test_id( "pytest_describe_plugin/nested_describe.py::describe_list", nested_describe_path, ), + "lineno": find_class_line_number( + "describe_list", nested_describe_path + ), } ], } diff --git a/python_files/tests/pytestadapter/helpers.py b/python_files/tests/pytestadapter/helpers.py index 4c337585bece..7b822ba071ce 100644 --- a/python_files/tests/pytestadapter/helpers.py +++ b/python_files/tests/pytestadapter/helpers.py @@ -370,6 +370,26 @@ def find_test_line_number(test_name: str, test_file_path) -> str: raise ValueError(error_str) +def find_class_line_number(class_name: str, test_file_path) -> str: + """Function which finds the correct line number for a class definition. + + Args: + class_name: The name of the class to find the line number for. + 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 + for i, line in enumerate(f): + # Match "class ClassName" or "class ClassName(" or "class ClassName:" + # Also match "def ClassName(" for pytest-describe blocks + if line.strip().startswith(f"class {class_name}") or line.strip().startswith( + f"class {class_name}(" + ) or line.strip().startswith(f"def {class_name}("): + return str(i + 1) + error_str: str = f"Class {class_name!r} not found on any line in {test_file_path}" + raise ValueError(error_str) + + def get_absolute_test_id(test_id: str, test_path: pathlib.Path) -> str: """Get the absolute test id by joining the testPath with the test_id.""" split_id = test_id.split("::")[1:] diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 0eac4a74f4c3..e5e261118e05 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -830,12 +830,25 @@ def create_class_node(class_module: pytest.Class | DescribeBlock) -> TestNode: Keyword arguments: class_module -- the pytest object representing a class module. """ + # Get line number for the class definition + class_line = "" + try: + if hasattr(class_module, "obj"): + import inspect + + _, lineno = inspect.getsourcelines(class_module.obj) + class_line = str(lineno) + except (OSError, TypeError): + # If we can't get the source lines, leave lineno empty + pass + return { "name": class_module.name, "path": get_node_path(class_module), "type_": "class", "children": [], "id_": get_absolute_test_id(class_module.nodeid, get_node_path(class_module)), + "lineno": class_line, } diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 5c6796905024..6121b3e24442 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -177,7 +177,7 @@ export interface ITestExecutionAdapter { } // Same types as in python_files/unittestadapter/utils.py -export type DiscoveredTestType = 'folder' | 'file' | 'class' | 'test'; +export type DiscoveredTestType = 'folder' | 'file' | 'class' | 'function' | 'test'; export type DiscoveredTestCommon = { path: string; @@ -194,6 +194,7 @@ export type DiscoveredTestItem = DiscoveredTestCommon & { export type DiscoveredTestNode = DiscoveredTestCommon & { children: (DiscoveredTestNode | DiscoveredTestItem)[]; + lineno?: number | string; }; export type DiscoveredTestPayload = { diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 0bbf0e449dcd..606865e5ad7e 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -257,6 +257,21 @@ export function populateTestTree( node.canResolveChildren = true; node.tags = [RunTestTag, DebugTestTag]; + + // Set range for class nodes (and other nodes) if lineno is available + let range: Range | undefined; + if ('lineno' in child && child.lineno) { + if (Number(child.lineno) === 0) { + range = new Range(new Position(0, 0), new Position(0, 0)); + } else { + range = new Range( + new Position(Number(child.lineno) - 1, 0), + new Position(Number(child.lineno), 0), + ); + } + node.range = range; + } + testRoot!.children.add(node); } populateTestTree(testController, child, node, resultResolver, token); From 75ec7fd4a28460a57c10e796db03c7fe4baa0190 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:35:05 +0000 Subject: [PATCH 3/7] Fix Python type checking errors and format code - Added NotRequired to imports for optional TypedDict fields - Made lineno an optional field in TestNode TypedDict - Formatted Python files with ruff Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../expected_discovery_test_output.py | 24 +++++-------------- python_files/tests/pytestadapter/helpers.py | 8 ++++--- python_files/vscode_pytest/__init__.py | 13 +++++++++- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index 0641529c0d98..b6f0779cf982 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -100,9 +100,7 @@ "unittest_pytest_same_file.py::TestExample", unit_pytest_same_file_path, ), - "lineno": find_class_line_number( - "TestExample", unit_pytest_same_file_path - ), + "lineno": find_class_line_number("TestExample", unit_pytest_same_file_path), }, { "name": "test_true_pytest", @@ -215,9 +213,7 @@ "unittest_folder/test_add.py::TestAddFunction", test_add_path, ), - "lineno": find_class_line_number( - "TestAddFunction", test_add_path - ), + "lineno": find_class_line_number("TestAddFunction", test_add_path), }, { "name": "TestDuplicateFunction", @@ -573,9 +569,7 @@ "parametrize_tests.py::TestClass", parameterize_tests_path, ), - "lineno": find_class_line_number( - "TestClass", parameterize_tests_path - ), + "lineno": find_class_line_number("TestClass", parameterize_tests_path), "children": [ { "name": "test_adding", @@ -952,9 +946,7 @@ "test_multi_class_nest.py::TestFirstClass", TEST_MULTI_CLASS_NEST_PATH, ), - "lineno": find_class_line_number( - "TestFirstClass", TEST_MULTI_CLASS_NEST_PATH - ), + "lineno": find_class_line_number("TestFirstClass", TEST_MULTI_CLASS_NEST_PATH), "children": [ { "name": "TestSecondClass", @@ -1547,9 +1539,7 @@ "pytest_describe_plugin/describe_only.py::describe_A", describe_only_path, ), - "lineno": find_class_line_number( - "describe_A", describe_only_path - ), + "lineno": find_class_line_number("describe_A", describe_only_path), } ], } @@ -1673,9 +1663,7 @@ "pytest_describe_plugin/nested_describe.py::describe_list", nested_describe_path, ), - "lineno": find_class_line_number( - "describe_list", nested_describe_path - ), + "lineno": find_class_line_number("describe_list", nested_describe_path), } ], } diff --git a/python_files/tests/pytestadapter/helpers.py b/python_files/tests/pytestadapter/helpers.py index 7b822ba071ce..25e6187e2efa 100644 --- a/python_files/tests/pytestadapter/helpers.py +++ b/python_files/tests/pytestadapter/helpers.py @@ -382,9 +382,11 @@ def find_class_line_number(class_name: str, test_file_path) -> str: for i, line in enumerate(f): # Match "class ClassName" or "class ClassName(" or "class ClassName:" # Also match "def ClassName(" for pytest-describe blocks - if line.strip().startswith(f"class {class_name}") or line.strip().startswith( - f"class {class_name}(" - ) or line.strip().startswith(f"def {class_name}("): + if ( + line.strip().startswith(f"class {class_name}") + or line.strip().startswith(f"class {class_name}(") + or line.strip().startswith(f"def {class_name}(") + ): return str(i + 1) error_str: str = f"Class {class_name!r} not found on any line in {test_file_path}" raise ValueError(error_str) diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index e5e261118e05..c4265751a2dd 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -10,7 +10,17 @@ import pathlib import sys import traceback -from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, Protocol, TypedDict, cast +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + Literal, + NotRequired, + Protocol, + TypedDict, + cast, +) import pytest @@ -52,6 +62,7 @@ class TestNode(TestData): """A general class that handles all test data which contains children.""" children: list[TestNode | TestItem | None] + lineno: NotRequired[str] # Optional field for class/function nodes class VSCodePytestError(Exception): From e22f3c3cca4812119c2727941c6453690163d2ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:13:06 +0000 Subject: [PATCH 4/7] Add lineno to class nodes for unittest discovery - Modified build_test_tree() in pvsc_utils.py to add line numbers to class nodes - Added get_class_line() function to extract class line numbers using inspect - Made lineno an optional field in TestNode TypedDict - Added find_class_line_number() helper for test expectations - Updated expected test outputs to include lineno for class nodes This implements the same functionality for unittest as was done for pytest, enabling TestClass items to show the green arrow and be runnable in VS Code's Test Explorer. Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../expected_discovery_test_output.py | 33 +++++++++++++++++++ python_files/unittestadapter/pvsc_utils.py | 15 +++++++++ 2 files changed, 48 insertions(+) diff --git a/python_files/tests/unittestadapter/expected_discovery_test_output.py b/python_files/tests/unittestadapter/expected_discovery_test_output.py index 9de0eff8238c..0901f21bfbc2 100644 --- a/python_files/tests/unittestadapter/expected_discovery_test_output.py +++ b/python_files/tests/unittestadapter/expected_discovery_test_output.py @@ -9,6 +9,25 @@ TEST_DATA_PATH = pathlib.Path(__file__).parent / ".data" +def find_class_line_number(class_name: str, test_file_path) -> str: + """Function which finds the correct line number for a class definition. + + Args: + class_name: The name of the class to find the line number for. + test_file_path: The path to the test file where the class is located. + """ + # Look for the class definition line + with pathlib.Path(test_file_path).open() as f: + for i, line in enumerate(f): + # Match "class ClassName" or "class ClassName(" or "class ClassName:" + if line.strip().startswith(f"class {class_name}") or line.strip().startswith( + f"class {class_name}(" + ): + return str(i + 1) + error_str: str = f"Class {class_name!r} not found on any line in {test_file_path}" + raise ValueError(error_str) + + skip_unittest_folder_discovery_output = { "path": os.fspath(TEST_DATA_PATH / "unittest_skip"), "name": "unittest_skip", @@ -49,6 +68,10 @@ ], "id_": os.fspath(TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py") + "\\SimpleTest", + "lineno": find_class_line_number( + "SimpleTest", + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py", + ), } ], "id_": os.fspath(TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py"), @@ -114,6 +137,16 @@ }, ], "id_": complex_tree_file_path + "\\" + "TreeOne", + "lineno": find_class_line_number( + "TreeOne", + pathlib.PurePath( + TEST_DATA_PATH, + "utils_complex_tree", + "test_outer_folder", + "test_inner_folder", + "test_utils_complex_tree.py", + ), + ), } ], "id_": complex_tree_file_path, diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index e9d7bc092992..6447319c8b4e 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -44,6 +44,7 @@ class TestItem(TestData): class TestNode(TestData): children: "List[TestNode | TestItem]" + lineno: NotRequired[str] # Optional field for class nodes class TestExecutionStatus(str, enum.Enum): @@ -101,6 +102,16 @@ def get_test_case(suite): yield from get_test_case(test) +def get_class_line(test_case: unittest.TestCase) -> str: + """Get the line number where a test class is defined.""" + try: + test_class = test_case.__class__ + _sourcelines, lineno = inspect.getsourcelines(test_class) + return str(lineno) + except Exception: + return "*" + + def get_source_line(obj) -> str: """Get the line number of a test case start line.""" try: @@ -249,6 +260,10 @@ def build_test_tree( class_name, file_path, TestNodeTypeEnum.class_, current_node ) + # Add line number to class node if not already present. + if "lineno" not in current_node: + current_node["lineno"] = get_class_line(test_case) + # Get test line number. test_method = getattr(test_case, test_case._testMethodName) # noqa: SLF001 lineno = get_source_line(test_method) From feeccf8c822247bf2d427b6d7d10b76fef2311c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:38:13 +0000 Subject: [PATCH 5/7] Return empty string instead of asterisk when class line cannot be determined Changed get_class_line() to return "" instead of "*" when inspect.getsourcelines() fails, making it consistent with the pytest implementation and leaving the field blank when the line number cannot be determined. Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- python_files/unittestadapter/pvsc_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index 6447319c8b4e..6f6d7f70825b 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -109,7 +109,7 @@ def get_class_line(test_case: unittest.TestCase) -> str: _sourcelines, lineno = inspect.getsourcelines(test_class) return str(lineno) except Exception: - return "*" + return "" def get_source_line(obj) -> str: From 03ed7b2ed4b03ca7cbc8afcf3804cc159332b951 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:47:59 -0800 Subject: [PATCH 6/7] opt for undefined as null value --- python_files/unittestadapter/pvsc_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index 6f6d7f70825b..b336d8e35d98 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -102,14 +102,14 @@ def get_test_case(suite): yield from get_test_case(test) -def get_class_line(test_case: unittest.TestCase) -> str: +def get_class_line(test_case: unittest.TestCase) -> str | None: """Get the line number where a test class is defined.""" try: test_class = test_case.__class__ _sourcelines, lineno = inspect.getsourcelines(test_class) return str(lineno) except Exception: - return "" + return None def get_source_line(obj) -> str: From cfce9b4e6952331f5d59e3adce5f8be301befe55 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:49:48 -0800 Subject: [PATCH 7/7] linting --- python_files/unittestadapter/pvsc_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index b336d8e35d98..9bb22b7bc130 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -262,7 +262,9 @@ def build_test_tree( # Add line number to class node if not already present. if "lineno" not in current_node: - current_node["lineno"] = get_class_line(test_case) + class_lineno = get_class_line(test_case) + if class_lineno is not None: + current_node["lineno"] = class_lineno # Get test line number. test_method = getattr(test_case, test_case._testMethodName) # noqa: SLF001