diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts new file mode 100644 index 000000000000..00e62fff5e09 --- /dev/null +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -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>; + + /** + * Maps test file paths to their owning project (after API resolution). + * Value is the ProjectAdapter whose pythonProject.uri matches API response. + */ + fileOwnership: Map; + + /** + * Progress tracking for parallel discovery. + */ + projectsCompleted: Set; + + /** + * Total number of projects in this workspace. + */ + totalProjects: number; + + /** + * Whether all projects have completed discovery. + */ + isComplete: boolean; +} diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts new file mode 100644 index 000000000000..a66ab31c2da3 --- /dev/null +++ b/src/client/testing/testController/common/projectUtils.ts @@ -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. + * + * @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})`; +} diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index 959d08fee1a9..acb2d083aa32 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -26,10 +26,23 @@ export class PythonResultResolver implements ITestResultResolver { public detailedCoverageMap = new Map(); - 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(); } @@ -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, @@ -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, diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index 50f4fa71406a..8af48a203680 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -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. @@ -27,6 +28,7 @@ export class TestDiscoveryHandler { workspaceUri: Uri, testProvider: TestProvider, token?: CancellationToken, + projectId?: string, ): void { if (!payload) { // No test data is available @@ -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) { @@ -64,6 +69,7 @@ export class TestDiscoveryHandler { vsIdToRunId: testItemIndex.vsIdToRunIdMap, } as any, token, + projectId, ); } } @@ -76,6 +82,7 @@ export class TestDiscoveryHandler { workspaceUri: Uri, error: string[] | undefined, testProvider: TestProvider, + projectId?: string, ): void { const workspacePath = workspaceUri.fsPath; const testingErrorConst = @@ -83,7 +90,10 @@ export class TestDiscoveryHandler { 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') ?? '', @@ -91,6 +101,8 @@ export class TestDiscoveryHandler { 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); } diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 606865e5ad7e..20bb6e08cd37 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -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); @@ -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]; @@ -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; @@ -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]; @@ -274,7 +282,7 @@ export function populateTestTree( testRoot!.children.add(node); } - populateTestTree(testController, child, node, resultResolver, token); + populateTestTree(testController, child, node, resultResolver, token, projectId); } } }); diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 8c8ce422e3c1..ced7acf52701 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -4,6 +4,7 @@ import { inject, injectable, named } from 'inversify'; import { uniq } from 'lodash'; import * as minimatch from 'minimatch'; +import * as path from 'path'; import { CancellationToken, TestController, @@ -29,7 +30,7 @@ import { IConfigurationService, IDisposableRegistry, Resource } from '../../comm import { DelayedTrigger, IDelayedTrigger } from '../../common/utils/delayTrigger'; import { noop } from '../../common/utils/misc'; import { IInterpreterService } from '../../interpreter/contracts'; -import { traceError, traceVerbose } from '../../logging'; +import { traceError, traceInfo, traceVerbose } from '../../logging'; import { IEventNamePropertyMapping, sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; @@ -52,6 +53,11 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; +import { ProjectAdapter } from './common/projectAdapter'; +import { getProjectId, createProjectDisplayName } from './common/projectUtils'; +import { PythonProject, PythonEnvironment } from '../../envExt/types'; +import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal'; +import { isParentPath } from '../../common/platform/fs-paths'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -62,8 +68,27 @@ type TriggerType = EventPropertyType[TriggerKeyType]; export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + /** + * Feature flag for project-based testing. + * Set to true to enable multi-project testing support (Phases 2-4 must be complete). + * Default: false (use legacy single-workspace mode) + */ + private readonly useProjectBasedTesting = false; + + // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); + // === NEW: PROJECT-BASED STATE === + // Map of workspace URI -> Map of project URI string -> ProjectAdapter + // Note: Project URI strings match Python Environments extension's Map keys + private readonly workspaceProjects: Map> = new Map(); + + // TODO: Phase 3-4 - Add these maps when implementing discovery and execution: + // - vsIdToProject: Map - Fast lookup for test execution + // - fileUriToProject: Map - File watching and change detection + // - projectToVsIds: Map> - Project cleanup and refresh + // - workspaceDiscoveryState: Map - Temporary overlap detection + private readonly triggerTypes: TriggerType[] = []; private readonly testController: TestController; @@ -160,60 +185,306 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }; } - public async activate(): Promise { - const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; - workspaces.forEach((workspace) => { - const settings = this.configSettings.getSettings(workspace.uri); - - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - let testProvider: TestProvider; - let resultResolver: PythonResultResolver; - - if (settings.testing.unittestEnabled) { - testProvider = UNITTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } else { - testProvider = PYTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new PytestTestDiscoveryAdapter( + /** + * Creates test adapters (discovery and execution) for a given test provider. + * Centralizes adapter creation to reduce code duplication. + */ + private createTestAdapters( + testProvider: TestProvider, + resultResolver: PythonResultResolver, + ): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { + if (testProvider === UNITTEST_PROVIDER) { + return { + discoveryAdapter: new UnittestTestDiscoveryAdapter( this.configSettings, resultResolver, this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( + ), + executionAdapter: new UnittestTestExecutionAdapter( this.configSettings, resultResolver, this.envVarsService, - ); + ), + }; + } + + return { + discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService), + executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService), + }; + } + + /** + * Determines the test provider (pytest or unittest) based on workspace settings. + */ + private getTestProvider(workspaceUri: Uri): TestProvider { + const settings = this.configSettings.getSettings(workspaceUri); + return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; + } + + /** + * Sets up file watchers for test discovery triggers. + */ + private setupFileWatchers(workspace: WorkspaceFolder): void { + const settings = this.configSettings.getSettings(workspace.uri); + if (settings.testing.autoTestDiscoverOnSaveEnabled) { + traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); + this.watchForSettingsChanges(workspace); + this.watchForTestContentChangeOnSave(); + } + } + + public async activate(): Promise { + const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; + + // Try to use project-based testing if feature flag is enabled AND environment extension is available + if (this.useProjectBasedTesting && useEnvExtension()) { + traceInfo('[test-by-project] Activating project-based testing mode'); + + // Use Promise.allSettled to allow partial success in multi-root workspaces + const results = await Promise.allSettled( + Array.from(workspaces).map(async (workspace) => { + traceInfo(`[test-by-project] Processing workspace: ${workspace.uri.fsPath}`); + + // Discover projects in this workspace + const projects = await this.discoverWorkspaceProjects(workspace.uri); + + // Create map for this workspace, keyed by project URI (matches Python Environments extension) + const projectsMap = new Map(); + projects.forEach((project) => { + const projectKey = getProjectId(project.projectUri); + projectsMap.set(projectKey, project); + }); + + traceInfo( + `[test-by-project] Discovered ${projects.length} project(s) for workspace ${workspace.uri.fsPath}`, + ); + + return { workspace, projectsMap }; + }), + ); + + // Handle results individually - allows partial success + results.forEach((result, index) => { + const workspace = workspaces[index]; + if (result.status === 'fulfilled') { + this.workspaceProjects.set(workspace.uri, result.value.projectsMap); + traceInfo( + `[test-by-project] Successfully activated ${result.value.projectsMap.size} project(s) for ${workspace.uri.fsPath}`, + ); + this.setupFileWatchers(workspace); + } else { + traceError( + `[test-by-project] Failed to activate project-based testing for ${workspace.uri.fsPath}:`, + result.reason, + ); + traceInfo('[test-by-project] Falling back to legacy mode for this workspace'); + // Fall back to legacy mode for this workspace only + this.activateLegacyWorkspace(workspace); + } + }); + return; + } + + // Legacy activation (backward compatibility) + workspaces.forEach((workspace) => { + this.activateLegacyWorkspace(workspace); + }); + } + + /** + * Activates testing for a workspace using the legacy single-adapter approach. + * Used for backward compatibility when project-based testing is disabled or unavailable. + */ + private activateLegacyWorkspace(workspace: WorkspaceFolder): void { + const testProvider = this.getTestProvider(workspace.uri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); + const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); + + const workspaceTestAdapter = new WorkspaceTestAdapter( + testProvider, + discoveryAdapter, + executionAdapter, + workspace.uri, + resultResolver, + ); + + this.testAdapters.set(workspace.uri, workspaceTestAdapter); + this.setupFileWatchers(workspace); + } + + /** + * Discovers Python projects in a workspace using the Python Environment API. + * Falls back to creating a single default project if API is unavailable or returns no projects. + */ + private async discoverWorkspaceProjects(workspaceUri: Uri): Promise { + traceInfo(`[test-by-project] Discovering projects for workspace: ${workspaceUri.fsPath}`); + try { + // Check if we should use the environment extension + if (!useEnvExtension()) { + traceInfo('[test-by-project] Python Environments extension not enabled, using single project mode'); + return [await this.createDefaultProject(workspaceUri)]; } - const workspaceTestAdapter = new WorkspaceTestAdapter( - testProvider, - discoveryAdapter, - executionAdapter, - workspace.uri, - resultResolver, + // Get the environment API + const envExtApi = await getEnvExtApi(); + traceInfo('[test-by-project] Successfully retrieved Python Environments API'); + + // Query for all Python projects in this workspace + const pythonProjects = envExtApi.getPythonProjects(); + traceInfo(`[test-by-project] Found ${pythonProjects.length} total Python projects from API`); + + // Filter projects to only those in this workspace TODO; check this + const workspaceProjects = pythonProjects.filter((project) => + isParentPath(project.uri.fsPath, workspaceUri.fsPath), ); + traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`); - this.testAdapters.set(workspace.uri, workspaceTestAdapter); + if (workspaceProjects.length === 0) { + traceInfo( + `[test-by-project] No Python projects found for workspace ${workspaceUri.fsPath}, creating default project`, + ); + return [await this.createDefaultProject(workspaceUri)]; + } - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); + // Create ProjectAdapter for each Python project + const projectAdapters: ProjectAdapter[] = []; + for (const pythonProject of workspaceProjects) { + try { + const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); + projectAdapters.push(adapter); + } catch (error) { + traceError( + `[test-by-project] Failed to create project adapter for ${pythonProject.uri.fsPath}:`, + error, + ); + // Continue with other projects + } } - }); + + if (projectAdapters.length === 0) { + traceInfo('[test-by-project] All project adapters failed to create, falling back to default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + traceInfo(`[test-by-project] Successfully created ${projectAdapters.length} project adapter(s)`); + return projectAdapters; + } catch (error) { + traceError( + '[test-by-project] Failed to discover workspace projects, falling back to single project mode:', + error, + ); + return [await this.createDefaultProject(workspaceUri)]; + } + } + + /** + * Creates a ProjectAdapter from a PythonProject object. + */ + private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { + traceInfo( + `[test-by-project] Creating project adapter for: ${pythonProject.name} at ${pythonProject.uri.fsPath}`, + ); + // Use project URI as the project ID (no hashing needed) + const projectId = pythonProject.uri.fsPath; + + // Resolve the Python environment + const envExtApi = await getEnvExtApi(); + const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri); + + if (!pythonEnvironment) { + throw new Error(`Failed to resolve Python environment for project ${pythonProject.uri.fsPath}`); + } + + // Get test provider and create resolver + const testProvider = this.getTestProvider(workspaceUri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId); + + // Create adapters + const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); + + // Create display name with Python version + const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); + + traceInfo(`[test-by-project] Created project adapter: ${projectName} (ID: ${projectId})`); + + // Create project adapter + return { + projectId, + projectName, + projectUri: pythonProject.uri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + } + + /** + * Creates a default project adapter using the workspace interpreter. + * Used for backward compatibility when environment API is unavailable. + */ + private async createDefaultProject(workspaceUri: Uri): Promise { + traceInfo(`[test-by-project] Creating default project for workspace: ${workspaceUri.fsPath}`); + // Get test provider and create resolver (WITHOUT project ID for legacy mode) + const testProvider = this.getTestProvider(workspaceUri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); + + // Create adapters + const { discoveryAdapter, executionAdapter } = this.createTestAdapters(testProvider, resultResolver); + + // Get active interpreter + const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); + + // Create a mock PythonEnvironment from the interpreter + const pythonEnvironment: PythonEnvironment = { + name: 'default', + displayName: interpreter?.displayName || 'Python', + shortDisplayName: interpreter?.displayName || 'Python', + displayPath: interpreter?.path || 'python', + version: interpreter?.version?.raw || '3.x', + environmentPath: Uri.file(interpreter?.path || 'python'), + sysPrefix: interpreter?.sysPrefix || '', + execInfo: { + run: { + executable: interpreter?.path || 'python', + }, + }, + envId: { + id: 'default', + managerId: 'default', + }, + }; + + // Create a mock PythonProject + const pythonProject: PythonProject = { + // Do not assume path separators (fsPath is platform-specific). + name: path.basename(workspaceUri.fsPath) || 'workspace', + uri: workspaceUri, + }; + + // Use workspace URI as the project ID + const projectId = getProjectId(workspaceUri); + + return { + projectId, + projectName: pythonProject.name, + projectUri: workspaceUri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; } public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { diff --git a/src/test/testing/testController/common/projectUtils.unit.test.ts b/src/test/testing/testController/common/projectUtils.unit.test.ts new file mode 100644 index 000000000000..75f399e89fc0 --- /dev/null +++ b/src/test/testing/testController/common/projectUtils.unit.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import { Uri } from 'vscode'; +import { + getProjectId, + createProjectDisplayName, + parseVsId, + PROJECT_ID_SEPARATOR, +} from '../../../../client/testing/testController/common/projectUtils'; + +suite('Project Utils Tests', () => { + suite('getProjectId', () => { + test('should return URI string representation', () => { + const uri = Uri.file('/workspace/project'); + + const id = getProjectId(uri); + + expect(id).to.equal(uri.toString()); + }); + + test('should be consistent for same URI', () => { + const uri = Uri.file('/workspace/project'); + + const id1 = getProjectId(uri); + const id2 = getProjectId(uri); + + expect(id1).to.equal(id2); + }); + + test('should be different for different URIs', () => { + const uri1 = Uri.file('/workspace/project1'); + const uri2 = Uri.file('/workspace/project2'); + + const id1 = getProjectId(uri1); + const id2 = getProjectId(uri2); + + expect(id1).to.not.equal(id2); + }); + + test('should handle Windows paths', () => { + const uri = Uri.file('C:\\workspace\\project'); + + const id = getProjectId(uri); + + expect(id).to.be.a('string'); + expect(id).to.have.length.greaterThan(0); + }); + + test('should handle nested project paths', () => { + const parentUri = Uri.file('/workspace/parent'); + const childUri = Uri.file('/workspace/parent/child'); + + const parentId = getProjectId(parentUri); + const childId = getProjectId(childUri); + + expect(parentId).to.not.equal(childId); + }); + + test('should match Python Environments extension format', () => { + const uri = Uri.file('/workspace/project'); + + const id = getProjectId(uri); + + // Should match how Python Environments extension keys projects + expect(id).to.equal(uri.toString()); + expect(typeof id).to.equal('string'); + }); + }); + + suite('createProjectDisplayName', () => { + test('should format name with major.minor version', () => { + const result = createProjectDisplayName('MyProject', '3.11.2'); + + expect(result).to.equal('MyProject (Python 3.11)'); + }); + + test('should handle version with patch and pre-release', () => { + const result = createProjectDisplayName('MyProject', '3.12.0rc1'); + + expect(result).to.equal('MyProject (Python 3.12)'); + }); + + test('should handle version with only major.minor', () => { + const result = createProjectDisplayName('MyProject', '3.10'); + + expect(result).to.equal('MyProject (Python 3.10)'); + }); + + test('should handle invalid version format gracefully', () => { + const result = createProjectDisplayName('MyProject', 'invalid-version'); + + expect(result).to.equal('MyProject (Python invalid-version)'); + }); + + test('should handle empty version string', () => { + const result = createProjectDisplayName('MyProject', ''); + + expect(result).to.equal('MyProject (Python )'); + }); + + test('should handle version with single digit', () => { + const result = createProjectDisplayName('MyProject', '3'); + + expect(result).to.equal('MyProject (Python 3)'); + }); + + test('should handle project name with special characters', () => { + const result = createProjectDisplayName('My-Project_123', '3.11.5'); + + expect(result).to.equal('My-Project_123 (Python 3.11)'); + }); + + test('should handle empty project name', () => { + const result = createProjectDisplayName('', '3.11.2'); + + expect(result).to.equal(' (Python 3.11)'); + }); + }); + + suite('parseVsId', () => { + test('should parse project-scoped ID correctly', () => { + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + + const [parsedProjectId, runId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(runId).to.equal('test_file.py::test_name'); + }); + + test('should handle legacy ID without project scope', () => { + const vsId = 'test_file.py'; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.be.undefined; + expect(runId).to.equal('test_file.py'); + }); + + test('should handle runId containing separator', () => { + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}test_file.py::test_class::test_method`; + + const [parsedProjectId, runId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(runId).to.equal('test_file.py::test_class::test_method'); + }); + + test('should handle empty project ID', () => { + const vsId = `${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal(''); + expect(runId).to.equal('test_file.py::test_name'); + }); + + test('should handle empty runId', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal(''); + }); + + test('should handle ID with file path', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}/workspace/tests/test_file.py`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal('/workspace/tests/test_file.py'); + }); + + test('should handle Windows file paths', () => { + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}C:\\workspace\\tests\\test_file.py`; + + const [parsedProjectId, runId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(runId).to.equal('C:\\workspace\\tests\\test_file.py'); + }); + }); + + suite('Integration Tests', () => { + test('should generate unique IDs for different URIs', () => { + const uris = [ + Uri.file('/workspace/a'), + Uri.file('/workspace/b'), + Uri.file('/workspace/c'), + Uri.file('/workspace/d'), + Uri.file('/workspace/e'), + ]; + + const ids = uris.map((uri) => getProjectId(uri)); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).to.equal(uris.length, 'All IDs should be unique'); + }); + + test('should handle nested project paths', () => { + const parentUri = Uri.file('/workspace/parent'); + const childUri = Uri.file('/workspace/parent/child'); + + const parentId = getProjectId(parentUri); + const childId = getProjectId(childUri); + + expect(parentId).to.not.equal(childId); + }); + + test('should create complete vsId and parse it back', () => { + const projectUri = Uri.file('/workspace/myproject'); + const projectId = getProjectId(projectUri); + const runId = 'tests/test_module.py::TestClass::test_method'; + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}${runId}`; + + const [parsedProjectId, parsedRunId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(parsedRunId).to.equal(runId); + }); + + test('should match Python Environments extension URI format', () => { + const uri = Uri.file('/workspace/project'); + + const projectId = getProjectId(uri); + + // Should be string representation of URI + expect(projectId).to.equal(uri.toString()); + expect(typeof projectId).to.equal('string'); + }); + }); +}); diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts new file mode 100644 index 000000000000..2916e383605b --- /dev/null +++ b/src/test/testing/testController/controller.unit.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import type { TestController, Uri } from 'vscode'; + +// We must mutate the actual mocked vscode module export (not an __importStar copy), +// otherwise `tests.createTestController` will still be undefined inside the controller module. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const vscodeApi = require('vscode') as typeof import('vscode'); + +import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants'; +import * as envExtApiInternal from '../../../client/envExt/api.internal'; +import { getProjectId } from '../../../client/testing/testController/common/projectUtils'; + +function createStubTestController(): TestController { + const disposable = { dispose: () => undefined }; + + const controller = ({ + items: { + forEach: sinon.stub(), + get: sinon.stub(), + add: sinon.stub(), + replace: sinon.stub(), + delete: sinon.stub(), + size: 0, + [Symbol.iterator]: sinon.stub(), + }, + createRunProfile: sinon.stub().returns(disposable), + createTestItem: sinon.stub(), + dispose: sinon.stub(), + resolveHandler: undefined, + refreshHandler: undefined, + } as unknown) as TestController; + + return controller; +} + +function ensureVscodeTestsNamespace(): void { + const vscodeAny = vscodeApi as any; + if (!vscodeAny.tests) { + vscodeAny.tests = {}; + } + if (!vscodeAny.tests.createTestController) { + vscodeAny.tests.createTestController = () => createStubTestController(); + } +} + +// NOTE: +// `PythonTestController` calls `vscode.tests.createTestController(...)` in its constructor. +// In unit tests, `vscode` is a mocked module (see `src/test/vscode-mock.ts`) and it does not +// provide the `tests` namespace by default. If we import the controller normally, the module +// will be evaluated before this file runs (ES imports are hoisted), and construction will +// crash with `tests`/`createTestController` being undefined. +// +// To keep this test isolated (without changing production code), we: +// 1) Patch the mocked vscode export to provide `tests.createTestController`. +// 2) Require the controller module *after* patching so the constructor can run safely. +ensureVscodeTestsNamespace(); + +// Dynamically require AFTER the vscode.tests namespace exists. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { PythonTestController } = require('../../../client/testing/testController/controller'); + +suite('PythonTestController', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + function createController(options?: { unittestEnabled?: boolean; interpreter?: any }): any { + const unittestEnabled = options?.unittestEnabled ?? false; + const interpreter = + options?.interpreter ?? + ({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + } as any); + + const workspaceService = ({ workspaceFolders: [] } as unknown) as any; + const configSettings = ({ + getSettings: sandbox.stub().returns({ + testing: { + unittestEnabled, + autoTestDiscoverOnSaveEnabled: false, + }, + }), + } as unknown) as any; + + const pytest = ({} as unknown) as any; + const unittest = ({} as unknown) as any; + const disposables: any[] = []; + const interpreterService = ({ + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as unknown) as any; + + const commandManager = ({ + registerCommand: sandbox.stub().returns({ dispose: () => undefined }), + } as unknown) as any; + const pythonExecFactory = ({} as unknown) as any; + const debugLauncher = ({} as unknown) as any; + const envVarsService = ({} as unknown) as any; + + return new PythonTestController( + workspaceService, + configSettings, + pytest, + unittest, + disposables, + interpreterService, + commandManager, + pythonExecFactory, + debugLauncher, + envVarsService, + ); + } + + suite('getTestProvider', () => { + test('returns unittest when enabled', () => { + const controller = createController({ unittestEnabled: true }); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace'); + + const provider = (controller as any).getTestProvider(workspaceUri); + + assert.strictEqual(provider, UNITTEST_PROVIDER); + }); + + test('returns pytest when unittest not enabled', () => { + const controller = createController({ unittestEnabled: false }); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace'); + + const provider = (controller as any).getTestProvider(workspaceUri); + + assert.strictEqual(provider, PYTEST_PROVIDER); + }); + }); + + suite('createDefaultProject', () => { + test('creates a single default project using active interpreter', async () => { + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/myws'); + const interpreter = { + displayName: 'My Python', + path: '/opt/py/bin/python', + version: { raw: '3.12.1' }, + sysPrefix: '/opt/py', + }; + + const controller = createController({ unittestEnabled: false, interpreter }); + + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox + .stub(controller as any, 'createTestAdapters') + .returns({ discoveryAdapter: fakeDiscoveryAdapter, executionAdapter: fakeExecutionAdapter }); + + const project = await (controller as any).createDefaultProject(workspaceUri); + + assert.strictEqual(project.workspaceUri.toString(), workspaceUri.toString()); + assert.strictEqual(project.projectUri.toString(), workspaceUri.toString()); + assert.strictEqual(project.projectId, getProjectId(workspaceUri)); + assert.strictEqual(project.projectName, 'myws'); + + assert.strictEqual(project.testProvider, PYTEST_PROVIDER); + assert.strictEqual(project.discoveryAdapter, fakeDiscoveryAdapter); + assert.strictEqual(project.executionAdapter, fakeExecutionAdapter); + + assert.strictEqual(project.pythonProject.uri.toString(), workspaceUri.toString()); + assert.strictEqual(project.pythonProject.name, 'myws'); + + assert.strictEqual(project.pythonEnvironment.displayName, 'My Python'); + assert.strictEqual(project.pythonEnvironment.version, '3.12.1'); + assert.strictEqual(project.pythonEnvironment.execInfo.run.executable, '/opt/py/bin/python'); + }); + }); + + suite('discoverWorkspaceProjects', () => { + test('respects useEnvExtension() == false and falls back to single default project', async () => { + const controller = createController(); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/a'); + + const defaultProject = { projectId: 'default', projectUri: workspaceUri }; + const createDefaultProjectStub = sandbox + .stub(controller as any, 'createDefaultProject') + .resolves(defaultProject as any); + + const useEnvExtensionStub = sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + const getEnvExtApiStub = sandbox.stub(envExtApiInternal, 'getEnvExtApi'); + + const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + + assert.strictEqual(useEnvExtensionStub.called, true); + assert.strictEqual(getEnvExtApiStub.notCalled, true); + assert.strictEqual(createDefaultProjectStub.calledOnceWithExactly(workspaceUri), true); + assert.deepStrictEqual(projects, [defaultProject]); + }); + + test('filters Python projects to workspace and creates adapters for each', async () => { + const controller = createController(); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); + + const pythonProjects = [ + { name: 'p1', uri: vscodeApi.Uri.file('/workspace/root/p1') }, + { name: 'p2', uri: vscodeApi.Uri.file('/workspace/root/nested/p2') }, + { name: 'other', uri: vscodeApi.Uri.file('/other/root/p3') }, + ]; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => pythonProjects, + } as any); + + const createdAdapters = [ + { projectId: 'p1', projectUri: pythonProjects[0].uri }, + { projectId: 'p2', projectUri: pythonProjects[1].uri }, + ]; + + const createProjectAdapterStub = sandbox + .stub(controller as any, 'createProjectAdapter') + .onFirstCall() + .resolves(createdAdapters[0] as any) + .onSecondCall() + .resolves(createdAdapters[1] as any); + + const createDefaultProjectStub = sandbox.stub(controller as any, 'createDefaultProject'); + + const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + + // Should only create adapters for the 2 projects in the workspace. + assert.strictEqual(createProjectAdapterStub.callCount, 2); + assert.strictEqual( + createProjectAdapterStub.firstCall.args[0].uri.toString(), + pythonProjects[0].uri.toString(), + ); + assert.strictEqual( + createProjectAdapterStub.secondCall.args[0].uri.toString(), + pythonProjects[1].uri.toString(), + ); + + assert.strictEqual(createDefaultProjectStub.notCalled, true); + assert.deepStrictEqual(projects, createdAdapters); + }); + + test('falls back to default project when no projects are in the workspace', async () => { + const controller = createController(); + const workspaceUri: Uri = vscodeApi.Uri.file('/workspace/root'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [{ name: 'other', uri: vscodeApi.Uri.file('/other/root/p3') }], + } as any); + + const defaultProject = { projectId: 'default', projectUri: workspaceUri }; + const createDefaultProjectStub = sandbox + .stub(controller as any, 'createDefaultProject') + .resolves(defaultProject as any); + + const createProjectAdapterStub = sandbox.stub(controller as any, 'createProjectAdapter'); + + const projects = await (controller as any).discoverWorkspaceProjects(workspaceUri); + + assert.strictEqual(createProjectAdapterStub.notCalled, true); + assert.strictEqual(createDefaultProjectStub.calledOnceWithExactly(workspaceUri), true); + assert.deepStrictEqual(projects, [defaultProject]); + }); + }); +});