@@ -53,7 +53,7 @@ import { ITestDebugLauncher } from '../common/types';
5353import { PythonResultResolver } from './common/resultResolver' ;
5454import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis' ;
5555import { IEnvironmentVariablesProvider } from '../../common/variables/types' ;
56- import { ProjectAdapter } from './common/projectAdapter' ;
56+ import { ProjectAdapter , WorkspaceDiscoveryState } from './common/projectAdapter' ;
5757import { getProjectId , createProjectDisplayName } from './common/projectUtils' ;
5858import { PythonProject , PythonEnvironment } from '../../envExt/types' ;
5959import { getEnvExtApi , useEnvExtension } from '../../envExt/api.internal' ;
@@ -73,7 +73,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
7373 * Set to true to enable multi-project testing support (Phases 2-4 must be complete).
7474 * Default: false (use legacy single-workspace mode)
7575 */
76- private readonly useProjectBasedTesting = false ;
76+ private readonly useProjectBasedTesting = true ;
7777
7878 // Legacy: Single workspace test adapter per workspace (backward compatibility)
7979 private readonly testAdapters : Map < Uri , WorkspaceTestAdapter > = new Map ( ) ;
@@ -83,11 +83,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
8383 // Note: Project URI strings match Python Environments extension's Map<string, PythonProject> keys
8484 private readonly workspaceProjects : Map < Uri , Map < string , ProjectAdapter > > = new Map ( ) ;
8585
86- // TODO: Phase 3-4 - Add these maps when implementing discovery and execution:
86+ // Temporary state for tracking overlaps during discovery (created/destroyed per refresh)
87+ private readonly workspaceDiscoveryState : Map < Uri , WorkspaceDiscoveryState > = new Map ( ) ;
88+
89+ // TODO: Phase 3-4 - Add these maps when implementing execution:
8790 // - vsIdToProject: Map<string, ProjectAdapter> - Fast lookup for test execution
8891 // - fileUriToProject: Map<string, ProjectAdapter> - File watching and change detection
8992 // - projectToVsIds: Map<string, Set<string>> - Project cleanup and refresh
90- // - workspaceDiscoveryState: Map<Uri, WorkspaceDiscoveryState> - Temporary overlap detection
9193
9294 private readonly triggerTypes : TriggerType [ ] = [ ] ;
9395
@@ -551,6 +553,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
551553 // Ensure we send test telemetry if it gets disabled again
552554 this . sendTestDisabledTelemetry = true ;
553555
556+ // Branch: Use project-based discovery if feature flag enabled and projects exist
557+ if ( this . useProjectBasedTesting && this . workspaceProjects . has ( workspace . uri ) ) {
558+ await this . refreshWorkspaceProjects ( workspace . uri ) ;
559+ return ;
560+ }
561+
562+ // Legacy mode: Single workspace adapter
554563 if ( settings . testing . pytestEnabled ) {
555564 await this . discoverTestsForProvider ( workspace . uri , 'pytest' ) ;
556565 } else if ( settings . testing . unittestEnabled ) {
@@ -560,6 +569,137 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
560569 }
561570 }
562571
572+ /**
573+ * Phase 2: Discovers tests for all projects within a workspace (project-based testing).
574+ * Runs discovery in parallel for all projects and tracks file overlaps for Phase 3.
575+ * Each project populates its TestItems independently using the existing discovery flow.
576+ */
577+ private async refreshWorkspaceProjects ( workspaceUri : Uri ) : Promise < void > {
578+ const projectsMap = this . workspaceProjects . get ( workspaceUri ) ;
579+ if ( ! projectsMap || projectsMap . size === 0 ) {
580+ traceError ( `[test-by-project] No projects found for workspace: ${ workspaceUri . fsPath } ` ) ;
581+ return ;
582+ }
583+
584+ const projects = Array . from ( projectsMap . values ( ) ) ;
585+ traceInfo ( `[test-by-project] Starting discovery for ${ projects . length } project(s) in workspace` ) ;
586+
587+ // Initialize discovery state for overlap tracking
588+ const discoveryState : WorkspaceDiscoveryState = {
589+ workspaceUri,
590+ fileToProjects : new Map ( ) ,
591+ fileOwnership : new Map ( ) ,
592+ projectsCompleted : new Set ( ) ,
593+ totalProjects : projects . length ,
594+ isComplete : false ,
595+ } ;
596+ this . workspaceDiscoveryState . set ( workspaceUri , discoveryState ) ;
597+
598+ try {
599+ // Run discovery for all projects in parallel
600+ // Each project will populate TestItems independently via existing flow
601+ await Promise . all ( projects . map ( ( project ) => this . discoverProject ( project , discoveryState ) ) ) ;
602+
603+ // Mark discovery complete
604+ discoveryState . isComplete = true ;
605+ traceInfo (
606+ `[test-by-project] Discovery complete: ${ discoveryState . projectsCompleted . size } /${ projects . length } projects succeeded` ,
607+ ) ;
608+
609+ // Log overlap information for debugging
610+ const overlappingFiles = Array . from ( discoveryState . fileToProjects . entries ( ) ) . filter (
611+ ( [ , projects ] ) => projects . size > 1 ,
612+ ) ;
613+ if ( overlappingFiles . length > 0 ) {
614+ traceInfo ( `[test-by-project] Found ${ overlappingFiles . length } file(s) discovered by multiple projects` ) ;
615+ }
616+
617+ // TODO: Phase 3 - Resolve overlaps and rebuild test tree with proper ownership
618+ // await this.resolveOverlapsAndAssignTests(workspaceUri);
619+ } finally {
620+ // Clean up temporary discovery state
621+ this . workspaceDiscoveryState . delete ( workspaceUri ) ;
622+ }
623+ }
624+
625+ /**
626+ * Phase 2: Runs test discovery for a single project.
627+ * Uses the existing discovery flow which populates TestItems automatically.
628+ * Tracks which files were discovered for overlap detection in Phase 3.
629+ */
630+ private async discoverProject ( project : ProjectAdapter , discoveryState : WorkspaceDiscoveryState ) : Promise < void > {
631+ try {
632+ traceInfo ( `[test-by-project] Discovering tests for project: ${ project . projectName } ` ) ;
633+ project . isDiscovering = true ;
634+
635+ // Run discovery using project's adapter with project's interpreter
636+ // This will call the existing discovery flow which populates TestItems via result resolver
637+ // Note: The adapter expects the legacy PythonEnvironment type, but for now we can pass
638+ // the environment from the API. The adapters internally use execInfo which both types have.
639+ //
640+ // Pass the ProjectAdapter so discovery adapters can extract project.projectUri.fsPath
641+ // and set PROJECT_ROOT_PATH environment variable. This tells Python subprocess where to
642+ // trim the test tree, keeping test paths relative to project root instead of workspace root,
643+ // while preserving CWD for user's test configurations.
644+ //
645+ // TODO: Symlink consideration - If project.projectUri.fsPath contains symlinks,
646+ // Python's path resolution may differ from Node.js. Discovery adapters should consider
647+ // using fs.promises.realpath() to resolve symlinks before passing PROJECT_ROOT_PATH to Python,
648+ // similar to handleSymlinkAndRootDir() in pytest. This ensures PROJECT_ROOT_PATH matches
649+ // the resolved path Python will use.
650+ await project . discoveryAdapter . discoverTests (
651+ project . projectUri ,
652+ this . pythonExecFactory ,
653+ this . refreshCancellation . token ,
654+ project . pythonEnvironment as any , // Type cast needed - API type vs legacy type
655+ project , // Pass project for access to projectUri and other project-specific data
656+ ) ;
657+
658+ // Track which files this project discovered by inspecting created TestItems
659+ // This data will be used in Phase 3 for overlap resolution
660+ this . trackProjectDiscoveredFiles ( project , discoveryState ) ;
661+
662+ // Mark project as completed
663+ discoveryState . projectsCompleted . add ( project . projectId ) ;
664+ traceInfo ( `[test-by-project] Project ${ project . projectName } discovery completed` ) ;
665+ } catch ( error ) {
666+ traceError ( `[test-by-project] Discovery failed for project ${ project . projectName } :` , error ) ;
667+ // Individual project failures don't block others
668+ discoveryState . projectsCompleted . add ( project . projectId ) ; // Still mark as completed
669+ } finally {
670+ project . isDiscovering = false ;
671+ }
672+ }
673+
674+ /**
675+ * Tracks which files a project discovered by inspecting its TestItems.
676+ * Populates the fileToProjects map for overlap detection in Phase 3.
677+ */
678+ private trackProjectDiscoveredFiles ( project : ProjectAdapter , discoveryState : WorkspaceDiscoveryState ) : void {
679+ // Get all test items for this project from its result resolver
680+ const testItems = project . resultResolver . runIdToTestItem ;
681+
682+ // Extract unique file paths from test items
683+ const filePaths = new Set < string > ( ) ;
684+ testItems . forEach ( ( testItem ) => {
685+ if ( testItem . uri ) {
686+ filePaths . add ( testItem . uri . fsPath ) ;
687+ }
688+ } ) ;
689+
690+ // Track which projects discovered each file
691+ filePaths . forEach ( ( filePath ) => {
692+ if ( ! discoveryState . fileToProjects . has ( filePath ) ) {
693+ discoveryState . fileToProjects . set ( filePath , new Set ( ) ) ;
694+ }
695+ discoveryState . fileToProjects . get ( filePath ) ! . add ( project ) ;
696+ } ) ;
697+
698+ traceVerbose (
699+ `[test-by-project] Project ${ project . projectName } discovered ${ filePaths . size } file(s) with ${ testItems . size } test(s)` ,
700+ ) ;
701+ }
702+
563703 /**
564704 * Discovers tests for all workspaces in the workspace folders.
565705 */
0 commit comments