diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts index 3e431196b99..5525a20d605 100644 --- a/src/vs/base/common/types.ts +++ b/src/vs/base/common/types.ts @@ -287,6 +287,6 @@ export function NotImplementedProxy(name: string): { new(): T } { }; } -export function assertNever(value: never) { - throw new Error('Unreachable'); +export function assertNever(value: never, message = 'Unreachable') { + throw new Error(message); } diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 6ebd6b36556..a31736de59a 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -1776,8 +1776,9 @@ declare module 'vscode' { /** * Requests that tests be run by their controller. - * @param run Run options to use + * @param run Run options to use. * @param token Cancellation token for the test run + * @stability experimental */ export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; @@ -1844,6 +1845,83 @@ declare module 'vscode' { readonly removed: ReadonlyArray; } + // Todo: this is basically the same as the TaskGroup, which is a class that + // allows custom groups to be created. However I don't anticipate having any + // UI for that, so enum for now? + export enum TestRunConfigurationGroup { + Run = 1, + Debug = 2, + Coverage = 3, + } + + /** + * Handler called to start a test run. When invoked, the function should + * {@link TestController.createTestRun} at least once, and all tasks + * associated with the run should be created before the function returns + * or the reutrned promise is resolved. + * + * @param request Request information for the test run + * @param cancellationToken Token that signals the used asked to abort the + * test run. If cancellation is requested on this token, all {@link TestRun} + * instances associated with the request will be + * automatically cancelled as well. + */ + export type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable | void; + + export interface TestRunConfiguration { + /** + * Label shown to the user in the UI. + * + * Note that the label has some significance if the user requests that + * tests be re-run in a certain way. For example, if tests were run + * normally and the user requests to re-run them in debug mode, the editor + * will attempt use a configuration with the same label in the `Debug` + * group. If there is no such configuration, the default will be used. + */ + label: string; + + /** + * Configures where this configuration is grouped in the UI. If there + * are no configurations for a group, it will not be available in the UI. + */ + readonly group: TestRunConfigurationGroup; + + /** + * Controls whether this configuration is the default action that will + * be taken when its group is actions. For example, if the user clicks + * the generic "run all" button, then the default configuration for + * {@link TestRunConfigurationGroup.Run} will be executed. + */ + isDefault: boolean; + + /** + * If this method is present a configuration gear will be present in the + * UI, and this method will be invoked when it's clicked. When called, + * you can take other editor actions, such as showing a quick pick or + * opening a configuration file. + */ + configureHandler?: () => void; + + /** + * Starts a test run. When called, the controller should call + * {@link TestController.createTestRun}. All tasks associated with the + * run should be created before the function returns or the reutrned + * promise is resolved. + * + * @param request Request information for the test run + * @param cancellationToken Token that signals the used asked to abort the + * test run. If cancellation is requested on this token, all {@link TestRun} + * instances associated with the request will be + * automatically cancelled as well. + */ + runHandler: TestRunHandler; + + /** + * Deletes the run configuration. + */ + dispose(): void; + } + /** * Interface to discover and execute tests. */ @@ -1869,6 +1947,16 @@ declare module 'vscode' { // todo@API allow createTestItem-calls without parent and simply treat them as root (similar to createSourceControlResourceGroup) readonly root: TestItem; + /** + * Creates a configuration used for running tests. Extensions must create + * at least one configuration in order for tests to be run. + * @param label Human-readable label for this configuration + * @param group Configures where this configuration is grouped in the UI. + * @param runHandler Function called to start a test run + * @param isDefault Whether this is the default action for the group + */ + createRunConfiguration(label: string, group: TestRunConfigurationGroup, runHandler: TestRunHandler, isDefault?: boolean): TestRunConfiguration; + /** * Creates a new managed {@link TestItem} instance as a child of this * one. @@ -1886,7 +1974,6 @@ declare module 'vscode' { uri?: Uri, ): TestItem; - /** * A function provided by the extension that the editor may call to request * children of a test item, if the {@link TestItem.canExpand} is `true`. @@ -1904,19 +1991,6 @@ declare module 'vscode' { */ resolveChildrenHandler?: (item: TestItem) => Thenable | void; - /** - * Starts a test run. When called, the controller should call - * {@link TestController.createTestRun}. All tasks associated with the - * run should be created before the function returns or the reutrned - * promise is resolved. - * - * @param request Request information for the test run - * @param cancellationToken Token that signals the used asked to abort the - * test run. If cancellation is requested on this token, all {@link TestRun} - * instances associated with the request will be - * automatically cancelled as well. - */ - runHandler?: (request: TestRunRequest, token: CancellationToken) => Thenable | void; /** * Creates a {@link TestRun}. This should be called by the * {@link TestRunner} when a request is made to execute tests, and may also @@ -1924,6 +1998,10 @@ declare module 'vscode' { * that are included in the results will be moved into the * {@link TestResultState.Pending} state. * + * All runs created using the same `request` instance will be grouped + * together. This is useful if, for example, a single suite of tests is + * run on multiple platforms. + * * @param request Test run request. Only tests inside the `include` may be * modified, and tests in its `exclude` are ignored. * @param name The human-readable name of the run. This can be used to @@ -1961,16 +2039,18 @@ declare module 'vscode' { exclude?: TestItem[]; /** - * Whether tests in this run should be debugged. + * The configuration used for this request. This will always be defined + * for requests issued from the editor UI, though extensions may + * programmatically create requests not associated with any configuration. */ - debug: boolean; + configuration?: TestRunConfiguration; /** * @param tests Array of specific tests to run. * @param exclude Tests to exclude from the run - * @param debug Whether tests in this run should be debugged. + * @param configuration The run configuration used for this request. */ - constructor(tests: readonly TestItem[], exclude?: readonly TestItem[], debug?: boolean); + constructor(tests: readonly TestItem[], exclude?: readonly TestItem[], configuration?: TestRunConfiguration); } /** @@ -2099,18 +2179,6 @@ declare module 'vscode' { */ error?: string | MarkdownString; - /** - * Whether this test item can be run by providing it in the - * {@link TestRunRequest.tests} array. Defaults to `true`. - */ - runnable: boolean; - - /** - * Whether this test item can be debugged by providing it in the - * {@link TestRunRequest.tests} array. Defaults to `false`. - */ - debuggable: boolean; - /** * Marks the test as outdated. This can happen as a result of file changes, * for example. In "auto run" mode, tests that are outdated will be diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 960f38b305e..ffb3578c0a3 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -5,14 +5,15 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { Range } from 'vs/editor/common/core/range'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; -import { ExtensionRunTestsRequest, ITestItem, ITestMessage, ITestRunTask, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ExtensionRunTestsRequest, ITestItem, ITestMessage, ITestRunConfiguration, ITestRunTask, ResolvedTestRunRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; @@ -42,6 +43,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh constructor( extHostContext: IExtHostContext, @ITestService private readonly testService: ITestService, + @ITestConfigurationService private readonly testConfiguration: ITestConfigurationService, @ITestResultService private readonly resultService: ITestResultService, ) { super(); @@ -65,6 +67,27 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh })); } + /** + * @inheritdoc + */ + $publishTestRunConfig(config: ITestRunConfiguration): void { + this.testConfiguration.addConfiguration(config); + } + + /** + * @inheritdoc + */ + $updateTestRunConfig(controllerId: string, configId: number, update: Partial): void { + this.testConfiguration.updateConfiguration(controllerId, configId, update); + } + + /** + * @inheritdoc + */ + $removeTestRunConfig(controllerId: string, configId: number): void { + this.testConfiguration.removeConfiguration(controllerId, configId); + } + /** * @inheritdoc */ @@ -158,10 +181,13 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh * @inheritdoc */ public $registerTestController(controllerId: string) { - const disposable = this.testService.registerTestController(controllerId, { + const disposable = new DisposableStore(); + + disposable.add(toDisposable(() => this.testConfiguration.removeConfiguration(controllerId))); + disposable.add(this.testService.registerTestController(controllerId, { runTests: (req, token) => this.proxy.$runControllerTests(req, token), expandTest: (src, levels) => this.proxy.$expandTest(src, isFinite(levels) ? levels : -1), - }); + })); this.testProviderRegistrations.set(controllerId, disposable); } @@ -169,9 +195,9 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - public $unregisterTestController(id: string) { - this.testProviderRegistrations.get(id)?.dispose(); - this.testProviderRegistrations.delete(id); + public $unregisterTestController(controllerId: string) { + this.testProviderRegistrations.get(controllerId)?.dispose(); + this.testProviderRegistrations.delete(controllerId); } /** @@ -197,8 +223,8 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh this.testService.publishDiff(controllerId, diff); } - public async $runTests(req: RunTestsRequest, token: CancellationToken): Promise { - const result = await this.testService.runTests(req, token); + public async $runTests(req: ResolvedTestRunRequest, token: CancellationToken): Promise { + const result = await this.testService.runResolvedTests(req, token); return result.id; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index dd7808fb806..f5fc963712d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1261,6 +1261,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TestResultState: extHostTypes.TestResultState, TestRunRequest: extHostTypes.TestRunRequest, TestMessage: extHostTypes.TestMessage, + TestRunConfigurationGroup: extHostTypes.TestRunConfigurationGroup, TextSearchCompleteMessageType: TextSearchCompleteMessageType, TestMessageSeverity: extHostTypes.TestMessageSeverity, CoveredCount: extHostTypes.CoveredCount, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ebd88795f41..f00b3fb45c1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -57,7 +57,7 @@ import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { InputValidationType } from 'vs/workbench/contrib/scm/common/scm'; import { ITextQueryBuilderOptions } from 'vs/workbench/contrib/search/common/queryBuilder'; import { ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable'; -import { ExtensionRunTestsRequest, ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, RunTestForControllerRequest, RunTestsRequest, ITestIdWithSrc, TestsDiff, IFileCoverage, CoverageDetails } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ExtensionRunTestsRequest, ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, RunTestForControllerRequest, ResolvedTestRunRequest, ITestIdWithSrc, TestsDiff, IFileCoverage, CoverageDetails, ITestRunConfiguration } from 'vs/workbench/contrib/testing/common/testCollection'; import { InternalTimelineOptions, Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; import { EditorGroupColumn } from 'vs/workbench/services/editor/common/editorGroupColumn'; import { ActivationKind, ExtensionHostKind, MissingExtensionDependency } from 'vs/workbench/services/extensions/common/extensions'; @@ -2090,6 +2090,8 @@ export interface ExtHostTestingShape { } export interface MainThreadTestingShape { + // --- test lifecycle: + /** Registeres that there's a test controller with the given ID */ $registerTestController(controllerId: string): void; /** Diposes of the test controller with the given ID */ @@ -2100,11 +2102,21 @@ export interface MainThreadTestingShape { $unsubscribeFromDiffs(): void; /** Publishes that new tests were available on the given source. */ $publishDiff(controllerId: string, diff: TestsDiff): void; - /** Request by an extension to run tests. */ - $runTests(req: RunTestsRequest, token: CancellationToken): Promise; + + // --- test run configurations: + + /** Called when a new test run configuration is available */ + $publishTestRunConfig(config: ITestRunConfiguration): void; + /** Updates an existing test run configuration */ + $updateTestRunConfig(controllerId: string, configId: number, update: Partial): void; + /** Removes a previously-published test run config */ + $removeTestRunConfig(controllerId: string, configId: number): void; + // --- test run handling: + /** Request by an extension to run tests. */ + $runTests(req: ResolvedTestRunRequest, token: CancellationToken): Promise; /** * Adds tests to the run. The tests are given in descending depth. The first * item will be a previously-known test, or a test root. diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index cb6fb32b626..e8160521230 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -9,6 +9,7 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; import { once } from 'vs/base/common/functional'; +import { hash } from 'vs/base/common/hash'; import { Iterable } from 'vs/base/common/iterator'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { MarshalledId } from 'vs/base/common/marshalling'; @@ -19,15 +20,16 @@ import { ExtHostTestingShape, MainContext, MainThreadTestingShape } from 'vs/wor import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import * as Convert from 'vs/workbench/api/common/extHostTypeConverters'; -import { TestItemImpl } from 'vs/workbench/api/common/extHostTypes'; +import { TestItemImpl, TestRunConfigurationGroup, TestRunRequest } from 'vs/workbench/api/common/extHostTypes'; import { SingleUseTestCollection, TestPosition } from 'vs/workbench/contrib/testing/common/ownedTestCollection'; -import { AbstractIncrementalTestCollection, CoverageDetails, IFileCoverage, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestIdWithSrc, ITestItem, RunTestForControllerRequest, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { AbstractIncrementalTestCollection, CoverageDetails, IFileCoverage, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestIdWithSrc, ITestItem, RunTestForControllerRequest, TestRunConfigurationBitset, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import type * as vscode from 'vscode'; export class ExtHostTesting implements ExtHostTestingShape { private readonly resultsChangedEmitter = new Emitter(); private readonly controllers = new Map, collection: SingleUseTestCollection, }>(); private readonly proxy: MainThreadTestingShape; @@ -55,12 +57,25 @@ export class ExtHostTesting implements ExtHostTestingShape { const disposable = new DisposableStore(); const collection = disposable.add(new SingleUseTestCollection(controllerId)); const initialExpand = disposable.add(new RunOnceScheduler(() => collection.expand(collection.root.id, 0), 0)); + const configurations = new Map(); const controller: vscode.TestController = { root: collection.root, get id() { return controllerId; }, + createRunConfiguration: (label, group, runHandler, isDefault) => { + // Derive the config ID from a hash so that the same config will tend + // to have the same hashes, allowing re-run requests to work across reloads. + let configId = hash(label); + while (configurations.has(configId)) { + configId++; + } + + const config = new TestRunConfigurationImpl(this.proxy, controllerId, configId, label, group, runHandler, isDefault); + configurations.set(configId, config); + return config; + }, createTestRun: (request, name, persist = true) => { return this.runTracker.createTestRun(controllerId, request, name, persist); }, @@ -88,7 +103,7 @@ export class ExtHostTesting implements ExtHostTestingShape { this.proxy.$registerTestController(controllerId); disposable.add(toDisposable(() => this.proxy.$unregisterTestController(controllerId))); - this.controllers.set(controllerId, { controller, collection }); + this.controllers.set(controllerId, { controller, collection, configurations }); disposable.add(toDisposable(() => this.controllers.delete(controllerId))); disposable.add(collection.onDidGenerateDiff(diff => this.proxy.$publishDiff(controllerId, diff))); @@ -108,16 +123,32 @@ export class ExtHostTesting implements ExtHostTestingShape { * Implements vscode.test.runTests */ public async runTests(req: vscode.TestRunRequest, token = CancellationToken.None) { + const config = tryGetConfigFromTestRunReq(req); + if (!config) { + throw new Error('The request passed to `vscode.test.runTests` must include a configuration'); + } + + if (!req.tests.length) { + return; + } + const testListToProviders = (tests: ReadonlyArray) => tests .map(this.getInternalTestForReference, this) .filter(isDefined) - .map(t => ({ controllerId: t.controllerId, testId: t.item.extId })); + .map(t => ({ controllerId: t.controllerId, testId: t.item.extId, configId: config })); await this.proxy.$runTests({ - exclude: req.exclude ? testListToProviders(req.exclude).map(t => t.testId) : undefined, - tests: testListToProviders(req.tests), - debug: req.debug + targets: [{ + testIds: req.tests.map(t => t.id), + configGroup: configGroupToBitset[config.group], + configLabel: config.label, + configId: config.configId, + controllerId: config.controllerId, + }], + exclude: req.exclude + ? testListToProviders(req.exclude).map(t => ({ testId: t.testId, controllerId: t.controllerId })) + : undefined, }, token); } @@ -182,7 +213,12 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } - const { controller, collection } = lookup; + const { collection, configurations } = lookup; + const configuration = configurations.get(req.configId); + if (!configuration) { + return; + } + const includeTests = req.testIds .map((testId) => collection.tree.get(testId)) .filter(isDefined); @@ -198,16 +234,16 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } - const publicReq: vscode.TestRunRequest = { - tests: includeTests.map(t => t.actual), - exclude: excludeTests.map(t => t.actual), - debug: req.debug, - }; + const publicReq = new TestRunRequest( + includeTests.map(t => t.actual), + excludeTests.map(t => t.actual), + configuration, + ); const tracker = this.runTracker.prepareForMainThreadTestRun(publicReq, TestRunDto.fromInternal(req), token); try { - await controller.runHandler?.(publicReq, token); + configuration.runHandler(publicReq, token); } finally { if (tracker.isRunning && !token.isCancellationRequested) { await Event.toPromise(tracker.onEnd); @@ -357,8 +393,10 @@ export class TestRunCoordinator { // If there is not an existing tracked extension for the request, start // a new, detached session. const dto = TestRunDto.fromPublic(controllerId, request); + const config = tryGetConfigFromTestRunReq(request); this.proxy.$startedExtensionTestRun({ - debug: request.debug, + controllerId, + config: config && { group: configGroupToBitset[config.group], id: config.configId }, exclude: request.exclude?.map(t => t.id) ?? [], id: dto.id, tests: request.tests.map(t => t.id), @@ -378,6 +416,18 @@ export class TestRunCoordinator { } } +const tryGetConfigFromTestRunReq = (request: vscode.TestRunRequest) => { + if (!request.configuration) { + return undefined; + } + + if (!(request.configuration instanceof TestRunConfigurationImpl)) { + throw new Error(`TestRunRequest.configuration is not an instance created from TestController.createRunConfiguration`); + } + + return request.configuration; +}; + export class TestRunDto { public static fromPublic(controllerId: string, request: vscode.TestRunRequest) { return new TestRunDto( @@ -748,3 +798,77 @@ class TestObservers { return { observers: 0, tests, }; } } + +export class TestRunConfigurationImpl implements vscode.TestRunConfiguration { + readonly #proxy: MainThreadTestingShape; + private _configureHandler?: (() => void); + + public get label() { + return this._label; + } + + public set label(label: string) { + if (label !== this._label) { + this._label = label; + this.#proxy.$updateTestRunConfig(this.controllerId, this.configId, { label }); + } + } + + public get isDefault() { + return this._isDefault; + } + + public set isDefault(isDefault: boolean) { + if (isDefault !== this._isDefault) { + this._isDefault = isDefault; + this.#proxy.$updateTestRunConfig(this.controllerId, this.configId, { isDefault }); + } + } + + public get configureHandler() { + return this._configureHandler; + } + + public set configureHandler(handler: undefined | (() => void)) { + if (handler !== this._configureHandler) { + this._configureHandler = handler; + this.#proxy.$updateTestRunConfig(this.controllerId, this.configId, { hasConfigurationHandler: !!handler }); + } + } + + constructor( + proxy: MainThreadTestingShape, + public readonly controllerId: string, + public readonly configId: number, + private _label: string, + public readonly group: vscode.TestRunConfigurationGroup, + public runHandler: vscode.TestRunHandler, + private _isDefault = false, + ) { + this.#proxy = proxy; + + const groupBitset = configGroupToBitset[group]; + if (typeof groupBitset !== 'number') { + throw new Error(`Unknown TestRunConfiguration.group ${group}`); + } + + this.#proxy.$publishTestRunConfig({ + configId, + controllerId, + label: _label, + group: groupBitset, + isDefault: _isDefault, + hasConfigurationHandler: false, + }); + } + + dispose(): void { + this.#proxy.$removeTestRunConfig(this.controllerId, this.configId); + } +} + +const configGroupToBitset: { [K in TestRunConfigurationGroup]: TestRunConfigurationBitset } = { + [TestRunConfigurationGroup.Coverage]: TestRunConfigurationBitset.Coverage, + [TestRunConfigurationGroup.Debug]: TestRunConfigurationBitset.Debug, + [TestRunConfigurationGroup.Run]: TestRunConfigurationBitset.Run, +}; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 52cf144bb2e..483f9732125 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -1643,9 +1643,7 @@ export namespace TestItem { label: item.label, uri: item.uri, range: Range.from(item.range) || null, - debuggable: item.debuggable ?? false, description: item.description || null, - runnable: item.runnable ?? true, error: item.error ? (MarkdownString.fromStrict(item.error) || null) : null, }; } @@ -1656,10 +1654,8 @@ export namespace TestItem { label: item.label, uri: item.uri, range: Range.from(item.range) || null, - debuggable: false, description: item.description || null, error: null, - runnable: true, }; } @@ -1673,18 +1669,14 @@ export namespace TestItem { invalidateResults: () => undefined, canResolveChildren: false, busy: false, - debuggable: item.debuggable, description: item.description || undefined, - runnable: item.runnable, }; } export function to(item: ITestItem, parent?: vscode.TestItem): types.TestItemImpl { const testItem = new types.TestItemImpl(item.extId, item.label, URI.revive(item.uri), undefined, parent); testItem.range = Range.to(item.range || undefined); - testItem.debuggable = item.debuggable; testItem.description = item.description || undefined; - testItem.runnable = item.runnable; return testItem; } @@ -1717,7 +1709,7 @@ export namespace TestResults { const byInternalId = new Map(); for (const item of serialized.items) { byInternalId.set(item.item.extId, item); - if (item.direct) { + if (serialized.request.targets.some(t => t.controllerId === item.controllerId && t.testIds.includes(item.item.extId))) { roots.push(item); } } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 2cc35df7c98..9d3ecb7d331 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3331,11 +3331,17 @@ const rangeComparator = (a: vscode.Range | undefined, b: vscode.Range | undefine return a.isEqual(b); }; +export enum TestRunConfigurationGroup { + Run = 1, + Debug = 2, + Coverage = 3, +} + export class TestRunRequest implements vscode.TestRunRequest { constructor( public readonly tests: vscode.TestItem[], public readonly exclude?: vscode.TestItem[] | undefined, - public readonly debug = false, + public readonly configuration?: vscode.TestRunConfiguration, ) { } } @@ -3347,8 +3353,6 @@ export class TestItemImpl implements vscode.TestItem { public range!: vscode.Range | undefined; public description!: string | undefined; - public runnable!: boolean; - public debuggable!: boolean; public label!: string; public error!: string | vscode.MarkdownString; public busy!: boolean; @@ -3384,8 +3388,6 @@ export class TestItemImpl implements vscode.TestItem { range: testItemPropAccessor(api, 'range', undefined, rangeComparator), label: testItemPropAccessor(api, 'label', label, strictEqualComparator), description: testItemPropAccessor(api, 'description', undefined, strictEqualComparator), - runnable: testItemPropAccessor(api, 'runnable', true, strictEqualComparator), - debuggable: testItemPropAccessor(api, 'debuggable', false, strictEqualComparator), canResolveChildren: testItemPropAccessor(api, 'canResolveChildren', false, strictEqualComparator), busy: testItemPropAccessor(api, 'busy', false, strictEqualComparator), error: testItemPropAccessor(api, 'error', undefined, strictEqualComparator), diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts index 8aed628b719..105744b39e4 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts @@ -3,13 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Iterable } from 'vs/base/common/iterator'; import { TestExplorerTreeElement } from 'vs/workbench/contrib/testing/browser/explorerProjections'; import { flatTestItemDelimiter } from 'vs/workbench/contrib/testing/browser/explorerProjections/display'; import { HierarchicalByLocationProjection as HierarchicalByLocationProjection } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation'; import { ByLocationTestItemElement } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes'; import { NodeRenderDirective } from 'vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper'; -import { InternalTestItem, ITestItemUpdate } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; @@ -18,11 +17,9 @@ import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; */ export const enum ListElementType { /** The element is a leaf test that should be shown in the list */ - TestLeaf, + Leaf, /** The element is not runnable, but doesn't have any nested leaf tests */ - BranchWithLeaf, - /** The element has nested leaf tests */ - BranchWithoutLeaf, + Branch, /** State not yet computed */ Unset, } @@ -58,17 +55,6 @@ export class ByNameTestItemElement extends ByLocationTestItemElement { this.updateLeafTestState(); } - /** - * @override - */ - public override update(patch: ITestItemUpdate) { - super.update(patch); - - if (patch.item?.runnable !== undefined) { - this.updateLeafTestState(); - } - } - /** * Should be called when the list element is removed. */ @@ -92,11 +78,9 @@ export class ByNameTestItemElement extends ByLocationTestItemElement { * here, the children will already be leaves, or not. */ private updateLeafTestState() { - const newType = Iterable.some(this.actualChildren, c => c.elementType !== ListElementType.BranchWithoutLeaf) - ? ListElementType.BranchWithLeaf - : this.test.item.runnable - ? ListElementType.TestLeaf - : ListElementType.BranchWithoutLeaf; + const newType = this.children.size + ? ListElementType.Branch + : ListElementType.Leaf; if (newType !== this.elementType) { this.elementType = newType; @@ -118,7 +102,7 @@ export class HierarchicalByNameProjection extends HierarchicalByLocationProjecti const originalRenderNode = this.renderNode.bind(this); this.renderNode = (node, recurse) => { - if (node instanceof ByNameTestItemElement && node.elementType !== ListElementType.TestLeaf && !node.isTestRoot) { + if (node instanceof ByNameTestItemElement && node.elementType !== ListElementType.Leaf && !node.isTestRoot) { return NodeRenderDirective.Concat; } diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts index 5136d861d79..070f01b9bb6 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts @@ -67,14 +67,9 @@ export interface IActionableTestTreeElement { depth: number; /** - * Tests to debug when the 'debug' context action is taken on this item. + * Iterable of the tests this element contains. */ - debuggable: Iterable; - - /** - * Tests to run when the 'debug' context action is taken on this item. - */ - runnable: Iterable; + tests: Iterable; /** * State to show on the item. This is generally the item's computed state @@ -113,22 +108,8 @@ export class TestItemTreeElement implements IActionableTestTreeElement { */ public depth: number = this.parent ? this.parent.depth + 1 : 0; - /** - * @inheritdoc - */ - public get runnable() { - return this.test.item.runnable - ? Iterable.single(identifyTest(this.test)) - : Iterable.empty(); - } - - /** - * @inheritdoc - */ - public get debuggable() { - return this.test.item.debuggable - ? Iterable.single(identifyTest(this.test)) - : Iterable.empty(); + public get tests() { + return Iterable.single(identifyTest(this.test)); } public get description() { diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index be07f2f5522..853850e2e10 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -29,7 +29,7 @@ import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/browser/t import { TestingExplorerView, TestingExplorerViewModel } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { ITestingOutputTerminalService } from 'vs/workbench/contrib/testing/browser/testingOutputTerminalService'; import { TestExplorerViewMode, TestExplorerViewSorting, Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { identifyTest, InternalTestItem, ITestIdWithSrc, ITestItem, TestIdPath } from 'vs/workbench/contrib/testing/common/testCollection'; +import { identifyTest, InternalTestItem, ITestIdWithSrc, ITestItem, TestIdPath, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestingAutoRun } from 'vs/workbench/contrib/testing/common/testingAutoRun'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; @@ -46,6 +46,7 @@ const enum ActionOrder { // Navigation: Run = 10, Debug, + Coverage, AutoRun, Collapse, @@ -74,7 +75,7 @@ export class HideTestAction extends Action2 { const service = accessor.get(ITestService); for (const element of elements) { if (element instanceof TestItemTreeElement) { - service.setTestExcluded(element.test.item.extId, true); + service.excluded.toggle(identifyTest(element.test), true); } } return Promise.resolve(); @@ -98,7 +99,7 @@ export class UnhideTestAction extends Action2 { const service = accessor.get(ITestService); for (const element of elements) { if (element instanceof TestItemTreeElement) { - service.setTestExcluded(element.test.item.extId, false); + service.excluded.toggle(identifyTest(element.test), false); } } return Promise.resolve(); @@ -123,8 +124,8 @@ export class DebugAction extends Action2 { public override run(acessor: ServicesAccessor, ...elements: IActionableTestTreeElement[]): Promise { return acessor.get(ITestService).runTests({ - tests: [...Iterable.concatNested(elements.map(e => e.debuggable))], - debug: true, + tests: [...Iterable.concatNested(elements.map(e => e.tests))], + group: TestRunConfigurationBitset.Debug, }); } } @@ -151,14 +152,14 @@ export class RunAction extends Action2 { */ public override run(acessor: ServicesAccessor, ...elements: IActionableTestTreeElement[]): Promise { return acessor.get(ITestService).runTests({ - tests: [...Iterable.concatNested(elements.map(e => e.runnable))], - debug: false, + tests: [...Iterable.concatNested(elements.map(e => e.tests))], + group: TestRunConfigurationBitset.Run, }); } } -abstract class RunOrDebugSelectedAction extends ViewAction { - constructor(id: string, title: string, icon: ThemeIcon, private readonly debug: boolean) { +abstract class ExecuteSelectedAction extends ViewAction { + constructor(id: string, title: string, icon: ThemeIcon, private readonly group: TestRunConfigurationBitset) { super({ id, title, @@ -179,7 +180,7 @@ abstract class RunOrDebugSelectedAction extends ViewAction return Promise.resolve(undefined); } - return accessor.get(ITestService).runTests({ tests, debug: this.debug }); + return accessor.get(ITestService).runTests({ tests, group: this.group }); } private getActionableTests(testService: ITestService, viewModel: TestingExplorerViewModel) { @@ -189,18 +190,16 @@ abstract class RunOrDebugSelectedAction extends ViewAction tests = ([...testService.collection.rootItems].map(identifyTest)); } else { tests = selected - .map(treeElement => treeElement instanceof TestItemTreeElement && this.filter(treeElement.test) ? treeElement.test : undefined) + .map(treeElement => treeElement instanceof TestItemTreeElement ? treeElement.test : undefined) .filter(isDefined) .map(identifyTest); } return tests; } - - protected abstract filter(item: InternalTestItem): boolean; } -export class RunSelectedAction extends RunOrDebugSelectedAction { +export class RunSelectedAction extends ExecuteSelectedAction { public static readonly ID = 'testing.runSelected'; constructor() { @@ -208,35 +207,21 @@ export class RunSelectedAction extends RunOrDebugSelectedAction { RunSelectedAction.ID, localize('runSelectedTests', 'Run Selected Tests'), icons.testingRunIcon, - false, + TestRunConfigurationBitset.Run, ); } - - /** - * @override - */ - public filter({ item }: InternalTestItem) { - return item.runnable; - } } -export class DebugSelectedAction extends RunOrDebugSelectedAction { +export class DebugSelectedAction extends ExecuteSelectedAction { public static readonly ID = 'testing.debugSelected'; constructor() { super( DebugSelectedAction.ID, localize('debugSelectedTests', 'Debug Selected Tests'), icons.testingDebugIcon, - true, + TestRunConfigurationBitset.Debug, ); } - - /** - * @override - */ - public filter({ item }: InternalTestItem) { - return item.debuggable; - } } const showDiscoveringWhile = (progress: IProgressService, task: Promise): Promise => { @@ -250,27 +235,26 @@ const showDiscoveringWhile = (progress: IProgressService, task: Promise): }; abstract class RunOrDebugAllTestsAction extends Action2 { - constructor(id: string, title: string, icon: ThemeIcon, private readonly debug: boolean, private noTestsFoundError: string, keybinding: IAction2Options['keybinding']) { + constructor(options: IAction2Options, private readonly group: TestRunConfigurationBitset, private noTestsFoundError: string) { super({ - id, - title, - icon, + ...options, category, - keybinding, menu: [{ id: MenuId.ViewTitle, - order: debug ? ActionOrder.Debug : ActionOrder.Run, + order: group === TestRunConfigurationBitset.Run + ? ActionOrder.Run + : group === TestRunConfigurationBitset.Debug + ? ActionOrder.Debug + : ActionOrder.Coverage, group: 'navigation', when: ContextKeyAndExpr.create([ ContextKeyEqualsExpr.create('view', Testing.ExplorerViewId), TestingContextKeys.isRunning.isEqualTo(false), - debug - ? TestingContextKeys.hasDebuggableTests.isEqualTo(true) - : TestingContextKeys.hasRunnableTests.isEqualTo(true), + TestingContextKeys.capabilityToContextKey[group].isEqualTo(true), ]) }, { id: MenuId.CommandPalette, - when: hasAnyTestProvider, + when: TestingContextKeys.capabilityToContextKey[group].isEqualTo(true), }] }); } @@ -285,7 +269,7 @@ abstract class RunOrDebugAllTestsAction extends Action2 { return; } - await testService.runTests({ tests: roots.map(identifyTest), debug: this.debug }); + await testService.runTests({ tests: roots.map(identifyTest), group: this.group }); } } @@ -293,15 +277,17 @@ export class RunAllAction extends RunOrDebugAllTestsAction { public static readonly ID = 'testing.runAll'; constructor() { super( - RunAllAction.ID, - localize('runAllTests', 'Run All Tests'), - icons.testingRunAllIcon, - false, - localize('noTestProvider', 'No tests found in this workspace. You may need to install a test provider extension'), { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyCode.KEY_A), - } + id: RunAllAction.ID, + title: localize('runAllTests', 'Run All Tests'), + icon: icons.testingRunAllIcon, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyCode.KEY_A), + }, + }, + TestRunConfigurationBitset.Run, + localize('noTestProvider', 'No tests found in this workspace. You may need to install a test provider extension'), ); } } @@ -310,15 +296,17 @@ export class DebugAllAction extends RunOrDebugAllTestsAction { public static readonly ID = 'testing.debugAll'; constructor() { super( - DebugAllAction.ID, - localize('debugAllTests', 'Debug All Tests'), - icons.testingDebugIcon, - true, - localize('noDebugTestProvider', 'No debuggable tests found in this workspace. You may need to install a test provider extension'), { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyMod.CtrlCmd | KeyCode.KEY_A), - } + id: DebugAllAction.ID, + title: localize('debugAllTests', 'Debug All Tests'), + icon: icons.testingDebugIcon, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyMod.CtrlCmd | KeyCode.KEY_A), + }, + }, + TestRunConfigurationBitset.Debug, + localize('noDebugTestProvider', 'No debuggable tests found in this workspace. You may need to install a test provider extension'), ); } } @@ -718,8 +706,8 @@ export class AutoRunOffAction extends ToggleAutoRun { } -abstract class RunOrDebugAtCursor extends Action2 { - constructor(options: IAction2Options) { +abstract class ExecuteTestAtCursor extends Action2 { + constructor(options: IAction2Options, protected readonly group: TestRunConfigurationBitset) { super({ ...options, menu: { @@ -745,7 +733,7 @@ abstract class RunOrDebugAtCursor extends Action2 { await showDiscoveringWhile(accessor.get(IProgressService), (async () => { for await (const test of testsInFile(testService.collection, model.uri)) { - if (this.filter(test) && test.item.range && Range.containsPosition(test.item.range, position)) { + if (test.item.range && Range.containsPosition(test.item.range, position)) { bestNode = test; } } @@ -753,16 +741,15 @@ abstract class RunOrDebugAtCursor extends Action2 { if (bestNode) { - await this.runTest(testService, bestNode); + await testService.runTests({ + group: this.group, + tests: [identifyTest(bestNode)], + }); } } - - protected abstract filter(node: InternalTestItem): boolean; - - protected abstract runTest(service: ITestService, node: InternalTestItem): Promise; } -export class RunAtCursor extends RunOrDebugAtCursor { +export class RunAtCursor extends ExecuteTestAtCursor { public static readonly ID = 'testing.runAtCursor'; constructor() { super({ @@ -774,22 +761,11 @@ export class RunAtCursor extends RunOrDebugAtCursor { when: EditorContextKeys.editorTextFocus, primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyCode.KEY_C), }, - }); - } - - protected filter(node: InternalTestItem): boolean { - return node.item.runnable; - } - - protected runTest(service: ITestService, internalTest: InternalTestItem): Promise { - return service.runTests({ - debug: false, - tests: [identifyTest(internalTest)], - }); + }, TestRunConfigurationBitset.Run); } } -export class DebugAtCursor extends RunOrDebugAtCursor { +export class DebugAtCursor extends ExecuteTestAtCursor { public static readonly ID = 'testing.debugAtCursor'; constructor() { super({ @@ -801,28 +777,17 @@ export class DebugAtCursor extends RunOrDebugAtCursor { when: EditorContextKeys.editorTextFocus, primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyMod.CtrlCmd | KeyCode.KEY_C), }, - }); - } - - protected filter(node: InternalTestItem): boolean { - return node.item.debuggable; - } - - protected runTest(service: ITestService, internalTest: InternalTestItem): Promise { - return service.runTests({ - debug: true, - tests: [identifyTest(internalTest)], - }); + }, TestRunConfigurationBitset.Debug); } } -abstract class RunOrDebugCurrentFile extends Action2 { - constructor(options: IAction2Options) { +abstract class ExecuteTestsInCurrentFile extends Action2 { + constructor(options: IAction2Options, protected readonly group: TestRunConfigurationBitset) { super({ ...options, menu: { id: MenuId.CommandPalette, - when: hasAnyTestProvider, + when: TestingContextKeys.capabilityToContextKey[group].isEqualTo(true), }, }); } @@ -843,21 +808,20 @@ abstract class RunOrDebugCurrentFile extends Action2 { const demandedUri = model.uri.toString(); for (const test of testService.collection.all) { if (test.item.uri?.toString() === demandedUri) { - return this.runTest(testService, [test]); + return testService.runTests({ + tests: [identifyTest(test)], + group: this.group, + }); } } return undefined; } - - - protected abstract filter(node: InternalTestItem): boolean; - - protected abstract runTest(service: ITestService, node: InternalTestItem[]): Promise; } -export class RunCurrentFile extends RunOrDebugCurrentFile { +export class RunCurrentFile extends ExecuteTestsInCurrentFile { public static readonly ID = 'testing.runCurrentFile'; + constructor() { super({ id: RunCurrentFile.ID, @@ -868,27 +832,13 @@ export class RunCurrentFile extends RunOrDebugCurrentFile { when: EditorContextKeys.editorTextFocus, primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyCode.KEY_F), }, - menu: { - id: MenuId.CommandPalette, - when: hasAnyTestProvider, - }, - }); - } - - protected filter(node: InternalTestItem): boolean { - return node.item.runnable; - } - - protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { - return service.runTests({ - debug: false, - tests: internalTests.map(identifyTest), - }); + }, TestRunConfigurationBitset.Run); } } -export class DebugCurrentFile extends RunOrDebugCurrentFile { +export class DebugCurrentFile extends ExecuteTestsInCurrentFile { public static readonly ID = 'testing.debugCurrentFile'; + constructor() { super({ id: DebugCurrentFile.ID, @@ -899,18 +849,7 @@ export class DebugCurrentFile extends RunOrDebugCurrentFile { when: EditorContextKeys.editorTextFocus, primary: KeyChord(KeyMod.CtrlCmd | KeyCode.US_SEMICOLON, KeyMod.CtrlCmd | KeyCode.KEY_F), }, - }); - } - - protected filter(node: InternalTestItem): boolean { - return node.item.debuggable; - } - - protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { - return service.runTests({ - debug: true, - tests: internalTests.map(identifyTest) - }); + }, TestRunConfigurationBitset.Debug); } } @@ -941,8 +880,6 @@ abstract class RunOrDebugExtsByPath extends Action2 { protected abstract getTestExtIdsToRun(accessor: ServicesAccessor, ...args: unknown[]): Iterable; - protected abstract filter(node: InternalTestItem): boolean; - protected abstract runTest(service: ITestService, node: readonly InternalTestItem[]): Promise; } @@ -1003,9 +940,12 @@ abstract class RunOrDebugLastRun extends RunOrDebugExtsByPath { return; } - for (const test of lastResult.tests) { - if (test.direct) { - yield getPathForTestInResult(test, lastResult); + for (const test of lastResult.request.targets) { + for (const testId of test.testIds) { + const test = lastResult.getStateById(testId); + if (test) { + yield getPathForTestInResult(test, lastResult); + } } } } @@ -1025,13 +965,9 @@ export class ReRunFailedTests extends RunOrDebugFailedTests { }); } - protected filter(node: InternalTestItem): boolean { - return node.item.runnable; - } - protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ - debug: false, + group: TestRunConfigurationBitset.Run, tests: internalTests.map(identifyTest), }); } @@ -1051,13 +987,9 @@ export class DebugFailedTests extends RunOrDebugFailedTests { }); } - protected filter(node: InternalTestItem): boolean { - return node.item.debuggable; - } - protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ - debug: true, + group: TestRunConfigurationBitset.Debug, tests: internalTests.map(identifyTest), }); } @@ -1077,13 +1009,9 @@ export class ReRunLastRun extends RunOrDebugLastRun { }); } - protected filter(node: InternalTestItem): boolean { - return node.item.runnable; - } - protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ - debug: false, + group: TestRunConfigurationBitset.Debug, tests: internalTests.map(identifyTest), }); } @@ -1103,13 +1031,9 @@ export class DebugLastRun extends RunOrDebugLastRun { }); } - protected filter(node: InternalTestItem): boolean { - return node.item.debuggable; - } - protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ - debug: true, + group: TestRunConfigurationBitset.Debug, tests: internalTests.map(identifyTest), }); } @@ -1187,5 +1111,3 @@ export const allTestActions = [ TestingViewAsTreeAction, UnhideTestAction, ]; - -export const internalTestActionIds = new Set(allTestActions.map(a => a.ID)); diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index a2035975fa8..f3e73ffeeed 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -26,7 +26,8 @@ import { ITestingProgressUiService, TestingProgressUiService } from 'vs/workbenc import { TestingViewPaneContainer } from 'vs/workbench/contrib/testing/browser/testingViewPaneContainer'; import { testingConfiguation } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { TestIdPath, ITestIdWithSrc, identifyTest } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestIdPath, ITestIdWithSrc, identifyTest, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestConfigurationService, TestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { ITestingAutoRun, TestingAutoRun } from 'vs/workbench/contrib/testing/common/testingAutoRun'; import { TestingContentProvider } from 'vs/workbench/contrib/testing/common/testingContentProvider'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; @@ -38,14 +39,15 @@ import { TestService } from 'vs/workbench/contrib/testing/common/testServiceImpl import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { allTestActions, runTestsByPath } from './testExplorerActions'; -registerSingleton(ITestService, TestService); -registerSingleton(ITestResultStorage, TestResultStorage); -registerSingleton(ITestResultService, TestResultService); -registerSingleton(ITestExplorerFilterState, TestExplorerFilterState); +registerSingleton(ITestService, TestService, true); +registerSingleton(ITestResultStorage, TestResultStorage, true); +registerSingleton(ITestConfigurationService, TestConfigurationService, true); +registerSingleton(ITestResultService, TestResultService, true); +registerSingleton(ITestExplorerFilterState, TestExplorerFilterState, true); registerSingleton(ITestingAutoRun, TestingAutoRun, true); registerSingleton(ITestingOutputTerminalService, TestingOutputTerminalService, true); -registerSingleton(ITestingPeekOpener, TestingPeekOpener); -registerSingleton(ITestingProgressUiService, TestingProgressUiService); +registerSingleton(ITestingPeekOpener, TestingPeekOpener, true); +registerSingleton(ITestingProgressUiService, TestingProgressUiService, true); const viewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ id: Testing.ViewletId, @@ -110,7 +112,7 @@ CommandsRegistry.registerCommand({ id: 'vscode.runTests', handler: async (accessor: ServicesAccessor, tests: ITestIdWithSrc[]) => { const testService = accessor.get(ITestService); - testService.runTests({ debug: false, tests }); + testService.runTests({ group: TestRunConfigurationBitset.Run, tests }); } }); @@ -118,7 +120,7 @@ CommandsRegistry.registerCommand({ id: 'vscode.debugTests', handler: async (accessor: ServicesAccessor, tests: ITestIdWithSrc[]) => { const testService = accessor.get(ITestService); - testService.runTests({ debug: true, tests }); + testService.runTests({ group: TestRunConfigurationBitset.Debug, tests }); } }); @@ -142,16 +144,13 @@ CommandsRegistry.registerCommand({ CommandsRegistry.registerCommand({ id: 'vscode.runTestsByPath', - handler: async (accessor: ServicesAccessor, debug: boolean, ...pathToTests: TestIdPath[]) => { + handler: async (accessor: ServicesAccessor, group: TestRunConfigurationBitset, ...pathToTests: TestIdPath[]) => { const testService = accessor.get(ITestService); await runTestsByPath( accessor.get(ITestService).collection, accessor.get(IProgressService), pathToTests, - tests => testService.runTests({ - debug: false, - tests: tests.map(identifyTest), - }), + tests => testService.runTests({ group, tests: tests.map(identifyTest) }), ); } }); diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts index 6628cfdddc1..98f3717d0d7 100644 --- a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -30,7 +30,8 @@ import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browse import { testMessageSeverityColors } from 'vs/workbench/contrib/testing/browser/theme'; import { DefaultGutterClickAction, getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; import { labelForTestInState } from 'vs/workbench/contrib/testing/common/constants'; -import { identifyTest, IncrementalTestCollectionItem, InternalTestItem, IRichLocation, ITestMessage, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { identifyTest, IncrementalTestCollectionItem, InternalTestItem, IRichLocation, ITestMessage, TestResultItem, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { maxPriority } from 'vs/workbench/contrib/testing/common/testingStates'; import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; @@ -121,7 +122,7 @@ export class TestingDecorations extends Disposable implements IEditorContributio } })); - this._register(Event.any(this.results.onResultsChanged, this.testService.excludeTests.onDidChange)(() => { + this._register(Event.any(this.results.onResultsChanged, this.testService.excluded.onTestExclusionsChanged)(() => { if (this.currentUri) { this.setDecorations(this.currentUri); } @@ -313,6 +314,7 @@ abstract class RunTestDecoration extends Disposable { @IContextMenuService protected readonly contextMenuService: IContextMenuService, @ICommandService protected readonly commandService: ICommandService, @IConfigurationService protected readonly configurationService: IConfigurationService, + @ITestConfigurationService protected readonly testConfigurationService: ITestConfigurationService, ) { super(); editorDecoration.options.glyphMarginHoverMessage = new MarkdownString().appendText(this.getGutterLabel()); @@ -402,16 +404,17 @@ abstract class RunTestDecoration extends Disposable { */ protected getTestContextMenuActions(collection: IMainThreadTestCollection, test: InternalTestItem) { const testActions: IAction[] = []; - if (test.item.runnable) { + const capabilities = this.testConfigurationService.controllerCapabilities(test.controllerId); + if (capabilities & TestRunConfigurationBitset.Run) { testActions.push(new Action('testing.gutter.run', localize('run test', 'Run Test'), undefined, undefined, () => this.testService.runTests({ - debug: false, + group: TestRunConfigurationBitset.Run, tests: [identifyTest(test)], }))); } - if (test.item.debuggable) { + if (capabilities & TestRunConfigurationBitset.Debug) { testActions.push(new Action('testing.gutter.debug', localize('debug test', 'Debug Test'), undefined, undefined, () => this.testService.runTests({ - debug: true, + group: TestRunConfigurationBitset.Debug, tests: [identifyTest(test)], }))); } @@ -446,8 +449,9 @@ class MultiRunTestDecoration extends RunTestDecoration implements ITestDecoratio @ICommandService commandService: ICommandService, @IContextMenuService contextMenuService: IContextMenuService, @IConfigurationService configurationService: IConfigurationService, + @ITestConfigurationService testConfigurationService: ITestConfigurationService, ) { - super(createRunTestDecoration(tests.map(t => t.test), tests.map(t => t.resultItem)), editor, testService, contextMenuService, commandService, configurationService); + super(createRunTestDecoration(tests.map(t => t.test), tests.map(t => t.resultItem)), editor, testService, contextMenuService, commandService, configurationService, testConfigurationService); } public override merge(test: IncrementalTestCollectionItem, resultItem: TestResultItem | undefined): RunTestDecoration { @@ -458,11 +462,11 @@ class MultiRunTestDecoration extends RunTestDecoration implements ITestDecoratio protected override getContextMenuActions() { const allActions: IAction[] = []; - if (this.tests.some(({ test }) => test.item.runnable)) { + if (this.tests.some(({ test }) => this.testConfigurationService.controllerCapabilities(test.controllerId) & TestRunConfigurationBitset.Run)) { allActions.push(new Action('testing.gutter.runAll', localize('run all test', 'Run All Tests'), undefined, undefined, () => this.defaultRun())); } - if (this.tests.some(({ test }) => test.item.debuggable)) { + if (this.tests.some(({ test }) => this.testConfigurationService.controllerCapabilities(test.controllerId) & TestRunConfigurationBitset.Debug)) { allActions.push(new Action('testing.gutter.debugAll', localize('debug all test', 'Debug All Tests'), undefined, undefined, () => this.defaultDebug())); } @@ -474,19 +478,15 @@ class MultiRunTestDecoration extends RunTestDecoration implements ITestDecoratio protected override defaultRun() { return this.testService.runTests({ - tests: this.tests - .filter(({ test }) => test.item.runnable) - .map(({ test }) => identifyTest(test)), - debug: false, + tests: this.tests.map(({ test }) => identifyTest(test)), + group: TestRunConfigurationBitset.Run, }); } protected override defaultDebug() { return this.testService.runTests({ - tests: this.tests - .filter(({ test }) => test.item.debuggable) - .map(({ test }) => identifyTest(test)), - debug: true, + tests: this.tests.map(({ test }) => identifyTest(test)), + group: TestRunConfigurationBitset.Run, }); } } @@ -500,15 +500,16 @@ class RunSingleTestDecoration extends RunTestDecoration implements ITestDecorati @ICommandService commandService: ICommandService, @IContextMenuService contextMenuService: IContextMenuService, @IConfigurationService configurationService: IConfigurationService, + @ITestConfigurationService testConfigurationService: ITestConfigurationService, ) { - super(createRunTestDecoration([test], [resultItem]), editor, testService, contextMenuService, commandService, configurationService); + super(createRunTestDecoration([test], [resultItem]), editor, testService, contextMenuService, commandService, configurationService, testConfigurationService); } public override merge(test: IncrementalTestCollectionItem, resultItem: TestResultItem | undefined): RunTestDecoration { return new MultiRunTestDecoration([ { test: this.test, resultItem: this.resultItem }, { test, resultItem }, - ], this.editor, this.testService, this.commandService, this.contextMenuService, this.configurationService); + ], this.editor, this.testService, this.commandService, this.contextMenuService, this.configurationService, this.testConfigurationService); } protected override getContextMenuActions(e: IEditorMouseEvent) { @@ -516,24 +517,16 @@ class RunSingleTestDecoration extends RunTestDecoration implements ITestDecorati } protected override defaultRun() { - if (!this.test.item.runnable) { - return; - } - return this.testService.runTests({ tests: [identifyTest(this.test)], - debug: false, + group: TestRunConfigurationBitset.Run, }); } protected override defaultDebug() { - if (!this.test.item.debuggable) { - return; - } - return this.testService.runTests({ tests: [identifyTest(this.test)], - debug: true, + group: TestRunConfigurationBitset.Debug, }); } } diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts index a4f8d4e1641..41ff97d5148 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -246,10 +246,10 @@ class FiltersDropdownMenuActionViewItem extends DropdownMenuActionViewItem { { checked: false, class: undefined, - enabled: this.testService.excludeTests.value.size > 0, + enabled: this.testService.excluded.hasAny, id: 'removeExcluded', label: localize('testing.filters.removeTestExclusions', "Unhide All Tests"), - run: async () => this.testService.clearExcludedTests(), + run: async () => this.testService.excluded.clear(), tooltip: '', dispose: () => null }, diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index f83e90bab2d..83ea9739960 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -17,7 +17,6 @@ import { Color, RGBA } from 'vs/base/common/color'; import { Emitter, Event } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; import { splitGlobAware } from 'vs/base/common/glob'; -import { Iterable } from 'vs/base/common/iterator'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable, dispose, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; @@ -54,7 +53,8 @@ import { ITestExplorerFilterState, TestExplorerFilterState, TestingExplorerFilte import { ITestingProgressUiService } from 'vs/workbench/contrib/testing/browser/testingProgressUiService'; import { getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; import { labelForTestInState, TestExplorerStateFilter, TestExplorerViewMode, TestExplorerViewSorting, Testing, testStateNames } from 'vs/workbench/contrib/testing/common/constants'; -import { identifyTest, TestIdPath, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; +import { identifyTest, TestIdPath, TestItemExpandState, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; +import { capabilityContextKeys, ITestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; import { cmpPriority, isFailedState, isStateWithResult } from 'vs/workbench/contrib/testing/common/testingStates'; @@ -278,6 +278,7 @@ export class TestingExplorerViewModel extends Disposable { @IContextKeyService private readonly contextKeyService: IContextKeyService, @ITestResultService private readonly testResults: ITestResultService, @ITestingPeekOpener private readonly peekOpener: ITestingPeekOpener, + @ITestConfigurationService private readonly testConfigurationService: ITestConfigurationService, ) { super(); @@ -321,7 +322,7 @@ export class TestingExplorerViewModel extends Disposable { filterState.text.onDidChange, filterState.stateFilter.onDidChange, filterState.showExcludedTests.onDidChange, - testService.excludeTests.onDidChange, + testService.excluded.onTestExclusionsChanged, )(this.tree.refilter, this.tree)); this._register(this.tree); @@ -396,6 +397,10 @@ export class TestingExplorerViewModel extends Disposable { } } })); + + this._register(this.testConfigurationService.onDidChange(() => { + this.tree.rerender(); + })); } /** @@ -446,7 +451,7 @@ export class TestingExplorerViewModel extends Disposable { // If the node or any of its children are excluded, flip on the 'show // excluded tests' checkbox automatically. for (let n: TestItemTreeElement | null = element; n instanceof TestItemTreeElement; n = n.parent) { - if (n.test && this.testService.excludeTests.value.has(n.test.item.extId)) { + if (n.test && this.testService.excluded.contains(identifyTest(n.test))) { this.filterState.showExcludedTests.value = true; break; } @@ -499,7 +504,7 @@ export class TestingExplorerViewModel extends Disposable { return; } - const actions = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, element); + const actions = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, this.testConfigurationService, element); this.contextMenuService.showContextMenu({ getAnchor: () => evt.anchor, getActions: () => [ @@ -525,12 +530,11 @@ export class TestingExplorerViewModel extends Disposable { } const toRun = targeted - .filter((e): e is TestItemTreeElement => e instanceof TestItemTreeElement) - .filter(e => e.test.item.runnable); + .filter((e): e is TestItemTreeElement => e instanceof TestItemTreeElement); if (toRun.length) { this.testService.runTests({ - debug: false, + group: TestRunConfigurationBitset.Run, tests: toRun.map(t => identifyTest(t.test)), }); } @@ -641,7 +645,7 @@ class TestsFilter implements ITreeFilter { if ( element.test && !this.state.showExcludedTests.value - && this.testService.excludeTests.value.has(element.test.item.extId) + && this.testService.excluded.contains(identifyTest(element.test)) ) { return TreeVisibility.Hidden; } @@ -883,10 +887,11 @@ abstract class ActionableItemTemplateData extends constructor( protected readonly labels: ResourceLabels, private readonly actionRunner: TestExplorerActionRunner, - private readonly menuService: IMenuService, - protected readonly testService: ITestService, - private readonly contextKeyService: IContextKeyService, - private readonly instantiationService: IInstantiationService, + @IMenuService private readonly menuService: IMenuService, + @ITestService protected readonly testService: ITestService, + @ITestConfigurationService protected readonly configurations: ITestConfigurationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); } @@ -942,7 +947,7 @@ abstract class ActionableItemTemplateData extends } private fillActionBar(element: T, data: IActionableElementTemplateData) { - const actions = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, element); + const actions = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, this.configurations, element); data.elementDisposable.push(actions); data.actionBar.clear(); data.actionBar.context = element; @@ -953,17 +958,6 @@ abstract class ActionableItemTemplateData extends class TestItemRenderer extends ActionableItemTemplateData { public static readonly ID = 'testItem'; - constructor( - labels: ResourceLabels, - actionRunner: TestExplorerActionRunner, - @IMenuService menuService: IMenuService, - @ITestService testService: ITestService, - @IContextKeyService contextKeyService: IContextKeyService, - @IInstantiationService instantiationService: IInstantiationService, - ) { - super(labels, actionRunner, menuService, testService, contextKeyService, instantiationService); - } - /** * @inheritdoc */ @@ -981,7 +975,7 @@ class TestItemRenderer extends ActionableItemTemplateData { const options: IResourceLabelOptions = {}; data.label.setResource(label, options); - const testHidden = this.testService.excludeTests.value.has(node.element.test.item.extId); + const testHidden = this.testService.excluded.contains(identifyTest(node.element.test)); data.wrapper.classList.toggle('test-is-hidden', testHidden); const icon = testingStatesToIcons.get( @@ -1025,6 +1019,7 @@ const getActionableElementActions = ( contextKeyService: IContextKeyService, menuService: IMenuService, testService: ITestService, + configurations: ITestConfigurationService, element: TestItemTreeElement, ) => { const test = element instanceof TestItemTreeElement ? element.test : undefined; @@ -1032,9 +1027,8 @@ const getActionableElementActions = ( ['view', Testing.ExplorerViewId], [TestingContextKeys.testItemExtId.key, test?.item.extId], [TestingContextKeys.testItemHasUri.key, !!test?.item.uri], - [TestingContextKeys.testItemIsHidden.key, !!test && testService.excludeTests.value.has(test.item.extId)], - [TestingContextKeys.hasDebuggableTests.key, !Iterable.isEmpty(element.debuggable)], - [TestingContextKeys.hasRunnableTests.key, !Iterable.isEmpty(element.runnable)], + [TestingContextKeys.testItemIsHidden.key, !!test && testService.excluded.contains(identifyTest(test))], + ...(test ? capabilityContextKeys(configurations.controllerCapabilities(test.controllerId)) : []), ]); const menu = menuService.createMenu(MenuId.TestItem, contextOverlay); diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 7fa1e04b51f..8b582ce0c93 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -58,7 +58,8 @@ import { ITestingOutputTerminalService } from 'vs/workbench/contrib/testing/brow import { testingPeekBorder } from 'vs/workbench/contrib/testing/browser/theme'; import { AutoOpenPeekViewWhen, getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { IRichLocation, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { IRichLocation, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, TestResultItem, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; +import { capabilityContextKeys, ITestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; import { isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; @@ -186,7 +187,7 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener return; } - if (evt.result.isAutoRun && !getTestingConfiguration(this.configuration, TestingConfigKeys.AutoOpenPeekViewDuringAutoRun)) { + if (evt.result.request.isAutoRun && !getTestingConfiguration(this.configuration, TestingConfigKeys.AutoOpenPeekViewDuringAutoRun)) { return; } @@ -1269,17 +1270,18 @@ class TreeActionsProvider { @ITestingOutputTerminalService private readonly testTerminalService: ITestingOutputTerminalService, @IMenuService private readonly menuService: IMenuService, @ICommandService private readonly commandService: ICommandService, + @ITestConfigurationService private readonly testConfigurationService: ITestConfigurationService, ) { } public provideActionBar(element: ITreeElement) { const test = element instanceof TestCaseElement ? element.test : undefined; + const capabilities = test ? this.testConfigurationService.controllerCapabilities(test.controllerId) : 0; const contextOverlay = this.contextKeyService.createOverlay([ ['peek', Testing.OutputPeekContributionId], [TestingContextKeys.peekItemType.key, element.type], [TestingContextKeys.testItemExtId.key, test?.item.extId], [TestingContextKeys.testItemHasUri.key, !!test?.item.uri], - [TestingContextKeys.hasDebuggableTests.key, test?.item.debuggable], - [TestingContextKeys.hasRunnableTests.key, test?.item.debuggable], + ...(test ? capabilityContextKeys(capabilities) : []) ]); const menu = this.menuService.createMenu(MenuId.TestPeekElement, contextOverlay); @@ -1304,7 +1306,7 @@ class TreeActionsProvider { () => this.commandService.executeCommand('testing.reRunLastRun', element.value.id), )); - if (Iterable.some(element.value.tests, t => t.item.debuggable)) { + if (capabilities & TestRunConfigurationBitset.Debug) { primary.push(new Action( 'testing.outputPeek.debugLastRun', localize('testing.debugLastRun', "Debug Test Run"), @@ -1324,7 +1326,7 @@ class TreeActionsProvider { () => this.commandService.executeCommand('vscode.revealTestInExplorer', element.path), )); - if (element.test.item.runnable) { + if (capabilities & TestRunConfigurationBitset.Run) { primary.push(new Action( 'testing.outputPeek.runTest', localize('run test', 'Run Test'), @@ -1334,7 +1336,7 @@ class TreeActionsProvider { )); } - if (element.test.item.debuggable) { + if (capabilities & TestRunConfigurationBitset.Coverage) { primary.push(new Action( 'testing.outputPeek.debugTest', localize('debug test', 'Debug Test'), diff --git a/src/vs/workbench/contrib/testing/common/observableValue.ts b/src/vs/workbench/contrib/testing/common/observableValue.ts index a736966b46d..bc14ca7fb0f 100644 --- a/src/vs/workbench/contrib/testing/common/observableValue.ts +++ b/src/vs/workbench/contrib/testing/common/observableValue.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; export interface IObservableValue { @@ -16,8 +17,8 @@ export const staticObservableValue = (value: T): IObservableValue => ({ value, }); -export class MutableObservableValue implements IObservableValue { - private readonly changeEmitter = new Emitter(); +export class MutableObservableValue extends Disposable implements IObservableValue { + private readonly changeEmitter = this._register(new Emitter()); public readonly onDidChange = this.changeEmitter.event; @@ -38,5 +39,7 @@ export class MutableObservableValue implements IObservableValue { return o; } - constructor(private _value: T) { } + constructor(private _value: T) { + super(); + } } diff --git a/src/vs/workbench/contrib/testing/common/testCollection.ts b/src/vs/workbench/contrib/testing/common/testCollection.ts index 2821c77ab34..1b67949cf4a 100644 --- a/src/vs/workbench/contrib/testing/common/testCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -18,6 +18,33 @@ export interface ITestIdWithSrc { export const identifyTest = (test: { controllerId: string, item: { extId: string } }): ITestIdWithSrc => ({ testId: test.item.extId, controllerId: test.controllerId }); +export const enum TestRunConfigurationBitset { + Run = 1 << 1, + Debug = 1 << 2, + Coverage = 1 << 3, +} + +/** + * List of all test run configuration bitset values. + */ +export const testRunConfigurationBitsetList = [ + TestRunConfigurationBitset.Run, + TestRunConfigurationBitset.Debug, + TestRunConfigurationBitset.Coverage, +]; + +/** + * DTO for a controller's run configurations. + */ +export interface ITestRunConfiguration { + controllerId: string; + configId: number; + label: string; + group: TestRunConfigurationBitset; + isDefault: boolean; + hasConfigurationHandler: boolean; +} + /** * Defines the path to a test, as a list of test IDs. The last element of the * array is the test ID, and the predecessors are its parents, in order. @@ -25,12 +52,18 @@ export const identifyTest = (test: { controllerId: string, item: { extId: string export type TestIdPath = string[]; /** - * Request to the main thread to run a set of tests. + * A fully-resolved request to run tests, passsed between the main thread + * and extension host. */ -export interface RunTestsRequest { - tests: ITestIdWithSrc[]; - exclude?: string[]; - debug: boolean; +export interface ResolvedTestRunRequest { + targets: { + testIds: string[]; + controllerId: string; + configLabel: string; + configGroup: TestRunConfigurationBitset; + configId: number; + }[] + exclude?: ITestIdWithSrc[]; isAutoRun?: boolean; } @@ -41,7 +74,8 @@ export interface ExtensionRunTestsRequest { id: string; tests: string[]; exclude: string[]; - debug: boolean; + controllerId: string; + config?: { group: TestRunConfigurationBitset, id: number }; persist: boolean; } @@ -51,9 +85,9 @@ export interface ExtensionRunTestsRequest { export interface RunTestForControllerRequest { runId: string; controllerId: string; + configId: number; excludeExtIds: string[]; testIds: string[]; - debug: boolean; } /** @@ -97,8 +131,6 @@ export interface ITestItem { range: IRange | null; description: string | null; error: string | IMarkdownString | null; - runnable: boolean; - debuggable: boolean; } export const enum TestItemExpandState { @@ -154,8 +186,6 @@ export interface TestResultItem { retired: boolean; /** Max duration of the item's tasks (if run directly) */ ownDuration?: number; - /** True if the test was directly requested by the run (is not a child or parent) */ - direct?: boolean; /** Controller ID from whence this test came */ controllerId: string; } @@ -179,6 +209,8 @@ export interface ISerializedTestResults { tasks: ITestRunTask[]; /** Human-readable name of the test run. */ name: string; + /** Test trigger informaton */ + request: ResolvedTestRunRequest; } export interface ITestCoverage { diff --git a/src/vs/workbench/contrib/testing/common/testConfigurationService.ts b/src/vs/workbench/contrib/testing/common/testConfigurationService.ts new file mode 100644 index 00000000000..38fa40750db --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testConfigurationService.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { ITestRunConfiguration, TestRunConfigurationBitset, testRunConfigurationBitsetList } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; + +export const ITestConfigurationService = createDecorator('testConfigurationService'); + +export interface ITestConfigurationService { + readonly _serviceBrand: undefined; + + /** + * Fired when any configuration changes. + */ + readonly onDidChange: Event; + + /** + * Publishes a new test configuration. + */ + addConfiguration(config: ITestRunConfiguration): void; + + /** + * Updates an existing test run configuration + */ + updateConfiguration(controllerId: string, configId: number, update: Partial): void; + + /** + * Removes a configuration. If configId is not given, all configurations + * for the given controller will be removed. + */ + removeConfiguration(controllerId: string, configId?: number): void; + + /** + * Gets capabilities for the given controller by ID, indicating whether + * there's any configurations available for those groups. + * @returns a bitset to use with {@link TestRunConfigurationBitset} + */ + controllerCapabilities(controllerId: string): number; + + /** + * Gets the configurations for the group, in priorty order. + */ + controllerGroupConfigurations(controllerId: string, group: TestRunConfigurationBitset): readonly ITestRunConfiguration[]; +} + +const sorter = (a: ITestRunConfiguration, b: ITestRunConfiguration) => { + if (a.isDefault !== b.isDefault) { + return a.isDefault ? -1 : 1; + } + + return a.label.localeCompare(b.label); +}; + +/** + * Given a capabilities bitset, returns a map of context keys representing + * them. + */ +export const capabilityContextKeys = (capabilities: number): [key: string, value: boolean][] => [ + [TestingContextKeys.hasRunnableTests.key, (capabilities & TestRunConfigurationBitset.Run) !== 0], + [TestingContextKeys.hasDebuggableTests.key, (capabilities & TestRunConfigurationBitset.Debug) !== 0], + [TestingContextKeys.hasCoverableTests.key, (capabilities & TestRunConfigurationBitset.Coverage) !== 0], +]; + +export class TestConfigurationService implements ITestConfigurationService { + declare readonly _serviceBrand: undefined; + private readonly capabilitiesContexts: { [K in TestRunConfigurationBitset]: IContextKey }; + private readonly changeEmitter = new Emitter(); + private readonly controllerConfigs = new Map(); + + /** @inheritdoc */ + public readonly onDidChange = this.changeEmitter.event; + + constructor(@IContextKeyService contextKeyService: IContextKeyService) { + this.capabilitiesContexts = { + [TestRunConfigurationBitset.Run]: TestingContextKeys.hasRunnableTests.bindTo(contextKeyService), + [TestRunConfigurationBitset.Debug]: TestingContextKeys.hasDebuggableTests.bindTo(contextKeyService), + [TestRunConfigurationBitset.Coverage]: TestingContextKeys.hasCoverableTests.bindTo(contextKeyService), + }; + + this.refreshContextKeys(); + } + + /** @inheritdoc */ + public addConfiguration(config: ITestRunConfiguration): void { + const existing = this.controllerConfigs.get(config.controllerId); + if (existing) { + existing.configs.push(config); + existing.configs.sort(sorter); + existing.capabilities |= config.group; + } else { + this.controllerConfigs.set(config.controllerId, { + configs: [config], + capabilities: config.group + }); + } + + this.refreshContextKeys(); + this.changeEmitter.fire(); + } + + /** @inheritdoc */ + public updateConfiguration(controllerId: string, configId: number, update: Partial): void { + const ctrl = this.controllerConfigs.get(controllerId); + if (!ctrl) { + return; + } + + const config = ctrl.configs.find(c => c.controllerId === controllerId && c.configId === configId); + if (!config) { + return; + } + + Object.assign(config, update); + ctrl.configs.sort(sorter); + this.changeEmitter.fire(); + } + + /** @inheritdoc */ + public removeConfiguration(controllerId: string, configId?: number): void { + const ctrl = this.controllerConfigs.get(controllerId); + if (!ctrl) { + return; + } + + if (!configId) { + this.controllerConfigs.delete(controllerId); + this.changeEmitter.fire(); + return; + } + + const index = ctrl.configs.findIndex(c => c.configId === configId); + if (index === -1) { + return; + } + + ctrl.configs.splice(index, 1); + ctrl.capabilities = 0; + for (const { group } of ctrl.configs) { + ctrl.capabilities |= group; + } + + this.refreshContextKeys(); + this.changeEmitter.fire(); + } + + /** @inheritdoc */ + public controllerCapabilities(controllerId: string) { + return this.controllerConfigs.get(controllerId)?.capabilities || 0; + } + + /** @inheritdoc */ + public controllerGroupConfigurations(controllerId: string, group: TestRunConfigurationBitset) { + return this.controllerConfigs.get(controllerId)?.configs.filter(c => c.group === group) ?? []; + } + + private refreshContextKeys() { + let allCapabilities = 0; + for (const { capabilities } of this.controllerConfigs.values()) { + allCapabilities |= capabilities; + } + + for (const group of testRunConfigurationBitsetList) { + this.capabilitiesContexts[group].set((allCapabilities & group) !== 0); + } + } +} diff --git a/src/vs/workbench/contrib/testing/common/testExclusions.ts b/src/vs/workbench/contrib/testing/common/testExclusions.ts new file mode 100644 index 00000000000..8a607959567 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testExclusions.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { Iterable } from 'vs/base/common/iterator'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; +import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; +import { ITestIdWithSrc } from 'vs/workbench/contrib/testing/common/testCollection'; + +export class TestExclusions extends Disposable { + private readonly excluded = this._register( + MutableObservableValue.stored(new StoredValue>({ + key: 'excludedTestItems', + scope: StorageScope.WORKSPACE, + target: StorageTarget.USER, + serialization: { + deserialize: v => new Set(JSON.parse(v)), + serialize: v => JSON.stringify([...v]) + }, + }, this.storageService), new Set()) + ); + + constructor(@IStorageService private readonly storageService: IStorageService) { + super(); + } + + /** + * Event that fires when the excluded tests change. + */ + public readonly onTestExclusionsChanged: Event = this.excluded.onDidChange; + + /** + * Gets whether there's any excluded tests. + */ + public get hasAny() { + return this.excluded.value.size > 0; + } + + /** + * Gets all excluded tests. + */ + public get all() { + return Iterable.map(this.excluded.value, v => { + const [controllerId, testId] = JSON.parse(v); + return { controllerId, testId }; + }); + } + + /** + * Sets whether a test is excluded. + */ + public toggle(test: ITestIdWithSrc, exclude?: boolean): void { + const slug = this.identify(test); + if (exclude !== true && this.excluded.value.has(slug)) { + this.excluded.value = new Set(Iterable.filter(this.excluded.value, e => e !== slug)); + } else if (exclude !== false && !this.excluded.value.has(slug)) { + this.excluded.value = new Set([...this.excluded.value, slug]); + } + } + + /** + * Gets whether a test is excluded. + */ + public contains(test: ITestIdWithSrc): boolean { + return this.excluded.value.has(this.identify(test)); + } + + /** + * Removes all test exclusions. + */ + public clear(): void { + this.excluded.value = new Set(); + } + + private identify(test: ITestIdWithSrc) { + return JSON.stringify([test.controllerId, test.testId]); + } +} diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index e5fceb26238..ae6cc612bbd 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -13,7 +13,7 @@ import { localize } from 'vs/nls'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; import { IObservableValue, MutableObservableValue, staticObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; -import { ExtensionRunTestsRequest, ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, RunTestsRequest, TestIdPath, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, ResolvedTestRunRequest, TestIdPath, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { maxPriority, statesInOrder } from 'vs/workbench/contrib/testing/common/testingStates'; @@ -44,7 +44,7 @@ export interface ITestResult { /** * Whether this test result is triggered from an auto run. */ - readonly isAutoRun?: boolean; + readonly request: ResolvedTestRunRequest; /** * Human-readable name of the test result. @@ -255,21 +255,6 @@ export class LiveTestResult implements ITestResult { public readonly tasks: ITestRunTaskWithCoverage[] = []; public readonly name = localize('runFinished', 'Test run at {0}', new Date().toLocaleString()); - /** - * Test IDs directly included in this run. - */ - public readonly includedIds: ReadonlySet; - - /** - * Test IDs excluded from this run. - */ - public readonly excludedIds: ReadonlySet; - - /** - * Gets whether this test is from an auto-run. - */ - public readonly isAutoRun: boolean; - /** * @inheritdoc */ @@ -313,11 +298,9 @@ export class LiveTestResult implements ITestResult { constructor( public readonly id: string, public readonly output: LiveOutputController, - private readonly req: ExtensionRunTestsRequest | RunTestsRequest, + public readonly persist: boolean, + public readonly request: ResolvedTestRunRequest, ) { - this.isAutoRun = 'isAutoRun' in this.req && !!this.req.isAutoRun; - this.includedIds = new Set(req.tests.map(t => typeof t === 'string' ? t : t.testId)); - this.excludedIds = new Set(req.exclude); } /** @@ -465,9 +448,7 @@ export class LiveTestResult implements ITestResult { * @inheritdoc */ public toJSON(): ISerializedTestResults | undefined { - return this.completedAt && !('persist' in this.req && this.req.persist === false) - ? this.doSerialize.getValue() - : undefined; + return this.completedAt && this.persist ? this.doSerialize.getValue() : undefined; } /** @@ -504,7 +485,6 @@ export class LiveTestResult implements ITestResult { private addTestToRun(controllerId: string, item: ITestItem, parent: string | null) { const node = itemToNode(controllerId, item, parent); - node.direct = this.includedIds.has(item.extId); this.testById.set(item.extId, node); this.counts[TestResultState.Unset]++; @@ -535,6 +515,7 @@ export class LiveTestResult implements ITestResult { completedAt: this.completedAt!, tasks: this.tasks, name: this.name, + request: this.request, items: [...this.testById.values()].map(entry => ({ ...entry, retired: undefined, @@ -580,6 +561,11 @@ export class HydratedTestResult implements ITestResult { */ public readonly name: string; + /** + * @inheritdoc + */ + public readonly request: ResolvedTestRunRequest; + private readonly testById = new Map(); constructor( @@ -591,6 +577,7 @@ export class HydratedTestResult implements ITestResult { this.completedAt = serialized.completedAt; this.tasks = serialized.tasks.map(task => ({ ...task, coverage: staticObservableValue(undefined) })); this.name = serialized.name; + this.request = serialized.request; for (const item of serialized.items) { const cast: TestResultItem = { ...item, retired: true }; diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index c2be63937f2..f1e7133880b 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -11,7 +11,8 @@ import { generateUuid } from 'vs/base/common/uuid'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; -import { ExtensionRunTestsRequest, RunTestsRequest, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ExtensionRunTestsRequest, ITestRunConfiguration, ResolvedTestRunRequest, TestResultItem, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestResult, LiveTestResult, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultStorage, RETAIN_MAX_RESULTS } from 'vs/workbench/contrib/testing/common/testResultStorage'; @@ -47,7 +48,7 @@ export interface ITestResultService { /** * Creates a new, live test result. */ - createLiveResult(req: RunTestsRequest | ExtensionRunTestsRequest): LiveTestResult; + createLiveResult(req: ResolvedTestRunRequest | ExtensionRunTestsRequest): LiveTestResult; /** * Adds a new test result to the collection. @@ -107,6 +108,7 @@ export class TestResultService implements ITestResultService { constructor( @IContextKeyService contextKeyService: IContextKeyService, @ITestResultStorage private readonly storage: ITestResultStorage, + @ITestConfigurationService private readonly testConfiguration: ITestConfigurationService, ) { this.isRunning = TestingContextKeys.isRunning.bindTo(contextKeyService); this.hasAnyResults = TestingContextKeys.hasAnyResults.bindTo(contextKeyService); @@ -129,13 +131,37 @@ export class TestResultService implements ITestResultService { /** * @inheritdoc */ - public createLiveResult(req: RunTestsRequest | ExtensionRunTestsRequest) { - if ('id' in req) { - return this.push(new LiveTestResult(req.id, this.storage.getOutputController(req.id), req)); - } else { + public createLiveResult(req: ResolvedTestRunRequest | ExtensionRunTestsRequest) { + if ('targets' in req) { const id = generateUuid(); - return this.push(new LiveTestResult(id, this.storage.getOutputController(id), req)); + return this.push(new LiveTestResult(id, this.storage.getOutputController(id), true, req)); } + + let config: ITestRunConfiguration | undefined; + if (!req.config) { + config = this.testConfiguration.controllerGroupConfigurations(req.controllerId, TestRunConfigurationBitset.Run)[0]; + } else { + const configs = this.testConfiguration.controllerGroupConfigurations(req.controllerId, req.config.group); + config = configs.find(c => c.configId === req.config!.id) || configs[0]; + } + + const resolved: ResolvedTestRunRequest = { + targets: [], + exclude: req.exclude.map(testId => ({ testId, controllerId: req.controllerId })), + isAutoRun: false, + }; + + if (config) { + resolved.targets.push({ + configGroup: config.group, + configId: config.configId, + configLabel: config.label, + controllerId: req.controllerId, + testIds: req.tests, + }); + } + + return this.push(new LiveTestResult(req.id, this.storage.getOutputController(req.id), req.persist, resolved)); } /** diff --git a/src/vs/workbench/contrib/testing/common/testResultStorage.ts b/src/vs/workbench/contrib/testing/common/testResultStorage.ts index 411f9dab959..9b60f30c906 100644 --- a/src/vs/workbench/contrib/testing/common/testResultStorage.ts +++ b/src/vs/workbench/contrib/testing/common/testResultStorage.ts @@ -43,10 +43,12 @@ export interface ITestResultStorage { export const ITestResultStorage = createDecorator('ITestResultStorage'); +const currentRevision = 0; + export abstract class BaseTestResultStorage implements ITestResultStorage { declare readonly _serviceBrand: undefined; - protected readonly stored = new StoredValue>({ + protected readonly stored = new StoredValue>({ key: 'storedTestResults', scope: StorageScope.WORKSPACE, target: StorageTarget.MACHINE @@ -62,7 +64,11 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { * @override */ public async read(): Promise { - const results = await Promise.all(this.stored.get([]).map(async ({ id }) => { + const results = await Promise.all(this.stored.get([]).map(async ({ id, rev }) => { + if (rev !== currentRevision) { + return undefined; + } + try { const contents = await this.readForResultId(id); if (!contents) { @@ -107,7 +113,7 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { */ public async persist(results: ReadonlyArray): Promise { const toDelete = new Map(this.stored.get([]).map(({ id, bytes }) => [id, bytes])); - const toStore: { id: string; bytes: number }[] = []; + const toStore: { rev: number, id: string; bytes: number }[] = []; const todo: Promise[] = []; let budget = RETAIN_MAX_BYTES; @@ -124,7 +130,7 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { const existingBytes = toDelete.get(result.id); if (existingBytes !== undefined) { toDelete.delete(result.id); - toStore.push({ id: result.id, bytes: existingBytes }); + toStore.push({ id: result.id, rev: currentRevision, bytes: existingBytes }); budget -= existingBytes; continue; } @@ -136,7 +142,7 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { const contents = VSBuffer.fromString(JSON.stringify(obj)); todo.push(this.storeForResultId(result.id, obj)); - toStore.push({ id: result.id, bytes: contents.byteLength }); + toStore.push({ id: result.id, rev: currentRevision, bytes: contents.byteLength }); budget -= contents.byteLength; if (result instanceof LiveTestResult && result.completedAt !== undefined) { @@ -264,7 +270,7 @@ export class TestResultStorage extends BaseTestResultStorage { return; } - const stored = new Set(this.stored.get()?.map(({ id }) => id)); + const stored = new Set(this.stored.get([]).filter(s => s.rev === currentRevision).map(s => s.id)); await Promise.all( children diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index ccb7dfdf461..2235fd228d7 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -5,14 +5,14 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; +import * as extpath from 'vs/base/common/extpath'; +import { Iterable } from 'vs/base/common/iterator'; import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; -import { AbstractIncrementalTestCollection, IncrementalTestCollectionItem, InternalTestItem, ITestIdWithSrc, RunTestForControllerRequest, RunTestsRequest, TestIdPath, TestItemExpandState, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { AbstractIncrementalTestCollection, IncrementalTestCollectionItem, InternalTestItem, ITestIdWithSrc, ResolvedTestRunRequest, RunTestForControllerRequest, TestIdPath, TestItemExpandState, TestRunConfigurationBitset, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestExclusions } from 'vs/workbench/contrib/testing/common/testExclusions'; import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; -import * as extpath from 'vs/base/common/extpath'; -import { Iterable } from 'vs/base/common/iterator'; export const ITestService = createDecorator('testService'); @@ -161,6 +161,23 @@ export interface ITestRootProvider { // todo: nothing, yet } +/** + * A run request that expresses the intent of the request and allows the + * test service to resolve the specifics of the group. + */ +export interface AmbiguousRunTestsRequest { + /** Group to run */ + group: TestRunConfigurationBitset; + /** If there is a configuration in the group with this label, use it */ + preferGroupLabel?: string; + /** Tests to run. Allowed to be from different controllers */ + tests: ITestIdWithSrc[]; + /** Tests to exclude. If not given, the current UI excluded tests are used */ + exclude?: ITestIdWithSrc[]; + /** Whether this was triggered from an auto run. */ + isAutoRun?: boolean; +} + export interface ITestService { readonly _serviceBrand: undefined; /** @@ -170,9 +187,9 @@ export interface ITestService { readonly onDidCancelTestRun: Event<{ runId: string | undefined; }>; /** - * Set of test IDs the user asked to exclude. + * Event that fires when the excluded tests change. */ - readonly excludeTests: MutableObservableValue>; + readonly excluded: TestExclusions; /** * Test collection instance. @@ -184,16 +201,6 @@ export interface ITestService { */ readonly onDidProcessDiff: Event; - /** - * Sets whether a test is excluded. - */ - setTestExcluded(testId: string, exclude?: boolean): void; - - /** - * Removes all test exclusions. - */ - clearExcludedTests(): void; - /** * Registers an interface that runs tests for the given provider ID. */ @@ -202,7 +209,12 @@ export interface ITestService { /** * Requests that tests be executed. */ - runTests(req: RunTestsRequest, token?: CancellationToken): Promise; + runTests(req: AmbiguousRunTestsRequest, token?: CancellationToken): Promise; + + /** + * Requests that tests be executed. + */ + runResolvedTests(req: ResolvedTestRunRequest, token?: CancellationToken): Promise; /** * Cancels an ongoing test run by its ID, or all runs if no ID is given. diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 30f84c9db12..4baef55f1ae 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -6,21 +6,20 @@ import { groupBy } from 'vs/base/common/arrays'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; -import { Iterable } from 'vs/base/common/iterator'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { localize } from 'vs/nls'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IWorkspaceTrustRequestService } from 'vs/platform/workspace/common/workspaceTrust'; import { MainThreadTestCollection } from 'vs/workbench/contrib/testing/common/mainThreadTestCollection'; -import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; -import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; -import { RunTestsRequest, ITestIdWithSrc, TestsDiff, TestDiffOpType } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestIdWithSrc, ResolvedTestRunRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; +import { TestExclusions } from 'vs/workbench/contrib/testing/common/testExclusions'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; -import { ITestService, MainTestController } from 'vs/workbench/contrib/testing/common/testService'; +import { AmbiguousRunTestsRequest, ITestService, MainTestController } from 'vs/workbench/contrib/testing/common/testService'; export class TestService extends Disposable implements ITestService { declare readonly _serviceBrand: undefined; @@ -29,27 +28,12 @@ export class TestService extends Disposable implements ITestService { private readonly cancelExtensionTestRunEmitter = new Emitter<{ runId: string | undefined }>(); private readonly processDiffEmitter = new Emitter(); private readonly providerCount: IContextKey; - private readonly hasRunnable: IContextKey; - private readonly hasDebuggable: IContextKey; /** * Cancellation for runs requested by the user being managed by the UI. * Test runs initiated by extensions are not included here. */ private readonly uiRunningTests = new Map(); - /** - * @inheritdoc - */ - public readonly excludeTests = MutableObservableValue.stored(new StoredValue>({ - key: 'excludedTestItems', - scope: StorageScope.WORKSPACE, - target: StorageTarget.USER, - serialization: { - deserialize: v => new Set(JSON.parse(v)), - serialize: v => JSON.stringify([...v]) - }, - }, this.storageService), new Set()); - /** * @inheritdoc */ @@ -61,21 +45,26 @@ export class TestService extends Disposable implements ITestService { public readonly onDidCancelTestRun = this.cancelExtensionTestRunEmitter.event; /** - * @inheritdoc + * @inheritdoc */ public readonly collection = new MainThreadTestCollection(this.expandTest.bind(this)); + /** + * @inheritdoc + */ + public readonly excluded: TestExclusions; + constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IStorageService private readonly storageService: IStorageService, + @IInstantiationService instantiationService: IInstantiationService, + @ITestConfigurationService private readonly testConfigurationService: ITestConfigurationService, @INotificationService private readonly notificationService: INotificationService, @ITestResultService private readonly testResults: ITestResultService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); + this.excluded = instantiationService.createInstance(TestExclusions); this.providerCount = TestingContextKeys.providerCount.bindTo(contextKeyService); - this.hasDebuggable = TestingContextKeys.hasDebuggableTests.bindTo(contextKeyService); - this.hasRunnable = TestingContextKeys.hasRunnableTests.bindTo(contextKeyService); } /** @@ -85,30 +74,6 @@ export class TestService extends Disposable implements ITestService { await this.testControllers.get(test.controllerId)?.expandTest(test, levels); } - /** - * @inheritdoc - */ - public clearExcludedTests() { - this.excludeTests.value = new Set(); - } - - /** - * @inheritdoc - */ - public setTestExcluded(testId: string, exclude = !this.excludeTests.value.has(testId)) { - const newSet = new Set(this.excludeTests.value); - if (exclude) { - newSet.add(testId); - } else { - newSet.delete(testId); - } - - if (newSet.size !== this.excludeTests.value.size) { - this.excludeTests.value = newSet; - } - } - - /** * @inheritdoc */ @@ -127,9 +92,31 @@ export class TestService extends Disposable implements ITestService { /** * @inheritdoc */ - public async runTests(req: RunTestsRequest, token = CancellationToken.None): Promise { + public async runTests(req: AmbiguousRunTestsRequest, token = CancellationToken.None): Promise { + const resolved: ResolvedTestRunRequest = { targets: [], exclude: req.exclude, isAutoRun: req.isAutoRun }; + for (const byController of groupBy(req.tests, (a, b) => a.controllerId === b.controllerId ? 0 : 1)) { + const groups = this.testConfigurationService.controllerGroupConfigurations(byController[0].controllerId, req.group); + const group = req.preferGroupLabel ? (groups.find(g => g.label === req.preferGroupLabel) || groups[0]) : groups[0]; + if (group) { + resolved.targets.push({ + testIds: byController.map(t => t.testId), + configLabel: group.label, + configGroup: group.group, + configId: group.configId, + controllerId: group.controllerId, + }); + } + } + + return this.runResolvedTests(resolved, token); + } + + /** + * @inheritdoc + */ + public async runResolvedTests(req: ResolvedTestRunRequest, token = CancellationToken.None) { if (!req.exclude) { - req.exclude = [...this.excludeTests.value]; + req.exclude = [...this.excluded.all]; } const result = this.testResults.createLiveResult(req); @@ -143,18 +130,17 @@ export class TestService extends Disposable implements ITestService { } try { - const tests = groupBy(req.tests, (a, b) => a.controllerId === b.controllerId ? 0 : 1); const cancelSource = new CancellationTokenSource(token); this.uiRunningTests.set(result.id, cancelSource); - const requests = tests.map( - group => this.testControllers.get(group[0].controllerId)?.runTests( + const requests = req.targets.map( + group => this.testControllers.get(group.controllerId)?.runTests( { runId: result.id, - debug: req.debug, - excludeExtIds: req.exclude ?? [], - testIds: group.map(g => g.testId), - controllerId: group[0].controllerId, + excludeExtIds: req.exclude!.filter(t => t.controllerId === group.controllerId).map(t => t.testId), + configId: group.configId, + controllerId: group.controllerId, + testIds: group.testIds, }, cancelSource.token, ).catch(err => { @@ -182,8 +168,6 @@ export class TestService extends Disposable implements ITestService { */ public publishDiff(_controllerId: string, diff: TestsDiff) { this.collection.apply(diff); - this.hasDebuggable.set(Iterable.some(this.collection.all, t => t.item.debuggable)); - this.hasRunnable.set(Iterable.some(this.collection.all, t => t.item.runnable)); this.processDiffEmitter.fire(diff); } diff --git a/src/vs/workbench/contrib/testing/common/testingAutoRun.ts b/src/vs/workbench/contrib/testing/common/testingAutoRun.ts index 605fdb9ab90..59f43fee9c3 100644 --- a/src/vs/workbench/contrib/testing/common/testingAutoRun.ts +++ b/src/vs/workbench/contrib/testing/common/testingAutoRun.ts @@ -11,7 +11,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { AutoRunMode, getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; -import { identifyTest, ITestIdWithSrc, TestDiffOpType } from 'vs/workbench/contrib/testing/common/testCollection'; +import { identifyTest, ITestIdWithSrc, TestDiffOpType, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { isRunningTests, ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; @@ -83,7 +83,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { const tests = [...rerunIds.values()]; rerunIds.clear(); - await this.testService.runTests({ debug: false, tests, isAutoRun: true }); + await this.testService.runTests({ group: TestRunConfigurationBitset.Run, tests, isAutoRun: true }); if (rerunIds.size > 0) { scheduler.schedule(delay); diff --git a/src/vs/workbench/contrib/testing/common/testingContextKeys.ts b/src/vs/workbench/contrib/testing/common/testingContextKeys.ts index b9bfb497595..2298c3b9bb5 100644 --- a/src/vs/workbench/contrib/testing/common/testingContextKeys.ts +++ b/src/vs/workbench/contrib/testing/common/testingContextKeys.ts @@ -7,11 +7,20 @@ import { localize } from 'vs/nls'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { ViewContainerLocation } from 'vs/workbench/common/views'; import { TestExplorerViewMode, TestExplorerViewSorting } from 'vs/workbench/contrib/testing/common/constants'; +import { TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; export namespace TestingContextKeys { export const providerCount = new RawContextKey('testing.providerCount', 0); - export const hasDebuggableTests = new RawContextKey('testing.hasDebuggableTests', false); - export const hasRunnableTests = new RawContextKey('testing.hasRunnableTests', false); + export const hasDebuggableTests = new RawContextKey('testing.hasDebuggableTests', false, { type: 'boolean', description: localize('testing.hasDebuggableTests', 'Indicates whether any test controller has registered a debug configuration') }); + export const hasRunnableTests = new RawContextKey('testing.hasRunnableTests', false, { type: 'boolean', description: localize('testing.hasRunnableTests', 'Indicates whether any test controller has registered a run configuration') }); + export const hasCoverableTests = new RawContextKey('testing.hasCoverableTests', false, { type: 'boolean', description: localize('testing.hasCoverableTests', 'Indicates whether any test controller has registered a coverage configuration') }); + + export const capabilityToContextKey: { [K in TestRunConfigurationBitset]: RawContextKey } = { + [TestRunConfigurationBitset.Run]: hasRunnableTests, + [TestRunConfigurationBitset.Coverage]: hasCoverableTests, + [TestRunConfigurationBitset.Debug]: hasDebuggableTests, + }; + export const hasAnyResults = new RawContextKey('testing.hasAnyResults', false); export const viewMode = new RawContextKey('testing.explorerViewMode', TestExplorerViewMode.List); export const viewSorting = new RawContextKey('testing.explorerViewSorting', TestExplorerViewSorting.ByLocation); diff --git a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByName.test.ts b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByName.test.ts index fe70f44ea51..9632f27667d 100644 --- a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByName.test.ts +++ b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByName.test.ts @@ -87,17 +87,5 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { { e: 'ba' }, ]); }); - - test('swaps when node is no longer runnable', async () => { - harness.flush(); - const ba = new TestItemImpl('ba', 'ba', undefined, undefined, harness.c.root.children.get('id-b')!); - ba.runnable = false; - - assert.deepStrictEqual(harness.flush(), [ - { e: 'aa' }, - { e: 'ab' }, - { e: 'b' }, - ]); - }); }); diff --git a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts index 01a4df4463f..5adb18ff7fe 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -10,7 +10,8 @@ import { Lazy } from 'vs/base/common/lazy'; import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; import { NullLogService } from 'vs/platform/log/common/log'; import { SingleUseTestCollection } from 'vs/workbench/contrib/testing/common/ownedTestCollection'; -import { ITestTaskState, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestTaskState, ResolvedTestRunRequest, TestResultItem, TestRunConfigurationBitset } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestConfigurationService } from 'vs/workbench/contrib/testing/common/testConfigurationService'; import { getPathForTestInResult, HydratedTestResult, LiveOutputController, LiveTestResult, makeEmptyCounts, resultItemParents, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { TestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { InMemoryResultStorage, ITestResultStorage } from 'vs/workbench/contrib/testing/common/testResultStorage'; @@ -32,12 +33,15 @@ suite('Workbench - Test Results Service', () => { let changed = new Set(); let tests: SingleUseTestCollection; - const defaultOpts = { - exclude: [], - debug: false, - id: 'x', - persist: true, - }; + const defaultOpts = (testIds: string[]): ResolvedTestRunRequest => ({ + targets: [{ + configGroup: TestRunConfigurationBitset.Run, + configId: 0, + configLabel: 'Run Tests', + controllerId: 'ctrlId', + testIds, + }] + }); class TestLiveTestResult extends LiveTestResult { public override setAllToState(state: TestResultState, taskId: string, when: (task: ITestTaskState, item: TestResultItem) => boolean) { @@ -50,7 +54,8 @@ suite('Workbench - Test Results Service', () => { r = new TestLiveTestResult( 'foo', emptyOutputController(), - { ...defaultOpts, tests: ['id-a'] }, + true, + defaultOpts(['id-a']), ); r.onChange(e => changed.add(e)); @@ -75,7 +80,8 @@ suite('Workbench - Test Results Service', () => { assert.deepStrictEqual(getLabelsIn(new TestLiveTestResult( 'foo', emptyOutputController(), - { ...defaultOpts, tests: ['id-a'] }, + false, + defaultOpts(['id-a']), ).tests), []); }); @@ -198,7 +204,7 @@ suite('Workbench - Test Results Service', () => { setup(() => { storage = new InMemoryResultStorage(new TestStorageService(), new NullLogService()); - results = new TestTestResultService(new MockContextKeyService(), storage); + results = new TestTestResultService(new MockContextKeyService(), storage, new TestConfigurationService(new MockContextKeyService())); }); test('pushes new result', () => { @@ -210,15 +216,16 @@ suite('Workbench - Test Results Service', () => { results.push(r); r.updateState('id-aa', 't', TestRunState.Passed); r.markComplete(); - await timeout(0); // allow persistImmediately async to happen + await timeout(10); // allow persistImmediately async to happen results = new TestResultService( new MockContextKeyService(), storage, + new TestConfigurationService(new MockContextKeyService()), ); assert.strictEqual(0, results.results.length); - await timeout(0); // allow load promise to resolve + await timeout(10); // allow load promise to resolve assert.strictEqual(1, results.results.length); const [rehydrated, actual] = results.getStateById(tests.root.id)!; @@ -240,7 +247,8 @@ suite('Workbench - Test Results Service', () => { const r2 = results.push(new LiveTestResult( '', emptyOutputController(), - { ...defaultOpts, tests: [] } + false, + defaultOpts([]), )); results.clear(); @@ -252,7 +260,8 @@ suite('Workbench - Test Results Service', () => { const r2 = results.push(new LiveTestResult( '', emptyOutputController(), - { ...defaultOpts, tests: [] } + false, + defaultOpts([]), )); assert.deepStrictEqual(results.results, [r2, r]); @@ -267,6 +276,7 @@ suite('Workbench - Test Results Service', () => { id: 'some-id', tasks: [{ id: 't', running: false, name: undefined }], name: 'hello world', + request: defaultOpts([]), items: [{ ...(await getInitializedMainTestCollection()).getNodeById('id-a')!, tasks: [{ state, duration: 0, messages: [] }], diff --git a/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts index 6e5411524c8..56a49f73aa8 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts @@ -19,13 +19,8 @@ suite('Workbench - Test Result Storage', () => { const t = new LiveTestResult( '', emptyOutputController(), - { - tests: [], - exclude: [], - debug: false, - id: 'x', - persist: true, - } + true, + { targets: [] } ); t.addTask({ id: 't', name: undefined, running: true }); diff --git a/src/vs/workbench/test/browser/api/extHostTesting.test.ts b/src/vs/workbench/test/browser/api/extHostTesting.test.ts index 6b0bc21e295..1113cd4a99d 100644 --- a/src/vs/workbench/test/browser/api/extHostTesting.test.ts +++ b/src/vs/workbench/test/browser/api/extHostTesting.test.ts @@ -9,9 +9,9 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Iterable } from 'vs/base/common/iterator'; import { mockObject, MockObject } from 'vs/base/test/common/mock'; import { MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol'; -import { TestRunCoordinator, TestRunDto } from 'vs/workbench/api/common/extHostTesting'; +import { TestRunConfigurationImpl, TestRunCoordinator, TestRunDto } from 'vs/workbench/api/common/extHostTesting'; import * as convert from 'vs/workbench/api/common/extHostTypeConverters'; -import { TestMessage } from 'vs/workbench/api/common/extHostTypes'; +import { TestMessage, TestRunConfigurationGroup } from 'vs/workbench/api/common/extHostTypes'; import { TestDiffOpType, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestItemImpl, TestResultState, testStubs } from 'vs/workbench/contrib/testing/common/testStubs'; import { TestSingleUseCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; @@ -22,8 +22,6 @@ const simplify = (item: TestItem) => ({ label: item.label, uri: item.uri, range: item.range, - runnable: item.runnable, - debuggable: item.debuggable, }); const assertTreesEqual = (a: TestItem | undefined, b: TestItem | undefined) => { @@ -302,6 +300,7 @@ suite('ExtHost Testing', () => { let proxy: MockObject; let c: TestRunCoordinator; let cts: CancellationTokenSource; + let configuration: TestRunConfigurationImpl; let req: TestRunRequest; @@ -312,15 +311,17 @@ suite('ExtHost Testing', () => { cts = new CancellationTokenSource(); c = new TestRunCoordinator(proxy); + configuration = new TestRunConfigurationImpl(mockObject(), 'ctrlId', 42, 'Do Run', TestRunConfigurationGroup.Run, () => { }, false); + req = { tests: [single.root], exclude: [single.root.children.get('id-b')!], - debug: false, + configuration, }; dto = TestRunDto.fromInternal({ controllerId: 'ctrl', - debug: false, + configId: configuration.configId, excludeExtIds: ['id-b'], runId: 'run-id', testIds: [single.root.id], @@ -356,10 +357,11 @@ suite('ExtHost Testing', () => { assert.strictEqual(tracker.isRunning, true); assert.deepStrictEqual(proxy.$startedExtensionTestRun.args, [ [{ + config: { group: 2, id: 42 }, + controllerId: 'ctrl', id: tracker.id, tests: [single.root.id], exclude: ['id-b'], - debug: false, persist: false, }] ]); @@ -430,7 +432,7 @@ suite('ExtHost Testing', () => { single.expand(single.root.id, Infinity); const task = c.createTestRun('ctrl', { - debug: false, + configuration, tests: [single.root.children.get('id-a')!], exclude: [single.root.children.get('id-a')!.children.get('id-aa')!], }, 'hello world', false);