Skip to content

Commit 29533cf

Browse files
committed
cleanup
1 parent 225ff12 commit 29533cf

File tree

8 files changed

+599
-99
lines changed

8 files changed

+599
-99
lines changed

.github/instructions/testing_feature_area.instructions.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ This document maps the testing support in the extension: discovery, execution (r
2626
- `src/client/testing/serviceRegistry.ts` — DI/wiring for testing services.
2727
- Workspace orchestration
2828
- `src/client/testing/testController/workspaceTestAdapter.ts``WorkspaceTestAdapter` (provider-agnostic entry used by controller).
29+
- **Project-based testing (multi-project workspaces)**
30+
- `src/client/testing/testController/common/testProjectRegistry.ts``TestProjectRegistry` (manages project lifecycle, discovery, and nested project handling).
31+
- `src/client/testing/testController/common/projectAdapter.ts``ProjectAdapter` interface (represents a single Python project with its own test infrastructure).
32+
- `src/client/testing/testController/common/projectUtils.ts` — utilities for project ID generation, display names, and shared adapter creation.
2933
- Provider adapters
3034
- Unittest
3135
- `src/client/testing/testController/unittest/testDiscoveryAdapter.ts`
@@ -151,6 +155,51 @@ The adapters in the extension don't implement test discovery/run logic themselve
151155
- Settings are consumed by `src/client/testing/common/testConfigurationManager.ts`, `src/client/testing/configurationFactory.ts`, and adapters under `src/client/testing/testController/*` which read settings to build CLI args and env for subprocesses.
152156
- The setting definitions and descriptions are in `package.json` and localized strings in `package.nls.json`.
153157

158+
## Project-based testing (multi-project workspaces)
159+
160+
Project-based testing enables multi-project workspace support where each Python project gets its own test tree root with its own Python environment.
161+
162+
> **⚠️ Note: unittest support for project-based testing is NOT yet implemented.** Project-based testing currently only works with pytest. unittest support will be added in a future PR.
163+
164+
### Architecture
165+
166+
- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that:
167+
168+
- Discovers Python projects via the Python Environments API
169+
- Creates and manages `ProjectAdapter` instances per workspace
170+
- Computes nested project relationships and configures ignore lists
171+
- Falls back to "legacy" single-adapter mode when API unavailable
172+
173+
- **ProjectAdapter** (`projectAdapter.ts`): Interface representing a single project with:
174+
- Project identity (ID, name, URI from Python Environments API)
175+
- Python environment with execution details
176+
- Test framework adapters (discovery/execution)
177+
- Nested project ignore paths (for parent projects)
178+
179+
### How it works
180+
181+
1. **Activation**: When the extension activates, `PythonTestController` checks if the Python Environments API is available.
182+
2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace.
183+
3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists.
184+
4. **Test discovery**: For each project, the controller calls `project.discoveryAdapter.discoverTests()` with the project's URI. The adapter sets `PROJECT_ROOT_PATH` environment variable for the Python runner.
185+
5. **Python side**: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`.
186+
6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `||` separator.
187+
188+
### Logging prefix
189+
190+
All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel.
191+
192+
### Key files
193+
194+
- Python side: `python_files/vscode_pytest/__init__.py``get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable.
195+
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters.
196+
197+
### Tests
198+
199+
- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests
200+
- `src/test/testing/testController/common/projectUtils.unit.test.ts` — Project utility function tests
201+
- `python_files/tests/pytestadapter/test_get_test_root_path.py` — Python-side get_test_root_path() tests
202+
154203
## Coverage support (how it works)
155204

156205
- Coverage is supported by running the Python helper scripts with coverage enabled and then collecting a coverage payload from the runner.

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
// Licensed under the MIT License.
33

44
import { Uri } from 'vscode';
5+
import { IConfigurationService } from '../../../common/types';
6+
import { IEnvironmentVariablesProvider } from '../../../common/variables/types';
7+
import { UNITTEST_PROVIDER } from '../../common/constants';
8+
import { TestProvider } from '../../types';
9+
import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './types';
10+
import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter';
11+
import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter';
12+
import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter';
13+
import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter';
514

615
/**
716
* Separator used to scope test IDs to a specific project.
@@ -52,3 +61,32 @@ export function createProjectDisplayName(projectName: string, pythonVersion: str
5261

5362
return `${projectName} (Python ${shortVersion})`;
5463
}
64+
65+
/**
66+
* Creates test adapters (discovery and execution) for a given test provider.
67+
* Centralizes adapter creation to avoid code duplication across Controller and TestProjectRegistry.
68+
*
69+
* @param testProvider The test framework provider ('pytest' | 'unittest')
70+
* @param resultResolver The result resolver to use for test results
71+
* @param configSettings The configuration service
72+
* @param envVarsService The environment variables provider
73+
* @returns An object containing the discovery and execution adapters
74+
*/
75+
export function createTestAdapters(
76+
testProvider: TestProvider,
77+
resultResolver: ITestResultResolver,
78+
configSettings: IConfigurationService,
79+
envVarsService: IEnvironmentVariablesProvider,
80+
): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } {
81+
if (testProvider === UNITTEST_PROVIDER) {
82+
return {
83+
discoveryAdapter: new UnittestTestDiscoveryAdapter(configSettings, resultResolver, envVarsService),
84+
executionAdapter: new UnittestTestExecutionAdapter(configSettings, resultResolver, envVarsService),
85+
};
86+
}
87+
88+
return {
89+
discoveryAdapter: new PytestTestDiscoveryAdapter(configSettings, resultResolver, envVarsService),
90+
executionAdapter: new PytestTestExecutionAdapter(configSettings, resultResolver, envVarsService),
91+
};
92+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class TestDiscoveryHandler {
6767
runIdToTestItem: testItemIndex.runIdToTestItemMap,
6868
runIdToVSid: testItemIndex.runIdToVSidMap,
6969
vsIdToRunId: testItemIndex.vsIdToRunIdMap,
70-
} as any,
70+
},
7171
token,
7272
projectId,
7373
);

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

