Skip to content

Commit d8ebd63

Browse files
committed
Code Lenses for Tests
When the `swift.showTestCodeLenses` setting is `true`, show code lenses inline in the editor above suites and tests. This setting defaults to `false` and must be enabled first. The three lenses are Run, Debug, and Run w/ Coverage.
1 parent 52b4f93 commit d8ebd63

File tree

6 files changed

+259
-72
lines changed

6 files changed

+259
-72
lines changed

package.json

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,27 @@
306306
"category": "Test",
307307
"icon": "$(debug-coverage)"
308308
},
309+
{
310+
"command": "swift.runTest",
311+
"title": "Run Test",
312+
"category": "Test",
313+
"icon": "$(testing-run-icon)",
314+
"enablement": "false"
315+
},
316+
{
317+
"command": "swift.debugTest",
318+
"title": "Debug Test",
319+
"category": "Test",
320+
"icon": "$(testing-debug-icon)",
321+
"enablement": "false"
322+
},
323+
{
324+
"command": "swift.runTestWithCoverage",
325+
"title": "Run Test with Coverage",
326+
"category": "Test",
327+
"icon": "$(debug-coverage)",
328+
"enablement": "false"
329+
},
309330
{
310331
"command": "swift.openDocumentation",
311332
"title": "Open Documentation",
@@ -552,7 +573,7 @@
552573
}
553574
},
554575
{
555-
"title": "Code Coverage",
576+
"title": "Testing",
556577
"properties": {
557578
"swift.excludeFromCodeCoverage": {
558579
"description": "A list of paths to exclude from code coverage reports. Paths can be absolute or relative to the workspace root.",
@@ -561,6 +582,11 @@
561582
"type": "string"
562583
},
563584
"default": []
585+
},
586+
"swift.showTestCodeLenses": {
587+
"type": "boolean",
588+
"default": false,
589+
"markdownDescription": "Controls whether or not to show inline code lenses for running and debugging tests inline, above test and suite declarations."
564590
}
565591
}
566592
},
@@ -1017,6 +1043,18 @@
10171043
"command": "swift.coverAllTests",
10181044
"when": "swift.isActivated"
10191045
},
1046+
{
1047+
"command": "swift.runTest",
1048+
"when": "false"
1049+
},
1050+
{
1051+
"command": "swift.debugTest",
1052+
"when": "false"
1053+
},
1054+
{
1055+
"command": "swift.runTestWithCoverage",
1056+
"when": "false"
1057+
},
10201058
{
10211059
"command": "swift.openEducationalNote",
10221060
"when": "false"
@@ -1748,4 +1786,4 @@
17481786
"vscode-languageclient": "^9.0.1",
17491787
"xml2js": "^0.6.2"
17501788
}
1751-
}
1789+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as vscode from "vscode";
2+
import { TestExplorer } from "./TestExplorer";
3+
import { flattenTestItemCollection } from "./TestUtils";
4+
import configuration from "../configuration";
5+
6+
export class TestCodeLensProvider implements vscode.CodeLensProvider, vscode.Disposable {
7+
private onDidChangeCodeLensesEmitter = new vscode.EventEmitter<void>();
8+
public onDidChangeCodeLenses = this.onDidChangeCodeLensesEmitter.event;
9+
private disposables: vscode.Disposable[] = [];
10+
11+
constructor(private testExplorer: TestExplorer) {
12+
this.disposables = [
13+
testExplorer.onTestItemsDidChange(() => this.onDidChangeCodeLensesEmitter.fire()),
14+
vscode.languages.registerCodeLensProvider({ language: "swift", scheme: "file" }, this),
15+
];
16+
}
17+
18+
dispose() {
19+
this.disposables.forEach(disposable => disposable.dispose());
20+
}
21+
22+
public provideCodeLenses(
23+
document: vscode.TextDocument,
24+
_token: vscode.CancellationToken
25+
): vscode.ProviderResult<vscode.CodeLens[]> {
26+
if (configuration.showTestCodeLenses === false) {
27+
return [];
28+
}
29+
30+
const items = flattenTestItemCollection(this.testExplorer.controller.items);
31+
return items
32+
.filter(item => item.uri?.fsPath === document.uri.fsPath)
33+
.flatMap(item => this.codeLensesForTestItem(item));
34+
}
35+
36+
private codeLensesForTestItem(item: vscode.TestItem): vscode.CodeLens[] {
37+
if (!item.range) {
38+
return [];
39+
}
40+
41+
return [
42+
new vscode.CodeLens(item.range, {
43+
title: "$(play) Run",
44+
command: "swift.runTest",
45+
arguments: [item],
46+
}),
47+
new vscode.CodeLens(item.range, {
48+
title: "$(debug) Debug",
49+
command: "swift.debugTest",
50+
arguments: [item],
51+
}),
52+
new vscode.CodeLens(item.range, {
53+
title: "$(debug-coverage) Run w/ Coverage",
54+
command: "swift.runTestWithCoverage",
55+
arguments: [item],
56+
}),
57+
];
58+
}
59+
}

src/TestExplorer/TestExplorer.ts

