Skip to content

Commit ecc92c4

Browse files
committed
updates
1 parent 9222587 commit ecc92c4

File tree

4 files changed

+113
-25
lines changed

4 files changed

+113
-25
lines changed

src/client/testing/common/debugLauncher.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getWorkspaceFolder, getWorkspaceFolders } from '../../common/vscodeApis
1717
import { showErrorMessage } from '../../common/vscodeApis/windowApis';
1818
import { createDeferred } from '../../common/utils/async';
1919
import { addPathToPythonpath } from './helpers';
20+
import * as envExtApi from '../../envExt/api.internal';
2021

2122
/**
2223
* Key used to mark debug configurations with a unique session identifier.
@@ -186,8 +187,8 @@ export class DebugLauncher implements ITestDebugLauncher {
186187
}
187188

188189
// Use project name in debug session name if provided
189-
if (options.debugSessionName) {
190-
debugConfig.name = `Debug Tests: ${options.debugSessionName}`;
190+
if (options.project) {
191+
debugConfig.name = `Debug Tests: ${options.project.name}`;
191192
}
192193

193194
if (!debugConfig.rules) {
@@ -339,11 +340,20 @@ export class DebugLauncher implements ITestDebugLauncher {
339340
// run via F5 style debugging.
340341
launchArgs.purpose = [];
341342

342-
// For project-based execution, use the explicit Python path if provided.
343+
// For project-based execution, get the Python path from the project's environment.
343344
// This ensures debug sessions use the correct interpreter for each project.
344-
if (options.pythonPath) {
345-
launchArgs.python = options.pythonPath;
346-
traceVerbose(`[test-by-project] Debug session using explicit Python path: ${options.pythonPath}`);
345+
if (options.project && envExtApi.useEnvExtension()) {
346+
try {
347+
const pythonEnv = await envExtApi.getEnvironment(options.project.uri);
348+
if (pythonEnv?.execInfo?.run?.executable) {
349+
launchArgs.python = pythonEnv.execInfo.run.executable;
350+
traceVerbose(
351+
`[test-by-project] Debug session using Python path from project: ${launchArgs.python}`,
352+
);
353+
}
354+
} catch (error) {
355+
traceVerbose(`[test-by-project] Could not get environment for project, using default: ${error}`);
356+
}
347357
}
348358

349359
return launchArgs;

src/client/testing/common/types.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CancellationToken, DebugSessionOptions, OutputChannel, Uri } from 'vsco
22
import { Product } from '../../common/types';
33
import { TestSettingsPropertyNames } from '../configuration/types';
44
import { TestProvider } from '../types';
5+
import { PythonProject } from '../../envExt/types';
56

67
export type UnitTestProduct = Product.pytest | Product.unittest;
78

@@ -27,15 +28,12 @@ export type LaunchOptions = {
2728
pytestUUID?: string;
2829
runTestIdsPort?: string;
2930
/**
30-
* Optional explicit Python path for project-based execution.
31-
* When provided, debug sessions should use this interpreter instead of the workspace default.
31+
* Optional Python project for project-based execution.
32+
* When provided, the debug launcher will:
33+
* - Use the project's associated Python environment
34+
* - Name the debug session after the project
3235
*/
33-
pythonPath?: string;
34-
/**
35-
* Optional name for the debug session (e.g., project name).
36-
* Used to identify debug sessions in the VS Code debug panel.
37-
*/
38-
debugSessionName?: string;
36+
project?: PythonProject;
3937
};
4038

4139
export enum TestFilter {

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

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ITestDebugLauncher } from '../../common/types';
1010
import { ProjectAdapter } from './projectAdapter';
1111
import { TestProjectRegistry } from './testProjectRegistry';
1212
import { 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;

src/client/testing/testController/pytest/pytestExecutionAdapter.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,8 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
176176
testProvider: PYTEST_PROVIDER,
177177
runTestIdsPort: testIdsFileName,
178178
pytestPort: resultNamedPipeName,
179-
// Pass explicit Python path for project-based debugging
180-
pythonPath: project?.pythonEnvironment.execInfo?.run?.executable,
181-
// Pass project name for debug session identification
182-
debugSessionName: project?.projectName,
179+
// Pass project for project-based debugging (Python path and session name derived from this)
180+
project: project?.pythonProject,
183181
};
184182
const sessionOptions: DebugSessionOptions = {
185183
testRun: runInstance,

0 commit comments

Comments
 (0)