Skip to content

Commit 7af8ea3

Browse files
Copiloteleanorjboyd
andcommitted
Phase 1: Add core infrastructure for project-based testing
- Created ProjectAdapter and WorkspaceDiscoveryState interfaces - Added project utility functions (ID generation, scoping, nested project detection) - Updated PythonResultResolver to support optional projectId parameter - Modified populateTestTree to create project-scoped test IDs - Updated TestDiscoveryHandler to handle project-scoped error nodes Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent ad3ae47 commit 7af8ea3

File tree

5 files changed

+293
-12
lines changed

5 files changed

+293
-12
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { TestItem, Uri } from 'vscode';
5+
import { TestProvider } from '../../types';
6+
import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver, DiscoveredTestPayload, DiscoveredTestNode } from './types';
7+
import { PythonEnvironment, PythonProject } from '../../../envExt/types';
8+
9+
/**
10+
* Represents a single Python project with its own test infrastructure.
11+
* A project is defined as a combination of a Python executable + URI (folder/file).
12+
*/
13+
export interface ProjectAdapter {
14+
// === IDENTITY ===
15+
/**
16+
* Unique identifier for this project, generated by hashing the PythonProject object.
17+
*/
18+
projectId: string;
19+
20+
/**
21+
* Display name for the project (e.g., "alice (Python 3.11)").
22+
*/
23+
projectName: string;
24+
25+
/**
26+
* URI of the project root folder or file.
27+
*/
28+
projectUri: Uri;
29+
30+
/**
31+
* Parent workspace URI containing this project.
32+
*/
33+
workspaceUri: Uri;
34+
35+
// === API OBJECTS (from vscode-python-environments extension) ===
36+
/**
37+
* The PythonProject object from the environment API.
38+
*/
39+
pythonProject: PythonProject;
40+
41+
/**
42+
* The resolved PythonEnvironment with execution details.
43+
* Contains execInfo.run.executable for running tests.
44+
*/
45+
pythonEnvironment: PythonEnvironment;
46+
47+
// === TEST INFRASTRUCTURE ===
48+
/**
49+
* Test framework provider ('pytest' | 'unittest').
50+
*/
51+
testProvider: TestProvider;
52+
53+
/**
54+
* Adapter for test discovery.
55+
*/
56+
discoveryAdapter: ITestDiscoveryAdapter;
57+
58+
/**
59+
* Adapter for test execution.
60+
*/
61+
executionAdapter: ITestExecutionAdapter;
62+
63+
/**
64+
* Result resolver for this project (maps test IDs and handles results).
65+
*/
66+
resultResolver: ITestResultResolver;
67+
68+
// === DISCOVERY STATE ===
69+
/**
70+
* Raw discovery data before filtering (all discovered tests).
71+
* Cleared after ownership resolution to save memory.
72+
*/
73+
rawDiscoveryData?: DiscoveredTestPayload;
74+
75+
/**
76+
* Filtered tests that this project owns (after API verification).
77+
* This is the tree structure passed to populateTestTree().
78+
*/
79+
ownedTests?: DiscoveredTestNode;
80+
81+
// === LIFECYCLE ===
82+
/**
83+
* Whether discovery is currently running for this project.
84+
*/
85+
isDiscovering: boolean;
86+
87+
/**
88+
* Whether tests are currently executing for this project.
89+
*/
90+
isExecuting: boolean;
91+
92+
/**
93+
* Root TestItem for this project in the VS Code test tree.
94+
* All project tests are children of this item.
95+
*/
96+
projectRootTestItem?: TestItem;
97+
}
98+
99+
/**
100+
* Temporary state used during workspace-wide test discovery.
101+
* Created at the start of discovery and cleared after ownership resolution.
102+
*/
103+
export interface WorkspaceDiscoveryState {
104+
/**
105+
* The workspace being discovered.
106+
*/
107+
workspaceUri: Uri;
108+
109+
/**
110+
* Maps test file paths to the set of projects that discovered them.
111+
* Used to detect overlapping discovery.
112+
*/
113+
fileToProjects: Map<string, Set<ProjectAdapter>>;
114+
115+
/**
116+
* Maps test file paths to their owning project (after API resolution).
117+
* Value is the ProjectAdapter whose pythonProject.uri matches API response.
118+
*/
119+
fileOwnership: Map<string, ProjectAdapter>;
120+
121+
/**
122+
* Progress tracking for parallel discovery.
123+
*/
124+
projectsCompleted: Set<string>;
125+
126+
/**
127+
* Total number of projects in this workspace.
128+
*/
129+
totalProjects: number;
130+
131+
/**
132+
* Whether all projects have completed discovery.
133+
*/
134+
isComplete: boolean;
135+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as crypto from 'crypto';
5+
import { Uri } from 'vscode';
6+
import { ProjectAdapter } from './projectAdapter';
7+
import { PythonProject } from '../../../envExt/types';
8+
9+
/**
10+
* Generates a unique project ID by hashing the PythonProject object.
11+
* This ensures consistent IDs across extension reloads for the same project.
12+
*
13+
* @param pythonProject The PythonProject object from the environment API
14+
* @returns A unique string identifier for the project
15+
*/
16+
export function generateProjectId(pythonProject: PythonProject): string {
17+
// Create a stable string representation of the project
18+
const projectString = JSON.stringify({
19+
name: pythonProject.name,
20+
uri: pythonProject.uri.toString(),
21+
});
22+
23+
// Generate a hash to create a shorter, unique ID
24+
const hash = crypto.createHash('sha256').update(projectString).digest('hex');
25+
return `project-${hash.substring(0, 12)}`;
26+
}
27+
28+
/**
29+
* Creates a project-scoped VS Code test item ID.
30+
* Format: "{projectId}::{testPath}"
31+
*
32+
* @param projectId The unique project identifier
33+
* @param testPath The test path (e.g., "/workspace/test.py::test_func")
34+
* @returns The project-scoped VS Code test ID
35+
*/
36+
export function createProjectScopedVsId(projectId: string, testPath: string): string {
37+
return `${projectId}::${testPath}`;
38+
}
39+
40+
/**
41+
* Parses a project-scoped VS Code test ID to extract the project ID and test path.
42+
*
43+
* @param vsId The VS Code test item ID
44+
* @returns Object containing projectId and testPath, or null if invalid
45+
*/
46+
export function parseProjectScopedVsId(vsId: string): { projectId: string; testPath: string } | null {
47+
const separatorIndex = vsId.indexOf('::');
48+
if (separatorIndex === -1) {
49+
return null;
50+
}
51+
52+
return {
53+
projectId: vsId.substring(0, separatorIndex),
54+
testPath: vsId.substring(separatorIndex + 2),
55+
};
56+
}
57+
58+
/**
59+
* Checks if a test file path is within a nested project's directory.
60+
* This is used to determine when to query the API for ownership even if
61+
* only one project discovered the file.
62+
*
63+
* @param testFilePath Absolute path to the test file
64+
* @param allProjects All projects in the workspace
65+
* @param excludeProject Optional project to exclude from the check (typically the discoverer)
66+
* @returns True if the file is within any nested project's directory
67+
*/
68+
export function hasNestedProjectForPath(
69+
testFilePath: string,
70+
allProjects: ProjectAdapter[],
71+
excludeProject?: ProjectAdapter,
72+
): boolean {
73+
return allProjects.some(
74+
(p) =>
75+
p !== excludeProject &&
76+
testFilePath.startsWith(p.projectUri.fsPath),
77+
);
78+
}
79+
80+
/**
81+
* Finds the project that owns a specific test file based on project URI.
82+
* This is typically used after the API returns ownership information.
83+
*
84+
* @param projectUri The URI of the owning project (from API)
85+
* @param allProjects All projects to search
86+
* @returns The ProjectAdapter with matching URI, or undefined if not found
87+
*/
88+
export function findProjectByUri(projectUri: Uri, allProjects: ProjectAdapter[]): ProjectAdapter | undefined {
89+
return allProjects.find((p) => p.projectUri.fsPath === projectUri.fsPath);
90+
}
91+
92+
/**
93+
* Creates a display name for a project including Python version.
94+
* Format: "{projectName} (Python {version})"
95+
*
96+
* @param projectName The name of the project
97+
* @param pythonVersion The Python version string (e.g., "3.11.2")
98+
* @returns Formatted display name
99+
*/
100+
export function createProjectDisplayName(projectName: string, pythonVersion: string): string {
101+
// Extract major.minor version if full version provided
102+
const versionMatch = pythonVersion.match(/^(\d+\.\d+)/);
103+
const shortVersion = versionMatch ? versionMatch[1] : pythonVersion;
104+
105+
return `${projectName} (Python ${shortVersion})`;
106+
}

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,23 @@ export class PythonResultResolver implements ITestResultResolver {
2626

2727
public detailedCoverageMap = new Map<string, FileCoverageDetail[]>();
2828

29-
constructor(testController: TestController, testProvider: TestProvider, private workspaceUri: Uri) {
29+
/**
30+
* Optional project ID for scoping test IDs.
31+
* When set, all test IDs are prefixed with "{projectId}::" for project-based testing.
32+
* When undefined, uses legacy workspace-level IDs for backward compatibility.
33+
*/
34+
private projectId?: string;
35+
36+
constructor(
37+
testController: TestController,
38+
testProvider: TestProvider,
39+
private workspaceUri: Uri,
40+
projectId?: string,
41+
) {
3042
this.testController = testController;
3143
this.testProvider = testProvider;
32-
// Initialize a new TestItemIndex which will be used to track test items in this workspace
44+
this.projectId = projectId;
45+
// Initialize a new TestItemIndex which will be used to track test items in this workspace/project
3346
this.testItemIndex = new TestItemIndex();
3447
}
3548

@@ -46,6 +59,14 @@ export class PythonResultResolver implements ITestResultResolver {
4659
return this.testItemIndex.vsIdToRunIdMap;
4760
}
4861

62+
/**
63+
* Gets the project ID for this resolver (if any).
64+
* Used for project-scoped test ID generation.
65+
*/
66+
public getProjectId(): string | undefined {
67+
return this.projectId;
68+
}
69+
4970
public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void {
5071
PythonResultResolver.discoveryHandler.processDiscovery(
5172
payload,
@@ -54,6 +75,7 @@ export class PythonResultResolver implements ITestResultResolver {
5475
this.workspaceUri,
5576
this.testProvider,
5677
token,
78+
this.projectId,
5779
);
5880
sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, {
5981
tool: this.testProvider,

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class TestDiscoveryHandler {
2727
workspaceUri: Uri,
2828
testProvider: TestProvider,
2929
token?: CancellationToken,
30+
projectId?: string,
3031
): void {
3132
if (!payload) {
3233
// No test data is available
@@ -38,10 +39,13 @@ export class TestDiscoveryHandler {
3839

3940
// Check if there were any errors in the discovery process.
4041
if (rawTestData.status === 'error') {
41-
this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider);
42+
this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider, projectId);
4243
} else {
4344
// remove error node only if no errors exist.
44-
testController.items.delete(`DiscoveryError:${workspacePath}`);
45+
const errorNodeId = projectId
46+
? `${projectId}::DiscoveryError:${workspacePath}`
47+
: `DiscoveryError:${workspacePath}`;
48+
testController.items.delete(errorNodeId);
4549
}
4650

4751
if (rawTestData.tests || rawTestData.tests === null) {
@@ -64,6 +68,7 @@ export class TestDiscoveryHandler {
6468
vsIdToRunId: testItemIndex.vsIdToRunIdMap,
6569
} as any,
6670
token,
71+
projectId,
6772
);
6873
}
6974
}
@@ -76,21 +81,27 @@ export class TestDiscoveryHandler {
7681
workspaceUri: Uri,
7782
error: string[] | undefined,
7883
testProvider: TestProvider,
84+
projectId?: string,
7985
): void {
8086
const workspacePath = workspaceUri.fsPath;
8187
const testingErrorConst =
8288
testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery;
8389

8490
traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? '');
8591

86-
let errorNode = testController.items.get(`DiscoveryError:${workspacePath}`);
92+
const errorNodeId = projectId
93+
? `${projectId}::DiscoveryError:${workspacePath}`
94+
: `DiscoveryError:${workspacePath}`;
95+
let errorNode = testController.items.get(errorNodeId);
8796
const message = util.format(
8897
`${testingErrorConst} ${Testing.seePythonOutput}\r\n`,
8998
error?.join('\r\n\r\n') ?? '',
9099
);
91100

92101
if (errorNode === undefined) {
93102
const options = buildErrorNodeOptions(workspaceUri, message, testProvider);
103+
// Update the error node ID to include project scope if applicable
104+
options.id = errorNodeId;
94105
errorNode = createErrorTestItem(testController, options);
95106
testController.items.add(errorNode);
96107
}

0 commit comments

Comments
 (0)