From dfab7758afa39cd71a689ce9aa26ddc7163061a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:02:26 +0000 Subject: [PATCH 1/4] Initial plan From 243af23b0d571f7e902139957ff6837c1d339ec2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:12:18 +0000 Subject: [PATCH 2/4] Add Phase 2: Per-project discovery infrastructure - Created projectRootFinder.ts utility to detect Python project roots - Updated PythonTestController to support multiple projects per workspace - Modified discovery flow to detect projects and create adapters for each - Updated test execution to route tests to correct project adapter Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../common/projectRootFinder.ts | 136 +++++++++ .../testing/testController/controller.ts | 269 ++++++++++++------ 2 files changed, 317 insertions(+), 88 deletions(-) create mode 100644 src/client/testing/testController/common/projectRootFinder.ts diff --git a/src/client/testing/testController/common/projectRootFinder.ts b/src/client/testing/testController/common/projectRootFinder.ts new file mode 100644 index 000000000000..f297072b5d90 --- /dev/null +++ b/src/client/testing/testController/common/projectRootFinder.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { Uri, workspace, RelativePattern } from 'vscode'; +import { traceVerbose } from '../../../logging'; +import { TestProvider } from '../../types'; + +/** + * Markers that indicate a Python project root for different test frameworks + */ +const PYTEST_PROJECT_MARKERS = ['pytest.ini', 'pyproject.toml', 'setup.py', 'setup.cfg', 'tox.ini']; +const UNITTEST_PROJECT_MARKERS = ['pyproject.toml', 'setup.py', 'setup.cfg']; + +/** + * Represents a detected Python project within a workspace + */ +export interface ProjectRoot { + /** + * URI of the project root directory + */ + uri: Uri; + + /** + * Marker file that was used to identify this project + */ + markerFile: string; +} + +/** + * Finds all Python project roots within a workspace folder. + * A project root is identified by the presence of configuration files like + * pyproject.toml, setup.py, pytest.ini, etc. + * + * @param workspaceUri The workspace folder URI to search + * @param testProvider The test provider (pytest or unittest) to determine which markers to look for + * @returns Array of detected project roots, or a single element with the workspace root if no projects found + */ +export async function findProjectRoots(workspaceUri: Uri, testProvider: TestProvider): Promise { + const markers = testProvider === 'pytest' ? PYTEST_PROJECT_MARKERS : UNITTEST_PROJECT_MARKERS; + const projectRoots: Map = new Map(); + + traceVerbose(`Searching for ${testProvider} project roots in workspace: ${workspaceUri.fsPath}`); + + // Search for each marker file type + for (const marker of markers) { + try { + // Use VS Code's findFiles API to search for marker files + // Exclude common directories to improve performance + const pattern = `**/${marker}`; + const exclude = '**/node_modules/**,**/.venv/**,**/venv/**,**/__pycache__/**,**/.git/**'; + const foundFiles = await workspace.findFiles( + new RelativePattern(workspaceUri, pattern), + exclude, + 100, // Limit to 100 projects max + ); + + for (const fileUri of foundFiles) { + // The project root is the directory containing the marker file + const projectRootPath = path.dirname(fileUri.fsPath); + + // Only add if we haven't already found a project at this location + if (!projectRoots.has(projectRootPath)) { + projectRoots.set(projectRootPath, { + uri: Uri.file(projectRootPath), + markerFile: marker, + }); + traceVerbose(`Found ${testProvider} project root at ${projectRootPath} (marker: ${marker})`); + } + } + } catch (error) { + traceVerbose(`Error searching for ${marker}: ${error}`); + } + } + + // If no projects found, treat the entire workspace as a single project + if (projectRoots.size === 0) { + traceVerbose(`No project markers found, using workspace root as single project: ${workspaceUri.fsPath}`); + return [{ + uri: workspaceUri, + markerFile: 'none', + }]; + } + + // Sort projects by path depth (shallowest first) to handle nested projects + const sortedProjects = Array.from(projectRoots.values()).sort((a, b) => { + const depthA = a.uri.fsPath.split(path.sep).length; + const depthB = b.uri.fsPath.split(path.sep).length; + return depthA - depthB; + }); + + // Filter out nested projects (projects contained within other projects) + const filteredProjects = sortedProjects.filter((project, index) => { + // Keep the project if no earlier project contains it + for (let i = 0; i < index; i++) { + const parentProject = sortedProjects[i]; + if (project.uri.fsPath.startsWith(parentProject.uri.fsPath + path.sep)) { + traceVerbose(`Filtering out nested project at ${project.uri.fsPath} (contained in ${parentProject.uri.fsPath})`); + return false; + } + } + return true; + }); + + traceVerbose(`Found ${filteredProjects.length} ${testProvider} project(s) in workspace ${workspaceUri.fsPath}`); + return filteredProjects; +} + +/** + * Checks if a file path belongs to a specific project root + * @param filePath The file path to check + * @param projectRoot The project root to check against + * @returns true if the file belongs to the project + */ +export function isFileInProject(filePath: string, projectRoot: ProjectRoot): boolean { + const normalizedFilePath = path.normalize(filePath); + const normalizedProjectPath = path.normalize(projectRoot.uri.fsPath); + + return normalizedFilePath === normalizedProjectPath || + normalizedFilePath.startsWith(normalizedProjectPath + path.sep); +} + +/** + * Finds which project root a test item belongs to based on its URI + * @param testItemUri The URI of the test item + * @param projectRoots Array of project roots to search + * @returns The matching project root, or undefined if not found + */ +export function findProjectForTestItem(testItemUri: Uri, projectRoots: ProjectRoot[]): ProjectRoot | undefined { + // Find the most specific (deepest) project root that contains this test + const matchingProjects = projectRoots + .filter(project => isFileInProject(testItemUri.fsPath, project)) + .sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length); // Sort by path length descending + + return matchingProjects[0]; +} diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 8c8ce422e3c1..806234fcda24 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -52,17 +52,27 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; +import { findProjectRoots, ProjectRoot, findProjectForTestItem, isFileInProject } from './common/projectRootFinder'; // 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 root information + */ +interface ProjectTestAdapter { + adapter: WorkspaceTestAdapter; + projectRoot: ProjectRoot; +} + @injectable() export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; - private readonly testAdapters: Map = new Map(); + // Map of workspace URI -> array of project-specific test adapters + private readonly testAdapters: Map = new Map(); private readonly triggerTypes: TriggerType[] = []; @@ -163,57 +173,75 @@ 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 root within a workspace. + * @param workspaceUri The workspace folder URI + * @param projectRoot The project root to create adapter for + * @param testProvider The test provider (pytest or unittest) + * @returns A ProjectTestAdapter instance + */ + private createProjectAdapter( + workspaceUri: Uri, + projectRoot: ProjectRoot, + testProvider: TestProvider, + ): ProjectTestAdapter { + traceVerbose( + `Creating ${testProvider} adapter for project at ${projectRoot.uri.fsPath} (marker: ${projectRoot.markerFile})`, + ); - 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, projectRoot.uri); - const workspaceTestAdapter = new WorkspaceTestAdapter( - testProvider, - discoveryAdapter, - executionAdapter, - workspace.uri, + let discoveryAdapter: ITestDiscoveryAdapter; + let executionAdapter: ITestExecutionAdapter; + + 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, + projectRoot.uri, // Use project root 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, + projectRoot, + }; } public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { @@ -311,32 +339,65 @@ 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 project roots 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(); - if (!testAdapter) { - traceError('Unable to find test adapter for workspace.'); - return; - } + // Step 1: Detect all project roots in the workspace + traceVerbose(`Detecting ${expectedProvider} projects in workspace: ${workspaceUri.fsPath}`); + const projectRoots = await findProjectRoots(workspaceUri, expectedProvider); + traceVerbose(`Found ${projectRoots.length} project(s) in workspace ${workspaceUri.fsPath}`); - 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 projectRoot of projectRoots) { + // Check if we already have an adapter for this project + const existingAdapter = existingAdapters.find( + (a) => a.projectRoot.uri.fsPath === projectRoot.uri.fsPath && a.adapter.getTestProvider() === expectedProvider, ); - return; + + if (existingAdapter) { + traceVerbose(`Reusing existing adapter for project at ${projectRoot.uri.fsPath}`); + newAdapters.push(existingAdapter); + } else { + // Create new adapter for this project + const projectAdapter = this.createProjectAdapter(workspaceUri, projectRoot, 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, projectRoot }) => { + traceVerbose( + `Running ${expectedProvider} discovery for project at ${projectRoot.uri.fsPath}`, + ); + try { + await adapter.discoverTests( + this.testController, + this.pythonExecFactory, + this.refreshCancellation.token, + interpreter, + ); + } catch (error) { + traceError( + `Error during ${expectedProvider} discovery for project at ${projectRoot.uri.fsPath}:`, + error, + ); + this.surfaceErrorNode( + projectRoot.uri, + `Error discovering tests in project at ${projectRoot.uri.fsPath}: ${error}`, + expectedProvider, + ); + } + }), ); } @@ -447,6 +508,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Runs tests for a single workspace. + * Groups tests by project and executes them using the appropriate project adapter. */ private async runTestsForWorkspace( workspace: WorkspaceFolder, @@ -472,34 +534,65 @@ 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(({ projectRoot }) => { + const testPath = testItem.uri?.fsPath; + if (!testPath) { + return false; + } + return isFileInProject(testPath, projectRoot); + }); + + if (projectAdapter) { + const projectKey = projectAdapter.projectRoot.uri.fsPath; + 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( + ({ projectRoot }) => projectRoot.uri.fsPath === 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, + ); + }), + ); } /** From b497a41d81adc2c054d2fffff9ce9dacb7ec6316 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:18:29 +0000 Subject: [PATCH 3/4] Add unit tests for project root finder utility - Created comprehensive unit tests for projectRootFinder - 8/12 tests passing - helper functions working correctly - Some stub configuration issues with findFiles to be resolved - Core functionality (isFileInProject, findProjectForTestItem) fully tested Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../common/projectRootFinder.ts | 5 +- .../testing/testController/controller.ts | 6 +- .../common/projectRootFinder.unit.test.ts | 210 ++++++++++++++++++ 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 src/test/testing/testController/common/projectRootFinder.unit.test.ts diff --git a/src/client/testing/testController/common/projectRootFinder.ts b/src/client/testing/testController/common/projectRootFinder.ts index f297072b5d90..f4d0a221a657 100644 --- a/src/client/testing/testController/common/projectRootFinder.ts +++ b/src/client/testing/testController/common/projectRootFinder.ts @@ -2,9 +2,10 @@ // Licensed under the MIT License. import * as path from 'path'; -import { Uri, workspace, RelativePattern } from 'vscode'; +import { Uri, RelativePattern } from 'vscode'; import { traceVerbose } from '../../../logging'; import { TestProvider } from '../../types'; +import * as workspaceApis from '../../../common/vscodeApis/workspaceApis'; /** * Markers that indicate a Python project root for different test frameworks @@ -49,7 +50,7 @@ export async function findProjectRoots(workspaceUri: Uri, testProvider: TestProv // Exclude common directories to improve performance const pattern = `**/${marker}`; const exclude = '**/node_modules/**,**/.venv/**,**/venv/**,**/__pycache__/**,**/.git/**'; - const foundFiles = await workspace.findFiles( + const foundFiles = await workspaceApis.findFiles( new RelativePattern(workspaceUri, pattern), exclude, 100, // Limit to 100 projects max diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 806234fcda24..834afe678ce1 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -52,7 +52,7 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; -import { findProjectRoots, ProjectRoot, findProjectForTestItem, isFileInProject } from './common/projectRootFinder'; +import { findProjectRoots, ProjectRoot, isFileInProject } from './common/projectRootFinder'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -187,13 +187,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Creates a test adapter for a specific project root within a workspace. - * @param workspaceUri The workspace folder URI + * @param _workspaceUri The workspace folder URI (for future use) * @param projectRoot The project root to create adapter for * @param testProvider The test provider (pytest or unittest) * @returns A ProjectTestAdapter instance */ private createProjectAdapter( - workspaceUri: Uri, + _workspaceUri: Uri, projectRoot: ProjectRoot, testProvider: TestProvider, ): ProjectTestAdapter { diff --git a/src/test/testing/testController/common/projectRootFinder.unit.test.ts b/src/test/testing/testController/common/projectRootFinder.unit.test.ts new file mode 100644 index 000000000000..5a12a41b513c --- /dev/null +++ b/src/test/testing/testController/common/projectRootFinder.unit.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; +import { + findProjectRoots, + isFileInProject, + findProjectForTestItem, + ProjectRoot, +} from '../../../../client/testing/testController/common/projectRootFinder'; + +suite('Project Root Finder Tests', () => { + let findFilesStub: sinon.SinonStub; + + setup(() => { + findFilesStub = sinon.stub(workspaceApis, 'findFiles'); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('findProjectRoots', () => { + test('should return workspace root when no project markers found', async () => { + const workspaceUri = Uri.file('/workspace'); + findFilesStub.resolves([]); + + const result = await findProjectRoots(workspaceUri, 'pytest'); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].uri.fsPath, workspaceUri.fsPath); + assert.strictEqual(result[0].markerFile, 'none'); + }); + + test('should detect single pytest project with pytest.ini', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectMarker = Uri.file('/workspace/pytest.ini'); + + let callCount = 0; + findFilesStub.callsFake(() => { + // Return marker on first call (pytest.ini), empty on others + if (callCount++ === 0) { + return Promise.resolve([projectMarker]); + } + return Promise.resolve([]); + }); + + const result = await findProjectRoots(workspaceUri, 'pytest'); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].uri.fsPath, workspaceUri.fsPath); + assert.strictEqual(result[0].markerFile, 'pytest.ini'); + }); + + test('should detect multiple projects with different markers', async () => { + const workspaceUri = Uri.file('/workspace'); + const project1Marker = Uri.file('/workspace/project1/pyproject.toml'); + const project2Marker = Uri.file('/workspace/project2/setup.py'); + + findFilesStub.callsFake((pattern: any) => { + const patternStr = pattern.pattern || pattern; + if (typeof patternStr === 'string' && patternStr.includes('pyproject.toml')) { + return Promise.resolve([project1Marker]); + } else if (typeof patternStr === 'string' && patternStr.includes('setup.py')) { + return Promise.resolve([project2Marker]); + } + return Promise.resolve([]); + }); + + const result = await findProjectRoots(workspaceUri, 'pytest'); + + assert.strictEqual(result.length, 2); + const paths = result.map(p => p.uri.fsPath).sort(); + assert.strictEqual(paths[0], path.join(workspaceUri.fsPath, 'project1')); + assert.strictEqual(paths[1], path.join(workspaceUri.fsPath, 'project2')); + }); + + test('should filter out nested projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const parentMarker = Uri.file('/workspace/pyproject.toml'); + const nestedMarker = Uri.file('/workspace/subproject/pyproject.toml'); + + findFilesStub.callsFake((pattern: any) => { + const patternStr = pattern.pattern || pattern; + if (typeof patternStr === 'string' && patternStr.includes('pyproject.toml')) { + return Promise.resolve([parentMarker, nestedMarker]); + } + return Promise.resolve([]); + }); + + const result = await findProjectRoots(workspaceUri, 'pytest'); + + // Should only return the parent project, filtering out nested one + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].uri.fsPath, workspaceUri.fsPath); + assert.strictEqual(result[0].markerFile, 'pyproject.toml'); + }); + + test('should use unittest markers for unittest provider', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectMarker = Uri.file('/workspace/setup.py'); + + findFilesStub.callsFake((pattern: any) => { + const patternStr = pattern.pattern || pattern; + if (typeof patternStr === 'string' && patternStr.includes('setup.py')) { + return Promise.resolve([projectMarker]); + } + return Promise.resolve([]); + }); + + const result = await findProjectRoots(workspaceUri, 'unittest'); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].markerFile, 'setup.py'); + }); + }); + + suite('isFileInProject', () => { + test('should return true for file in project root', () => { + const projectRoot: ProjectRoot = { + uri: Uri.file('/workspace/project1'), + markerFile: 'pyproject.toml', + }; + const filePath = path.join('/workspace', 'project1', 'test_file.py'); + + const result = isFileInProject(filePath, projectRoot); + + assert.strictEqual(result, true); + }); + + test('should return true for file in subdirectory of project', () => { + const projectRoot: ProjectRoot = { + uri: Uri.file('/workspace/project1'), + markerFile: 'pyproject.toml', + }; + const filePath = path.join('/workspace', 'project1', 'subdir', 'test_file.py'); + + const result = isFileInProject(filePath, projectRoot); + + assert.strictEqual(result, true); + }); + + test('should return false for file outside project', () => { + const projectRoot: ProjectRoot = { + uri: Uri.file('/workspace/project1'), + markerFile: 'pyproject.toml', + }; + const filePath = path.join('/workspace', 'project2', 'test_file.py'); + + const result = isFileInProject(filePath, projectRoot); + + assert.strictEqual(result, false); + }); + + test('should return true for exact project root path', () => { + const projectRoot: ProjectRoot = { + uri: Uri.file('/workspace/project1'), + markerFile: 'pyproject.toml', + }; + const filePath = '/workspace/project1'; + + const result = isFileInProject(filePath, projectRoot); + + assert.strictEqual(result, true); + }); + }); + + suite('findProjectForTestItem', () => { + test('should find correct project for test item', () => { + const projects: ProjectRoot[] = [ + { uri: Uri.file('/workspace/project1'), markerFile: 'pyproject.toml' }, + { uri: Uri.file('/workspace/project2'), markerFile: 'setup.py' }, + ]; + const testItemUri = Uri.file('/workspace/project1/tests/test_foo.py'); + + const result = findProjectForTestItem(testItemUri, projects); + + assert.ok(result); + assert.strictEqual(result.uri.fsPath, projects[0].uri.fsPath); + }); + + test('should return deepest matching project for nested structure', () => { + const projects: ProjectRoot[] = [ + { uri: Uri.file('/workspace'), markerFile: 'pyproject.toml' }, + { uri: Uri.file('/workspace/subproject'), markerFile: 'pyproject.toml' }, + ]; + const testItemUri = Uri.file('/workspace/subproject/tests/test_foo.py'); + + const result = findProjectForTestItem(testItemUri, projects); + + assert.ok(result); + assert.strictEqual(result.uri.fsPath, projects[1].uri.fsPath); + }); + + test('should return undefined when no matching project found', () => { + const projects: ProjectRoot[] = [ + { uri: Uri.file('/workspace/project1'), markerFile: 'pyproject.toml' }, + ]; + const testItemUri = Uri.file('/different/workspace/test_foo.py'); + + const result = findProjectForTestItem(testItemUri, projects); + + assert.strictEqual(result, undefined); + }); + }); +}); From 24d86133a5ad46f6510573ccf98b417419dc8780 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:25:17 +0000 Subject: [PATCH 4/4] Use environment extension API for project detection - Removed custom projectRootFinder.ts utility - Use environment extension's getPythonProjects() API - Query projects from env ext when available - Fall back to workspace as single project if env ext not available - Maintain per-project adapter architecture for discovery and execution Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- .../common/projectRootFinder.ts | 137 ------------ .../testing/testController/controller.ts | 102 +++++---- .../common/projectRootFinder.unit.test.ts | 210 ------------------ 3 files changed, 61 insertions(+), 388 deletions(-) delete mode 100644 src/client/testing/testController/common/projectRootFinder.ts delete mode 100644 src/test/testing/testController/common/projectRootFinder.unit.test.ts diff --git a/src/client/testing/testController/common/projectRootFinder.ts b/src/client/testing/testController/common/projectRootFinder.ts deleted file mode 100644 index f4d0a221a657..000000000000 --- a/src/client/testing/testController/common/projectRootFinder.ts +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import * as path from 'path'; -import { Uri, RelativePattern } from 'vscode'; -import { traceVerbose } from '../../../logging'; -import { TestProvider } from '../../types'; -import * as workspaceApis from '../../../common/vscodeApis/workspaceApis'; - -/** - * Markers that indicate a Python project root for different test frameworks - */ -const PYTEST_PROJECT_MARKERS = ['pytest.ini', 'pyproject.toml', 'setup.py', 'setup.cfg', 'tox.ini']; -const UNITTEST_PROJECT_MARKERS = ['pyproject.toml', 'setup.py', 'setup.cfg']; - -/** - * Represents a detected Python project within a workspace - */ -export interface ProjectRoot { - /** - * URI of the project root directory - */ - uri: Uri; - - /** - * Marker file that was used to identify this project - */ - markerFile: string; -} - -/** - * Finds all Python project roots within a workspace folder. - * A project root is identified by the presence of configuration files like - * pyproject.toml, setup.py, pytest.ini, etc. - * - * @param workspaceUri The workspace folder URI to search - * @param testProvider The test provider (pytest or unittest) to determine which markers to look for - * @returns Array of detected project roots, or a single element with the workspace root if no projects found - */ -export async function findProjectRoots(workspaceUri: Uri, testProvider: TestProvider): Promise { - const markers = testProvider === 'pytest' ? PYTEST_PROJECT_MARKERS : UNITTEST_PROJECT_MARKERS; - const projectRoots: Map = new Map(); - - traceVerbose(`Searching for ${testProvider} project roots in workspace: ${workspaceUri.fsPath}`); - - // Search for each marker file type - for (const marker of markers) { - try { - // Use VS Code's findFiles API to search for marker files - // Exclude common directories to improve performance - const pattern = `**/${marker}`; - const exclude = '**/node_modules/**,**/.venv/**,**/venv/**,**/__pycache__/**,**/.git/**'; - const foundFiles = await workspaceApis.findFiles( - new RelativePattern(workspaceUri, pattern), - exclude, - 100, // Limit to 100 projects max - ); - - for (const fileUri of foundFiles) { - // The project root is the directory containing the marker file - const projectRootPath = path.dirname(fileUri.fsPath); - - // Only add if we haven't already found a project at this location - if (!projectRoots.has(projectRootPath)) { - projectRoots.set(projectRootPath, { - uri: Uri.file(projectRootPath), - markerFile: marker, - }); - traceVerbose(`Found ${testProvider} project root at ${projectRootPath} (marker: ${marker})`); - } - } - } catch (error) { - traceVerbose(`Error searching for ${marker}: ${error}`); - } - } - - // If no projects found, treat the entire workspace as a single project - if (projectRoots.size === 0) { - traceVerbose(`No project markers found, using workspace root as single project: ${workspaceUri.fsPath}`); - return [{ - uri: workspaceUri, - markerFile: 'none', - }]; - } - - // Sort projects by path depth (shallowest first) to handle nested projects - const sortedProjects = Array.from(projectRoots.values()).sort((a, b) => { - const depthA = a.uri.fsPath.split(path.sep).length; - const depthB = b.uri.fsPath.split(path.sep).length; - return depthA - depthB; - }); - - // Filter out nested projects (projects contained within other projects) - const filteredProjects = sortedProjects.filter((project, index) => { - // Keep the project if no earlier project contains it - for (let i = 0; i < index; i++) { - const parentProject = sortedProjects[i]; - if (project.uri.fsPath.startsWith(parentProject.uri.fsPath + path.sep)) { - traceVerbose(`Filtering out nested project at ${project.uri.fsPath} (contained in ${parentProject.uri.fsPath})`); - return false; - } - } - return true; - }); - - traceVerbose(`Found ${filteredProjects.length} ${testProvider} project(s) in workspace ${workspaceUri.fsPath}`); - return filteredProjects; -} - -/** - * Checks if a file path belongs to a specific project root - * @param filePath The file path to check - * @param projectRoot The project root to check against - * @returns true if the file belongs to the project - */ -export function isFileInProject(filePath: string, projectRoot: ProjectRoot): boolean { - const normalizedFilePath = path.normalize(filePath); - const normalizedProjectPath = path.normalize(projectRoot.uri.fsPath); - - return normalizedFilePath === normalizedProjectPath || - normalizedFilePath.startsWith(normalizedProjectPath + path.sep); -} - -/** - * Finds which project root a test item belongs to based on its URI - * @param testItemUri The URI of the test item - * @param projectRoots Array of project roots to search - * @returns The matching project root, or undefined if not found - */ -export function findProjectForTestItem(testItemUri: Uri, projectRoots: ProjectRoot[]): ProjectRoot | undefined { - // Find the most specific (deepest) project root that contains this test - const matchingProjects = projectRoots - .filter(project => isFileInProject(testItemUri.fsPath, project)) - .sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length); // Sort by path length descending - - return matchingProjects[0]; -} diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index 834afe678ce1..70ae316c874b 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -52,7 +52,8 @@ import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; -import { findProjectRoots, ProjectRoot, isFileInProject } from './common/projectRootFinder'; +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]; @@ -60,18 +61,18 @@ type TriggerKeyType = keyof EventPropertyType; type TriggerType = EventPropertyType[TriggerKeyType]; /** - * Represents a project-specific test adapter with its project root information + * Represents a project-specific test adapter with its project information */ interface ProjectTestAdapter { adapter: WorkspaceTestAdapter; - projectRoot: ProjectRoot; + project: PythonProject; } @injectable() export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; - // Map of workspace URI -> array of project-specific test adapters + // Map of workspace URI string -> array of project-specific test adapters private readonly testAdapters: Map = new Map(); private readonly triggerTypes: TriggerType[] = []; @@ -186,22 +187,15 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } /** - * Creates a test adapter for a specific project root within a workspace. - * @param _workspaceUri The workspace folder URI (for future use) - * @param projectRoot The project root to create adapter for + * 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( - _workspaceUri: Uri, - projectRoot: ProjectRoot, - testProvider: TestProvider, - ): ProjectTestAdapter { - traceVerbose( - `Creating ${testProvider} adapter for project at ${projectRoot.uri.fsPath} (marker: ${projectRoot.markerFile})`, - ); + private createProjectAdapter(project: PythonProject, testProvider: TestProvider): ProjectTestAdapter { + traceVerbose(`Creating ${testProvider} adapter for project at ${project.uri.fsPath}`); - const resultResolver = new PythonResultResolver(this.testController, testProvider, projectRoot.uri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, project.uri); let discoveryAdapter: ITestDiscoveryAdapter; let executionAdapter: ITestExecutionAdapter; @@ -234,13 +228,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc testProvider, discoveryAdapter, executionAdapter, - projectRoot.uri, // Use project root URI instead of workspace URI + project.uri, // Use project URI instead of workspace URI resultResolver, ); return { adapter: workspaceTestAdapter, - projectRoot, + project, }; } @@ -339,32 +333,59 @@ export class PythonTestController implements ITestController, IExtensionSingleAc /** * Discovers tests for a specific test provider (pytest or unittest). - * Detects all project roots within the workspace and runs discovery for each project. + * Detects all projects within the workspace and runs discovery for each project. */ private async discoverTestsForProvider(workspaceUri: Uri, expectedProvider: TestProvider): Promise { const workspaceKey = workspaceUri.toString(); - // Step 1: Detect all project roots in the workspace - traceVerbose(`Detecting ${expectedProvider} projects in workspace: ${workspaceUri.fsPath}`); - const projectRoots = await findProjectRoots(workspaceUri, expectedProvider); - traceVerbose(`Found ${projectRoots.length} project(s) in workspace ${workspaceUri.fsPath}`); + // 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 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, + }]; + } // Step 2: Create or reuse adapters for each project const existingAdapters = this.testAdapters.get(workspaceKey) || []; const newAdapters: ProjectTestAdapter[] = []; - for (const projectRoot of projectRoots) { + for (const project of projects) { // Check if we already have an adapter for this project const existingAdapter = existingAdapters.find( - (a) => a.projectRoot.uri.fsPath === projectRoot.uri.fsPath && a.adapter.getTestProvider() === expectedProvider, + (a) => a.project.uri.toString() === project.uri.toString() && + a.adapter.getTestProvider() === expectedProvider, ); if (existingAdapter) { - traceVerbose(`Reusing existing adapter for project at ${projectRoot.uri.fsPath}`); + traceVerbose(`Reusing existing adapter for project at ${project.uri.fsPath}`); newAdapters.push(existingAdapter); } else { // Create new adapter for this project - const projectAdapter = this.createProjectAdapter(workspaceUri, projectRoot, expectedProvider); + const projectAdapter = this.createProjectAdapter(project, expectedProvider); newAdapters.push(projectAdapter); } } @@ -375,10 +396,8 @@ export class PythonTestController implements ITestController, IExtensionSingleAc // Step 3: Run discovery for each project const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); await Promise.all( - newAdapters.map(async ({ adapter, projectRoot }) => { - traceVerbose( - `Running ${expectedProvider} discovery for project at ${projectRoot.uri.fsPath}`, - ); + newAdapters.map(async ({ adapter, project }) => { + traceVerbose(`Running ${expectedProvider} discovery for project at ${project.uri.fsPath}`); try { await adapter.discoverTests( this.testController, @@ -387,13 +406,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc interpreter, ); } catch (error) { - traceError( - `Error during ${expectedProvider} discovery for project at ${projectRoot.uri.fsPath}:`, - error, - ); + traceError(`Error during ${expectedProvider} discovery for project at ${project.uri.fsPath}:`, error); this.surfaceErrorNode( - projectRoot.uri, - `Error discovering tests in project at ${projectRoot.uri.fsPath}: ${error}`, + project.uri, + `Error discovering tests in project at ${project.uri.fsPath}: ${error}`, expectedProvider, ); } @@ -506,6 +522,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc return Array.from(this.workspaceService.workspaceFolders || []); } + /** + * Runs tests for a single workspace. + */ /** * Runs tests for a single workspace. * Groups tests by project and executes them using the appropriate project adapter. @@ -548,16 +567,17 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const testItemsByProject = new Map(); for (const testItem of testItems) { // Find which project this test belongs to - const projectAdapter = projectAdapters.find(({ projectRoot }) => { + const projectAdapter = projectAdapters.find(({ project }) => { const testPath = testItem.uri?.fsPath; if (!testPath) { return false; } - return isFileInProject(testPath, projectRoot); + // Check if test path is within project path + return testPath === project.uri.fsPath || testPath.startsWith(project.uri.fsPath + '/'); }); if (projectAdapter) { - const projectKey = projectAdapter.projectRoot.uri.fsPath; + const projectKey = projectAdapter.project.uri.toString(); if (!testItemsByProject.has(projectKey)) { testItemsByProject.set(projectKey, []); } @@ -572,7 +592,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc await Promise.all( Array.from(testItemsByProject.entries()).map(async ([projectPath, projectTestItems]) => { const projectAdapter = projectAdapters.find( - ({ projectRoot }) => projectRoot.uri.fsPath === projectPath, + ({ project }) => project.uri.toString() === projectPath, ); if (!projectAdapter) { diff --git a/src/test/testing/testController/common/projectRootFinder.unit.test.ts b/src/test/testing/testController/common/projectRootFinder.unit.test.ts deleted file mode 100644 index 5a12a41b513c..000000000000 --- a/src/test/testing/testController/common/projectRootFinder.unit.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import * as assert from 'assert'; -import * as path from 'path'; -import * as sinon from 'sinon'; -import { Uri } from 'vscode'; -import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; -import { - findProjectRoots, - isFileInProject, - findProjectForTestItem, - ProjectRoot, -} from '../../../../client/testing/testController/common/projectRootFinder'; - -suite('Project Root Finder Tests', () => { - let findFilesStub: sinon.SinonStub; - - setup(() => { - findFilesStub = sinon.stub(workspaceApis, 'findFiles'); - }); - - teardown(() => { - sinon.restore(); - }); - - suite('findProjectRoots', () => { - test('should return workspace root when no project markers found', async () => { - const workspaceUri = Uri.file('/workspace'); - findFilesStub.resolves([]); - - const result = await findProjectRoots(workspaceUri, 'pytest'); - - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri.fsPath, workspaceUri.fsPath); - assert.strictEqual(result[0].markerFile, 'none'); - }); - - test('should detect single pytest project with pytest.ini', async () => { - const workspaceUri = Uri.file('/workspace'); - const projectMarker = Uri.file('/workspace/pytest.ini'); - - let callCount = 0; - findFilesStub.callsFake(() => { - // Return marker on first call (pytest.ini), empty on others - if (callCount++ === 0) { - return Promise.resolve([projectMarker]); - } - return Promise.resolve([]); - }); - - const result = await findProjectRoots(workspaceUri, 'pytest'); - - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri.fsPath, workspaceUri.fsPath); - assert.strictEqual(result[0].markerFile, 'pytest.ini'); - }); - - test('should detect multiple projects with different markers', async () => { - const workspaceUri = Uri.file('/workspace'); - const project1Marker = Uri.file('/workspace/project1/pyproject.toml'); - const project2Marker = Uri.file('/workspace/project2/setup.py'); - - findFilesStub.callsFake((pattern: any) => { - const patternStr = pattern.pattern || pattern; - if (typeof patternStr === 'string' && patternStr.includes('pyproject.toml')) { - return Promise.resolve([project1Marker]); - } else if (typeof patternStr === 'string' && patternStr.includes('setup.py')) { - return Promise.resolve([project2Marker]); - } - return Promise.resolve([]); - }); - - const result = await findProjectRoots(workspaceUri, 'pytest'); - - assert.strictEqual(result.length, 2); - const paths = result.map(p => p.uri.fsPath).sort(); - assert.strictEqual(paths[0], path.join(workspaceUri.fsPath, 'project1')); - assert.strictEqual(paths[1], path.join(workspaceUri.fsPath, 'project2')); - }); - - test('should filter out nested projects', async () => { - const workspaceUri = Uri.file('/workspace'); - const parentMarker = Uri.file('/workspace/pyproject.toml'); - const nestedMarker = Uri.file('/workspace/subproject/pyproject.toml'); - - findFilesStub.callsFake((pattern: any) => { - const patternStr = pattern.pattern || pattern; - if (typeof patternStr === 'string' && patternStr.includes('pyproject.toml')) { - return Promise.resolve([parentMarker, nestedMarker]); - } - return Promise.resolve([]); - }); - - const result = await findProjectRoots(workspaceUri, 'pytest'); - - // Should only return the parent project, filtering out nested one - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri.fsPath, workspaceUri.fsPath); - assert.strictEqual(result[0].markerFile, 'pyproject.toml'); - }); - - test('should use unittest markers for unittest provider', async () => { - const workspaceUri = Uri.file('/workspace'); - const projectMarker = Uri.file('/workspace/setup.py'); - - findFilesStub.callsFake((pattern: any) => { - const patternStr = pattern.pattern || pattern; - if (typeof patternStr === 'string' && patternStr.includes('setup.py')) { - return Promise.resolve([projectMarker]); - } - return Promise.resolve([]); - }); - - const result = await findProjectRoots(workspaceUri, 'unittest'); - - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].markerFile, 'setup.py'); - }); - }); - - suite('isFileInProject', () => { - test('should return true for file in project root', () => { - const projectRoot: ProjectRoot = { - uri: Uri.file('/workspace/project1'), - markerFile: 'pyproject.toml', - }; - const filePath = path.join('/workspace', 'project1', 'test_file.py'); - - const result = isFileInProject(filePath, projectRoot); - - assert.strictEqual(result, true); - }); - - test('should return true for file in subdirectory of project', () => { - const projectRoot: ProjectRoot = { - uri: Uri.file('/workspace/project1'), - markerFile: 'pyproject.toml', - }; - const filePath = path.join('/workspace', 'project1', 'subdir', 'test_file.py'); - - const result = isFileInProject(filePath, projectRoot); - - assert.strictEqual(result, true); - }); - - test('should return false for file outside project', () => { - const projectRoot: ProjectRoot = { - uri: Uri.file('/workspace/project1'), - markerFile: 'pyproject.toml', - }; - const filePath = path.join('/workspace', 'project2', 'test_file.py'); - - const result = isFileInProject(filePath, projectRoot); - - assert.strictEqual(result, false); - }); - - test('should return true for exact project root path', () => { - const projectRoot: ProjectRoot = { - uri: Uri.file('/workspace/project1'), - markerFile: 'pyproject.toml', - }; - const filePath = '/workspace/project1'; - - const result = isFileInProject(filePath, projectRoot); - - assert.strictEqual(result, true); - }); - }); - - suite('findProjectForTestItem', () => { - test('should find correct project for test item', () => { - const projects: ProjectRoot[] = [ - { uri: Uri.file('/workspace/project1'), markerFile: 'pyproject.toml' }, - { uri: Uri.file('/workspace/project2'), markerFile: 'setup.py' }, - ]; - const testItemUri = Uri.file('/workspace/project1/tests/test_foo.py'); - - const result = findProjectForTestItem(testItemUri, projects); - - assert.ok(result); - assert.strictEqual(result.uri.fsPath, projects[0].uri.fsPath); - }); - - test('should return deepest matching project for nested structure', () => { - const projects: ProjectRoot[] = [ - { uri: Uri.file('/workspace'), markerFile: 'pyproject.toml' }, - { uri: Uri.file('/workspace/subproject'), markerFile: 'pyproject.toml' }, - ]; - const testItemUri = Uri.file('/workspace/subproject/tests/test_foo.py'); - - const result = findProjectForTestItem(testItemUri, projects); - - assert.ok(result); - assert.strictEqual(result.uri.fsPath, projects[1].uri.fsPath); - }); - - test('should return undefined when no matching project found', () => { - const projects: ProjectRoot[] = [ - { uri: Uri.file('/workspace/project1'), markerFile: 'pyproject.toml' }, - ]; - const testItemUri = Uri.file('/different/workspace/test_foo.py'); - - const result = findProjectForTestItem(testItemUri, projects); - - assert.strictEqual(result, undefined); - }); - }); -});