Skip to content
142 changes: 142 additions & 0 deletions src/client/testing/testController/common/projectAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { TestItem, Uri } from 'vscode';
import { TestProvider } from '../../types';
import {
ITestDiscoveryAdapter,
ITestExecutionAdapter,
ITestResultResolver,
DiscoveredTestPayload,
DiscoveredTestNode,
} from './types';
import { PythonEnvironment, PythonProject } from '../../../envExt/types';

/**
* Represents a single Python project with its own test infrastructure.
* A project is defined as a combination of a Python executable + URI (folder/file).
* Projects are keyed by projectUri.toString()
*/
export interface ProjectAdapter {
// === IDENTITY ===
/**
* Project identifier, which is the string representation of the project URI.
*/
projectId: string;

/**
* Display name for the project (e.g., "alice (Python 3.11)").
*/
projectName: string;

/**
* URI of the project root folder or file.
*/
projectUri: Uri;

/**
* Parent workspace URI containing this project.
*/
workspaceUri: Uri;

// === API OBJECTS (from vscode-python-environments extension) ===
/**
* The PythonProject object from the environment API.
*/
pythonProject: PythonProject;

/**
* The resolved PythonEnvironment with execution details.
* Contains execInfo.run.executable for running tests.
*/
pythonEnvironment: PythonEnvironment;

// === TEST INFRASTRUCTURE ===
/**
* Test framework provider ('pytest' | 'unittest').
*/
testProvider: TestProvider;

/**
* Adapter for test discovery.
*/
discoveryAdapter: ITestDiscoveryAdapter;

/**
* Adapter for test execution.
*/
executionAdapter: ITestExecutionAdapter;

/**
* Result resolver for this project (maps test IDs and handles results).
*/
resultResolver: ITestResultResolver;

// === DISCOVERY STATE ===
/**
* Raw discovery data before filtering (all discovered tests).
* Cleared after ownership resolution to save memory.
*/
rawDiscoveryData?: DiscoveredTestPayload;

/**
* Filtered tests that this project owns (after API verification).
* This is the tree structure passed to populateTestTree().
*/
ownedTests?: DiscoveredTestNode;

// === LIFECYCLE ===
/**
* Whether discovery is currently running for this project.
*/
isDiscovering: boolean;

/**
* Whether tests are currently executing for this project.
*/
isExecuting: boolean;

/**
* Root TestItem for this project in the VS Code test tree.
* All project tests are children of this item.
*/
projectRootTestItem?: TestItem;
}

/**
* Temporary state used during workspace-wide test discovery.
* Created at the start of discovery and cleared after ownership resolution.
*/
export interface WorkspaceDiscoveryState {
/**
* The workspace being discovered.
*/
workspaceUri: Uri;

/**
* Maps test file paths to the set of projects that discovered them.
* Used to detect overlapping discovery.
*/
fileToProjects: Map<string, Set<ProjectAdapter>>;

/**
* Maps test file paths to their owning project (after API resolution).
* Value is the ProjectAdapter whose pythonProject.uri matches API response.
*/
fileOwnership: Map<string, ProjectAdapter>;

/**
* Progress tracking for parallel discovery.
*/
projectsCompleted: Set<string>;

/**
* Total number of projects in this workspace.
*/
totalProjects: number;

/**
* Whether all projects have completed discovery.
*/
isComplete: boolean;
}
54 changes: 54 additions & 0 deletions src/client/testing/testController/common/projectUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { Uri } from 'vscode';

/**
* Separator used to scope test IDs to a specific project.
* Format: {projectId}{SEPARATOR}{testPath}
* Example: "file:///workspace/project||test_file.py::test_name"
*/
export const PROJECT_ID_SEPARATOR = '||';

/**
* Gets the project ID from a project URI.
* The project ID is simply the string representation of the URI, matching how
* the Python Environments extension stores projects in Map<string, PythonProject>.
*
* @param projectUri The project URI
* @returns The project ID (URI as string)
*/
export function getProjectId(projectUri: Uri): string {
return projectUri.toString();
}

