Skip to content

Commit 0747917

Browse files
authored
Add "Run Test Multiple Times" (#1009)
Adds two new items to the context menu when you right click a test in the test explorer: - Run Multiple Times - Run Until Failure Selecting either of these promts with a text input where the user can input how many times they want to run the test(s). If the user selected Run Until Failure the tests will be run a maximum number of times, stopping at the first iteration that produces a failure. A current limitation in VS Code is if you have multiple tests selected in the Test Explorer (by shift or ctrl clicking them) only the first is passed to the command. Issue: #832
1 parent e0a0fba commit 0747917

File tree

5 files changed

+219
-28
lines changed

5 files changed

+219
-28
lines changed

package.json

+22
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,16 @@
201201
"command": "swift.clearDiagnosticsCollection",
202202
"title": "Clear Diagnostics Collection",
203203
"category": "Swift"
204+
},
205+
{
206+
"command": "swift.runTestsMultipleTimes",
207+
"title": "Run Multiple Times...",
208+
"category": "Swift"
209+
},
210+
{
211+
"command": "swift.runTestsUntilFailure",
212+
"title": "Run Until Failure...",
213+
"category": "Swift"
204214
}
205215
],
206216
"configuration": [
@@ -598,6 +608,18 @@
598608
}
599609
],
600610
"menus": {
611+
"testing/item/context": [
612+
{
613+
"command": "swift.runTestsMultipleTimes",
614+
"group": "testExtras",
615+
"when": "testId"
616+
},
617+
{
618+
"command": "swift.runTestsUntilFailure",
619+
"group": "testExtras",
620+
"when": "testId"
621+
}
622+
],
601623
"commandPalette": [
602624
{
603625
"command": "swift.createNewProject",

src/TestExplorer/TestExplorer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class TestExplorer {
4141
private onTestItemsDidChangeEmitter = new vscode.EventEmitter<vscode.TestController>();
4242
public onTestItemsDidChange: vscode.Event<vscode.TestController>;
4343

44-
private onDidCreateTestRunEmitter = new vscode.EventEmitter<TestRunProxy>();
44+
public onDidCreateTestRunEmitter = new vscode.EventEmitter<TestRunProxy>();
4545
public onCreateTestRun: vscode.Event<TestRunProxy>;
4646

4747
constructor(public folderContext: FolderContext) {

src/TestExplorer/TestRunner.ts

+59-17
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,28 @@ export class TestRunProxy {
5252
private runStarted: boolean = false;
5353
private queuedOutput: string[] = [];
5454
private _testItems: vscode.TestItem[];
55+
private iteration: number | undefined;
5556
public coverage: TestCoverage;
5657

58+
public testRunCompleteEmitter = new vscode.EventEmitter<void>();
59+
public onTestRunComplete: vscode.Event<void>;
60+
5761
// Allows for introspection on the state of TestItems after a test run.
58-
public runState = {
59-
failed: [] as {
60-
test: vscode.TestItem;
61-
message: vscode.TestMessage | readonly vscode.TestMessage[];
62-
}[],
63-
passed: [] as vscode.TestItem[],
64-
skipped: [] as vscode.TestItem[],
65-
errored: [] as vscode.TestItem[],
66-
unknown: 0,
67-
output: [] as string[],
68-
};
62+
public runState = TestRunProxy.initialTestRunState();
63+
64+
private static initialTestRunState() {
65+
return {
66+
failed: [] as {
67+
test: vscode.TestItem;
68+
message: vscode.TestMessage | readonly vscode.TestMessage[];
69+
}[],
70+
passed: [] as vscode.TestItem[],
71+
skipped: [] as vscode.TestItem[],
72+
errored: [] as vscode.TestItem[],
73+
unknown: 0,
74+
output: [] as string[],
75+
};
76+
}
6977

7078
public get testItems(): vscode.TestItem[] {
7179
return this._testItems;
@@ -79,6 +87,7 @@ export class TestRunProxy {
7987
) {
8088
this._testItems = args.testItems;
8189
this.coverage = new TestCoverage(folderContext);
90+
this.onTestRunComplete = this.testRunCompleteEmitter.event;
8291
}
8392

8493
public testRunStarted = () => {
@@ -194,20 +203,37 @@ export class TestRunProxy {
194203

195204
public async end() {
196205
this.testRun?.end();
206+
this.testRunCompleteEmitter.fire();
207+
}
208+
209+
public setIteration(iteration: number) {
210+
this.runState = TestRunProxy.initialTestRunState();
211+
this.iteration = iteration;
197212
}
198213

199214
public appendOutput(output: string) {
215+
const tranformedOutput = this.prependIterationToOutput(output);
200216
if (this.testRun) {
201-
this.testRun.appendOutput(output);
202-
this.runState.output.push(output);
217+
this.testRun.appendOutput(tranformedOutput);
218+
this.runState.output.push(tranformedOutput);
203219
} else {
204-
this.queuedOutput.push(output);
220+
this.queuedOutput.push(tranformedOutput);
205221
}
206222
}
207223

208224
public appendOutputToTest(output: string, test: vscode.TestItem, location?: vscode.Location) {
209-
this.testRun?.appendOutput(output, location, test);
210-
this.runState.output.push(output);
225+
const tranformedOutput = this.prependIterationToOutput(output);
226+
this.testRun?.appendOutput(tranformedOutput, location, test);
227+
this.runState.output.push(tranformedOutput);
228+
}
229+
230+
private prependIterationToOutput(output: string): string {
231+
if (this.iteration === undefined) {
232+
return output;
233+
}
234+
const itr = this.iteration + 1;
235+
const lines = output.match(/[^\r\n]*[\r\n]*/g);
236+
return lines?.map(line => (line ? `\x1b[34mRun ${itr}\x1b[0m ${line}` : "")).join("") ?? "";
211237
}
212238

213239
public async computeCoverage() {
@@ -222,7 +248,7 @@ export class TestRunProxy {
222248

223249
/** Class used to run tests */
224250
export class TestRunner {
225-
private testRun: TestRunProxy;
251+
public testRun: TestRunProxy;
226252
private testArgs: TestRunArguments;
227253
private xcTestOutputParser: IXCTestOutputParser;
228254
private swiftTestOutputParser: SwiftTestingOutputParser;
@@ -256,6 +282,20 @@ export class TestRunner {
256282
);
257283
}
258284

285+
/**
286+
* When performing a "Run test multiple times" run set the iteration
287+
* so it can be shown in the logs.
288+
* @param iteration The iteration counter
289+
*/
290+
public setIteration(iteration: number) {
291+
// The SwiftTestingOutputParser holds state and needs to be reset between iterations.
292+
this.swiftTestOutputParser = new SwiftTestingOutputParser(
293+
this.testRun.testRunStarted,
294+
this.testRun.addParameterizedTestCase
295+
);
296+
this.testRun.setIteration(iteration);
297+
}
298+
259299
/**
260300
* If the request has no test items to include in the run,
261301
* default to usig all the items in the `TestController`.
@@ -587,6 +627,7 @@ export class TestRunner {
587627
task.execution.onDidWrite(str => {
588628
const replaced = str
589629
.replace("[1/1] Planning build", "") // Work around SPM still emitting progress when doing --no-build.
630+
.replace(/\[1\/1\] Write swift-version-.*/gm, "")
590631
.replace(
591632
/LLVM Profile Error: Failed to write file "default.profraw": Operation not permitted\r\n/gm,
592633
""
@@ -1023,6 +1064,7 @@ export class TestRunnerTestRunState implements ITestRunState {
10231064
return;
10241065
}
10251066
const testItem = this.testRun.testItems[index];
1067+
this.issues.delete(index);
10261068
this.testRun.started(testItem);
10271069
this.currentTestItem = testItem;
10281070
this.startTimes.set(index, startTime);

src/commands.ts

+56
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { SwiftProjectTemplate } from "./toolchain/toolchain";
3333
import { showToolchainSelectionQuickPick, showToolchainError } from "./ui/ToolchainSelection";
3434
import { captureDiagnostics } from "./commands/captureDiagnostics";
3535
import { reindexProjectRequest } from "./sourcekit-lsp/lspExtensions";
36+
import { TestRunner, TestRunnerTestRunState } from "./TestExplorer/TestRunner";
37+
import { TestKind } from "./TestExplorer/TestKind";
3638

3739
/**
3840
* References:
@@ -734,6 +736,54 @@ function openInExternalEditor(packageNode: PackageNode) {
734736
}
735737
}
736738

739+
async function runTestMultipleTimes(
740+
ctx: WorkspaceContext,
741+
test: vscode.TestItem,
742+
untilFailure: boolean
743+
) {
744+
const str = await vscode.window.showInputBox({
745+
prompt: "Label: ",
746+
placeHolder: `${untilFailure ? "Maximum " : ""}# of times to run`,
747+
validateInput: value => (/^[1-9]\d*$/.test(value) ? undefined : "Enter an integer value"),
748+
});
749+
750+
if (!str || !ctx.currentFolder?.testExplorer) {
751+
return;
752+
}
753+
754+
const numExecutions = parseInt(str);
755+
const testExplorer = ctx.currentFolder.testExplorer;
756+
const runner = new TestRunner(
757+
TestKind.standard,
758+
new vscode.TestRunRequest([test]),
759+
ctx.currentFolder,
760+
testExplorer.controller
761+
);
762+
763+
testExplorer.onDidCreateTestRunEmitter.fire(runner.testRun);
764+
765+
const testRunState = new TestRunnerTestRunState(runner.testRun);
766+
const token = new vscode.CancellationTokenSource();
767+
768+
vscode.commands.executeCommand("workbench.panel.testResults.view.focus");
769+
770+
for (let i = 0; i < numExecutions; i++) {
771+
runner.setIteration(i);
772+
runner.testRun.appendOutput(`\x1b[36mBeginning Test Iteration #${i + 1}\x1b[0m\n`);
773+
774+
await runner.runSession(token.token, testRunState);
775+
776+
if (
777+
untilFailure &&
778+
(runner.testRun.runState.failed.length > 0 ||
779+
runner.testRun.runState.errored.length > 0)
780+
) {
781+
break;
782+
}
783+
}
784+
runner.testRun.end();
785+
}
786+
737787
/**
738788
* Switches the target SDK to the platform selected in a QuickPick UI.
739789
*/
@@ -841,6 +891,12 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] {
841891
vscode.commands.registerCommand("swift.run", () => runBuild(ctx)),
842892
vscode.commands.registerCommand("swift.debug", () => debugBuild(ctx)),
843893
vscode.commands.registerCommand("swift.cleanBuild", () => cleanBuild(ctx)),
894+
vscode.commands.registerCommand("swift.runTestsMultipleTimes", item =>
895+
runTestMultipleTimes(ctx, item, false)
896+
),
897+
vscode.commands.registerCommand("swift.runTestsUntilFailure", item =>
898+
runTestMultipleTimes(ctx, item, true)
899+
),
844900
// Note: This is only available on macOS (gated in `package.json`) because its the only OS that has the iOS SDK available.
845901
vscode.commands.registerCommand("swift.switchPlatform", () => switchPlatform()),
846902
vscode.commands.registerCommand("swift.resetPackage", () => resetPackage(ctx)),

test/suite/testexplorer/TestExplorerIntegration.test.ts

+81-10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import * as vscode from "vscode";
1616
import * as assert from "assert";
1717
import { beforeEach } from "mocha";
18+
import { when, anything } from "ts-mockito";
1819
import { testAssetUri } from "../../fixtures";
1920
import { globalWorkspaceContextPromise } from "../extension.test";
2021
import { TestExplorer } from "../../../src/TestExplorer/TestExplorer";
@@ -29,6 +30,7 @@ import { WorkspaceContext } from "../../../src/WorkspaceContext";
2930
import { TestRunProxy } from "../../../src/TestExplorer/TestRunner";
3031
import { Version } from "../../../src/utilities/version";
3132
import { TestKind } from "../../../src/TestExplorer/TestKind";
33+
import { mockNamespace } from "../../unit-tests/MockUtils";
3234

3335
suite("Test Explorer Suite", function () {
3436
const MAX_TEST_RUN_TIME_MINUTES = 5;
@@ -49,18 +51,10 @@ suite("Test Explorer Suite", function () {
4951
)[0];
5052
}
5153

52-
async function runTest(
54+
async function gatherTests(
5355
controller: vscode.TestController,
54-
runProfile: TestKind,
5556
...tests: string[]
56-
): Promise<TestRunProxy> {
57-
const targetProfile = testExplorer.testRunProfiles.find(
58-
profile => profile.label === runProfile
59-
);
60-
if (!targetProfile) {
61-
throw new Error(`Unable to find run profile named ${runProfile}`);
62-
}
63-
57+
): Promise<vscode.TestItem[]> {
6458
const testItems = tests.map(test => {
6559
const testItem = getTestItem(controller, test);
6660
if (!testItem) {
@@ -79,6 +73,21 @@ suite("Test Explorer Suite", function () {
7973
return testItem;
8074
});
8175

76+
return testItems;
77+
}
78+
79+
async function runTest(
80+
controller: vscode.TestController,
81+
runProfile: TestKind,
82+
...tests: string[]
83+
): Promise<TestRunProxy> {
84+
const targetProfile = testExplorer.testRunProfiles.find(
85+
profile => profile.label === runProfile
86+
);
87+
if (!targetProfile) {
88+
throw new Error(`Unable to find run profile named ${runProfile}`);
89+
}
90+
const testItems = await gatherTests(controller, ...tests);
8291
const request = new vscode.TestRunRequest(testItems);
8392

8493
return (
@@ -233,6 +242,37 @@ suite("Test Explorer Suite", function () {
233242
],
234243
});
235244
});
245+
246+
suite("Runs multiple", function () {
247+
const numIterations = 5;
248+
const windowMock = mockNamespace(vscode, "window");
249+
250+
test("@slow runs an swift-testing test multiple times", async function () {
251+
const testItems = await gatherTests(
252+
testExplorer.controller,
253+
"PackageTests.MixedXCTestSuite/testPassing"
254+
);
255+
256+
await testExplorer.folderContext.workspaceContext.focusFolder(
257+
testExplorer.folderContext
258+
);
259+
260+
// Stub the showInputBox method to return the input text
261+
when(windowMock.showInputBox(anything())).thenReturn(
262+
Promise.resolve(`${numIterations}`)
263+
);
264+
265+
vscode.commands.executeCommand("swift.runTestsMultipleTimes", testItems[0]);
266+
267+
const testRun = await eventPromise(testExplorer.onCreateTestRun);
268+
269+
await eventPromise(testRun.onTestRunComplete);
270+
271+
assertTestResults(testRun, {
272+
passed: ["PackageTests.MixedXCTestSuite/testPassing"],
273+
});
274+
});
275+
});
236276
});
237277

238278
suite("XCTest", () => {
@@ -262,6 +302,37 @@ suite("Test Explorer Suite", function () {
262302
passed: ["PackageTests.DebugReleaseTestSuite/testRelease"],
263303
});
264304
});
305+
306+
suite("Runs multiple", function () {
307+
const numIterations = 5;
308+
const windowMock = mockNamespace(vscode, "window");
309+
310+
test("@slow runs an XCTest multiple times", async function () {
311+
const testItems = await gatherTests(
312+
testExplorer.controller,
313+
"PackageTests.topLevelTestPassing()"
314+
);
315+
316+
await testExplorer.folderContext.workspaceContext.focusFolder(
317+
testExplorer.folderContext
318+
);
319+
320+
// Stub the showInputBox method to return the input text
321+
when(windowMock.showInputBox(anything())).thenReturn(
322+
Promise.resolve(`${numIterations}`)
323+
);
324+
325+
vscode.commands.executeCommand("swift.runTestsMultipleTimes", testItems[0]);
326+
327+
const testRun = await eventPromise(testExplorer.onCreateTestRun);
328+
329+
await eventPromise(testRun.onTestRunComplete);
330+
331+
assertTestResults(testRun, {
332+
passed: ["PackageTests.topLevelTestPassing()"],
333+
});
334+
});
335+
});
265336
});
266337

267338
// Do coverage last as it does a full rebuild, causing the stage after it to have to rebuild as well.

0 commit comments

Comments
 (0)