Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ad3ae47
Checkpoint from VS Code for cloud agent session
eleanorjboyd Jan 7, 2026
7af8ea3
Phase 1: Add core infrastructure for project-based testing
Copilot Jan 7, 2026
460faa8
Phase 2: Add project discovery integration
Copilot Jan 7, 2026
b670e8e
Update activate() to support project-based testing
Copilot Jan 7, 2026
8e13f1d
updates
eleanorjboyd Jan 8, 2026
133d97e
formatting
eleanorjboyd Jan 8, 2026
aba0183
remove doc on design
eleanorjboyd Jan 8, 2026
61745de
remove unneeded
eleanorjboyd Jan 8, 2026
3b7cbf9
adding tests for helpers
eleanorjboyd Jan 8, 2026
cf2e75c
testing and refinement
eleanorjboyd Jan 8, 2026
28b34dc
tests for controller
eleanorjboyd Jan 8, 2026
7b81f07
separators and update api calls
eleanorjboyd Jan 8, 2026
b2a3a8e
checkpoint- project test nodes
eleanorjboyd Jan 9, 2026
45675a4
second checkpoint- ignore implemented
eleanorjboyd Jan 9, 2026
267007b
cleanup cleanup everybody everywhere
eleanorjboyd Jan 9, 2026
2abfbbe
remove comments
eleanorjboyd Feb 3, 2026
4e7a325
refinement
eleanorjboyd Feb 3, 2026
225ff12
remove unittest refs
eleanorjboyd Feb 3, 2026
29533cf
cleanup
eleanorjboyd Feb 3, 2026
ca14068
pytest tests
eleanorjboyd Feb 3, 2026
34965e3
test fixes
eleanorjboyd Feb 3, 2026
7c3c879
fix
eleanorjboyd Feb 3, 2026
42cd011
address comments
eleanorjboyd Feb 3, 2026
145ccc8
fixes
eleanorjboyd Feb 4, 2026
ef32ac2
testing logging
eleanorjboyd Feb 4, 2026
b4563cd
test fix
eleanorjboyd Feb 5, 2026
d472e4b
remove unneeded edits
eleanorjboyd Feb 6, 2026
0718994
lots of fun fixes
eleanorjboyd Feb 6, 2026
9050b23
fix
eleanorjboyd Feb 6, 2026
401e8b1
phase 1
eleanorjboyd Feb 3, 2026
c7b66fe
updates
eleanorjboyd Feb 6, 2026
07d1883
updates
eleanorjboyd Feb 6, 2026
802f93b
Merge branch 'test-project-support' into instant-eagle-unittest-disco…
eleanorjboyd Feb 6, 2026
b0504d1
updates
eleanorjboyd Feb 6, 2026
ce9883d
project labeling
eleanorjboyd Feb 6, 2026
bb0e83d
actually move to showing empty node and comment fixes
eleanorjboyd Feb 6, 2026
0cd96d4
fix
eleanorjboyd Feb 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions .github/instructions/testing_feature_area.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,6 @@ The adapters in the extension don't implement test discovery/run logic themselve

Project-based testing enables multi-project workspace support where each Python project gets its own test tree root with its own Python environment.

> **⚠️ Note: unittest support for project-based testing is NOT yet implemented.** Project-based testing currently only works with pytest. unittest support will be added in a future PR.

### Architecture

- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that:
Expand All @@ -182,23 +180,52 @@ Project-based testing enables multi-project workspace support where each Python
2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace.
3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists.
4. **Test discovery**: For each project, the controller calls `project.discoveryAdapter.discoverTests()` with the project's URI. The adapter sets `PROJECT_ROOT_PATH` environment variable for the Python runner.
5. **Python side**: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`.
6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `||` separator.
5. **Python side**:
- For pytest: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`.
- For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `project_root_path` to root the test tree at the project directory.
6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `@@vsc@@` separator (defined in `projectUtils.ts`).

### Nested project handling: pytest vs unittest

