@@ -10,6 +10,7 @@ import { ITestDebugLauncher } from '../../common/types';
1010import { ProjectAdapter } from './projectAdapter' ;
1111import { TestProjectRegistry } from './testProjectRegistry' ;
1212import { getProjectId } from './projectUtils' ;
13+ import { getEnvExtApi , useEnvExtension } from '../../../envExt/api.internal' ;
1314
1415/**
1516 * Dependencies required for project-based test execution.
@@ -50,7 +51,7 @@ export async function executeTestsForProjects(
5051 }
5152
5253 // Group test items by project
53- const testsByProject = groupTestItemsByProject ( testItems , projects ) ;
54+ const testsByProject = await groupTestItemsByProject ( testItems , projects ) ;
5455
5556 const isDebugMode = request . profile ?. kind === TestRunProfileKind . Debug ;
5657 traceInfo ( `[test-by-project] Executing tests across ${ testsByProject . size } project(s), debug=${ isDebugMode } ` ) ;
@@ -99,23 +100,49 @@ export async function executeTestsForProjects(
99100}
100101
101102/**
102- * Groups test items by their owning project based on file path matching.
103- * Each test item's URI is matched against project root paths.
103+ * Lookup context for project resolution during a single test run.
104+ * Maps file paths to their resolved ProjectAdapter to avoid
105+ * repeated API calls and linear searches.
106+ * Created fresh per run and discarded after grouping completes.
104107 */
105- export function groupTestItemsByProject (
108+ interface ProjectLookupContext {
109+ /** Maps file URI fsPath → resolved ProjectAdapter (or undefined if no match) */
110+ uriToAdapter : Map < string , ProjectAdapter | undefined > ;
111+ /** Maps project URI fsPath → ProjectAdapter for O(1) adapter lookup */
112+ projectPathToAdapter : Map < string , ProjectAdapter > ;
113+ }
114+
115+ /**
116+ * Groups test items by their owning project using the Python Environment API.
117+ * Each test item's URI is matched to a project via the API's getPythonProject method.
118+ * Falls back to path-based matching when the extension API is not available.
119+ *
120+ * Uses a per-run cache to avoid redundant API calls for test items sharing the same file.
121+ *
122+ * Time complexity: O(n + p) amortized, where n = test items, p = projects
123+ * - Building adapter lookup map: O(p)
124+ * - Each test item: O(1) amortized (cached after first lookup per unique file)
125+ */
126+ export async function groupTestItemsByProject (
106127 testItems : TestItem [ ] ,
107128 projects : ProjectAdapter [ ] ,
108- ) : Map < string , { project : ProjectAdapter ; items : TestItem [ ] } > {
129+ ) : Promise < Map < string , { project : ProjectAdapter ; items : TestItem [ ] } > > {
109130 const result = new Map < string , { project : ProjectAdapter ; items : TestItem [ ] } > ( ) ;
110131
111132 // Initialize entries for all projects
112133 for ( const project of projects ) {
113134 result . set ( getProjectId ( project . projectUri ) , { project, items : [ ] } ) ;
114135 }
115136
137+ // Build lookup context for this run - O(p) setup, enables O(1) lookups
138+ const lookupContext : ProjectLookupContext = {
139+ uriToAdapter : new Map ( ) ,
140+ projectPathToAdapter : new Map ( projects . map ( ( p ) => [ p . projectUri . fsPath , p ] ) ) ,
141+ } ;
142+
116143 // Assign each test item to its project
117144 for ( const item of testItems ) {
118- const project = findProjectForTestItem ( item , projects ) ;
145+ const project = await findProjectForTestItem ( item , projects , lookupContext ) ;
119146 if ( project ) {
120147 const entry = result . get ( getProjectId ( project . projectUri ) ) ;
121148 if ( entry ) {
@@ -139,9 +166,64 @@ export function groupTestItemsByProject(
139166
140167/**
141168 * Finds the project that owns a test item based on the test item's URI.
169+ * Uses the Python Environment extension API when available, falling back
170+ * to path-based matching (longest matching path prefix).
171+ *
172+ * Results are stored in the lookup context to avoid redundant API calls for items in the same file.
173+ * Time complexity: O(1) amortized with context, O(p) worst case on context miss.
174+ */
175+ export async function findProjectForTestItem (
176+ item : TestItem ,
177+ projects : ProjectAdapter [ ] ,
178+ lookupContext ?: ProjectLookupContext ,
179+ ) : Promise < ProjectAdapter | undefined > {
180+ if ( ! item . uri ) return undefined ;
181+
182+ const uriPath = item . uri . fsPath ;
183+
184+ // Check lookup context first - O(1)
185+ if ( lookupContext ?. uriToAdapter . has ( uriPath ) ) {
186+ return lookupContext . uriToAdapter . get ( uriPath ) ;
187+ }
188+
189+ let result : ProjectAdapter | undefined ;
190+
191+ // Try using the Python Environment extension API first
192+ if ( useEnvExtension ( ) ) {
193+ try {
194+ const envExtApi = await getEnvExtApi ( ) ;
195+ const pythonProject = envExtApi . getPythonProject ( item . uri ) ;
196+ if ( pythonProject ) {
197+ // Use lookup context for O(1) adapter lookup instead of O(p) linear search
198+ result = lookupContext ?. projectPathToAdapter . get ( pythonProject . uri . fsPath ) ;
199+ if ( ! result ) {
200+ // Fallback to linear search if lookup context not available
201+ result = projects . find ( ( p ) => p . projectUri . fsPath === pythonProject . uri . fsPath ) ;
202+ }
203+ }
204+ } catch ( error ) {
205+ traceVerbose ( `[test-by-project] Failed to use env extension API, falling back to path matching: ${ error } ` ) ;
206+ }
207+ }
208+
209+ // Fallback: path-based matching (most specific/longest path wins)
210+ if ( ! result ) {
211+ result = findProjectByPath ( item , projects ) ;
212+ }
213+
214+ // Store result for future lookups of same file within this run - O(1)
215+ if ( lookupContext ) {
216+ lookupContext . uriToAdapter . set ( uriPath , result ) ;
217+ }
218+
219+ return result ;
220+ }
221+
222+ /**
223+ * Finds the project that owns a test item using path-based matching.
142224 * Returns the most specific (longest path) matching project.
143225 */
144- export function findProjectForTestItem ( item : TestItem , projects : ProjectAdapter [ ] ) : ProjectAdapter | undefined {
226+ function findProjectByPath ( item : TestItem , projects : ProjectAdapter [ ] ) : ProjectAdapter | undefined {
145227 if ( ! item . uri ) return undefined ;
146228
147229 const itemPath = item . uri . fsPath ;
0 commit comments