From cf94178b897a337d2efec7d55844c448e378f7ce Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 15 Dec 2020 16:18:13 -0800 Subject: [PATCH] testing: improved test explorer, cancellation --- src/vs/base/common/iterator.ts | 4 + src/vs/vscode.proposed.d.ts | 2 +- .../api/browser/mainThreadTesting.ts | 7 +- .../workbench/api/common/extHost.protocol.ts | 4 +- src/vs/workbench/api/common/extHostTesting.ts | 15 ++- .../contrib/testing/browser/icons.ts | 1 + .../testing/browser/testExplorerActions.ts | 94 ++++++++++++++++--- .../testing/browser/testExplorerTree.ts | 43 +++++++++ .../browser/testingCollectionService.ts | 2 - .../testing/browser/testingExplorerView.ts | 39 +++----- .../contrib/testing/common/testService.ts | 6 +- .../contrib/testing/common/testServiceImpl.ts | 36 +++++-- 12 files changed, 193 insertions(+), 60 deletions(-) create mode 100644 src/vs/workbench/contrib/testing/browser/testExplorerTree.ts diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts index d66882ac09f..3415ec26796 100644 --- a/src/vs/base/common/iterator.ts +++ b/src/vs/base/common/iterator.ts @@ -22,6 +22,10 @@ export namespace Iterable { return iterable || _empty; } + export function isEmpty(iterable: Iterable): boolean { + return iterable[Symbol.iterator]().next().done === true; + } + export function first(iterable: Iterable): T | undefined { return iterable[Symbol.iterator]().next().value; } diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index a0d41038425..7d642e35800 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2025,7 +2025,7 @@ declare module 'vscode' { * Runs tests with the given options. If no options are given, then * all tests are run. Returns the resulting test run. */ - export function runTests(options: TestRunOptions): Thenable; + export function runTests(options: TestRunOptions, cancellationToken?: CancellationToken): Thenable; /** * Returns an observer that retrieves tests in the given workspace folder. diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 825c4f7b818..70cb13387c5 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -9,6 +9,7 @@ import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { ExtHostContext, ExtHostTestingResource, ExtHostTestingShape, IExtHostContext, MainContext, MainThreadTestingShape } from '../common/extHost.protocol'; import { URI, UriComponents } from 'vs/base/common/uri'; +import { CancellationToken } from 'vs/base/common/cancellation'; @extHostNamedCustomer(MainContext.MainThreadTesting) export class MainThreadTesting extends Disposable implements MainThreadTestingShape { @@ -34,7 +35,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh */ public $registerTestProvider(id: string) { this.testService.registerTestController(id, { - runTests: req => this.proxy.$runTestsForProvider(req), + runTests: (req, token) => this.proxy.$runTestsForProvider(req, token), }); } @@ -71,8 +72,8 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh this.testService.publishDiff(resource, URI.revive(uri), diff); } - public $runTests(req: RunTestsRequest): Promise { - return this.testService.runTests(req); + public $runTests(req: RunTestsRequest, token: CancellationToken): Promise { + return this.testService.runTests(req, token); } public dispose() { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 103c3a49430..62fab3fac66 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1776,7 +1776,7 @@ export const enum ExtHostTestingResource { } export interface ExtHostTestingShape { - $runTestsForProvider(req: RunTestForProviderRequest): Promise; + $runTestsForProvider(req: RunTestForProviderRequest, token: CancellationToken): Promise; $subscribeToTests(resource: ExtHostTestingResource, uri: UriComponents): void; $unsubscribeFromTests(resource: ExtHostTestingResource, uri: UriComponents): void; @@ -1789,7 +1789,7 @@ export interface MainThreadTestingShape { $subscribeToDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; $unsubscribeFromDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; $publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void; - $runTests(req: RunTestsRequest): Promise; + $runTests(req: RunTestsRequest, token: CancellationToken): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 8b8cf915b28..ceb1044b9c3 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -82,7 +82,7 @@ export class ExtHostTesting implements ExtHostTestingShape { /** * Implements vscode.test.runTests */ - public async runTests(req: vscode.TestRunOptions) { + public async runTests(req: vscode.TestRunOptions, token = CancellationToken.None) { await this.proxy.$runTests({ tests: req.tests // Find workspace items first, then owned tests, then document tests. @@ -94,7 +94,7 @@ export class ExtHostTesting implements ExtHostTestingShape { .filter(isDefined) .map(item => ({ providerId: item.providerId, testId: item.id })), debug: req.debug - }); + }, token); } /** @@ -178,7 +178,7 @@ export class ExtHostTesting implements ExtHostTestingShape { * providers to be run. * @override */ - public async $runTestsForProvider(req: RunTestForProviderRequest): Promise { + public async $runTestsForProvider(req: RunTestForProviderRequest, cancellation: CancellationToken): Promise { const provider = this.providers.get(req.providerId); if (!provider || !provider.runTests) { return EMPTY_TEST_RESULT; @@ -189,8 +189,13 @@ export class ExtHostTesting implements ExtHostTestingShape { return EMPTY_TEST_RESULT; } - await provider.runTests({ tests, debug: req.debug }, CancellationToken.None); - return EMPTY_TEST_RESULT; + try { + await provider.runTests({ tests, debug: req.debug }, cancellation); + return EMPTY_TEST_RESULT; + } catch (e) { + console.error(e); // so it appears to attached debuggers + throw e; + } } } diff --git a/src/vs/workbench/contrib/testing/browser/icons.ts b/src/vs/workbench/contrib/testing/browser/icons.ts index e49c12590d9..bb16931126a 100644 --- a/src/vs/workbench/contrib/testing/browser/icons.ts +++ b/src/vs/workbench/contrib/testing/browser/icons.ts @@ -13,6 +13,7 @@ import { testStatesToIconColors } from 'vs/workbench/contrib/testing/browser/the export const testingViewIcon = registerIcon('testing-view-icon', Codicon.beaker, localize('testingViewIcon', 'View icon of the testing view.')); export const testingRunIcon = registerIcon('testing-run-icon', Codicon.debugStart, localize('testingRunIcon', 'Icon of the "run test" action.')); export const testingDebugIcon = registerIcon('testing-debug-icon', Codicon.debugAlt, localize('testingDebugIcon', 'Icon of the "debug test" action.')); +export const testingCancelIcon = registerIcon('testing-cancel-icon', Codicon.close, localize('testingCancelIcon', 'Icon to cancel ongoing test runs.')); export const testingShowAsList = registerIcon('testing-show-as-list-icon', Codicon.listTree, localize('testingShowAsList', 'Icon shown when the test explorer is disabled as a tree.')); export const testingShowAsTree = registerIcon('testing-show-as-list-icon', Codicon.listFlat, localize('testingShowAsTree', 'Icon shown when the test explorer is disabled as a list.')); diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index 6ef15830e3f..7846b4d6c6c 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -5,17 +5,35 @@ import { Action } from 'vs/base/common/actions'; -import { Event } from 'vs/base/common/event'; +import { Emitter } from 'vs/base/common/event'; import { Iterable } from 'vs/base/common/iterator'; import { localize } from 'vs/nls'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; import * as icons from 'vs/workbench/contrib/testing/browser/icons'; -import { isTestItem, ITestingCollectionService, ITestSubscriptionItem } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; +import { isTestItem } from 'vs/workbench/contrib/testing/browser/testExplorerTree'; +import { ITestingCollectionService, ITestSubscriptionItem } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; import { TestingExplorerViewModel } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { EMPTY_TEST_RESULT, RunTestsResult } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; +export class FilterableAction extends Action { + private visChangeEmitter = new Emitter(); + + public onDidChangeVisibility = this.visChangeEmitter.event; + public isVisible = true; + + protected _setVisible(isVisible: boolean) { + if (isVisible !== this.isVisible) { + this.isVisible = isVisible; + this.visChangeEmitter.fire(isVisible); + } + } +} + +export const filterVisibleActions = (actions: ReadonlyArray) => + actions.filter(a => !(a instanceof FilterableAction) || a.isVisible); + export class DebugAction extends Action { constructor( private readonly test: ITestSubscriptionItem, @@ -58,7 +76,7 @@ export class RunAction extends Action { } } -abstract class RunOrDebugAction extends Action { +abstract class RunOrDebugAction extends FilterableAction { constructor( private readonly viewModel: TestingExplorerViewModel, id: string, @@ -74,8 +92,8 @@ abstract class RunOrDebugAction extends Action { /* enabled= */ Iterable.first(testService.testRuns) === undefined, ); - this._register(testService.onTestRunStarted(this.updateEnablementState, this)); - this._register(testService.onTestRunCompleted(this.updateEnablementState, this)); + this._register(testService.onTestRunStarted(this.updateVisibility, this)); + this._register(testService.onTestRunCompleted(this.updateVisibility, this)); this._register(viewModel.onDidChangeSelection(this.updateEnablementState, this)); } @@ -88,12 +106,12 @@ abstract class RunOrDebugAction extends Action { return this.testService.runTests({ tests, debug: false }); } + private updateVisibility() { + this._setVisible(Iterable.isEmpty(this.testService.testRuns)); + } + private updateEnablementState() { - if (Iterable.first(this.testService.testRuns) !== undefined) { - this._setEnabled(false); - } else { - this._setEnabled(Iterable.first(this.getActionableTests()) !== undefined); - } + this._setEnabled(!Iterable.isEmpty(this.getActionableTests())); } private *getActionableTests() { @@ -167,7 +185,31 @@ export class DebugSelectedAction extends RunOrDebugAction { } public filter({ item }: ITestSubscriptionItem) { - return item.runnable; + return item.debuggable; + } +} + +export class CancelTestRunAction extends FilterableAction { + constructor(@ITestService private readonly testService: ITestService) { + super( + 'action.cancelRun', + localize('cancelRunTests', 'Cancel Test Run'), + ThemeIcon.asClassName(icons.testingCancelIcon), + ); + + this._register(testService.onTestRunStarted(this.updateVisibility, this)); + this._register(testService.onTestRunCompleted(this.updateVisibility, this)); + this.updateVisibility(); + } + + private updateVisibility() { + this._setVisible(!Iterable.isEmpty(this.testService.testRuns)); + } + + public async run(): Promise { + for (const run of this.testService.testRuns) { + this.testService.cancelTestRun(run); + } } } @@ -176,8 +218,36 @@ export const enum ViewMode { Tree } +export const enum ViewGrouping { + ByTree, + ByStatus, +} + export class ToggleViewModeAction extends Action { - constructor(private readonly viewModel: { viewMode: ViewMode, onViewModeChange: Event }) { + constructor(private readonly viewModel: TestingExplorerViewModel) { + super( + 'workbench.testing.action.toggleViewMode', + localize('toggleViewMode', "View as List"), + ); + this._register(viewModel.onViewModeChange(this.onDidChangeMode, this)); + this.onDidChangeMode(this.viewModel.viewMode); + } + + async run(): Promise { + this.viewModel.viewMode = this.viewModel.viewMode === ViewMode.List + ? ViewMode.Tree + : ViewMode.List; + } + + private onDidChangeMode(mode: ViewMode): void { + const iconClass = ThemeIcon.asClassName(mode === ViewMode.List ? icons.testingShowAsList : icons.testingShowAsTree); + this.class = iconClass; + this.checked = mode === ViewMode.List; + } +} + +export class ToggleViewGroupingAction extends Action { + constructor(private readonly viewModel: TestingExplorerViewModel) { super( 'workbench.testing.action.toggleViewMode', localize('toggleViewMode', "View as List"), diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerTree.ts b/src/vs/workbench/contrib/testing/browser/testExplorerTree.ts new file mode 100644 index 00000000000..3552087feff --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testExplorerTree.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; +import { ITestSubscriptionFolder, ITestSubscriptionItem } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; + + +export type TreeElement = ITestSubscriptionFolder | ITestSubscriptionItem; + +export type TreeStateNode = { statusNode: true; state: TestRunState; priority: number }; + +export const isTestItem = (v: TreeElement | undefined): v is ITestSubscriptionItem => !!v && (v as any).depth > 0; + +export const getLabel = (item: TreeElement) => isTestItem(item) + ? item.item.label + : item.folder.name; + +/** + * List of display priorities for different run states. When tests update, + * the highest-priority state from any of their children will be the state + * reflected in the parent node. + */ +export const statePriority: { [K in TestRunState]: number } = { + [TestRunState.Running]: 6, + [TestRunState.Queued]: 5, + [TestRunState.Errored]: 4, + [TestRunState.Failed]: 3, + [TestRunState.Passed]: 2, + [TestRunState.Skipped]: 1, + [TestRunState.Unset]: 0, +}; + +export const stateNodes = Object.entries(statePriority).reduce( + (acc, [stateStr, priority]) => { + const state = Number(stateStr) as TestRunState; + acc[state] = { statusNode: true, state, priority }; + return acc; + }, {} as { [K in TestRunState]: TreeStateNode } +); + +export const maxPriority = (a: TestRunState, b: TestRunState) => statePriority[a] > statePriority[b] ? a : b; diff --git a/src/vs/workbench/contrib/testing/browser/testingCollectionService.ts b/src/vs/workbench/contrib/testing/browser/testingCollectionService.ts index ee562fdbe5d..fe4116b035c 100644 --- a/src/vs/workbench/contrib/testing/browser/testingCollectionService.ts +++ b/src/vs/workbench/contrib/testing/browser/testingCollectionService.ts @@ -12,8 +12,6 @@ import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; -export const isTestItem = (v: ITestSubscriptionItem | ITestSubscriptionFolder | undefined): v is ITestSubscriptionItem => !!v && v.depth > 0; - export interface ITestSubscriptionFolder { depth: 0; folder: IWorkspaceFolder; diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index d395730658a..7d78f18ac11 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -37,10 +37,11 @@ import { ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import { testingStatesToIcons } from 'vs/workbench/contrib/testing/browser/icons'; -import { isTestItem, ITestingCollectionService, ITestSubscriptionFolder, ITestSubscriptionItem } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; +import { getLabel, isTestItem, maxPriority, statePriority, TreeElement } from 'vs/workbench/contrib/testing/browser/testExplorerTree'; +import { ITestingCollectionService, ITestSubscriptionItem } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { DebugAction, DebugSelectedAction, RunAction, RunSelectedAction, ToggleViewModeAction, ViewMode } from './testExplorerActions'; +import { CancelTestRunAction, DebugAction, DebugSelectedAction, FilterableAction, filterVisibleActions, RunAction, RunSelectedAction, ToggleViewModeAction, ViewMode } from './testExplorerActions'; export const TESTING_EXPLORER_VIEW_ID = 'workbench.view.testing'; @@ -94,9 +95,16 @@ export class TestingExplorerView extends ViewPane { this.primaryActions = [ this.instantiationService.createInstance(RunSelectedAction, this.viewModel), this.instantiationService.createInstance(DebugSelectedAction, this.viewModel), + this.instantiationService.createInstance(CancelTestRunAction), ]; this.primaryActions.forEach(this._register, this); + for (const action of [...this.primaryActions, ...this.secondaryActions]) { + if (action instanceof FilterableAction) { + action.onDidChangeVisibility(this.updateActions, this); + } + } + this._register(this.onDidChangeBodyVisibility(visible => { if (!visible && this.currentSubscription) { this.currentSubscription.dispose(); @@ -111,14 +119,14 @@ export class TestingExplorerView extends ViewPane { * @override */ public getActions() { - return [...this.primaryActions, ...super.getActions()]; + return [...filterVisibleActions(this.primaryActions), ...super.getActions()]; } /** * @override */ public getSecondaryActions() { - return [...this.secondaryActions, ...super.getSecondaryActions()]; + return [...filterVisibleActions(this.secondaryActions), ...super.getSecondaryActions()]; } @@ -348,6 +356,7 @@ export class TestingExplorerViewModel extends Disposable { private isRendered(node: TreeElement) { return this.viewMode === ViewMode.Tree || node.depth <= 1; } + private getListChildrenOf(node: ITestSubscriptionItem) { const leafNodes: ICompressedTreeElement[] = []; @@ -380,23 +389,6 @@ export class TestingExplorerViewModel extends Disposable { } } -/** - * List of display priorities for different run states. When tests update, - * the highest-priority state from any of their children will be the state - * reflected in the parent node. - */ -const statePriority: { [K in TestRunState]: number } = { - [TestRunState.Running]: 6, - [TestRunState.Queued]: 5, - [TestRunState.Errored]: 4, - [TestRunState.Failed]: 3, - [TestRunState.Passed]: 2, - [TestRunState.Skipped]: 1, - [TestRunState.Unset]: 0, -}; - -const maxPriority = (a: TestRunState, b: TestRunState) => statePriority[a] > statePriority[b] ? a : b; - /** * Gets the computed state for the node. */ @@ -419,8 +411,6 @@ const renderElement = (item: TreeElement): ICompressedTreeElement = }; }; -const getLabel = (item: TreeElement) => isTestItem(item) ? item.item.label : item.folder.name; - class TestsFilter implements ITreeFilter { private filterText: string | undefined; @@ -440,9 +430,6 @@ class TestsFilter implements ITreeFilter { return element.childCount ? TreeVisibility.Recurse : TreeVisibility.Hidden; } } - -type TreeElement = ITestSubscriptionFolder | ITestSubscriptionItem; - class TreeSorter implements ITreeSorter { public compare(a: TreeElement, b: TreeElement): number { return getLabel(a).localeCompare(getLabel(b)); diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index f57271da0d6..58127d24ae4 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; @@ -13,7 +14,7 @@ import { RunTestForProviderRequest, RunTestsRequest, RunTestsResult, TestsDiff } export const ITestService = createDecorator('testService'); export interface MainTestController { - runTests(request: RunTestForProviderRequest): Promise; + runTests(request: RunTestForProviderRequest, token: CancellationToken): Promise; } export type TestDiffListener = (diff: TestsDiff) => void; @@ -32,7 +33,8 @@ export interface ITestService { registerTestController(id: string, controller: MainTestController): void; unregisterTestController(id: string): void; - runTests(req: RunTestsRequest): Promise; + runTests(req: RunTestsRequest, token?: CancellationToken): Promise; + cancelTestRun(req: RunTestsRequest): void; publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void; subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff: TestDiffListener): IDisposable; } diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 962d7ff43a8..d73ff38ca95 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { groupBy } from 'vs/base/common/arrays'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; import { URI, UriComponents } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { INotificationService } from 'vs/platform/notification/common/notification'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; -import { AbstractIncrementalTestCollection, collectTestResults, getTestSubscriptionKey, IncrementalTestCollectionItem, InternalTestItem, RunTestsRequest, RunTestsResult, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { AbstractIncrementalTestCollection, collectTestResults, EMPTY_TEST_RESULT, getTestSubscriptionKey, IncrementalTestCollectionItem, InternalTestItem, RunTestsRequest, RunTestsResult, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestService, MainTestController, TestDiffListener } from 'vs/workbench/contrib/testing/common/testService'; @@ -30,16 +33,23 @@ export class TestService extends Disposable implements ITestService { private readonly providerCount: IContextKey; private readonly runStartedEmitter = new Emitter(); private readonly runCompletedEmitter = new Emitter<{ req: RunTestsRequest, result: RunTestsResult }>(); + private readonly runningTests = new Map(); - public readonly testRuns = new Set(); public readonly onTestRunStarted = this.runStartedEmitter.event; public readonly onTestRunCompleted = this.runCompletedEmitter.event; - constructor(@IContextKeyService contextKeyService: IContextKeyService) { + constructor(@IContextKeyService contextKeyService: IContextKeyService, @INotificationService private readonly notificationService: INotificationService) { super(); this.providerCount = TestingContextKeys.providerCount.bindTo(contextKeyService); } + /** + * Gets currently running tests. + */ + public get testRuns() { + return this.runningTests.keys(); + } + /** * Gets the current provider count. */ @@ -72,18 +82,30 @@ export class TestService extends Disposable implements ITestService { /** * @inheritdoc */ - async runTests(req: RunTestsRequest): Promise { + public cancelTestRun(req: RunTestsRequest) { + this.runningTests.get(req)?.cancel(); + } + + + /** + * @inheritdoc + */ + public async runTests(req: RunTestsRequest, token = CancellationToken.None): Promise { const tests = groupBy(req.tests, (a, b) => a.providerId === b.providerId ? 0 : 1); + const cancelSource = new CancellationTokenSource(token); const requests = tests.map(group => { const providerId = group[0].providerId; const controller = this.testControllers.get(providerId); - return controller?.runTests({ providerId, debug: req.debug, ids: group.map(t => t.testId) }); + return controller?.runTests({ providerId, debug: req.debug, ids: group.map(t => t.testId) }, cancelSource.token).catch(err => { + this.notificationService.error(localize('testError', 'An error occurred attempting to run tests: {0}', err.message)); + return EMPTY_TEST_RESULT; + }); }).filter(isDefined); - this.testRuns.add(req); + this.runningTests.set(req, cancelSource); this.runStartedEmitter.fire(req); const result = await collectTestResults(await Promise.all(requests)); - this.testRuns.delete(req); + this.runningTests.delete(req); this.runCompletedEmitter.fire({ req, result }); return result;