Skip to content

Commit 7a9491c

Browse files
committed
Prompt for cancellation when starting test run while one is in flight
If a test run is already in progress and another one is started VS Code will queue it and start the second run once the first has finished. Implement cancellation logic that prompts the user to cancel the active test run if one is in flight. If they chose to cancel the dialog, the second test run is not queued. If they do chose to cancel the active test run it is stopped and the new one starts immediately. Issue: #1748
1 parent d4561f3 commit 7a9491c

File tree

7 files changed

+313
-33
lines changed

7 files changed

+313
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- New `swift.outputChannelLogLevel` setting to control the verbosity of the `Swift` output channel ([#1746](https://github.com/swiftlang/vscode-swift/pull/1746))
99
- New `swift.debugTestsMultipleTimes` and `swift.debugTestsUntilFailure` commands for debugging tests over multiple runs ([#1763](https://github.com/swiftlang/vscode-swift/pull/1763))
1010
- Optionally include LLDB DAP logs in the Swift diagnostics bundle ([#1768](https://github.com/swiftlang/vscode-swift/pull/1758))
11+
- Prompt to cancel and replace the active test run if one is in flight ([#1774](https://github.com/swiftlang/vscode-swift/pull/1774))
1112

1213
### Changed
1314
- Added log levels and improved Swift extension logging so a logfile is produced in addition to logging messages to the existing `Swift` output channel. Deprecated the `swift.diagnostics` setting in favour of the new `swift.outputChannelLogLevel` setting ([#1746](https://github.com/swiftlang/vscode-swift/pull/1746))

src/FolderContext.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,22 @@ import { LinuxMain } from "./LinuxMain";
1818
import { PackageWatcher } from "./PackageWatcher";
1919
import { SwiftPackage, Target, TargetType } from "./SwiftPackage";
2020
import { TestExplorer } from "./TestExplorer/TestExplorer";
21+
import { TestRunManager } from "./TestExplorer/TestRunManager";
2122
import { WorkspaceContext, FolderOperation } from "./WorkspaceContext";
2223
import { BackgroundCompilation } from "./BackgroundCompilation";
2324
import { TaskQueue } from "./tasks/TaskQueue";
2425
import { isPathInsidePath } from "./utilities/filesystem";
2526
import { SwiftToolchain } from "./toolchain/toolchain";
2627
import { SwiftLogger } from "./logging/SwiftLogger";
28+
import { TestRunProxy } from "./TestExplorer/TestRunner";
2729

2830
export class FolderContext implements vscode.Disposable {
29-
private packageWatcher: PackageWatcher;
3031
public backgroundCompilation: BackgroundCompilation;
3132
public hasResolveErrors = false;
3233
public testExplorer?: TestExplorer;
3334
public taskQueue: TaskQueue;
35+
private packageWatcher: PackageWatcher;
36+
private testRunManager: TestRunManager;
3437

3538
/**
3639
* FolderContext constructor
@@ -49,6 +52,7 @@ export class FolderContext implements vscode.Disposable {
4952
this.packageWatcher = new PackageWatcher(this, workspaceContext);
5053
this.backgroundCompilation = new BackgroundCompilation(this);
5154
this.taskQueue = new TaskQueue(this);
55+
this.testRunManager = new TestRunManager();
5256
}
5357

5458
/** dispose of any thing FolderContext holds */
@@ -212,6 +216,34 @@ export class FolderContext implements vscode.Disposable {
212216
return target;
213217
}
214218

219+
/**
220+
* Register a new test run
221+
* @param testRun The test run to register
222+
* @param folder The folder context
223+
* @param testKind The kind of test run
224+
* @param tokenSource The cancellation token source
225+
*/
226+
public registerTestRun(testRun: TestRunProxy, tokenSource: vscode.CancellationTokenSource) {
227+
this.testRunManager.registerTestRun(testRun, this, tokenSource);
228+
}
229+
230+
/**
231+
* Returns true if there is an active test run for the given test kind
232+
* @param testKind The kind of test
233+
* @returns True if there is an active test run, false otherwise
234+
*/
235+
hasActiveTestRun() {
236+
return this.testRunManager.getActiveTestRun(this) !== undefined;
237+
}
238+
239+
/**
240+
* Cancels the active test run for the given test kind
241+
* @param testKind The kind of test run
242+
*/
243+
cancelTestRun() {
244+
this.testRunManager.cancelTestRun(this);
245+
}
246+
215247
/**
216248
* Called whenever we have new document symbols
217249
*/

src/TestExplorer/TestRunManager.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2021-2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import * as vscode from "vscode";
16+
import { TestRunProxy } from "./TestRunner";
17+
import { FolderContext } from "../FolderContext";
18+
19+
/**
20+
* Manages active test runs and provides functionality to check if a test run is in progress
21+
* and to cancel test runs.
22+
*/
23+
export class TestRunManager {
24+
private activeTestRuns = new Map<
25+
string,
26+
{ testRun: TestRunProxy; tokenSource: vscode.CancellationTokenSource }
27+
>();
28+
29+
/**
30+
* Register a new test run
31+
* @param testRun The test run to register
32+
* @param folder The folder context
33+
* @param tokenSource The cancellation token source
34+
*/
35+
public registerTestRun(
36+
testRun: TestRunProxy,
37+
folder: FolderContext,
38+
tokenSource: vscode.CancellationTokenSource
39+
) {
40+
const key = this.getTestRunKey(folder);
41+
this.activeTestRuns.set(key, { testRun, tokenSource });
42+
43+
// When the test run completes, remove it from active test runs
44+
testRun.onTestRunComplete(() => {
45+
this.activeTestRuns.delete(key);
46+
});
47+
}
48+
49+
/**
50+
* Cancel an active test run
51+
* @param folder The folder context
52+
*/
53+
public cancelTestRun(folder: FolderContext) {
54+
const key = this.getTestRunKey(folder);
55+
const activeRun = this.activeTestRuns.get(key);
56+
if (activeRun) {
57+
activeRun.tokenSource.cancel();
58+
}
59+
}
60+
61+
/**
62+
* Check if a test run is already in progress for the given folder and test kind
63+
* @param folder The folder context
64+
* @returns The active test run if one exists, undefined otherwise
65+
*/
66+
public getActiveTestRun(folder: FolderContext) {
67+
const key = this.getTestRunKey(folder);
68+
const activeRun = this.activeTestRuns.get(key);
69+
return activeRun?.testRun;
70+
}
71+
72+
/**
73+
* Generate a unique key for a test run based on folder and test kind
74+
* @param folder The folder context
75+
* @returns A unique key
76+
*/
77+
private getTestRunKey(folder: FolderContext) {
78+
return folder.folder.fsPath;
79+
}
80+
}

src/TestExplorer/TestRunner.ts

Lines changed: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ import {
4848
} from "../debugger/buildConfig";
4949
import { TestKind, isDebugging, isRelease } from "./TestKind";
5050
import { reduceTestItemChildren } from "./TestUtils";
51-
import { CompositeCancellationToken } from "../utilities/cancellation";
51+
import {
52+
CompositeCancellationToken,
53+
CompositeCancellationTokenSource,
54+
} from "../utilities/cancellation";
5255
// eslint-disable-next-line @typescript-eslint/no-require-imports
5356
import stripAnsi = require("strip-ansi");
5457
import { packageName, resolveScope } from "../utilities/tasks";
@@ -429,7 +432,7 @@ export class TestRunner {
429432
private testArgs: TestRunArguments;
430433
private xcTestOutputParser: IXCTestOutputParser;
431434
private swiftTestOutputParser: SwiftTestingOutputParser;
432-
private static CANCELLATION_ERROR = "Test run cancelled";
435+
private static CANCELLATION_ERROR = "Test run cancelled.";
433436

434437
/**
435438
* Constructor for TestRunner
@@ -517,15 +520,14 @@ export class TestRunner {
517520
TestKind.standard,
518521
vscode.TestRunProfileKind.Run,
519522
async (request, token) => {
520-
const runner = new TestRunner(
523+
await this.handleTestRunRequest(
521524
TestKind.standard,
522525
request,
523526
folderContext,
524527
controller,
525-
token
528+
token,
529+
onCreateTestRun
526530
);
527-
onCreateTestRun.fire(runner.testRun);
528-
await runner.runHandler();
529531
},
530532
true,
531533
runnableTag
@@ -534,15 +536,14 @@ export class TestRunner {
534536
TestKind.parallel,
535537
vscode.TestRunProfileKind.Run,
536538
async (request, token) => {
537-
const runner = new TestRunner(
539+
await this.handleTestRunRequest(
538540
TestKind.parallel,
539541
request,
540542
folderContext,
541543
controller,
542-
token
544+
token,
545+
onCreateTestRun
543546
);
544-
onCreateTestRun.fire(runner.testRun);
545-
await runner.runHandler();
546547
},
547548
false,
548549
runnableTag
@@ -551,15 +552,14 @@ export class TestRunner {
551552
TestKind.release,
552553
vscode.TestRunProfileKind.Run,
553554
async (request, token) => {
554-
const runner = new TestRunner(
555+
await this.handleTestRunRequest(
555556
TestKind.release,
556557
request,
557558
folderContext,
558559
controller,
559-
token
560+
token,
561+
onCreateTestRun
560562
);
561-
onCreateTestRun.fire(runner.testRun);
562-
await runner.runHandler();
563563
},
564564
false,
565565
runnableTag
@@ -569,21 +569,27 @@ export class TestRunner {
569569
TestKind.coverage,
570570
vscode.TestRunProfileKind.Coverage,
571571
async (request, token) => {
572-
const runner = new TestRunner(
572+
await this.handleTestRunRequest(
573573
TestKind.coverage,
574574
request,
575575
folderContext,
576576
controller,
577-
token
577+
token,
578+
onCreateTestRun,
579+
async runner => {
580+
if (request.profile) {
581+
request.profile.loadDetailedCoverage = async (
582+
_testRun,
583+
fileCoverage
584+
) => {
585+
return runner.testRun.coverage.loadDetailedCoverage(
586+
fileCoverage.uri
587+
);
588+
};
589+
}
590+
await vscode.commands.executeCommand("testing.openCoverage");
591+
}
578592
);
579-
onCreateTestRun.fire(runner.testRun);
580-
if (request.profile) {
581-
request.profile.loadDetailedCoverage = async (_testRun, fileCoverage) => {
582-
return runner.testRun.coverage.loadDetailedCoverage(fileCoverage.uri);
583-
};
584-
}
585-
await runner.runHandler();
586-
await vscode.commands.executeCommand("testing.openCoverage");
587593
},
588594
false,
589595
runnableTag
@@ -593,15 +599,14 @@ export class TestRunner {
593599
TestKind.debug,
594600
vscode.TestRunProfileKind.Debug,
595601
async (request, token) => {
596-
const runner = new TestRunner(
602+
await this.handleTestRunRequest(
597603
TestKind.debug,
598604
request,
599605
folderContext,
600606
controller,
601-
token
607+
token,
608+
onCreateTestRun
602609
);
603-
onCreateTestRun.fire(runner.testRun);
604-
await runner.runHandler();
605610
},
606611
false,
607612
runnableTag
@@ -610,22 +615,84 @@ export class TestRunner {
610615
TestKind.debugRelease,
611616
vscode.TestRunProfileKind.Debug,
612617
async (request, token) => {
613-
const runner = new TestRunner(
618+
await this.handleTestRunRequest(
614619
TestKind.debugRelease,
615620
request,
616621
folderContext,
617622
controller,
618-
token
623+
token,
624+
onCreateTestRun
619625
);
620-
onCreateTestRun.fire(runner.testRun);
621-
await runner.runHandler();
622626
},
623627
false,
624628
runnableTag
625629
),
626630
];
627631
}
628632

633+
/**
634+
* Handle a test run request, checking if a test run is already in progress
635+
* @param testKind The kind of test run
636+
* @param request The test run request
637+
* @param folderContext The folder context
638+
* @param controller The test controller
639+
* @param token The cancellation token
640+
* @param onCreateTestRun Event emitter for test run creation
641+
* @param postRunHandler Optional handler to run after the test run completes
642+
*/
643+
private static async handleTestRunRequest(
644+
testKind: TestKind,
645+
request: vscode.TestRunRequest,
646+
folderContext: FolderContext,
647+
controller: vscode.TestController,
648+
token: vscode.CancellationToken,
649+
onCreateTestRun: vscode.EventEmitter<TestRunProxy>,
650+
postRunHandler?: (runner: TestRunner) => Promise<void>
651+
): Promise<void> {
652+
// If there's an active test run, prompt the user to cancel
653+
if (folderContext.hasActiveTestRun()) {
654+
const cancelOption = "Replace Running Test";
655+
const result = await vscode.window.showInformationMessage(
656+
"A test run is already in progress. Would you like to cancel and replace the active test run?",
657+
{ modal: true },
658+
cancelOption
659+
);
660+
661+
if (result === cancelOption) {
662+
// Cancel the active test run
663+
folderContext.cancelTestRun();
664+
} else {
665+
return;
666+
}
667+
}
668+
669+
// Create a cancellation token source for this test run
670+
const compositeToken = new CompositeCancellationTokenSource(token);
671+
672+
// Create and run the test runner
673+
const runner = new TestRunner(
674+
testKind,
675+
request,
676+
folderContext,
677+
controller,
678+
compositeToken.token
679+
);
680+
681+
// Register the test run with the manager
682+
folderContext.registerTestRun(runner.testRun, compositeToken);
683+
684+
// Fire the event to notify that a test run was created
685+
onCreateTestRun.fire(runner.testRun);
686+
687+
// Run the tests
688+
await runner.runHandler();
689+
690+
// Run the post-run handler if provided
691+
if (postRunHandler) {
692+
await postRunHandler(runner);
693+
}
694+
}
695+
629696
/**
630697
* Extracts a list of unique test Targets from the list of test items.
631698
*/

0 commit comments

Comments
 (0)