Skip to content

Commit 460faa8

Browse files
Copiloteleanorjboyd
andcommitted
Phase 2: Add project discovery integration
- Added project-based state maps (workspaceProjects, vsIdToProject, fileUriToProject, projectToVsIds) - Implemented discoverWorkspaceProjects() to query Python Environment API - Created createProjectAdapter() to build ProjectAdapter from PythonProject - Added createDefaultProject() for backward compatibility - Imported necessary types from environment API - Added flag to enable/disable project-based testing Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent 7af8ea3 commit 460faa8

File tree

1 file changed

+245
-0
lines changed

1 file changed

+245
-0
lines changed

src/client/testing/testController/controller.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ import { ITestDebugLauncher } from '../common/types';
5252
import { PythonResultResolver } from './common/resultResolver';
5353
import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis';
5454
import { IEnvironmentVariablesProvider } from '../../common/variables/types';
55+
import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter';
56+
import { generateProjectId, createProjectDisplayName } from './common/projectUtils';
57+
import { PythonEnvironmentApi, PythonProject, PythonEnvironment } from '../../envExt/types';
58+
import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal';
5559

5660
// Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types.
5761
type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER];
@@ -62,8 +66,24 @@ type TriggerType = EventPropertyType[TriggerKeyType];
6266
export class PythonTestController implements ITestController, IExtensionSingleActivationService {
6367
public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false };
6468

69+
// Legacy: Single workspace test adapter per workspace (backward compatibility)
6570
private readonly testAdapters: Map<Uri, WorkspaceTestAdapter> = new Map();
6671

72+
// === NEW: PROJECT-BASED STATE ===
73+
// Map of workspace URI -> Map of project ID -> ProjectAdapter
74+
private readonly workspaceProjects: Map<Uri, Map<string, ProjectAdapter>> = new Map();
75+
76+
// Fast lookup maps for test execution
77+
private readonly vsIdToProject: Map<string, ProjectAdapter> = new Map();
78+
private readonly fileUriToProject: Map<string, ProjectAdapter> = new Map();
79+
private readonly projectToVsIds: Map<string, Set<string>> = new Map();
80+
81+
// Temporary discovery state (created during discovery, cleared after)
82+
private readonly workspaceDiscoveryState: Map<Uri, WorkspaceDiscoveryState> = new Map();
83+
84+
// Flag to enable/disable project-based testing
85+
private useProjectBasedTesting = false;
86+
6787
private readonly triggerTypes: TriggerType[] = [];
6888

6989
private readonly testController: TestController;
@@ -216,6 +236,231 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
216236
});
217237
}
218238