Lines changed: 103 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ import { TargetType } from "../SwiftPackage";
2727
import { parseTestsFromSwiftTestListOutput } from "./SPMTestDiscovery";
2828
import { parseTestsFromDocumentSymbols } from "./DocumentSymbolTestDiscovery";
2929
import { flattenTestItemCollection } from "./TestUtils";
30+
import { TestCodeLensProvider } from "./TestCodeLensProvider";
3031

3132
/** Build test explorer UI */
3233
export class TestExplorer {
3334
static errorTestItemId = "#Error#";
3435
public controller: vscode.TestController;
3536
public testRunProfiles: vscode.TestRunProfile[];
37+
3638
private lspTestDiscovery: LSPTestDiscovery;
3739
private subscriptions: { dispose(): unknown }[];
38-
private testFileEdited = true;
3940
private tokenSource = new vscode.CancellationTokenSource();
4041

4142
// Emits after the `vscode.TestController` has been updated.
@@ -45,9 +46,13 @@ export class TestExplorer {
4546
public onDidCreateTestRunEmitter = new vscode.EventEmitter<TestRunProxy>();
4647
public onCreateTestRun: vscode.Event<TestRunProxy>;
4748

49+
private codeLensProvider?: TestCodeLensProvider;
50+
4851
constructor(public folderContext: FolderContext) {
4952
this.onTestItemsDidChange = this.onTestItemsDidChangeEmitter.event;
5053
this.onCreateTestRun = this.onDidCreateTestRunEmitter.event;
54+
this.lspTestDiscovery = this.configureLSPTestDiscovery(folderContext);
55+
this.codeLensProvider = new TestCodeLensProvider(this);
5156

5257
this.controller = vscode.tests.createTestController(
5358
folderContext.name,
@@ -66,55 +71,109 @@ export class TestExplorer {
6671
this.onDidCreateTestRunEmitter
6772
);
6873

74+
this.subscriptions = [
75+
this.tokenSource,
76+
this.controller,
77+
this.onTestItemsDidChangeEmitter,
78+
this.onDidCreateTestRunEmitter,
79+
...this.testRunProfiles,
80+
this.onTestItemsDidChange(() => this.updateSwiftTestContext()),
81+
this.discoverUpdatedTestsAfterBuild(folderContext),
82+
];
83+
}
84+
85+
/**
86+
* Query the LSP for tests in the document. If the LSP is not available
87+
* this method will fallback to the legacy method of parsing document symbols,
88+
* but only for XCTests.
89+
* @param folder The folder context.
90+
* @param uri The document URI. If the document is not part of a test target, this method will do nothing.
91+
* @param symbols The document symbols.
92+
* @returns A promise that resolves when the tests have been retrieved.
93+
*/
94+
public async getDocumentTests(
95+
folder: FolderContext,
96+
uri: vscode.Uri,
97+
symbols: vscode.DocumentSymbol[]
98+
): Promise<void> {
99+
const target = await folder.swiftPackage.getTarget(uri.fsPath);
100+
if (!target || target.type !== "test") {
101+
return;
102+
}
103+
104+
try {
105+
const tests = await this.lspTestDiscovery.getDocumentTests(folder.swiftPackage, uri);
106+
TestDiscovery.updateTestsForTarget(
107+
this.controller,
108+
{ id: target.c99name, label: target.name },
109+
tests,
110+
uri
111+
);
112+
this.onTestItemsDidChangeEmitter.fire(this.controller);
113+
} catch {
114+
// Fallback to parsing document symbols for XCTests only
115+
const tests = parseTestsFromDocumentSymbols(target.name, symbols, uri);
116+
this.updateTests(this.controller, tests, uri);
117+
}
118+
}
119+
120+
/**
121+
* Creates an LSPTestDiscovery client for the given folder context.
122+
*/
123+
private configureLSPTestDiscovery(folderContext: FolderContext): LSPTestDiscovery {
69124
const workspaceContext = folderContext.workspaceContext;
70125
const languageClientManager = workspaceContext.languageClientManager.get(folderContext);
71-
this.lspTestDiscovery = new LSPTestDiscovery(languageClientManager);
126+
return new LSPTestDiscovery(languageClientManager);
127+
}
72128

73-
// add end of task handler to be called whenever a build task has finished. If
74-
// it is the build task for this folder then update the tests
75-
const onDidEndTask = folderContext.workspaceContext.tasks.onDidEndTaskProcess(event => {
76-
const task = event.execution.task;
77-
const execution = task.execution as vscode.ProcessExecution;
78-
if (
79-
task.scope === this.folderContext.workspaceFolder &&
80-
task.group === vscode.TaskGroup.Build &&
81-
execution?.options?.cwd === this.folderContext.folder.fsPath &&
82-
event.exitCode === 0 &&
83-
task.definition.dontTriggerTestDiscovery !== true &&
84-
this.testFileEdited
85-
) {
86-
this.testFileEdited = false;
129+
/**
130+
* Configure test discovery for updated tests after a build task has completed.
131+
*/
132+
private discoverUpdatedTestsAfterBuild(folderContext: FolderContext): vscode.Disposable {
133+
let testFileEdited = true;
134+
const endProcessDisposable = folderContext.workspaceContext.tasks.onDidEndTaskProcess(
135+
event => {
136+
const task = event.execution.task;
137+
const execution = task.execution as vscode.ProcessExecution;
138+
if (
139+
task.scope === folderContext.workspaceFolder &&
140+
task.group === vscode.TaskGroup.Build &&
141+
execution?.options?.cwd === folderContext.folder.fsPath &&
142+
event.exitCode === 0 &&
143+
task.definition.dontTriggerTestDiscovery !== true &&
144+
testFileEdited
145+
) {
146+
testFileEdited = false;
87147

88-
// only run discover tests if the library has tests
89-
void this.folderContext.swiftPackage.getTargets(TargetType.test).then(targets => {
90-
if (targets.length > 0) {
91-
void this.discoverTestsInWorkspace(this.tokenSource.token);
92-
}
93-
});
148+
// only run discover tests if the library has tests
149+
void folderContext.swiftPackage.getTargets(TargetType.test).then(targets => {
150+
if (targets.length > 0) {
151+
void this.discoverTestsInWorkspace(this.tokenSource.token);
152+
}
153+
});
154+
}
94155
}
95-
});
156+
);
96157

97158
// add file watcher to catch changes to swift test files
98-
const fileWatcher = this.folderContext.workspaceContext.onDidChangeSwiftFiles(({ uri }) => {
99-
if (this.testFileEdited === false) {
100-
void this.folderContext.getTestTarget(uri).then(target => {
101-
if (target) {
102-
this.testFileEdited = true;
103-
}
104-
});
159+
const didChangeSwiftFileDisposable = folderContext.workspaceContext.onDidChangeSwiftFiles(
160+
({ uri }) => {
161+
if (testFileEdited === false) {
162+
void folderContext.getTestTarget(uri).then(target => {
163+
if (target) {
164+
testFileEdited = true;
165+
}
166+
});
167+
}
105168
}
106-
});
169+
);
107170

108-
this.subscriptions = [
109-
this.tokenSource,
110-
fileWatcher,
111-
onDidEndTask,
112-
this.controller,
113-
this.onTestItemsDidChangeEmitter,
114-
this.onDidCreateTestRunEmitter,
115-
...this.testRunProfiles,
116-
this.onTestItemsDidChange(() => this.updateSwiftTestContext()),
117-
];
171+
return {
172+
dispose: () => {
173+
endProcessDisposable.dispose();
174+
didChangeSwiftFileDisposable.dispose();
175+
},
176+
};
118177
}
119178

120179
dispose() {
@@ -193,32 +252,6 @@ export class TestExplorer {
193252
});
194253
}
195254

196-
async getDocumentTests(
197-
folder: FolderContext,
198-
uri: vscode.Uri,
199-
symbols: vscode.DocumentSymbol[]
200-
): Promise<void> {
201-
const target = await folder.swiftPackage.getTarget(uri.fsPath);
202-
if (!target || target.type !== "test") {
203-
return;
204-
}
205-
206-
try {
207-
const tests = await this.lspTestDiscovery.getDocumentTests(folder.swiftPackage, uri);
208-
TestDiscovery.updateTestsForTarget(
209-
this.controller,
210-
{ id: target.c99name, label: target.name },
211-
tests,
212-
uri
213-
);
214-
this.onTestItemsDidChangeEmitter.fire(this.controller);
215-
} catch {
216-
// Fallback to parsing document symbols for XCTests only
217-
const tests = parseTestsFromDocumentSymbols(target.name, symbols, uri);
218-
this.updateTests(this.controller, tests, uri);
219-
}
220-
}
221-
222255
private updateTests(
223256
controller: vscode.TestController,
224257
tests: TestDiscovery.TestClass[],
@@ -231,7 +264,7 @@ export class TestExplorer {
231264
/**
232265
* Discover tests
233266
*/
234-
async discoverTestsInWorkspace(token: vscode.CancellationToken) {
267+
private async discoverTestsInWorkspace(token: vscode.CancellationToken) {
235268
try {
236269
// If the LSP cannot produce a list of tests it throws and
237270
// we fall back to discovering tests with SPM.
@@ -249,7 +282,7 @@ export class TestExplorer {
249282
* Discover tests
250283
* Uses `swift test --list-tests` to get the list of tests
251284
*/
252-
async discoverTestsInWorkspaceSPM(token: vscode.CancellationToken) {
285+
private async discoverTestsInWorkspaceSPM(token: vscode.CancellationToken) {
253286
async function runDiscover(explorer: TestExplorer, firstTry: boolean) {
254287
try {
255288
// we depend on sourcekit-lsp to detect swift-testing tests so let the user know
@@ -379,7 +412,7 @@ export class TestExplorer {
379412
/**
380413
* Discover tests
381414
*/
382-
async discoverTestsInWorkspaceLSP(token: vscode.CancellationToken) {
415+
private async discoverTestsInWorkspaceLSP(token: vscode.CancellationToken) {
383416
const tests = await this.lspTestDiscovery.getWorkspaceTests(
384417
this.folderContext.swiftPackage
385418
);

0 commit comments

Comments
 (0)