Skip to content

Commit 08bc46e

Browse files
ericsnowcurrentlybrettcannon
authored andcommitted
Support debug configs (launch.json) for running tests. (#4372)
1 parent 7e70925 commit 08bc46e

11 files changed

Lines changed: 808 additions & 174 deletions

File tree

.github/test_plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,5 +326,6 @@ def test_failure():
326326
- [ ] other frameworks disabled in workspace settings
327327
- [ ] `Configure Unit Tests` does not close if it loses focus
328328
- [ ] Cancelling configuration does not leave incomplete settings
329+
- [ ] The first `"request": "test"` entry in launch.json is used for running unit tests
329330

330331
</details>

news/1 Enhancements/332.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support launch configs for debugging tests.

package.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,15 @@
867867
}
868868
]
869869
}
870+
},
871+
{
872+
"label": "Python: Unit Tests",
873+
"description": "%python.snippet.launch.unitTests.description%",
874+
"body": {
875+
"name": "Unit Tests",
876+
"type": "python",
877+
"request": "test"
878+
}
870879
}
871880
],
872881
"configurationAttributes": {
@@ -990,6 +999,59 @@
990999
}
9911000
}
9921001
},
1002+
"test": {
1003+
"properties": {
1004+
"pythonPath": {
1005+
"type": "string",
1006+
"description": "Path (fully qualified) to python executable. Defaults to the value in settings.json",
1007+
"default": "${config:python.pythonPath}"
1008+
},
1009+
"stopOnEntry": {
1010+
"type": "boolean",
1011+
"description": "Automatically stop after launch.",
1012+
"default": false
1013+
},
1014+
"showReturnValue": {
1015+
"type": "boolean",
1016+
"description": "Show return value of functions when stepping.",
1017+
"default": false
1018+
},
1019+
"console": {
1020+
"enum": [
1021+
"none",
1022+
"integratedTerminal",
1023+
"externalTerminal"
1024+
],
1025+
"description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
1026+
"default": "none"
1027+
},
1028+
"cwd": {
1029+
"type": "string",
1030+
"description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
1031+
"default": "${workspaceFolder}"
1032+
},
1033+
"env": {
1034+
"type": "object",
1035+
"description": "Environment variables defined as a key value pair. Property ends up being the Environment Variable and the value of the property ends up being the value of the Env Variable.",
1036+
"default": {}
1037+
},
1038+
"envFile": {
1039+
"type": "string",
1040+
"description": "Absolute path to a file containing environment variable definitions.",
1041+
"default": "${workspaceFolder}/.env"
1042+
},
1043+
"redirectOutput": {
1044+
"type": "boolean",
1045+
"description": "Redirect output.",
1046+
"default": true
1047+
},
1048+
"debugStdLib": {
1049+
"type": "boolean",
1050+
"description": "Debug standard library code.",
1051+
"default": false
1052+
}
1053+
}
1054+
},
9931055
"attach": {
9941056
"required": [
9951057
"port"

src/client/debugger/extension/configuration/debugConfigurationService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export class PythonDebugConfigurationService implements IDebugConfigurationServi
4242
public async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: DebugConfiguration, token?: CancellationToken): Promise<DebugConfiguration | undefined> {
4343
if (debugConfiguration.request === 'attach') {
4444
return this.attachResolver.resolveDebugConfiguration(folder, debugConfiguration as AttachRequestArguments, token);
45+
} else if (debugConfiguration.request === 'test') {
46+
throw Error('Please use the command \'Python: Debug Unit Tests\'');
4547
} else {
4648
return this.launchResolver.resolveDebugConfiguration(folder, debugConfiguration as LaunchRequestArguments, token);
4749
}

src/client/debugger/extension/configuration/resolvers/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration> im
7474
protected isDebuggingFlask(debugConfiguration: Partial<LaunchRequestArguments & AttachRequestArguments>) {
7575
return (debugConfiguration.module && debugConfiguration.module.toUpperCase() === 'FLASK') ? true : false;
7676
}
77-
protected sendTelemetry(trigger: 'launch' | 'attach', debugConfiguration: Partial<LaunchRequestArguments & AttachRequestArguments>) {
77+
protected sendTelemetry(trigger: 'launch' | 'attach' | 'test', debugConfiguration: Partial<LaunchRequestArguments & AttachRequestArguments>) {
7878
const name = debugConfiguration.name || '';
7979
const moduleName = debugConfiguration.module || '';
8080
const telemetryProps: DebuggerTelemetry = {

src/client/debugger/extension/configuration/resolvers/launch.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ import { BaseConfigurationResolver } from './base';
1818

1919
@injectable()
2020
export class LaunchConfigurationResolver extends BaseConfigurationResolver<LaunchRequestArguments> {
21-
constructor(@inject(IWorkspaceService) workspaceService: IWorkspaceService,
21+
constructor(
22+
@inject(IWorkspaceService) workspaceService: IWorkspaceService,
2223
@inject(IDocumentManager) documentManager: IDocumentManager,
2324
@inject(IConfigurationProviderUtils) private readonly configurationProviderUtils: IConfigurationProviderUtils,
2425
@inject(IDiagnosticsService) @named(InvalidPythonPathInDebuggerServiceId) private readonly invalidPythonPathInDebuggerService: IInvalidPythonPathInDebuggerService,
2526
@inject(IPlatformService) private readonly platformService: IPlatformService,
26-
@inject(IConfigurationService) configurationService: IConfigurationService) {
27+
@inject(IConfigurationService) configurationService: IConfigurationService
28+
) {
2729
super(workspaceService, documentManager, configurationService);
2830
}
2931
public async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: LaunchRequestArguments, _token?: CancellationToken): Promise<LaunchRequestArguments | undefined> {
@@ -119,7 +121,10 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver<Launc
119121
if (debugConfiguration.pyramid) {
120122
debugConfiguration.program = (await this.configurationProviderUtils.getPyramidStartupScriptFilePath(workspaceFolder))!;
121123
}
122-
this.sendTelemetry('launch', debugConfiguration);
124+
this.sendTelemetry(
125+
debugConfiguration.request as 'launch' | 'test',
126+
debugConfiguration
127+
);
123128
}
124129

125130
protected async validateLaunchConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: LaunchRequestArguments): Promise<boolean> {

src/client/telemetry/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export type CodeExecutionTelemetry = {
7070
scope: 'file' | 'selection';
7171
};
7272
export type DebuggerTelemetry = {
73-
trigger: 'launch' | 'attach';
73+
trigger: 'launch' | 'attach' | 'test';
7474
console?: 'none' | 'integratedTerminal' | 'externalTerminal';
7575
hasEnvVars: boolean;
7676
hasArgs: boolean;

src/client/unittests/common/debugLauncher.ts

Lines changed: 154 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,180 @@
1-
import { inject, injectable } from 'inversify';
1+
import { inject, injectable, named } from 'inversify';
22
import * as path from 'path';
3-
import { Uri } from 'vscode';
4-
import { IDebugService, IWorkspaceService } from '../../common/application/types';
3+
import * as stripJsonComments from 'strip-json-comments';
4+
import { DebugConfiguration, Uri, WorkspaceFolder } from 'vscode';
5+
import { IApplicationShell, IDebugService, IWorkspaceService } from '../../common/application/types';
56
import { EXTENSION_ROOT_DIR } from '../../common/constants';
6-
import { IConfigurationService } from '../../common/types';
7-
import { DebugOptions } from '../../debugger/types';
7+
import { traceError } from '../../common/logger';
8+
import { IFileSystem } from '../../common/platform/types';
9+
import { IConfigurationService, IPythonSettings } from '../../common/types';
10+
import { noop } from '../../common/utils/misc';
11+
import { DebuggerTypeName } from '../../debugger/constants';
12+
import { IDebugConfigurationResolver } from '../../debugger/extension/configuration/types';
13+
import { LaunchRequestArguments } from '../../debugger/types';
814
import { IServiceContainer } from '../../ioc/types';
9-
import { ITestDebugLauncher, LaunchOptions, TestProvider } from './types';
15+
import {
16+
ITestDebugConfig, ITestDebugLauncher, LaunchOptions, TestProvider
17+
} from './types';
1018

1119
@injectable()
1220
export class DebugLauncher implements ITestDebugLauncher {
13-
constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer) { }
21+
private readonly configService: IConfigurationService;
22+
private readonly workspaceService: IWorkspaceService;
23+
private readonly fs: IFileSystem;
24+
constructor(
25+
@inject(IServiceContainer) private serviceContainer: IServiceContainer,
26+
@inject(IDebugConfigurationResolver) @named('launch') private readonly launchResolver: IDebugConfigurationResolver<LaunchRequestArguments>
27+
) {
28+
this.configService = this.serviceContainer.get<IConfigurationService>(IConfigurationService);
29+
this.workspaceService = this.serviceContainer.get<IWorkspaceService>(IWorkspaceService);
30+
this.fs = this.serviceContainer.get<IFileSystem>(IFileSystem);
31+
}
32+
1433
public async launchDebugger(options: LaunchOptions) {
1534
if (options.token && options.token!.isCancellationRequested) {
1635
return;
1736
}
18-
const cwdUri = options.cwd ? Uri.file(options.cwd) : undefined;
19-
const workspaceService = this.serviceContainer.get<IWorkspaceService>(IWorkspaceService);
20-
if (!workspaceService.hasWorkspaceFolders) {
37+
38+
const workspaceFolder = this.resolveWorkspaceFolder(options.cwd);
39+
const launchArgs = await this.getLaunchArgs(
40+
options,
41+
workspaceFolder,
42+
this.configService.getSettings(workspaceFolder.uri)
43+
);
44+
const debugManager = this.serviceContainer.get<IDebugService>(IDebugService);
45+
return debugManager.startDebugging(workspaceFolder, launchArgs)
46+
.then(noop, ex => traceError('Failed to start debugging tests', ex));
47+
}
48+
49+
private resolveWorkspaceFolder(cwd: string): WorkspaceFolder {
50+
if (!this.workspaceService.hasWorkspaceFolders) {
2151
throw new Error('Please open a workspace');
2252
}
23-
let workspaceFolder = workspaceService.getWorkspaceFolder(cwdUri!);
53+
54+
const cwdUri = cwd ? Uri.file(cwd) : undefined;
55+
let workspaceFolder = this.workspaceService.getWorkspaceFolder(cwdUri!);
2456
if (!workspaceFolder) {
25-
workspaceFolder = workspaceService.workspaceFolders![0];
57+
workspaceFolder = this.workspaceService.workspaceFolders![0];
2658
}
59+
return workspaceFolder;
60+
}
2761

28-
const cwd = cwdUri ? cwdUri.fsPath : workspaceFolder.uri.fsPath;
29-
const configSettings = this.serviceContainer.get<IConfigurationService>(IConfigurationService).getSettings(Uri.file(cwd));
30-
const debugManager = this.serviceContainer.get<IDebugService>(IDebugService);
31-
const debugArgs = this.fixArgs(options.args, options.testProvider);
32-
const program = this.getTestLauncherScript(options.testProvider);
33-
return debugManager.startDebugging(workspaceFolder, {
34-
name: 'Debug Unit Test',
35-
type: 'python',
36-
request: 'launch',
37-
program,
38-
cwd,
39-
args: debugArgs,
40-
console: 'none',
41-
envFile: configSettings.envFile,
42-
debugOptions: [DebugOptions.RedirectOutput]
43-
}).then(() => void (0));
62+
private async getLaunchArgs(
63+
options: LaunchOptions,
64+
workspaceFolder: WorkspaceFolder,
65+
configSettings: IPythonSettings
66+
): Promise<LaunchRequestArguments> {
67+
let debugConfig = await this.readDebugConfig();
68+
if (!debugConfig) {
69+
debugConfig = {
70+
name: 'Debug Unit Test',
71+
type: 'python',
72+
request: 'test'
73+
};
74+
}
75+
this.applyDefaults(debugConfig!, workspaceFolder, configSettings);
76+
77+
return this.convertConfigToArgs(debugConfig!, workspaceFolder, options);
78+
}
79+
80+
private async readDebugConfig(): Promise<ITestDebugConfig | undefined> {
81+
const configs = await this.readAllDebugConfigs();
82+
for (const cfg of configs) {
83+
if (!cfg.name || cfg.type !== DebuggerTypeName || cfg.request !== 'test') {
84+
continue;
85+
}
86+
// Return the first one.
87+
return cfg as ITestDebugConfig;
88+
}
89+
return undefined;
90+
}
91+
92+
private async readAllDebugConfigs(): Promise<DebugConfiguration[]> {
93+
const workspaceFolder = this.workspaceService.workspaceFolders![0];
94+
const filename = path.join(workspaceFolder.uri.fsPath, '.vscode', 'launch.json');
95+
let configs: DebugConfiguration[] = [];
96+
try {
97+
let text = await this.fs.readFile(filename);
98+
text = stripJsonComments(text);
99+
const parsed = JSON.parse(text);
100+
if (!parsed.version || !parsed.configurations || !Array.isArray(parsed.configurations)) {
101+
throw Error('malformed launch.json');
102+
}
103+
// We do not bother ensuring each item is a DebugConfiguration...
104+
configs = parsed.configurations;
105+
} catch (exc) {
106+
traceError('could not get debug config', exc);
107+
const appShell = this.serviceContainer.get<IApplicationShell>(IApplicationShell);
108+
await appShell.showErrorMessage('could not load unit test config from launch.json');
109+
}
110+
return configs;
44111
}
112+
113+
private applyDefaults(
114+
cfg: ITestDebugConfig,
115+
workspaceFolder: WorkspaceFolder,
116+
configSettings: IPythonSettings
117+
) {
118+
// cfg.pythonPath is handled by LaunchConfigurationResolver.
119+
if (!cfg.console) {
120+
cfg.console = 'none';
121+
}
122+
if (!cfg.cwd) {
123+
cfg.cwd = workspaceFolder.uri.fsPath;
124+
}
125+
if (!cfg.env) {
126+
cfg.env = {};
127+
}
128+
if (!cfg.envFile) {
129+
cfg.envFile = configSettings.envFile;
130+
}
131+
132+
if (cfg.stopOnEntry === undefined) {
133+
cfg.stopOnEntry = false;
134+
}
135+
if (cfg.showReturnValue === undefined) {
136+
cfg.showReturnValue = false;
137+
}
138+
if (cfg.redirectOutput === undefined) {
139+
cfg.redirectOutput = true;
140+
}
141+
if (cfg.debugStdLib === undefined) {
142+
cfg.debugStdLib = false;
143+
}
144+
}
145+
146+
private async convertConfigToArgs(
147+
debugConfig: ITestDebugConfig,
148+
workspaceFolder: WorkspaceFolder,
149+
options: LaunchOptions
150+
): Promise<LaunchRequestArguments> {
151+
const configArgs = debugConfig as LaunchRequestArguments;
152+
153+
configArgs.program = this.getTestLauncherScript(options.testProvider);
154+
configArgs.args = this.fixArgs(options.args, options.testProvider);
155+
// We leave configArgs.request as "test" so it will be sent in telemetry.
156+
157+
const launchArgs = await this.launchResolver.resolveDebugConfiguration(
158+
workspaceFolder,
159+
configArgs,
160+
options.token
161+
);
162+
if (!launchArgs) {
163+
throw Error(`Invalid debug config "${debugConfig.name}"`);
164+
}
165+
launchArgs.request = 'launch';
166+
167+
return launchArgs!;
168+
}
169+
45170
private fixArgs(args: string[], testProvider: TestProvider): string[] {
46171
if (testProvider === 'unittest') {
47172
return args.filter(item => item !== '--debug');
48173
} else {
49174
return args;
50175
}
51176
}
177+
52178
private getTestLauncherScript(testProvider: TestProvider) {
53179
switch (testProvider) {
54180
case 'unittest': {

src/client/unittests/common/types.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { CancellationToken, DiagnosticCollection, Disposable, Event, OutputChannel, Uri } from 'vscode';
1+
import {
2+
CancellationToken, DebugConfiguration, DiagnosticCollection,
3+
Disposable, Event, OutputChannel, Uri
4+
} from 'vscode';
25
import { IUnitTestSettings, Product } from '../../common/types';
6+
import { DebuggerTypeName } from '../../debugger/constants';
7+
import { ConsoleType } from '../../debugger/types';
38
import { IPythonUnitTestMessage } from '../types';
49
import { CommandSource } from './constants';
510

@@ -287,3 +292,20 @@ export const ITestMessageService = Symbol('ITestMessageService');
287292
export interface ITestMessageService {
288293
getFilteredTestMessages(rootDirectory: string, testResults: Tests): Promise<IPythonUnitTestMessage[]>;
289294
}
295+
296+
export interface ITestDebugConfig extends DebugConfiguration {
297+
type: typeof DebuggerTypeName;
298+
request: 'test';
299+
300+
pythonPath?: string;
301+
console?: ConsoleType;
302+
cwd?: string;
303+
env?: Record<string, string | undefined>;
304+
envFile?: string;
305+
306+
// converted to DebugOptions:
307+
stopOnEntry?: boolean;
308+
showReturnValue?: boolean;
309+
redirectOutput?: boolean; // default: true
310+
debugStdLib?: boolean;
311+
}

0 commit comments

Comments
 (0)