/**
* Parses a project-scoped vsId back into its components.
*
* @param vsId The VS Code test item ID to parse
* @returns A tuple of [projectId, runId]. If the ID is not project-scoped,
* returns [undefined, vsId] (legacy format)
*/
export function parseVsId(vsId: string): [string | undefined, string] {
const separatorIndex = vsId.indexOf(PROJECT_ID_SEPARATOR);
if (separatorIndex === -1) {
return [undefined, vsId]; // Legacy ID without project scope
}
return [vsId.substring(0, separatorIndex), vsId.substring(separatorIndex + PROJECT_ID_SEPARATOR.length)];
}

/**
* Creates a display name for a project including Python version.
* Format: "{projectName} (Python {version})"
*
* @param projectName The name of the project
* @param pythonVersion The Python version string (e.g., "3.11.2")
* @returns Formatted display name
*/
export function createProjectDisplayName(projectName: string, pythonVersion: string): string {
// Extract major.minor version if full version provided
const versionMatch = pythonVersion.match(/^(\d+\.\d+)/);
const shortVersion = versionMatch ? versionMatch[1] : pythonVersion;

return `${projectName} (Python ${shortVersion})`;
}
26 changes: 24 additions & 2 deletions src/client/testing/testController/common/resultResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,23 @@ export class PythonResultResolver implements ITestResultResolver {

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

constructor(testController: TestController, testProvider: TestProvider, private workspaceUri: Uri) {
/**
* Optional project ID for scoping test IDs.
* When set, all test IDs are prefixed with "{projectId}|" for project-based testing.
* When undefined, uses legacy workspace-level IDs for backward compatibility.
*/
private projectId?: string;

constructor(
testController: TestController,
testProvider: TestProvider,
private workspaceUri: Uri,
projectId?: string,
) {
this.testController = testController;
this.testProvider = testProvider;
// Initialize a new TestItemIndex which will be used to track test items in this workspace
this.projectId = projectId;
// Initialize a new TestItemIndex which will be used to track test items in this workspace/project
this.testItemIndex = new TestItemIndex();
}

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

/**
* Gets the project ID for this resolver (if any).
* Used for project-scoped test ID generation.
*/
public getProjectId(): string | undefined {
return this.projectId;
}

public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void {
PythonResultResolver.discoveryHandler.processDiscovery(
payload,
Expand All @@ -54,6 +75,7 @@ export class PythonResultResolver implements ITestResultResolver {
this.workspaceUri,
this.testProvider,
token,
this.projectId,
);
sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, {
tool: this.testProvider,
Expand Down
18 changes: 15 additions & 3 deletions src/client/testing/testController/common/testDiscoveryHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Testing } from '../../../common/utils/localize';
import { createErrorTestItem } from './testItemUtilities';
import { buildErrorNodeOptions, populateTestTree } from './utils';
import { TestItemIndex } from './testItemIndex';
import { PROJECT_ID_SEPARATOR } from './projectUtils';

/**
* Stateless handler for processing discovery payloads and building/updating the TestItem tree.
Expand All @@ -27,6 +28,7 @@ export class TestDiscoveryHandler {
workspaceUri: Uri,
testProvider: TestProvider,
token?: CancellationToken,
projectId?: string,
): void {
if (!payload) {
// No test data is available
Expand All @@ -38,10 +40,13 @@ export class TestDiscoveryHandler {

// Check if there were any errors in the discovery process.
if (rawTestData.status === 'error') {
this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider);
this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider, projectId);
} else {
// remove error node only if no errors exist.
testController.items.delete(`DiscoveryError:${workspacePath}`);
const errorNodeId = projectId
? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}`
: `DiscoveryError:${workspacePath}`;
testController.items.delete(errorNodeId);
}

if (rawTestData.tests || rawTestData.tests === null) {
Expand All @@ -64,6 +69,7 @@ export class TestDiscoveryHandler {
vsIdToRunId: testItemIndex.vsIdToRunIdMap,
} as any,
token,
projectId,
);
}
}
Expand All @@ -76,21 +82,27 @@ export class TestDiscoveryHandler {
workspaceUri: Uri,
error: string[] | undefined,
testProvider: TestProvider,
projectId?: string,
): void {
const workspacePath = workspaceUri.fsPath;
const testingErrorConst =
testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery;

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

let errorNode = testController.items.get(`DiscoveryError:${workspacePath}`);
const errorNodeId = projectId
? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}`
: `DiscoveryError:${workspacePath}`;
let errorNode = testController.items.get(errorNodeId);
const message = util.format(
`${testingErrorConst} ${Testing.seePythonOutput}\r\n`,
error?.join('\r\n\r\n') ?? '',
);

if (errorNode === undefined) {
const options = buildErrorNodeOptions(workspaceUri, message, testProvider);
// Update the error node ID to include project scope if applicable
options.id = errorNodeId;
errorNode = createErrorTestItem(testController, options);
testController.items.add(errorNode);
}
Expand Down
22 changes: 15 additions & 7 deletions src/client/testing/testController/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { Deferred, createDeferred } from '../../../common/utils/async';
import { createReaderPipe, generateRandomPipeName } from '../../../common/pipes/namedPipes';
import { EXTENSION_ROOT_DIR } from '../../../constants';
import { PROJECT_ID_SEPARATOR } from './projectUtils';

export function fixLogLinesNoTrailing(content: string): string {
const lines = content.split(/\r?\n/g);
Expand Down Expand Up @@ -211,10 +212,13 @@ export function populateTestTree(
testRoot: TestItem | undefined,
resultResolver: ITestResultResolver,
token?: CancellationToken,
projectId?: string,
): void {
// If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller.
if (!testRoot) {
testRoot = testController.createTestItem(testTreeData.path, testTreeData.name, Uri.file(testTreeData.path));
// Create project-scoped ID if projectId is provided
const rootId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${testTreeData.path}` : testTreeData.path;
testRoot = testController.createTestItem(rootId, testTreeData.name, Uri.file(testTreeData.path));

testRoot.canResolveChildren = true;
testRoot.tags = [RunTestTag, DebugTestTag];
Expand All @@ -226,7 +230,9 @@ export function populateTestTree(
testTreeData.children.forEach((child) => {
if (!token?.isCancellationRequested) {
if (isTestItem(child)) {
const testItem = testController.createTestItem(child.id_, child.name, Uri.file(child.path));
// Create project-scoped vsId
const vsId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_;
const testItem = testController.createTestItem(vsId, child.name, Uri.file(child.path));
testItem.tags = [RunTestTag, DebugTestTag];

let range: Range | undefined;
Expand All @@ -245,15 +251,17 @@ export function populateTestTree(
testItem.tags = [RunTestTag, DebugTestTag];

testRoot!.children.add(testItem);
// add to our map
// add to our map - use runID as key, vsId as value
resultResolver.runIdToTestItem.set(child.runID, testItem);
resultResolver.runIdToVSid.set(child.runID, child.id_);
resultResolver.vsIdToRunId.set(child.id_, child.runID);
resultResolver.runIdToVSid.set(child.runID, vsId);
resultResolver.vsIdToRunId.set(vsId, child.runID);
} else {
let node = testController.items.get(child.path);

if (!node) {
node = testController.createTestItem(child.id_, child.name, Uri.file(child.path));
// Create project-scoped ID for non-test nodes
const nodeId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_;
node = testController.createTestItem(nodeId, child.name, Uri.file(child.path));

node.canResolveChildren = true;
node.tags = [RunTestTag, DebugTestTag];
Expand All @@ -274,7 +282,7 @@ export function populateTestTree(

testRoot!.children.add(node);
}
populateTestTree(testController, child, node, resultResolver, token);
populateTestTree(testController, child, node, resultResolver, token, projectId);
}
}
});
Expand Down
Loading