239+
/**
240+
* Discovers Python projects in a workspace using the Python Environment API.
241+
* Falls back to creating a single default project if API is unavailable or returns no projects.
242+
*/
243+
private async discoverWorkspaceProjects(workspaceUri: Uri): Promise<ProjectAdapter[]> {
244+
try {
245+
// Check if we should use the environment extension
246+
if (!useEnvExtension()) {
247+
traceVerbose('Python Environments extension not enabled, using single project mode');
248+
return [await this.createDefaultProject(workspaceUri)];
249+
}
250+
251+
// Get the environment API
252+
const envExtApi = await getEnvExtApi();
253+
254+
// Query for all Python projects in this workspace
255+
const pythonProjects = envExtApi.getPythonProjects();
256+
257+
// Filter projects to only those in this workspace
258+
const workspaceProjects = pythonProjects.filter(
259+
(project) => project.uri.fsPath.startsWith(workspaceUri.fsPath),
260+
);
261+
262+
if (workspaceProjects.length === 0) {
263+
traceVerbose(
264+
`No Python projects found for workspace ${workspaceUri.fsPath}, creating default project`,
265+
);
266+
return [await this.createDefaultProject(workspaceUri)];
267+
}
268+
269+
// Create ProjectAdapter for each Python project
270+
const projectAdapters: ProjectAdapter[] = [];
271+
for (const pythonProject of workspaceProjects) {
272+
try {
273+
const adapter = await this.createProjectAdapter(pythonProject, workspaceUri);
274+
projectAdapters.push(adapter);
275+
} catch (error) {
276+
traceError(`Failed to create project adapter for ${pythonProject.uri.fsPath}:`, error);
277+
// Continue with other projects
278+
}
279+
}
280+
281+
if (projectAdapters.length === 0) {
282+
traceVerbose('All project adapters failed to create, falling back to default project');
283+
return [await this.createDefaultProject(workspaceUri)];
284+
}
285+
286+
return projectAdapters;
287+
} catch (error) {
288+
traceError('Failed to discover workspace projects, falling back to single project mode:', error);
289+
return [await this.createDefaultProject(workspaceUri)];
290+
}
291+
}
292+
293+
/**
294+
* Creates a ProjectAdapter from a PythonProject object.
295+
*/
296+
private async createProjectAdapter(
297+
pythonProject: PythonProject,
298+
workspaceUri: Uri,
299+
): Promise<ProjectAdapter> {
300+
// Generate unique project ID
301+
const projectId = generateProjectId(pythonProject);
302+
303+
// Resolve the Python environment
304+
const envExtApi = await getEnvExtApi();
305+
const pythonEnvironment = await envExtApi.resolveEnvironment(pythonProject.uri);
306+
307+
if (!pythonEnvironment) {
308+
throw new Error(`Failed to resolve Python environment for project ${pythonProject.uri.fsPath}`);
309+
}
310+
311+
// Get workspace settings (shared by all projects in workspace)
312+
const settings = this.configSettings.getSettings(workspaceUri);
313+
314+
// Determine test provider
315+
const testProvider: TestProvider = settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER;
316+
317+
// Create result resolver with project ID
318+
const resultResolver = new PythonResultResolver(
319+
this.testController,
320+
testProvider,
321+
workspaceUri,
322+
projectId,
323+
);
324+
325+
// Create discovery and execution adapters
326+
let discoveryAdapter: ITestDiscoveryAdapter;
327+
let executionAdapter: ITestExecutionAdapter;
328+
329+
if (testProvider === UNITTEST_PROVIDER) {
330+
discoveryAdapter = new UnittestTestDiscoveryAdapter(
331+
this.configSettings,
332+
resultResolver,
333+
this.envVarsService,
334+
);
335+
executionAdapter = new UnittestTestExecutionAdapter(
336+
this.configSettings,
337+
resultResolver,
338+
this.envVarsService,
339+
);
340+
} else {
341+
discoveryAdapter = new PytestTestDiscoveryAdapter(
342+
this.configSettings,
343+
resultResolver,
344+
this.envVarsService,
345+
);
346+
executionAdapter = new PytestTestExecutionAdapter(
347+
this.configSettings,
348+
resultResolver,
349+
this.envVarsService,
350+
);
351+
}
352+
353+
// Create display name with Python version
354+
const projectName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version);
355+
356+
// Create project adapter
357+
const projectAdapter: ProjectAdapter = {
358+
projectId,
359+
projectName,
360+
projectUri: pythonProject.uri,
361+
workspaceUri,
362+
pythonProject,
363+
pythonEnvironment,
364+
testProvider,
365+
discoveryAdapter,
366+
executionAdapter,
367+
resultResolver,
368+
isDiscovering: false,
369+
isExecuting: false,
370+
};
371+
372+
return projectAdapter;
373+
}
374+
375+
/**
376+
* Creates a default project adapter using the workspace interpreter.
377+
* Used for backward compatibility when environment API is unavailable.
378+
*/
379+
private async createDefaultProject(workspaceUri: Uri): Promise<ProjectAdapter> {
380+
const settings = this.configSettings.getSettings(workspaceUri);
381+
const testProvider: TestProvider = settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER;
382+
383+
// Create result resolver WITHOUT project ID (legacy mode)
384+
const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri);
385+
386+
// Create discovery and execution adapters
387+
let discoveryAdapter: ITestDiscoveryAdapter;
388+
let executionAdapter: ITestExecutionAdapter;
389+
390+
if (testProvider === UNITTEST_PROVIDER) {
391+
discoveryAdapter = new UnittestTestDiscoveryAdapter(
392+
this.configSettings,
393+
resultResolver,
394+
this.envVarsService,
395+
);
396+
executionAdapter = new UnittestTestExecutionAdapter(
397+
this.configSettings,
398+
resultResolver,
399+
this.envVarsService,
400+
);
401+
} else {
402+
discoveryAdapter = new PytestTestDiscoveryAdapter(
403+
this.configSettings,
404+
resultResolver,
405+
this.envVarsService,
406+
);
407+
executionAdapter = new PytestTestExecutionAdapter(
408+
this.configSettings,
409+
resultResolver,
410+
this.envVarsService,
411+
);
412+
}
413+
414+
// Get active interpreter
415+
const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri);
416+
417+
// Create a mock PythonEnvironment from the interpreter
418+
const pythonEnvironment: PythonEnvironment = {
419+
name: 'default',
420+
displayName: interpreter?.displayName || 'Python',
421+
shortDisplayName: interpreter?.displayName || 'Python',
422+
displayPath: interpreter?.path || 'python',
423+
version: interpreter?.version?.raw || '3.x',
424+
environmentPath: Uri.file(interpreter?.path || 'python'),
425+
sysPrefix: interpreter?.sysPrefix || '',
426+
execInfo: {
427+
run: {
428+
executable: interpreter?.path || 'python',
429+
},
430+
},
431+
envId: {
432+
id: 'default',
433+
managerId: 'default',
434+
},
435+
};
436+
437+
// Create a mock PythonProject
438+
const pythonProject: PythonProject = {
439+
name: workspaceUri.fsPath.split('/').pop() || 'workspace',
440+
uri: workspaceUri,
441+
};
442+
443+
// Use workspace URI as project ID for default project
444+
const projectId = `default-${workspaceUri.fsPath}`;
445+
446+
const projectAdapter: ProjectAdapter = {
447+
projectId,
448+
projectName: pythonProject.name,
449+
projectUri: workspaceUri,
450+
workspaceUri,
451+
pythonProject,
452+
pythonEnvironment,
453+
testProvider,
454+
discoveryAdapter,
455+
executionAdapter,
456+
resultResolver,
457+
isDiscovering: false,
458+
isExecuting: false,
459+
};
460+
461+
return projectAdapter;
462+
}
463+
219464
public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise<void> {
220465
if (options?.forceRefresh) {
221466
if (uri === undefined) {

0 commit comments

Comments
 (0)