**pytest** supports the `--ignore` flag to exclude paths during test collection. When nested projects are detected, parent projects automatically receive `--ignore` flags for child project paths. This ensures each test appears under exactly one project in the test tree.

**unittest** does not support path exclusion during `discover()`. Therefore, tests in nested project directories may appear under multiple project roots (both the parent and the child project). This is **expected behavior** for unittest:

- Each project discovers and displays all tests it finds within its directory structure
- There is no deduplication or collision detection
- Users may see the same test file under multiple project roots if their project structure has nesting

This approach was chosen because:

1. unittest's `TestLoader.discover()` has no built-in path exclusion mechanism
2. Implementing custom exclusion would add significant complexity with minimal benefit
3. The existing approach is transparent and predictable - each project shows what it finds

### Empty projects and root nodes

If a project discovers zero tests, its root node will still appear in the Test Explorer as an empty folder. This ensures consistent behavior and makes it clear which projects were discovered, even if they have no tests yet.

### Logging prefix

All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel.

### Key files

- Python side: `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable.
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters.
- Python side:
- `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest.
- `python_files/unittestadapter/discovery.py` — `discover_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery.
- `python_files/unittestadapter/execution.py` — `run_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest execution.
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery/execution adapters.

### Tests

- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests
- `src/test/testing/testController/common/projectUtils.unit.test.ts` — Project utility function tests
- `python_files/tests/pytestadapter/test_get_test_root_path.py` — Python-side get_test_root_path() tests
- `python_files/tests/pytestadapter/test_discovery.py` — pytest PROJECT_ROOT_PATH tests (see `test_project_root_path_env_var()` and `test_symlink_with_project_root_path()`)
- `python_files/tests/unittestadapter/test_discovery.py` — unittest `project_root_path` / PROJECT_ROOT_PATH discovery tests
- `python_files/tests/unittestadapter/test_execution.py` — unittest `project_root_path` / PROJECT_ROOT_PATH execution tests
- `src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts` — unittest discovery adapter PROJECT_ROOT_PATH tests
- `src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts` — unittest execution adapter PROJECT_ROOT_PATH tests

## Coverage support (how it works)

Expand Down
120 changes: 120 additions & 0 deletions python_files/tests/unittestadapter/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,123 @@ def test_simple_django_collect():
assert (
len(actual_item["tests"]["children"][0]["children"][0]["children"][0]["children"]) == 3
)


def test_project_root_path_with_cwd_override() -> None:
"""Test unittest discovery with project_root_path parameter.

This simulates project-based testing where the cwd in the payload should be
the project root (project_root_path) rather than the start_dir.

When project_root_path is provided:
- The cwd in the response should match project_root_path
- The test tree root should still be built correctly based on top_level_dir
"""
# Use unittest_skip folder as our "project" directory
project_path = TEST_DATA_PATH / "unittest_skip"
start_dir = os.fsdecode(project_path)
pattern = "unittest_*"

# Call discover_tests with project_root_path to simulate PROJECT_ROOT_PATH
actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir)

assert actual["status"] == "success"
# cwd in response should match the project_root_path (project root)
assert actual["cwd"] == os.fsdecode(project_path), (
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
)
assert "tests" in actual
# Verify the test tree structure matches expected output
assert is_same_tree(
actual.get("tests"),
expected_discovery_test_output.skip_unittest_folder_discovery_output,
["id_", "lineno", "name"],
)
assert "error" not in actual


def test_project_root_path_with_different_cwd_and_start_dir() -> None:
"""Test unittest discovery where project_root_path differs from start_dir.

This simulates the scenario where:
- start_dir points to a subfolder where tests are located
- project_root_path (PROJECT_ROOT_PATH) points to the project root

The cwd in the response should be the project root, while discovery
still runs from the start_dir.
"""
# Use utils_complex_tree as our test case - discovery from a subfolder
project_path = TEST_DATA_PATH / "utils_complex_tree"
start_dir = os.fsdecode(
pathlib.PurePath(
TEST_DATA_PATH,
"utils_complex_tree",
"test_outer_folder",
"test_inner_folder",
)
)
pattern = "test_*.py"
top_level_dir = os.fsdecode(project_path)

