@@ -52,6 +52,10 @@ import { ITestDebugLauncher } from '../common/types';
5252import { PythonResultResolver } from './common/resultResolver' ;
5353import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis' ;
5454import { 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.
5761type EventPropertyType = IEventNamePropertyMapping [ EventName . UNITTEST_DISCOVERY_TRIGGER ] ;
@@ -62,8 +66,24 @@ type TriggerType = EventPropertyType[TriggerKeyType];
6266export 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