Skip to content

Commit 3b87651

Browse files
authored
Add python-environment's project support for pytest execution (#25772)
1 parent 42fb7b7 commit 3b87651

File tree

10 files changed

+1816
-56
lines changed

10 files changed

+1816
-56
lines changed

src/client/testing/common/debugLauncher.ts

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { inject, injectable, named } from 'inversify';
22
import * as path from 'path';
3-
import { DebugConfiguration, l10n, Uri, WorkspaceFolder, DebugSession, DebugSessionOptions } from 'vscode';
3+
import { DebugConfiguration, l10n, Uri, WorkspaceFolder, DebugSession, DebugSessionOptions, Disposable } from 'vscode';
44
import { IApplicationShell, IDebugService } from '../../common/application/types';
55
import { EXTENSION_ROOT_DIR } from '../../common/constants';
66
import * as internalScripts from '../../common/process/internal/scripts';
@@ -17,6 +17,14 @@ 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';
21+
22+
/**
23+
* Key used to mark debug configurations with a unique session identifier.
24+
* This allows us to track which debug session belongs to which launchDebugger() call
25+
* when multiple debug sessions are launched in parallel.
26+
*/
27+
const TEST_SESSION_MARKER_KEY = '__vscodeTestSessionMarker';
2028

2129
@injectable()
2230
export class DebugLauncher implements ITestDebugLauncher {
@@ -31,25 +39,46 @@ export class DebugLauncher implements ITestDebugLauncher {
3139
this.configService = this.serviceContainer.get<IConfigurationService>(IConfigurationService);
3240
}
3341

42+
/**
43+
* Launches a debug session for test execution.
44+
* Handles cancellation, multi-session support via unique markers, and cleanup.
45+
*/
3446
public async launchDebugger(
3547
options: LaunchOptions,
3648
callback?: () => void,
3749
sessionOptions?: DebugSessionOptions,
3850
): Promise<void> {
3951
const deferred = createDeferred<void>();
4052
let hasCallbackBeenCalled = false;
53+
54+
// Collect disposables for cleanup when debugging completes
55+
const disposables: Disposable[] = [];
56+
57+
// Ensure callback is only invoked once, even if multiple termination paths fire
58+
const callCallbackOnce = () => {
59+
if (!hasCallbackBeenCalled) {
60+
hasCallbackBeenCalled = true;
61+
callback?.();
62+
}
63+
};
64+
65+
// Early exit if already cancelled before we start
4166
if (options.token && options.token.isCancellationRequested) {
42-
hasCallbackBeenCalled = true;
43-
return undefined;
67+
callCallbackOnce();
4468
deferred.resolve();
45-
callback?.();
69+
return deferred.promise;
4670
}
4771

48-
options.token?.onCancellationRequested(() => {
49-
deferred.resolve();
50-
callback?.();
51-
hasCallbackBeenCalled = true;
52-
});
72+
// Listen for cancellation from the test run (e.g., user clicks stop in Test Explorer)
73+
// This allows the caller to clean up resources even if the debug session is still running
74+
if (options.token) {
75+
disposables.push(
76+
options.token.onCancellationRequested(() => {
77+
deferred.resolve();
78+
callCallbackOnce();
79+
}),
80+
);
81+
}
5382

5483
const workspaceFolder = DebugLauncher.resolveWorkspaceFolder(options.cwd);
5584
const launchArgs = await this.getLaunchArgs(
@@ -59,23 +88,49 @@ export class DebugLauncher implements ITestDebugLauncher {
5988
);
6089
const debugManager = this.serviceContainer.get<IDebugService>(IDebugService);
6190

62-
let activatedDebugSession: DebugSession | undefined;
63-
debugManager.startDebugging(workspaceFolder, launchArgs, sessionOptions).then(() => {
64-
// Save the debug session after it is started so we can check if it is the one that was terminated.
65-
activatedDebugSession = debugManager.activeDebugSession;
66-
});
67-
debugManager.onDidTerminateDebugSession((session) => {
68-
traceVerbose(`Debug session terminated. sessionId: ${session.id}`);
69-
// Only resolve no callback has been made and the session is the one that was started.
70-
if (
71-
!hasCallbackBeenCalled &&
72-
activatedDebugSession !== undefined &&
73-
session.id === activatedDebugSession?.id
74-
) {
75-
deferred.resolve();
76-
callback?.();
77-
}
91+
// Unique marker to identify this session among concurrent debug sessions
92+
const sessionMarker = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
93+
launchArgs[TEST_SESSION_MARKER_KEY] = sessionMarker;
94+
95+
let ourSession: DebugSession | undefined;
96+
97+
// Capture our specific debug session when it starts by matching the marker.
98+
// This fires for ALL debug sessions, so we filter to only our marker.
99+
disposables.push(
100+
debugManager.onDidStartDebugSession((session) => {
101+
if (session.configuration[TEST_SESSION_MARKER_KEY] === sessionMarker) {
102+
ourSession = session;
103+
traceVerbose(`[test-debug] Debug session started: ${session.name} (${session.id})`);
104+
}
105+
}),
106+
);
107+
108+
// Handle debug session termination (user stops debugging, or tests complete).
109+
// Only react to OUR session terminating - other parallel sessions should
110+
// continue running independently.
111+
disposables.push(
112+
debugManager.onDidTerminateDebugSession((session) => {
113+
if (ourSession && session.id === ourSession.id) {
114+
traceVerbose(`[test-debug] Debug session terminated: ${session.name} (${session.id})`);
115+
deferred.resolve();
116+
callCallbackOnce();
117+
}
118+
}),
119+
);
120+
121+
// Start the debug session
122+
const started = await debugManager.startDebugging(workspaceFolder, launchArgs, sessionOptions);
123+
if (!started) {
124+
traceError('Failed to start debug session');
125+
deferred.resolve();
126+
callCallbackOnce();
127+
}
128+
129+
// Clean up event subscriptions when debugging completes (success, failure, or cancellation)
130+
deferred.promise.finally(() => {
131+
disposables.forEach((d) => d.dispose());
78132
});
133+
79134
return deferred.promise;
80135
}
81136

@@ -108,6 +163,12 @@ export class DebugLauncher implements ITestDebugLauncher {
108163
subProcess: true,
109164
};
110165
}
166+
167+
// Use project name in debug session name if provided
168+
if (options.project) {
169+
debugConfig.name = `Debug Tests: ${options.project.name}`;
170+
}
171+
111172
if (!debugConfig.rules) {
112173
debugConfig.rules = [];
113174
}
@@ -257,6 +318,23 @@ export class DebugLauncher implements ITestDebugLauncher {
257318
// run via F5 style debugging.
258319
launchArgs.purpose = [];
259320

321+
// For project-based execution, get the Python path from the project's environment.
322+
// Fallback: if env API unavailable or fails, LaunchConfigurationResolver already set
323+
// launchArgs.python from the active interpreter, so debugging still works.
324+
if (options.project && envExtApi.useEnvExtension()) {
325+
try {
326+
const pythonEnv = await envExtApi.getEnvironment(options.project.uri);
327+
if (pythonEnv?.execInfo?.run?.executable) {
328+
launchArgs.python = pythonEnv.execInfo.run.executable;
329+
traceVerbose(
330+
`[test-by-project] Debug session using Python path from project: ${launchArgs.python}`,
331+
);
332+
}
333+
} catch (error) {
334+
traceVerbose(`[test-by-project] Could not get environment for project, using default: ${error}`);
335+
}
336+
}
337+
260338
return launchArgs;
261339
}
262340

src/client/testing/common/types.ts

Lines changed: 3 additions & 0 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

@@ -26,6 +27,8 @@ export type LaunchOptions = {
2627
pytestPort?: string;
2728
pytestUUID?: string;
2829
runTestIdsPort?: string;
30+
/** Optional Python project for project-based execution. */
31+
project?: PythonProject;
2932
};
3033

3134
export enum TestFilter {

0 commit comments

Comments
 (0)