# Call discover_tests with project_root_path set to project root
actual = discover_tests(start_dir, pattern, top_level_dir, project_root_path=top_level_dir)

assert actual["status"] == "success"
# cwd should be the project root (project_root_path), not the start_dir
assert actual["cwd"] == os.fsdecode(project_path), (
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
)
assert "error" not in actual
# Test tree should still be structured correctly with top_level_dir as root
assert is_same_tree(
actual.get("tests"),
expected_discovery_test_output.complex_tree_expected_output,
["id_", "lineno", "name"],
)


@pytest.mark.skipif(
sys.platform == "win32",
reason="Symlinks require elevated privileges on Windows",
)
def test_symlink_with_project_root_path() -> None:
"""Test unittest discovery with both symlink and PROJECT_ROOT_PATH set.

This tests the combination of:
1. A symlinked test directory
2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path

This simulates project-based testing where the project root is a symlink,
ensuring test IDs and paths are correctly resolved through the symlink.
"""
with helpers.create_symlink(TEST_DATA_PATH, "unittest_skip", "symlink_unittest") as (
_source,
destination,
):
assert destination.is_symlink()

# Run discovery with:
# - start_dir pointing to the symlink destination
# - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH)
start_dir = os.fsdecode(destination)
pattern = "unittest_*"

actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir)

assert actual["status"] == "success", (
f"Status is not 'success', error is: {actual.get('error')}"
)
# cwd should be the symlink path (project_root_path)
assert actual["cwd"] == os.fsdecode(destination), (
f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}"
)
assert "tests" in actual
assert actual["tests"] is not None
# The test tree root should be named after the symlink directory
assert actual["tests"]["name"] == "symlink_unittest", (
f"Expected root name 'symlink_unittest', got '{actual['tests']['name']}'"
)
# The test tree root path should use the symlink path
assert actual["tests"]["path"] == os.fsdecode(destination), (
f"Expected root path to be symlink, got '{actual['tests']['path']}'"
)
131 changes: 131 additions & 0 deletions python_files/tests/unittestadapter/test_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,134 @@ def test_basic_run_django():
assert id_result["outcome"] == "failure"
else:
assert id_result["outcome"] == "success"


def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noqa: ARG001
"""Test unittest execution with project_root_path parameter.

This simulates project-based testing where the cwd in the payload should be
the project root (project_root_path) rather than the start_dir.

When project_root_path is provided:
- The cwd in the response should match project_root_path
- Test execution should still work correctly with start_dir
"""
# Use unittest_folder as our "project" directory
project_path = TEST_DATA_PATH / "unittest_folder"
start_dir = os.fsdecode(project_path)
pattern = "test_add*"
test_ids = [
"test_add.TestAddFunction.test_add_positive_numbers",
]

os.environ["TEST_RUN_PIPE"] = "fake"

# Call run_tests with project_root_path to simulate PROJECT_ROOT_PATH
actual = run_tests(
start_dir,
test_ids,
pattern,
None,
1,
None,
project_root_path=start_dir,
)

assert actual["status"] == "success"
# cwd in response should match the project_root_path (project root)
assert actual["cwd"] == os.fsdecode(project_path), (
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
)
assert actual["result"] is not None
assert test_ids[0] in actual["result"]
assert actual["result"][test_ids[0]]["outcome"] == "success"


def test_project_root_path_with_different_cwd_and_start_dir(mock_send_run_data) -> None: # noqa: ARG001
"""Test unittest execution where project_root_path differs from start_dir.

This simulates the scenario where:
- start_dir points to a subfolder where tests are located
- project_root_path (PROJECT_ROOT_PATH) points to the project root

The cwd in the response should be the project root, while execution
still runs from the start_dir.
"""
# Use utils_nested_cases as our test case
project_path = TEST_DATA_PATH / "utils_nested_cases"
start_dir = os.fsdecode(project_path)
pattern = "*"
test_ids = [
"file_one.CaseTwoFileOne.test_one",
]

