diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 8c8ce422e3c1..70ae316c874b 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -52,17 +52,28 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; +import { useEnvExtension, getEnvExtApi } from '../../envExt/api.internal'; +import { PythonProject } from '../../envExt/types'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; type TriggerKeyType = keyof EventPropertyType; type TriggerType = EventPropertyType[TriggerKeyType]; +/** + * Represents a project-specific test adapter with its project information + */ +interface ProjectTestAdapter { + adapter: WorkspaceTestAdapter; + project: PythonProject; +} + @injectable() export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; - private readonly testAdapters: Map = new Map(); + // Map of workspace URI string -> array of project-specific test adapters + private readonly testAdapters: Map = new Map(); private readonly triggerTypes: TriggerType[] = []; @@ -163,57 +174,68 @@ export class PythonTestController implements ITestController, IExtensionSingleAc public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; workspaces.forEach((workspace) => { + // Initialize with empty array - projects will be detected during discovery + this.testAdapters.set(workspace.uri.toString(), []); + 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(); + } + }); + } - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - let testProvider: TestProvider; - let resultResolver: PythonResultResolver; + /** + * Creates a test adapter for a specific project within a workspace. + * @param project The project to create adapter for + * @param testProvider The test provider (pytest or unittest) + * @returns A ProjectTestAdapter instance + */ + private createProjectAdapter(project: PythonProject, testProvider: TestProvider): ProjectTestAdapter { + traceVerbose(`Creating ${testProvider} adapter for project at ${project.uri.fsPath}`); - 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( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } + const resultResolver = new PythonResultResolver(this.testController, testProvider, project.uri); + + let discoveryAdapter: ITestDiscoveryAdapter; + let executionAdapter: ITestExecutionAdapter; - const workspaceTestAdapter = new WorkspaceTestAdapter( - testProvider, - discoveryAdapter, - executionAdapter, - workspace.uri, + if (testProvider === UNITTEST_PROVIDER) { + discoveryAdapter = new UnittestTestDiscoveryAdapter( + this.configSettings, resultResolver, + this.envVarsService, ); + executionAdapter = new UnittestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } else { + discoveryAdapter = new PytestTestDiscoveryAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + executionAdapter = new PytestTestExecutionAdapter( + this.configSettings, + resultResolver, + this.envVarsService, + ); + } - this.testAdapters.set(workspace.uri, workspaceTestAdapter); + const workspaceTestAdapter = new WorkspaceTestAdapter( + testProvider, + discoveryAdapter, + executionAdapter, + project.uri, // Use project URI instead of workspace URI + resultResolver, + ); - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); - } - }); + return { + adapter: workspaceTestAdapter, + project, + }; } public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { @@ -311,32 +333,87 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Discovers tests for a specific test provider (pytest or unittest). - * Validates that the adapter's provider matches the expected provider. + * Detects all projects within the workspace and runs discovery for each project. */ private async discoverTestsForProvider(workspaceUri: Uri, expectedProvider: TestProvider): Promise { - const testAdapter = this.testAdapters.get(workspaceUri); + const workspaceKey = workspaceUri.toString(); + + // Step 1: Get Python projects from the environment extension + let projects: PythonProject[] = []; + + if (useEnvExtension()) { + try { + const envExtApi = await getEnvExtApi(); + const allProjects = envExtApi.getPythonProjects(); + + // Filter projects that belong to this workspace + projects = allProjects.filter((project) => { + const projectWorkspace = this.workspaceService.getWorkspaceFolder(project.uri); + return projectWorkspace?.uri.toString() === workspaceKey; + }); + + traceVerbose(`Found ${projects.length} Python project(s) in workspace ${workspaceUri.fsPath}`); + } catch (error) { + traceError(`Error getting projects from environment extension: ${error}`); + // Fall back to using workspace as single project + projects = []; + } + } - if (!testAdapter) { - traceError('Unable to find test adapter for workspace.'); - return; + // If no projects found or env extension not available, treat workspace as single project + if (projects.length === 0) { + traceVerbose(`No projects detected, using workspace root as single project: ${workspaceUri.fsPath}`); + projects = [{ + name: this.workspaceService.getWorkspaceFolder(workspaceUri)?.name || 'workspace', + uri: workspaceUri, + }]; } - const actualProvider = testAdapter.getTestProvider(); - if (actualProvider !== expectedProvider) { - traceError(`Test provider in adapter is not ${expectedProvider}. Please reload window.`); - this.surfaceErrorNode( - workspaceUri, - 'Test provider types are not aligned, please reload your VS Code window.', - expectedProvider, + // Step 2: Create or reuse adapters for each project + const existingAdapters = this.testAdapters.get(workspaceKey) || []; + const newAdapters: ProjectTestAdapter[] = []; + + for (const project of projects) { + // Check if we already have an adapter for this project + const existingAdapter = existingAdapters.find( + (a) => a.project.uri.toString() === project.uri.toString() && + a.adapter.getTestProvider() === expectedProvider, ); - return; + + if (existingAdapter) { + traceVerbose(`Reusing existing adapter for project at ${project.uri.fsPath}`); + newAdapters.push(existingAdapter); + } else { + // Create new adapter for this project + const projectAdapter = this.createProjectAdapter(project, expectedProvider); + newAdapters.push(projectAdapter); + } } - await testAdapter.discoverTests( - this.testController, - this.pythonExecFactory, - this.refreshCancellation.token, - await this.interpreterService.getActiveInterpreter(workspaceUri), + // Update the adapters map + this.testAdapters.set(workspaceKey, newAdapters); + + // Step 3: Run discovery for each project + const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); + await Promise.all( + newAdapters.map(async ({ adapter, project }) => { + traceVerbose(`Running ${expectedProvider} discovery for project at ${project.uri.fsPath}`); + try { + await adapter.discoverTests( + this.testController, + this.pythonExecFactory, + this.refreshCancellation.token, + interpreter, + ); + } catch (error) { + traceError(`Error during ${expectedProvider} discovery for project at ${project.uri.fsPath}:`, error); + this.surfaceErrorNode( + project.uri, + `Error discovering tests in project at ${project.uri.fsPath}: ${error}`, + expectedProvider, + ); + } + }), ); } @@ -448,6 +525,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Runs tests for a single workspace. */ + /** + * Runs tests for a single workspace. + * Groups tests by project and executes them using the appropriate project adapter. + */ private async runTestsForWorkspace( workspace: WorkspaceFolder, request: TestRunRequest, @@ -472,34 +553,66 @@ export class PythonTestController implements ITestController, IExtensionSingleAc return; } - const testAdapter = - this.testAdapters.get(workspace.uri) || (this.testAdapters.values().next().value as WorkspaceTestAdapter); - - this.setupCoverageIfNeeded(request, testAdapter); + // Get project adapters for this workspace + const workspaceKey = workspace.uri.toString(); + const projectAdapters = this.testAdapters.get(workspaceKey) || []; - if (settings.testing.pytestEnabled) { - await this.executeTestsForProvider( - workspace, - testAdapter, - testItems, - runInstance, - request, - token, - 'pytest', - ); - } else if (settings.testing.unittestEnabled) { - await this.executeTestsForProvider( - workspace, - testAdapter, - testItems, - runInstance, - request, - token, - 'unittest', - ); - } else { + if (projectAdapters.length === 0) { + traceError('No test adapters found for workspace. Tests may not have been discovered yet.'); unconfiguredWorkspaces.push(workspace); + return; + } + + // Group test items by project + const testItemsByProject = new Map(); + for (const testItem of testItems) { + // Find which project this test belongs to + const projectAdapter = projectAdapters.find(({ project }) => { + const testPath = testItem.uri?.fsPath; + if (!testPath) { + return false; + } + // Check if test path is within project path + return testPath === project.uri.fsPath || testPath.startsWith(project.uri.fsPath + '/'); + }); + + if (projectAdapter) { + const projectKey = projectAdapter.project.uri.toString(); + if (!testItemsByProject.has(projectKey)) { + testItemsByProject.set(projectKey, []); + } + testItemsByProject.get(projectKey)!.push(testItem); + } else { + traceError(`Could not find project adapter for test: ${testItem.id}`); + } } + + // Execute tests for each project + const testProvider = settings.testing.pytestEnabled ? 'pytest' : 'unittest'; + await Promise.all( + Array.from(testItemsByProject.entries()).map(async ([projectPath, projectTestItems]) => { + const projectAdapter = projectAdapters.find( + ({ project }) => project.uri.toString() === projectPath, + ); + + if (!projectAdapter) { + traceError(`Project adapter not found for project at ${projectPath}`); + return; + } + + this.setupCoverageIfNeeded(request, projectAdapter.adapter); + + await this.executeTestsForProvider( + workspace, + projectAdapter.adapter, + projectTestItems, + runInstance, + request, + token, + testProvider, + ); + }), + ); } /**