Skip to content

Commit 3e45bd1

Browse files
authored
Improve Test Cancellation (#1153)
There are two ways to cancel a running test, one via the Cancel button at the top of the test explorer, and the other using the Cancel button in the test run tree in the bottom right of the Test Results output panel. We were handling cancellation via the first method, but the one in the test run tree uses a different cancellation token to indicate a test run should be cancelled that is found on the constructed vscode.TestRun. In our situation we want both of these to do the same thing, cancel the build if it is running and stop the test run if it is running. Create a CompositeCancellationToken that wraps both these child tokens and performs cancellation if either of them request cancellation. This approach also centralizes the cancellation logic to the TestRunProxy, removing the need to pass the test explorer's token around to the TestRunner methods. This patch also improves cancellation when debugging, ensuring that if you're doing a swift-testing + XCTest debugging run and you cancel during the first test type the second one wont start. Finally, print a "Test run cancelled" message in the test run output to make it easier to see if a run was cancelled. This is helpful when you're looking through the test results tree at old runs. Issue: #1145
1 parent 5c976a6 commit 3e45bd1

File tree

4 files changed

+155
-62
lines changed

4 files changed

+155
-62
lines changed

src/TestExplorer/TestRunner.ts

Lines changed: 64 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { TestCoverage } from "../coverage/LcovResults";
3939
import { BuildConfigurationFactory, TestingConfigurationFactory } from "../debugger/buildConfig";
4040
import { TestKind, isDebugging, isRelease } from "./TestKind";
4141
import { reduceTestItemChildren } from "./TestUtils";
42+
import { CompositeCancellationToken } from "../utilities/cancellation";
4243

4344
export enum TestLibrary {
4445
xctest = "XCTest",
@@ -65,6 +66,7 @@ export class TestRunProxy {
6566
private _testItems: vscode.TestItem[];
6667
private iteration: number | undefined;
6768
public coverage: TestCoverage;
69+
public token: CompositeCancellationToken;
6870

6971
public testRunCompleteEmitter = new vscode.EventEmitter<void>();
7072
public onTestRunComplete: vscode.Event<void>;
@@ -87,14 +89,20 @@ export class TestRunProxy {
8789
return this._testItems;
8890
}
8991

92+
public get isCancellationRequested(): boolean {
93+
return this.token.isCancellationRequested;
94+
}
95+
9096
constructor(
9197
private testRunRequest: vscode.TestRunRequest,
9298
private controller: vscode.TestController,
9399
private args: TestRunArguments,
94-
private folderContext: FolderContext
100+
private folderContext: FolderContext,
101+
testProfileCancellationToken: vscode.CancellationToken
95102
) {
96103
this._testItems = args.testItems;
97104
this.coverage = new TestCoverage(folderContext);
105+
this.token = new CompositeCancellationToken(testProfileCancellationToken);
98106
this.onTestRunComplete = this.testRunCompleteEmitter.event;
99107
}
100108

@@ -144,6 +152,7 @@ export class TestRunProxy {
144152
});
145153

146154
this.testRun = this.controller.createTestRun(this.testRunRequest);
155+
this.token.add(this.testRun.token);
147156
this._testItems = [...this.testItems, ...addedTestItems];
148157

149158
// Forward any output captured before the testRun was created.
@@ -217,6 +226,7 @@ export class TestRunProxy {
217226
}
218227
this.testRun?.end();
219228
this.testRunCompleteEmitter.fire();
229+
this.token.dispose();
220230
}
221231

222232
public setIteration(iteration: number) {
@@ -288,13 +298,14 @@ export class TestRunner {
288298
private testKind: TestKind,
289299
private request: vscode.TestRunRequest,
290300
private folderContext: FolderContext,
291-
private controller: vscode.TestController
301+
private controller: vscode.TestController,
302+
token: vscode.CancellationToken
292303
) {
293304
this.testArgs = new TestRunArguments(
294305
this.ensureRequestIncludesTests(this.request),
295306
isDebugging(testKind)
296307
);
297-
this.testRun = new TestRunProxy(request, controller, this.testArgs, folderContext);
308+
this.testRun = new TestRunProxy(request, controller, this.testArgs, folderContext, token);
298309
this.xcTestOutputParser =
299310
testKind === TestKind.parallel
300311
? new ParallelXCTestOutputParser(
@@ -358,10 +369,11 @@ export class TestRunner {
358369
TestKind.standard,
359370
request,
360371
folderContext,
361-
controller
372+
controller,
373+
token
362374
);
363375
onCreateTestRun.fire(runner.testRun);
364-
await runner.runHandler(token);
376+
await runner.runHandler();
365377
},
366378
true,
367379
runnableTag
@@ -374,10 +386,11 @@ export class TestRunner {
374386
TestKind.parallel,
375387
request,
376388
folderContext,
377-
controller
389+
controller,
390+
token
378391
);
379392
onCreateTestRun.fire(runner.testRun);
380-
await runner.runHandler(token);
393+
await runner.runHandler();
381394
},
382395
false,
383396
runnableTag
@@ -390,10 +403,11 @@ export class TestRunner {
390403
TestKind.release,
391404
request,
392405
folderContext,
393-
controller
406+
controller,
407+
token
394408
);
395409
onCreateTestRun.fire(runner.testRun);
396-
await runner.runHandler(token);
410+
await runner.runHandler();
397411
},
398412
false,
399413
runnableTag
@@ -407,15 +421,16 @@ export class TestRunner {
407421
TestKind.coverage,
408422
request,
409423
folderContext,
410-
controller
424+
controller,
425+
token
411426
);
412427
onCreateTestRun.fire(runner.testRun);
413428
if (request.profile) {
414429
request.profile.loadDetailedCoverage = async (testRun, fileCoverage) => {
415430
return runner.testRun.coverage.loadDetailedCoverage(fileCoverage.uri);
416431
};
417432
}
418-
await runner.runHandler(token);
433+
await runner.runHandler();
419434
await vscode.commands.executeCommand("testing.openCoverage");
420435
},
421436
false,
@@ -430,10 +445,11 @@ export class TestRunner {
430445
TestKind.debug,
431446
request,
432447
folderContext,
433-
controller
448+
controller,
449+
token
434450
);
435451
onCreateTestRun.fire(runner.testRun);
436-
await runner.runHandler(token);
452+
await runner.runHandler();
437453
},
438454
false,
439455
runnableTag
@@ -446,10 +462,11 @@ export class TestRunner {
446462
TestKind.debugRelease,
447463
request,
448464
folderContext,
449-
controller
465+
controller,
466+
token
450467
);
451468
onCreateTestRun.fire(runner.testRun);
452-
await runner.runHandler(token);
469+
await runner.runHandler();
453470
},
454471
false,
455472
runnableTag
@@ -463,13 +480,18 @@ export class TestRunner {
463480
* @param token Cancellation token
464481
* @returns When complete
465482
*/
466-
async runHandler(token: vscode.CancellationToken) {
483+
async runHandler() {
467484
const runState = new TestRunnerTestRunState(this.testRun);
485+
486+
const cancellationDisposable = this.testRun.token.onCancellationRequested(() => {
487+
this.testRun.appendOutput("\r\nTest run cancelled.");
488+
});
489+
468490
try {
469491
if (isDebugging(this.testKind)) {
470-
await this.debugSession(token, runState);
492+
await this.debugSession(runState);
471493
} else {
472-
await this.runSession(token, runState);
494+
await this.runSession(runState);
473495
}
474496
} catch (error) {
475497
this.workspaceContext.outputChannel.log(`Error: ${getErrorDescription(error)}`);
@@ -481,14 +503,12 @@ export class TestRunner {
481503
await this.testRun.computeCoverage();
482504
}
483505

506+
cancellationDisposable.dispose();
484507
await this.testRun.end();
485508
}
486509

487510
/** Run test session without attaching to a debugger */
488-
async runSession(
489-
token: vscode.CancellationToken,
490-
runState: TestRunnerTestRunState
491-
): Promise<TestRunState> {
511+
async runSession(runState: TestRunnerTestRunState): Promise<TestRunState> {
492512
// Run swift-testing first, then XCTest.
493513
// swift-testing being parallel by default should help these run faster.
494514
if (this.testArgs.hasSwiftTestingTests) {
@@ -509,13 +529,7 @@ export class TestRunner {
509529
true
510530
);
511531

512-
if (testBuildConfig === null) {
513-
return this.testRun.runState;
514-
}
515-
516-
const outputStream = this.testOutputWritable(TestLibrary.swiftTesting, runState);
517-
if (token.isCancellationRequested) {
518-
outputStream.end();
532+
if (testBuildConfig === null || this.testRun.isCancellationRequested) {
519533
return this.testRun.runState;
520534
}
521535

@@ -526,8 +540,7 @@ export class TestRunner {
526540
await this.launchTests(
527541
runState,
528542
this.testKind === TestKind.parallel ? TestKind.standard : this.testKind,
529-
token,
530-
outputStream,
543+
this.testOutputWritable(TestLibrary.swiftTesting, runState),
531544
testBuildConfig,
532545
TestLibrary.swiftTesting
533546
);
@@ -541,13 +554,8 @@ export class TestRunner {
541554
this.testArgs.xcTestArgs,
542555
true
543556
);
544-
if (testBuildConfig === null) {
545-
return this.testRun.runState;
546-
}
547557

548-
const parsedOutputStream = this.testOutputWritable(TestLibrary.xctest, runState);
549-
if (token.isCancellationRequested) {
550-
parsedOutputStream.end();
558+
if (testBuildConfig === null || this.testRun.isCancellationRequested) {
551559
return this.testRun.runState;
552560
}
553561

@@ -557,8 +565,7 @@ export class TestRunner {
557565
await this.launchTests(
558566
runState,
559567
this.testKind,
560-
token,
561-
parsedOutputStream,
568+
this.testOutputWritable(TestLibrary.xctest, runState),
562569
testBuildConfig,
563570
TestLibrary.xctest
564571
);
@@ -570,26 +577,20 @@ export class TestRunner {
570577
private async launchTests(
571578
runState: TestRunnerTestRunState,
572579
testKind: TestKind,
573-
token: vscode.CancellationToken,
574580
outputStream: stream.Writable,
575581
testBuildConfig: vscode.DebugConfiguration,
576582
testLibrary: TestLibrary
577583
) {
578584
try {
579585
switch (testKind) {
580586
case TestKind.coverage:
581-
await this.runCoverageSession(
582-
token,
583-
outputStream,
584-
testBuildConfig,
585-
testLibrary
586-
);
587+
await this.runCoverageSession(outputStream, testBuildConfig, testLibrary);
587588
break;
588589
case TestKind.parallel:
589-
await this.runParallelSession(token, outputStream, testBuildConfig, runState);
590+
await this.runParallelSession(outputStream, testBuildConfig, runState);
590591
break;
591592
default:
592-
await this.runStandardSession(token, outputStream, testBuildConfig, testKind);
593+
await this.runStandardSession(outputStream, testBuildConfig, testKind);
593594
break;
594595
}
595596
} catch (error) {
@@ -613,7 +614,6 @@ export class TestRunner {
613614

614615
/** Run tests outside of debugger */
615616
async runStandardSession(
616-
token: vscode.CancellationToken,
617617
outputStream: stream.Writable,
618618
testBuildConfig: vscode.DebugConfiguration,
619619
testKind: TestKind
@@ -679,19 +679,21 @@ export class TestRunner {
679679
}
680680
});
681681

682-
this.folderContext.taskQueue.queueOperation(new TaskOperation(task), token);
682+
this.folderContext.taskQueue.queueOperation(
683+
new TaskOperation(task),
684+
this.testRun.token
685+
);
683686
});
684687
}
685688

686689
/** Run tests with code coverage, and parse coverage results */
687690
async runCoverageSession(
688-
token: vscode.CancellationToken,
689691
outputStream: stream.Writable,
690692
testBuildConfig: vscode.DebugConfiguration,
691693
testLibrary: TestLibrary
692694
) {
693695
try {
694-
await this.runStandardSession(token, outputStream, testBuildConfig, TestKind.coverage);
696+
await this.runStandardSession(outputStream, testBuildConfig, TestKind.coverage);
695697
} catch (error) {
696698
// If this isn't a standard test failure, forward the error and skip generating coverage.
697699
if (error !== 1) {
@@ -704,7 +706,6 @@ export class TestRunner {
704706

705707
/** Run tests in parallel outside of debugger */
706708
async runParallelSession(
707-
token: vscode.CancellationToken,
708709
outputStream: stream.Writable,
709710
testBuildConfig: vscode.DebugConfiguration,
710711
runState: TestRunnerTestRunState
@@ -714,7 +715,6 @@ export class TestRunner {
714715

715716
try {
716717
testBuildConfig.args = await this.runStandardSession(
717-
token,
718718
outputStream,
719719
{
720720
...testBuildConfig,
@@ -743,13 +743,13 @@ export class TestRunner {
743743
}
744744

745745
/** Run test session inside debugger */
746-
async debugSession(token: vscode.CancellationToken, runState: TestRunnerTestRunState) {
746+
async debugSession(runState: TestRunnerTestRunState) {
747747
// Perform a build all first to produce the binaries we'll run later.
748748
let buildOutput = "";
749749
try {
750750
await this.runStandardSession(
751-
token,
752-
// discard the output as we dont want to associate it with the test run.
751+
// Capture the output to print it in case of a build error.
752+
// We dont want to associate it with the test run.
753753
new stream.Writable({
754754
write: (chunk, encoding, next) => {
755755
buildOutput += chunk.toString();
@@ -846,6 +846,11 @@ export class TestRunner {
846846
const debugRuns = validBuildConfigs.map(config => {
847847
return () =>
848848
new Promise<void>((resolve, reject) => {
849+
if (this.testRun.isCancellationRequested) {
850+
resolve();
851+
return;
852+
}
853+
849854
// add cancelation
850855
const startSession = vscode.debug.onDidStartDebugSession(session => {
851856
if (config.testType === TestLibrary.xctest) {
@@ -862,12 +867,13 @@ export class TestRunner {
862867
outputHandler(output);
863868
});
864869

865-
const cancellation = token.onCancellationRequested(() => {
870+
const cancellation = this.testRun.token.onCancellationRequested(() => {
866871
this.workspaceContext.outputChannel.logDiagnostic(
867872
"Test Debugging Cancelled",
868873
this.folderContext.name
869874
);
870875
vscode.debug.stopDebugging(session);
876+
resolve();
871877
});
872878
subscriptions.push(cancellation);
873879
});

0 commit comments

Comments
 (0)