Lines changed: 26 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,8 @@ import { PythonProject, PythonEnvironment } from '../../../envExt/types';
1313
import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal';
1414
import { isParentPath } from '../../../common/platform/fs-paths';
1515
import { ProjectAdapter } from './projectAdapter';
16-
import { getProjectId, createProjectDisplayName } from './projectUtils';
16+
import { getProjectId, createProjectDisplayName, createTestAdapters } from './projectUtils';
1717
import { PythonResultResolver } from './resultResolver';
18-
import { ITestDiscoveryAdapter, ITestExecutionAdapter } from './types';
19-
import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter';
20-
import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter';
21-
import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter';
22-
import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter';
2318

2419
/**
2520
* Registry for Python test projects within workspaces.
@@ -83,7 +78,7 @@ export class TestProjectRegistry {
8378
* Returns the discovered projects for the caller to use.
8479
*/
8580
public async discoverAndRegisterProjects(workspaceUri: Uri): Promise<ProjectAdapter[]> {
86-
traceInfo(`[ProjectManager] Discovering projects for workspace: ${workspaceUri.fsPath}`);
81+
traceInfo(`[test-by-project] Discovering projects for workspace: ${workspaceUri.fsPath}`);
8782

8883
const projects = await this.discoverProjects(workspaceUri);
8984

@@ -94,7 +89,7 @@ export class TestProjectRegistry {
9489
});
9590

9691
this.workspaceProjects.set(workspaceUri, projectsMap);
97-
traceInfo(`[ProjectManager] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`);
92+
traceInfo(`[test-by-project] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`);
9893

