Skip to content

Commit 0718994

Browse files
committed
lots of fun fixes
1 parent d472e4b commit 0718994

File tree

10 files changed

+346
-124
lines changed

10 files changed

+346
-124
lines changed

python_files/tests/pytestadapter/expected_discovery_test_output.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1879,19 +1879,32 @@
18791879

18801880
# This is the expected output for unittest_folder when PROJECT_ROOT_PATH is set to unittest_folder.
18811881
# The root of the tree is unittest_folder (not .data), simulating project-based testing.
1882-
# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH)
1883-
# ├── test_add.py
1884-
# │ └── TestAddFunction
1885-
# │ ├── test_add_negative_numbers
1886-
# │ └── test_add_positive_numbers
1887-
# │ └── TestDuplicateFunction
1888-
# │ └── test_dup_a
1889-
# └── test_subtract.py
1890-
# └── TestSubtractFunction
1891-
# ├── test_subtract_negative_numbers
1892-
# └── test_subtract_positive_numbers
1893-
# └── TestDuplicateFunction
1894-
# └── test_dup_s
1882+
#
1883+
# **Project Configuration:**
1884+
# In the VS Code Python extension, projects are defined by the Python Environments extension.
1885+
# Each project has a root directory (identified by pyproject.toml, setup.py, etc.).
1886+
# When PROJECT_ROOT_PATH is set, pytest uses that path as the test tree root instead of cwd.
1887+
#
1888+
# **Test Tree Structure:**
1889+
# Without PROJECT_ROOT_PATH (legacy mode):
1890+
# └── .data (cwd = workspace root)
1891+
# └── unittest_folder
1892+
# └── test_add.py, test_subtract.py...
1893+
#
1894+
# With PROJECT_ROOT_PATH set to unittest_folder (project-based mode):
1895+
# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH env var)
1896+
# ├── test_add.py
1897+
# │ └── TestAddFunction
1898+
# │ ├── test_add_negative_numbers
1899+
# │ └── test_add_positive_numbers
1900+
# │ └── TestDuplicateFunction
1901+
# │ └── test_dup_a
1902+
# └── test_subtract.py
1903+
# └── TestSubtractFunction
1904+
# ├── test_subtract_negative_numbers
1905+
# └── test_subtract_positive_numbers
1906+
# └── TestDuplicateFunction
1907+
# └── test_dup_s
18951908
#
18961909
# Note: This reuses the unittest_folder paths defined earlier in this file.
18971910
project_root_unittest_folder_expected_output = {

python_files/tests/pytestadapter/test_discovery.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,54 @@ def test_project_root_path_env_var():
428428
), (
429429
f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.project_root_unittest_folder_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}"
430430
)
431+
432+
433+
@pytest.mark.skipif(
434+
sys.platform == "win32",
435+
)
436+
def test_symlink_with_project_root_path():
437+
"""Test pytest discovery with both symlink and PROJECT_ROOT_PATH set.
438+
439+
This tests the combination of:
440+
1. A symlinked test directory (--rootdir points to symlink)
441+
2. PROJECT_ROOT_PATH set to the symlink path
442+
443+
This simulates project-based testing where the project root is a symlink,
444+
ensuring test IDs and paths are correctly resolved through the symlink.
445+
"""
446+
with helpers.create_symlink(helpers.TEST_DATA_PATH, "root", "symlink_folder") as (
447+
source,
448+
destination,
449+
):
450+
assert destination.is_symlink()
451+
452+
# Run pytest with:
453+
# - cwd being the resolved symlink path (simulating subprocess from node)
454+
# - PROJECT_ROOT_PATH set to the symlink destination
455+
actual = helpers.runner_with_cwd_env(
456+
["--collect-only", f"--rootdir={os.fspath(destination)}"],
457+
source, # cwd is the resolved (non-symlink) path
458+
{"PROJECT_ROOT_PATH": os.fspath(destination)}, # Project root is the symlink
459+
)
460+
461+
expected = expected_discovery_test_output.symlink_expected_discovery_output
462+
assert actual
463+
actual_list: List[Dict[str, Any]] = actual
464+
if actual_list is not None:
465+
actual_item = actual_list.pop(0)
466+
try:
467+
assert all(item in actual_item for item in ("status", "cwd", "error")), (
468+
"Required keys are missing"
469+
)
470+
assert actual_item.get("status") == "success", (
471+
f"Status is not 'success', error is: {actual_item.get('error')}"
472+
)
473+
# cwd should be the PROJECT_ROOT_PATH (the symlink destination)
474+
assert actual_item.get("cwd") == os.fspath(destination), (
475+
f"CWD does not match symlink path: expected {os.fspath(destination)}, got {actual_item.get('cwd')}"
476+
)
477+
assert actual_item.get("tests") == expected, "Tests do not match expected value"
478+
except AssertionError as e:
479+
# Print the actual_item in JSON format if an assertion fails
480+
print(json.dumps(actual_item, indent=4))
481+
pytest.fail(str(e))