os.environ["TEST_RUN_PIPE"] = "fake"

# Call run_tests with project_root_path set to project root
actual = run_tests(
start_dir,
test_ids,
pattern,
None,
1,
None,
project_root_path=os.fsdecode(project_path),
)

assert actual["status"] == "success"
# cwd should be the project root (project_root_path)
assert actual["cwd"] == os.fsdecode(project_path), (
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
)
assert actual["result"] is not None
assert test_ids[0] in actual["result"]


@pytest.mark.skipif(
sys.platform == "win32",
reason="Symlinks require elevated privileges on Windows",
)
def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: ARG001
"""Test unittest execution with both symlink and project_root_path set.

This tests the combination of:
1. A symlinked test directory
2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path

This simulates project-based testing where the project root is a symlink,
ensuring execution payloads correctly use the symlink path.
"""
with helpers.create_symlink(TEST_DATA_PATH, "unittest_folder", "symlink_unittest_exec") as (
_source,
destination,
):
assert destination.is_symlink()

# Run execution with:
# - start_dir pointing to the symlink destination
# - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH)
start_dir = os.fsdecode(destination)
pattern = "test_add*"
test_ids = [
"test_add.TestAddFunction.test_add_positive_numbers",
]

os.environ["TEST_RUN_PIPE"] = "fake"

actual = run_tests(
start_dir,
test_ids,
pattern,
None,
1,
None,
project_root_path=start_dir,
)

assert actual["status"] == "success", (
f"Status is not 'success', error is: {actual.get('error')}"
)
# cwd should be the symlink path (project_root_path)
assert actual["cwd"] == os.fsdecode(destination), (
f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}"
)
26 changes: 23 additions & 3 deletions python_files/unittestadapter/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ def discover_tests(
start_dir: str,
pattern: str,
top_level_dir: Optional[str],
project_root_path: Optional[str] = None,
) -> DiscoveryPayloadDict:
"""Returns a dictionary containing details of the discovered tests.

The returned dict has the following keys:

- cwd: Absolute path to the test start directory;
- cwd: Absolute path to the test start directory (or project_root_path if provided);
- status: Test discovery status, can be "success" or "error";
- tests: Discoverered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests;
- error: Discovery error if any, not present otherwise.
Expand All @@ -56,8 +57,15 @@ def discover_tests(
"": [list of errors]
"status": "error",
}

Args:
start_dir: Directory where test discovery starts
pattern: Pattern to match test files (e.g., "test*.py")
top_level_dir: Top-level directory for the test tree hierarchy
project_root_path: Optional project root path for the cwd in the response payload
(used for project-based testing to root test tree at project)
"""
cwd = os.path.abspath(start_dir) # noqa: PTH100
cwd = os.path.abspath(project_root_path or start_dir) # noqa: PTH100
if "/" in start_dir: # is a subdir
parent_dir = os.path.dirname(start_dir) # noqa: PTH120
sys.path.insert(0, parent_dir)
Expand Down Expand Up @@ -133,7 +141,19 @@ def discover_tests(
print(error_msg, file=sys.stderr)
raise VSCodeUnittestError(error_msg) # noqa: B904
else:
# Check for PROJECT_ROOT_PATH environment variable (project-based testing).
# When set, this overrides top_level_dir to root the test tree at the project directory.
project_root_path = os.environ.get("PROJECT_ROOT_PATH")
if project_root_path:
top_level_dir = project_root_path
print(
f"PROJECT_ROOT_PATH is set, using {project_root_path} as top_level_dir for discovery"
)

# Perform regular unittest test discovery.
payload = discover_tests(start_dir, pattern, top_level_dir)
# Pass project_root_path so the payload's cwd matches the project root.
payload = discover_tests(
start_dir, pattern, top_level_dir, project_root_path=project_root_path
)
# Post this discovery payload.
send_post_request(payload, test_run_pipe)
Loading