9994
return projects;
10095
}
@@ -111,7 +106,7 @@ export class TestProjectRegistry {
111106
const ignorePaths = projectIgnores.get(project.projectId);
112107
if (ignorePaths && ignorePaths.length > 0) {
113108
project.nestedProjectPathsToIgnore = ignorePaths;
114-
traceInfo(`[ProjectManager] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`);
109+
traceInfo(`[test-by-project] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`);
115110
}
116111
}
117112
}
@@ -132,22 +127,22 @@ export class TestProjectRegistry {
132127
private async discoverProjects(workspaceUri: Uri): Promise<ProjectAdapter[]> {
133128
try {
134129
if (!useEnvExtension()) {
135-
traceInfo('[ProjectManager] Python Environments API not available, using default project');
130+
traceInfo('[test-by-project] Python Environments API not available, using default project');
136131
return [await this.createDefaultProject(workspaceUri)];
137132
}
138133

139134
const envExtApi = await getEnvExtApi();
140135
const allProjects = envExtApi.getPythonProjects();
141-
traceInfo(`[ProjectManager] Found ${allProjects.length} total Python projects from API`);
136+
traceInfo(`[test-by-project] Found ${allProjects.length} total Python projects from API`);
142137

143138
// Filter to projects within this workspace
144139
const workspaceProjects = allProjects.filter((project) =>
145140
isParentPath(project.uri.fsPath, workspaceUri.fsPath),
146141
);
147-
traceInfo(`[ProjectManager] Filtered to ${workspaceProjects.length} projects in workspace`);
142+
traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`);
148143

149144
if (workspaceProjects.length === 0) {
150-
traceInfo('[ProjectManager] No projects found, creating default project');
145+
traceInfo('[test-by-project] No projects found, creating default project');
151146
return [await this.createDefaultProject(workspaceUri)];
152147
}
153148

@@ -158,18 +153,18 @@ export class TestProjectRegistry {
158153
const adapter = await this.createProjectAdapter(pythonProject, workspaceUri);
159154
adapters.push(adapter);
160155
} catch (error) {
161-
traceError(`[ProjectManager] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error);
156+
traceError(`[test-by-project] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error);
162157
}
163158
}
164159

165160
if (adapters.length === 0) {
166-
traceInfo('[ProjectManager] All adapters failed, falling back to default project');
161+
traceInfo('[test-by-project] All adapters failed, falling back to default project');
167162
return [await this.createDefaultProject(workspaceUri)];
168163
}
169164

170165
return adapters;
171166
} catch (error) {
172-
traceError('[ProjectManager] Discovery failed, using default project:', error);
167+
traceError('[test-by-project] Discovery failed, using default project:', error);
173168
return [await this.createDefaultProject(workspaceUri)];
174169
}
175170
}
@@ -179,7 +174,7 @@ export class TestProjectRegistry {
179174
*/
180175
private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise<ProjectAdapter> {
181176
const projectId = pythonProject.uri.fsPath;
182-
traceInfo(`[ProjectManager] Creating adapter for: ${pythonProject.name} at ${projectId}`);
177+
traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`);
183178

184179
// Resolve Python environment
185180
const envExtApi = await getEnvExtApi();
@@ -191,7 +186,12 @@ export class TestProjectRegistry {
191186
// Create test infrastructure
192187
const testProvider = this.getTestProvider(workspaceUri);
193188
const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri, projectId);
194-
const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver);
189+
const { discoveryAdapter, executionAdapter } = createTestAdapters(
190+
testProvider,
191+
resultResolver,
192+
this.configSettings,
193+
this.envVarsService,
194+
);
195195

196196
const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version);
197197

@@ -215,11 +215,16 @@ export class TestProjectRegistry {
215215
* Creates a default project for legacy/fallback mode.
216216
*/
217217
private async createDefaultProject(workspaceUri: Uri): Promise<ProjectAdapter> {
218-
traceInfo(`[ProjectManager] Creating default project for: ${workspaceUri.fsPath}`);
218+
traceInfo(`[test-by-project] Creating default project for: ${workspaceUri.fsPath}`);
219219

220220
const testProvider = this.getTestProvider(workspaceUri);
221221
const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri);
222-
const { discoveryAdapter, executionAdapter } = this.createAdapters(testProvider, resultResolver);
222+
const { discoveryAdapter, executionAdapter } = createTestAdapters(
223+
testProvider,
224+
resultResolver,
225+
this.configSettings,
226+
this.envVarsService,
227+
);
223228

224229
const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri);
225230