src/client/testing/testController/common/projectAdapter.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,18 @@ import { PythonEnvironment, PythonProject } from '../../../envExt/types';
99
/**
1010
* Represents a single Python project with its own test infrastructure.
1111
* A project is defined as a combination of a Python executable + URI (folder/file).
12-
* Projects are keyed by projectUri.toString()
12+
* Projects are uniquely identified by their projectUri (use projectUri.toString() for map keys).
1313
*/
1414
export interface ProjectAdapter {
1515
// === IDENTITY ===
16-
/**
17-
* Project identifier, which is the string representation of the project URI.
18-
*/
19-
projectId: string;
20-
2116
/**
2217
* Display name for the project (e.g., "alice (Python 3.11)").
2318
*/
2419
projectName: string;
2520

2621
/**
2722
* URI of the project root folder or file.
23+
* This is the unique identifier for the project.
2824
*/
2925
projectUri: Uri;
3026

src/client/testing/testController/common/projectUtils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ export function createProjectDisplayName(projectName: string, pythonVersion: str
6464

6565
/**
6666
* Creates test adapters (discovery and execution) for a given test provider.
67-
* Centralizes adapter creation to avoid code duplication across Controller and TestProjectRegistry.
6867
*
6968
* @param testProvider The test framework provider ('pytest' | 'unittest')
7069
* @param resultResolver The result resolver to use for test results

src/client/testing/testController/common/resultResolver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class PythonResultResolver implements ITestResultResolver {
2828

2929
/**
3030
* Optional project ID for scoping test IDs.
31-
* When set, all test IDs are prefixed with "{projectId}|" for project-based testing.
31+
* When set, all test IDs are prefixed with `{projectId}@@vsc@@` for project-based testing.
3232
* When undefined, uses legacy workspace-level IDs for backward compatibility.
3333
*/
3434
private projectId?: string;

src/client/testing/testController/common/testProjectRegistry.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,23 @@ import { PythonResultResolver } from './resultResolver';
2525
* - Computing nested project relationships for ignore lists
2626
* - Fallback to default "legacy" project when API unavailable
2727
*
28-
* Key concepts:
29-
* - Workspace: A VS Code workspace folder (may contain multiple projects)
30-
* - Project: A Python project within a workspace (has its own pyproject.toml, etc.)
31-
* - Each project gets its own test tree root and Python environment
28+
* **Key concepts:**
29+
* - **Workspace:** A VS Code workspace folder (may contain multiple projects)
30+
* - **Project:** A Python project within a workspace (identified by pyproject.toml, setup.py, etc.)
31+
* - **ProjectUri:** The unique identifier for a project (the URI of the project root directory)
32+
* - Each project gets its own test tree root, Python environment, and test adapters
33+
*
34+
* **Project identification:**
35+
* Projects are identified and tracked by their URI (projectUri.toString()). This matches
36+
* how the Python Environments extension stores projects in its Map<string, PythonProject>.
3237
*/
3338
export class TestProjectRegistry {
3439
/**
35-
* Map of workspace URI -> Map of project ID -> ProjectAdapter
36-
* Project IDs match Python Environments extension's Map<string, PythonProject> keys
40+
* Map of workspace URI -> Map of project URI string -> ProjectAdapter
41+
*
42+
* Projects are keyed by their URI string (projectUri.toString()) which matches how
43+
* the Python Environments extension identifies projects. This enables O(1) lookups
44+
* when given a project URI.
3745
*/
3846
private readonly workspaceProjects: Map<Uri, Map<string, ProjectAdapter>> = new Map();
3947

@@ -44,13 +52,6 @@ export class TestProjectRegistry {
4452
private readonly envVarsService: IEnvironmentVariablesProvider,
4553
) {}
4654

47-
/**
48-
* Checks if project-based testing is available (Python Environments API).
49-
*/
50-
public isProjectBasedTestingAvailable(): boolean {
51-
return useEnvExtension();
52-
}
53-
5455
/**
5556
* Gets the projects map for a workspace, if it exists.
5657
*/
@@ -103,7 +104,7 @@ export class TestProjectRegistry {
103104
const projects = this.getProjectsArray(workspaceUri);
104105

105106
for (const project of projects) {
106-
const ignorePaths = projectIgnores.get(project.projectId);
107+
const ignorePaths = projectIgnores.get(getProjectId(project.projectUri));
107108
if (ignorePaths && ignorePaths.length > 0) {
108109
project.nestedProjectPathsToIgnore = ignorePaths;
109110
traceInfo(`[test-by-project] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`);
@@ -171,6 +172,12 @@ export class TestProjectRegistry {
171172

172173
/**
173174
* Creates a ProjectAdapter from a PythonProject.
175+
*
176+
* Each project gets its own isolated test infrastructure:
177+
* - **ResultResolver:** Handles mapping test IDs and processing results for this project
178+
* - **DiscoveryAdapter:** Discovers tests scoped to this project's root directory
179+
* - **ExecutionAdapter:** Runs tests for this project using its Python environment
180+
*
174181
*/
175182
private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise<ProjectAdapter> {
176183
const projectId = getProjectId(pythonProject.uri);
@@ -196,7 +203,6 @@ export class TestProjectRegistry {
196203
const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version);
197204

198205
return {
199-
projectId,
200206
projectName,
201207
projectUri: pythonProject.uri,
202208
workspaceUri,
@@ -246,7 +252,6 @@ export class TestProjectRegistry {
246252
};
247253

248254
return {
249-
projectId: getProjectId(workspaceUri),
250255
projectName: pythonProject.name,
251256
projectUri: workspaceUri,
252257
workspaceUri,
@@ -263,6 +268,9 @@ export class TestProjectRegistry {
263268

264269
/**
265270
* Identifies nested projects and returns ignore paths for parent projects.
271+
*
272+
* **Time complexity:** O(n²) where n is the number of projects in the workspace.
273+
* For each project, checks all other projects to find nested relationships.
266274
*/
267275
private computeNestedProjectIgnores(workspaceUri: Uri): Map<string, string[]> {
268276
const ignoreMap = new Map<string, string[]>();
@@ -274,7 +282,8 @@ export class TestProjectRegistry {
274282
const nestedPaths: string[] = [];
275283

276284
for (const child of projects) {
277-
if (parent.projectId === child.projectId) continue;
285+
// Skip self-comparison using URI
286+
if (parent.projectUri.toString() === child.projectUri.toString()) continue;
278287

279288
const parentPath = parent.projectUri.fsPath;
280289
const childPath = child.projectUri.fsPath;
@@ -286,7 +295,7 @@ export class TestProjectRegistry {
286295
}
287296

288297
if (nestedPaths.length > 0) {
289-
ignoreMap.set(parent.projectId, nestedPaths);
298+
ignoreMap.set(getProjectId(parent.projectUri), nestedPaths);
290299
}
291300
}
292301

0 commit comments

Comments
 (0)