@@ -276,7 +281,7 @@ export class TestProjectRegistry {
276281

277282
if (childPath.startsWith(parentPath + path.sep)) {
278283
nestedPaths.push(childPath);
279-
traceVerbose(`[ProjectManager] Nested: ${child.projectName} under ${parent.projectName}`);
284+
traceVerbose(`[test-by-project] Nested: ${child.projectName} under ${parent.projectName}`);
280285
}
281286
}
282287

@@ -295,32 +300,4 @@ export class TestProjectRegistry {
295300
const settings = this.configSettings.getSettings(workspaceUri);
296301
return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : 'pytest';
297302
}
298-
299-
/**
300-
* Creates discovery and execution adapters for a test provider.
301-
*/
302-
private createAdapters(
303-
testProvider: TestProvider,
304-
resultResolver: PythonResultResolver,
305-
): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } {
306-
if (testProvider === UNITTEST_PROVIDER) {
307-
return {
308-
discoveryAdapter: new UnittestTestDiscoveryAdapter(
309-
this.configSettings,
310-
resultResolver,
311-
this.envVarsService,
312-
),
313-
executionAdapter: new UnittestTestExecutionAdapter(
314-
this.configSettings,
315-
resultResolver,
316-
this.envVarsService,
317-
),
318-
};
319-
}
320-
321-
return {
322-
discoveryAdapter: new PytestTestDiscoveryAdapter(this.configSettings, resultResolver, this.envVarsService),
323-
executionAdapter: new PytestTestExecutionAdapter(this.configSettings, resultResolver, this.envVarsService),
324-
};
325-
}
326303
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,18 @@ export type TestCommandOptions = {
143143
// triggerRunDataReceivedEvent(data: DataReceivedEvent): void;
144144
// triggerDiscoveryDataReceivedEvent(data: DataReceivedEvent): void;
145145
// }
146-
export interface ITestResultResolver {
146+
147+
/**
148+
* Test item mapping interface used by populateTestTree.
149+
* Contains only the maps needed for building the test tree.
150+
*/
151+
export interface ITestItemMappings {
147152
runIdToVSid: Map<string, string>;
148153
runIdToTestItem: Map<string, TestItem>;
149154
vsIdToRunId: Map<string, string>;
155+
}
156+
157+
export interface ITestResultResolver extends ITestItemMappings {
150158
detailedCoverageMap: Map<string, FileCoverageDetail[]>;
151159

152160
resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void;

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
DiscoveredTestNode,
1414
DiscoveredTestPayload,
1515
ExecutionTestPayload,
16-
ITestResultResolver,
16+
ITestItemMappings,
1717
} from './types';
1818
import { Deferred, createDeferred } from '../../../common/utils/async';
1919
import { createReaderPipe, generateRandomPipeName } from '../../../common/pipes/namedPipes';
@@ -210,7 +210,7 @@ export function populateTestTree(
210210
testController: TestController,
211211
testTreeData: DiscoveredTestNode,
212212
testRoot: TestItem | undefined,
213-
resultResolver: ITestResultResolver,
213+
testItemMappings: ITestItemMappings,
214214
token?: CancellationToken,
215215
projectId?: string,
216216
): void {
@@ -252,9 +252,9 @@ export function populateTestTree(
252252

253253
testRoot!.children.add(testItem);
254254
// add to our map - use runID as key, vsId as value
255-
resultResolver.runIdToTestItem.set(child.runID, testItem);
256-
resultResolver.runIdToVSid.set(child.runID, vsId);
257-
resultResolver.vsIdToRunId.set(vsId, child.runID);
255+
testItemMappings.runIdToTestItem.set(child.runID, testItem);
256+
testItemMappings.runIdToVSid.set(child.runID, vsId);
257+
testItemMappings.vsIdToRunId.set(vsId, child.runID);
258258
} else {
259259
let node = testController.items.get(child.path);
260260

@@ -282,7 +282,7 @@ export function populateTestTree(
282282

283283
testRoot!.children.add(node);
284284
}
285-
populateTestTree(testController, child, node, resultResolver, token, projectId);
285+
populateTestTree(testController, child, node, testItemMappings, token, projectId);
286286
}
287287
}
288288
});

0 commit comments

Comments
 (0)