diff --git a/.vscode/settings.json b/.vscode/settings.json index 7eede8c30a9..a97841683c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -86,5 +86,5 @@ }, "typescript.tsc.autoDetect": "off", "notebook.experimental.useMarkdownRenderer": true, - "testing.autoRun.mode": "onlyPreviouslyRun", + "testing.autoRun.mode": "rerun", } diff --git a/src/vs/base/common/map.ts b/src/vs/base/common/map.ts index 2ca5fe50f9a..50f5650b83b 100644 --- a/src/vs/base/common/map.ts +++ b/src/vs/base/common/map.ts @@ -1048,3 +1048,47 @@ export class LRUCache extends LinkedMap { } } } + +/** + * Wraps the map in type that only implements readonly properties. Useful + * in the extension host to prevent the consumer from making any mutations. + */ +export class ReadonlyMapView implements ReadonlyMap{ + readonly #source: ReadonlyMap; + + public get size() { + return this.#source.size; + } + + constructor(source: ReadonlyMap) { + this.#source = source; + } + + forEach(callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: any): void { + this.#source.forEach(callbackfn, thisArg); + } + + get(key: K): V | undefined { + return this.#source.get(key); + } + + has(key: K): boolean { + return this.#source.has(key); + } + + entries(): IterableIterator<[K, V]> { + return this.#source.entries(); + } + + keys(): IterableIterator { + return this.#source.keys(); + } + + values(): IterableIterator { + return this.#source.values(); + } + + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.#source.entries(); + } +} diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts index 71966e69b8d..3e431196b99 100644 --- a/src/vs/base/common/types.ts +++ b/src/vs/base/common/types.ts @@ -286,3 +286,7 @@ export function NotImplementedProxy(name: string): { new(): T } { } }; } + +export function assertNever(value: never) { + throw new Error('Unreachable'); +} diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index a02f57a931b..9d247a7eb42 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2176,70 +2176,85 @@ declare module 'vscode' { //#endregion //#region https://github.com/microsoft/vscode/issues/107467 - /* - General activation events: - - `onLanguage:*` most test extensions will want to activate when their - language is opened to provide code lenses. - - `onTests:*` new activation event very simiular to `workspaceContains`, - but only fired when the user wants to run tests or opens the test explorer. - */ export namespace test { /** - * Registers a provider that discovers tests in workspaces and documents. + * Registers a controller that can discover and + * run tests in workspaces and documents. */ - export function registerTestProvider(testProvider: TestProvider): Disposable; + export function registerTestController(testController: TestController): Disposable; /** - * Runs tests. The "run" contains the list of tests to run as well as a - * method that can be used to update their state. At the point in time - * that "run" is called, all tests given in the run have their state - * automatically set to {@link TestRunState.Queued}. + * Requests that tests be run by their controller. + * @param run Run options to use + * @param token Cancellation token for the test run */ - export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; + export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; /** * Returns an observer that retrieves tests in the given workspace folder. + * @stability experimental */ export function createWorkspaceTestObserver(workspaceFolder: WorkspaceFolder): TestObserver; /** * Returns an observer that retrieves tests in the given text document. + * @stability experimental */ export function createDocumentTestObserver(document: TextDocument): TestObserver; /** - * Inserts custom test results into the VS Code UI. The results are - * inserted and sorted based off the `completedAt` timestamp. If the - * results are being read from a file, for example, the `completedAt` - * time should generally be the modified time of the file if not more - * specific time is available. + * Creates a {@link TestRunTask}. This should be called by the + * {@link TestRunner} when a request is made to execute tests, and may also + * be called if a test run is detected externally. Once created, tests + * that are included in the results will be moved into the + * {@link TestResultState.Pending} state. * - * This will no-op if the inserted results are deeply equal to an - * existing result. - * - * @param results test results - * @param persist whether the test results should be saved by VS Code - * and persisted across reloads. Defaults to true. + * @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 + * disambiguate multiple sets of results in a test run. It is useful if + * tests are run across multiple platforms, for example. + * @param persist Whether the results created by the run should be + * persisted in VS Code. This may be false if the results are coming from + * a file already saved externally, such as a coverage information file. */ - export function publishTestResult(results: TestRunResult, persist?: boolean): void; + export function createTestRunTask(request: TestRunRequest, name?: string, persist?: boolean): TestRunTask; /** - * List of test results stored by VS Code, sorted in descnding - * order by their `completedAt` time. - */ + * Creates a new managed {@link TestItem} instance. + * @param options Initial/required options for the item + * @param data Custom data to be stored in {@link TestItem.data} + */ + export function createTestItem(options: TestItemOptions, data: T): TestItem; + + /** + * Creates a new managed {@link TestItem} instance. + * @param options Initial/required options for the item + */ + export function createTestItem(options: TestItemOptions): TestItem; + + /** + * List of test results stored by VS Code, sorted in descnding + * order by their `completedAt` time. + * @stability experimental + */ export const testResults: ReadonlyArray; /** - * Event that fires when the {@link testResults} array is updated. - */ + * Event that fires when the {@link testResults} array is updated. + * @stability experimental + */ export const onDidChangeTestResults: Event; } + /** + * @stability experimental + */ export interface TestObserver { /** * List of tests returned by test provider for files in the workspace. */ - readonly tests: ReadonlyArray; + readonly tests: ReadonlyArray>; /** * An event that fires when an existing test in the collection changes, or @@ -2265,32 +2280,30 @@ declare module 'vscode' { dispose(): void; } + /** + * @stability experimental + */ export interface TestsChangeEvent { /** * List of all tests that are newly added. */ - readonly added: ReadonlyArray; + readonly added: ReadonlyArray>; /** * List of existing tests that have updated. */ - readonly updated: ReadonlyArray; + readonly updated: ReadonlyArray>; /** * List of existing tests that have been removed. */ - readonly removed: ReadonlyArray; + readonly removed: ReadonlyArray>; } /** - * Discovers and provides tests. - * - * Additionally, the UI may request it to discover tests for the workspace - * via `addWorkspaceTests`. - * - * @todo rename from provider + * Interface to discover and execute tests. */ - export interface TestProvider { + export interface TestController { /** * Requests that tests be provided for the given workspace. This will * be called when tests need to be enumerated for the workspace, such as @@ -2303,7 +2316,7 @@ declare module 'vscode' { * @param cancellationToken Token that signals the used asked to abort the test run. * @returns the root test item for the workspace */ - provideWorkspaceTestRoot(workspace: WorkspaceFolder, token: CancellationToken): ProviderResult; + createWorkspaceTestRoot(workspace: WorkspaceFolder, token: CancellationToken): ProviderResult>; /** * Requests that tests be provided for the given document. This will be @@ -2311,8 +2324,8 @@ declare module 'vscode' { * instance by code lens UI. * * It's suggested that the provider listen to change events for the text - * document to provide information for test that might not yet be - * saved, if possible. + * document to provide information for tests that might not yet be + * saved. * * If the test system is not able to provide or estimate for tests on a * per-file basis, this method may not be implemented. In that case, the @@ -2320,74 +2333,79 @@ declare module 'vscode' { * * @param document The document in which to observe tests * @param cancellationToken Token that signals the used asked to abort the test run. - * @returns the root test item for the workspace + * @returns the root test item for the document */ - provideDocumentTestRoot?(document: TextDocument, token: CancellationToken): ProviderResult; + createDocumentTestRoot?(document: TextDocument, token: CancellationToken): ProviderResult>; /** - * @todo this will move out of the provider soon - * @todo this will eventually need to be able to return a summary report, coverage for example. + * Starts a test run. When called, the controller should call + * {@link vscode.test.createTestRunTask}. All tasks associated with the + * run should be created before the function returns or the reutrned + * promise is resolved. * - * Starts a test run. This should cause {@link onDidChangeTest} to - * fire with update test states during the run. * @param options Options for this test run * @param cancellationToken Token that signals the used asked to abort the test run. */ - // eslint-disable-next-line vscode-dts-provider-naming - runTests(options: TestRunOptions, token: CancellationToken): ProviderResult; + runTests(options: TestRunRequest, token: CancellationToken): Thenable | void; } /** * Options given to {@link test.runTests}. */ - export interface TestRunRequest { + export interface TestRunRequest { /** - * Array of specific tests to run. The {@link TestProvider.testRoot} may - * be provided as an indication to run all tests. + * Array of specific tests to run. The controllers should run all of the + * given tests and all children of the given tests, excluding any tests + * that appear in {@link TestRunRequest.exclude}. */ - tests: T[]; + tests: TestItem[]; /** * An array of tests the user has marked as excluded in VS Code. May be - * omitted if no exclusions were requested. Test providers should not run + * omitted if no exclusions were requested. Test controllers should not run * excluded tests or any children of excluded tests. */ - exclude?: T[]; + exclude?: TestItem[]; /** - * Whether or not tests in this run should be debugged. + * Whether tests in this run should be debugged. */ debug: boolean; } /** - * Options given to {@link TestProvider.runTests} + * Options given to {@link TestController.runTests} */ - export interface TestRunOptions extends TestRunRequest { + export interface TestRunTask { /** - * Updates the state of the test in the run. By default, all tests involved - * in the run will have a "queued" state until they are updated by this method. - * - * Calling with method with nodes outside the {@link TestRunRequesttests} - * or in the {@link TestRunRequestexclude} array will no-op. + * The human-readable name of the run. This can be used to + * disambiguate multiple sets of results in a test run. It is useful if + * tests are run across multiple platforms, for example. + */ + readonly name?: string; + + /** + * Updates the state of the test in the run. Calling with method with nodes + * outside the {@link TestRunRequest.tests} or in the + * {@link TestRunRequest.exclude} array will no-op. * * @param test The test to update * @param state The state to assign to the test * @param duration Optionally sets how long the test took to run */ - setState(test: T, state: TestResultState, duration?: number): void; + setState(test: TestItem, state: TestResultState, duration?: number): void; /** * Appends a message, such as an assertion error, to the test item. * - * Calling with method with nodes outside the {@link TestRunRequesttests} - * or in the {@link TestRunRequestexclude} array will no-op. + * Calling with method with nodes outside the {@link TestRunRequest.tests} + * or in the {@link TestRunRequest.exclude} array will no-op. * * @param test The test to update * @param state The state to assign to the test * */ - appendMessage(test: T, message: TestMessage): void; + appendMessage(test: TestItem, message: TestMessage): void; /** * Appends raw output from the test runner. On the user's request, the @@ -2398,44 +2416,56 @@ declare module 'vscode' { * @param associateTo Optionally, associate the given segment of output */ appendOutput(output: string): void; + + /** + * Signals that the end of the test run. Any tests whose states have not + * been updated will be moved into the {@link TestResultState.Unset} state. + */ + end(): void; } - export interface TestChildrenCollection extends Iterable { + /** + * Indicates the the activity state of the {@link TestItem}. + */ + export enum TestItemStatus { /** - * Gets the number of children in the collection. + * All children of the test item, if any, have been discovered. */ - readonly size: number; + Resolved = 1, /** - * Gets an existing TestItem by its ID, if it exists. - * @param id ID of the test. - * @returns the TestItem instance if it exists. + * The test item may have children who have not been discovered yet. */ - get(id: string): T | undefined; + Pending = 0, + } + + /** + * Options initially passed into `vscode.test.createTestItem` + */ + export interface TestItemOptions { + /** + * Unique identifier for the TestItem. This is used to correlate + * test results and tests in the document with those in the workspace + * (test explorer). This cannot change for the lifetime of the TestItem. + */ + id: string; /** - * Adds a new child test item. No-ops if the test was already a child. - * @param child The test item to add. + * URI this TestItem is associated with. May be a file or directory. */ - add(child: T): void; + uri: Uri; /** - * Removes the child test item by reference or ID from the collection. - * @param child Child ID or instance to remove. + * Display name describing the test item. */ - delete(child: T | string): void; - - /** - * Removes all children from the collection. - */ - clear(): void; + label: string; } /** * A test item is an item shown in the "test explorer" view. It encompasses * both a suite and a test, since they have almost or identical capabilities. */ - export class TestItem { + export interface TestItem { /** * Unique identifier for the TestItem. This is used to correlate * test results and tests in the document with those in the workspace @@ -2449,10 +2479,28 @@ declare module 'vscode' { readonly uri: Uri; /** - * A set of children this item has. You can add new children to it, which - * will propagate to the editor UI. + * A mapping of children by ID to the associated TestItem instances. */ - readonly children: TestChildrenCollection; + readonly children: ReadonlyMap>; + + /** + * The parent of this item, if any. Assigned automatically when calling + * {@link TestItem.addChild}. + */ + readonly parent?: TestItem; + + /** + * Indicates the state of the test item's children. The editor will show + * TestItems in the `Pending` state and with a `resolveHandler` as being + * expandable, and will call the `resolveHandler` to request items. + * + * A TestItem in the `Resolved` state is assumed to have discovered and be + * watching for changes in its children if applicable. TestItems are in the + * `Resolved` state when initially created; if the editor should call + * the `resolveHandler` to discover children, set the state to `Pending` + * after creating the item. + */ + status: TestItemStatus; /** * Display name describing the test case. @@ -2470,6 +2518,13 @@ declare module 'vscode' { */ range?: Range; + /** + * May be set to an error associated with loading the test. Note that this + * is not a test result and should only be used to represent errors in + * discovery, such as syntax errors. + */ + error?: string | MarkdownString; + /** * Whether this test item can be run by providing it in the * {@link TestRunRequest.tests} array. Defaults to `true`. @@ -2483,20 +2538,10 @@ declare module 'vscode' { debuggable: boolean; /** - * Whether this test item can be expanded in the tree view, implying it - * has (or may have) children. If this is true, VS Code may call - * the {@link TestItem.discoverChildren} method. + * Custom extension data on the item. This data will never be serialized + * or shared outside the extenion who created the item. */ - expandable: boolean; - - /** - * Creates a new TestItem instance. - * @param id Value of the "id" property - * @param label Value of the "label" property. - * @param uri Value of the "uri" property. - * @param expandable Value of the "expandable" property. - */ - constructor(id: string, label: string, uri: Uri, expandable: boolean); + data: T; /** * Marks the test as outdated. This can happen as a result of file changes, @@ -2509,17 +2554,18 @@ declare module 'vscode' { invalidate(): void; /** - * Requests the children of the test item. Extensions should override this - * method for any test that can discover children. + * A function provided by the extension that the editor may call to request + * children of the item, if the {@link TestItem.status} is `Pending`. * - * When called, the item should discover tests and update its's `children`. - * The provider will be marked as 'busy' when this method is called, and - * the provider should report `{ busy: false }` to {@link Progress.report} - * once discovery is complete. + * When called, the item should discover tests and call {@link TestItem.addChild}. + * The items should set its {@link TestItem.status} to `Resolved` when + * discovery is finished. * * The item should continue watching for changes to the children and * firing updates until the token is cancelled. The process of watching - * the tests may involve creating a file watcher, for example. + * the tests may involve creating a file watcher, for example. After the + * token is cancelled and watching stops, the TestItem should set its + * {@link TestItem.status} back to `Pending`. * * The editor will only call this method when it's interested in refreshing * the children of the item, and will not call it again while there's an @@ -2527,9 +2573,20 @@ declare module 'vscode' { * * @param token Cancellation for the request. Cancellation will be * requested if the test changes before the previous call completes. - * @returns a provider result of child test items */ - discoverChildren(progress: Progress<{ busy: boolean }>, token: CancellationToken): void; + resolveHandler?: (token: CancellationToken) => void; + + /** + * Attaches a child, created from the {@link test.createTestItem} function, + * to this item. A `TestItem` may be a child of at most one other item. + */ + addChild(child: TestItem): void; + + /** + * Removes the test and its children from the tree. Any tokens passed to + * child `resolveHandler` methods will be cancelled. + */ + dispose(): void; } /** @@ -2669,6 +2726,19 @@ declare module 'vscode' { */ readonly range?: Range; + /** + * State of the test in each task. In the common case, a test will only + * be executed in a single task and the length of this array will be 1. + */ + readonly taskStates: ReadonlyArray; + + /** + * Optional list of nested tests for this item. + */ + readonly children: Readonly[]; + } + + export interface TestSnapshoptTaskState { /** * Current result of the test. */ @@ -2685,11 +2755,6 @@ declare module 'vscode' { * failure information if the test fails. */ readonly messages: ReadonlyArray; - - /** - * Optional list of nested tests for this item. - */ - readonly children: Readonly[]; } //#endregion diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index daf1e90856e..64f6a4a307c 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -3,17 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; +import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; -import { emptyStream } from 'vs/base/common/stream'; import { isDefined } from 'vs/base/common/types'; import { URI, UriComponents } 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 { getTestSubscriptionKey, ISerializedTestResults, ITestMessage, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; -import { HydratedTestResult, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; +import { ExtensionRunTestsRequest, getTestSubscriptionKey, ITestItem, ITestMessage, ITestRunTask, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { ITestRootProvider, ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { ExtHostContext, ExtHostTestingResource, ExtHostTestingShape, IExtHostContext, MainContext, MainThreadTestingShape } from '../common/extHost.protocol'; @@ -48,7 +47,6 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh this._register(this.testService.onShouldSubscribe(args => this.proxy.$subscribeToTests(args.resource, args.uri))); this._register(this.testService.onShouldUnsubscribe(args => this.proxy.$unsubscribeFromTests(args.resource, args.uri))); - const prevResults = resultService.results.map(r => r.toJSON()).filter(isDefined); if (prevResults.length) { this.proxy.$publishTestResults(prevResults); @@ -72,43 +70,64 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - public $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void { - this.resultService.push(new HydratedTestResult( - results, - () => Promise.resolve( - results.output - ? bufferToStream(VSBuffer.fromString(results.output)) - : emptyStream(), - ), - persist, - )); - } - - /** - * @inheritdoc - */ - public $updateTestStateInRun(runId: string, testId: string, state: TestResultState, duration?: number): void { - const r = this.resultService.getResult(runId); - if (r && r instanceof LiveTestResult) { - r.updateState(testId, state, duration); + $addTestsToRun(runId: string, tests: ITestItem[]): void { + for (const test of tests) { + test.uri = URI.revive(test.uri); + if (test.range) { + test.range = Range.lift(test.range); + } } + + this.withLiveRun(runId, r => r.addTestChainToRun(tests)); } /** * @inheritdoc */ - public $appendOutputToRun(runId: string, output: VSBuffer): void { - const r = this.resultService.getResult(runId); - if (r && r instanceof LiveTestResult) { - r.output.append(output); - } + $startedExtensionTestRun(req: ExtensionRunTestsRequest): void { + this.resultService.createLiveResult(req); + } + + /** + * @inheritdoc + */ + $startedTestRunTask(runId: string, task: ITestRunTask): void { + this.withLiveRun(runId, r => r.addTask(task)); + } + + /** + * @inheritdoc + */ + $finishedTestRunTask(runId: string, taskId: string): void { + this.withLiveRun(runId, r => r.markTaskComplete(taskId)); + } + + /** + * @inheritdoc + */ + $finishedExtensionTestRun(runId: string): void { + this.withLiveRun(runId, r => r.markComplete()); + } + + /** + * @inheritdoc + */ + public $updateTestStateInRun(runId: string, taskId: string, testId: string, state: TestResultState, duration?: number): void { + this.withLiveRun(runId, r => r.updateState(testId, taskId, state, duration)); + } + + /** + * @inheritdoc + */ + public $appendOutputToRun(runId: string, _taskId: string, output: VSBuffer): void { + this.withLiveRun(runId, r => r.output.append(output)); } /** * @inheritdoc */ - public $appendTestMessageInRun(runId: string, testId: string, message: ITestMessage): void { + public $appendTestMessageInRun(runId: string, taskId: string, testId: string, message: ITestMessage): void { const r = this.resultService.getResult(runId); if (r && r instanceof LiveTestResult) { if (message.location) { @@ -116,14 +135,14 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh message.location.range = Range.lift(message.location.range); } - r.appendMessage(testId, message); + r.appendMessage(testId, taskId, message); } } /** * @inheritdoc */ - public $registerTestProvider(id: string) { + public $registerTestController(id: string) { const disposable = this.testService.registerTestController(id, { runTests: (req, token) => this.proxy.$runTestsForProvider(req, token), lookupTest: test => this.proxy.$lookupTest(test), @@ -136,7 +155,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - public $unregisterTestProvider(id: string) { + public $unregisterTestController(id: string) { this.testProviderRegistrations.get(id)?.dispose(); this.testProviderRegistrations.delete(id); } @@ -180,4 +199,9 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh } this.testSubscriptions.clear(); } + + private withLiveRun(runId: string, fn: (run: LiveTestResult) => T): T | undefined { + const r = this.resultService.getResult(runId); + return r && r instanceof LiveTestResult ? fn(r) : undefined; + } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index c1dcf6773ca..24e2fcb43a9 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -336,9 +336,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I : extHostTypes.ExtensionKind.UI; const test: typeof vscode.test = { - registerTestProvider(provider) { + registerTestController(provider) { checkProposedApiEnabled(extension); - return extHostTesting.registerTestProvider(provider); + return extHostTesting.registerTestController(extension.identifier.value, provider); }, createDocumentTestObserver(document) { checkProposedApiEnabled(extension); @@ -352,9 +352,12 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension); return extHostTesting.runTests(provider); }, - publishTestResult(results, persist = true) { + createTestItem(options: vscode.TestItemOptions, data?: T) { + return new extHostTypes.TestItemImpl(options.id, options.label, options.uri, data); + }, + createTestRunTask(request, name, persist) { checkProposedApiEnabled(extension); - return extHostTesting.publishExtensionProvidedResults(results, persist); + return extHostTesting.createTestRunTask(extension.identifier.value, request, name, persist); }, get onDidChangeTestResults() { checkProposedApiEnabled(extension); @@ -1259,7 +1262,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I NotebookCellOutputItem: extHostTypes.NotebookCellOutputItem, NotebookCellStatusBarItem: extHostTypes.NotebookCellStatusBarItem, LinkedEditingRanges: extHostTypes.LinkedEditingRanges, - TestItem: extHostTypes.TestItem, + TestItemStatus: extHostTypes.TestItemStatus, TestResultState: extHostTypes.TestResultState, TestMessage: extHostTypes.TestMessage, TestMessageSeverity: extHostTypes.TestMessageSeverity, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 263eb22ad51..3b2a20810b9 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -56,7 +56,7 @@ import { Dto } from 'vs/base/common/types'; import { DebugConfigurationProviderTriggerKind, TestResultState } from 'vs/workbench/api/common/extHostTypes'; import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility'; import { IExtensionIdWithVersion } from 'vs/platform/userDataSync/common/extensionsStorageSync'; -import { InternalTestItem, RunTestForProviderRequest, RunTestsRequest, TestIdWithSrc, TestsDiff, ISerializedTestResults, ITestMessage } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, RunTestForProviderRequest, RunTestsRequest, TestIdWithSrc, TestsDiff, ISerializedTestResults, ITestMessage, ITestItem, ITestRunTask, ExtensionRunTestsRequest } from 'vs/workbench/contrib/testing/common/testCollection'; import { CandidatePort } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { WorkspaceTrustRequestOptions } from 'vs/platform/workspace/common/workspaceTrust'; import { ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable'; @@ -2008,16 +2008,39 @@ export interface ExtHostTestingShape { } export interface MainThreadTestingShape { - $registerTestProvider(id: string): void; - $unregisterTestProvider(id: string): void; + /** Registeres that there's a test controller with the given ID */ + $registerTestController(id: string): void; + /** Diposes of the test controller with the given ID */ + $unregisterTestController(id: string): void; + /** Requests tests from the given resource/uri, from the observer API. */ $subscribeToDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; + /** Stops requesting tests from the given resource/uri, from the observer API. */ $unsubscribeFromDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; + /** Publishes that new tests were available on the given source. */ $publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void; - $updateTestStateInRun(runId: string, testId: string, state: TestResultState, duration?: number): void; - $appendTestMessageInRun(runId: string, testId: string, message: ITestMessage): void; - $appendOutputToRun(runId: string, output: VSBuffer): void; + /** Request by an extension to run tests. */ $runTests(req: RunTestsRequest, token: CancellationToken): Promise; - $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void; + + // --- test run handling: + /** + * 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. + */ + $addTestsToRun(runId: string, tests: ITestItem[]): void; + /** Updates the state of a test run in the given run. */ + $updateTestStateInRun(runId: string, taskId: string, testId: string, state: TestResultState, duration?: number): void; + /** Appends a message to a test in the run. */ + $appendTestMessageInRun(runId: string, taskId: string, testId: string, message: ITestMessage): void; + /** Appends raw output to the test run.. */ + $appendOutputToRun(runId: string, taskId: string, output: VSBuffer): void; + /** Signals a task in a test run started. */ + $startedTestRunTask(runId: string, task: ITestRunTask): void; + /** Signals a task in a test run ended. */ + $finishedTestRunTask(runId: string, taskId: string): void; + /** Start a new extension-provided test run. */ + $startedExtensionTestRun(req: ExtensionRunTestsRequest): void; + /** Signals that an extension-provided test run finished. */ + $finishedExtensionTestRun(runId: string): void; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 142aa312694..e8aad5e6eec 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { mapFind } from 'vs/base/common/arrays'; -import { disposableTimeout } from 'vs/base/common/async'; +import { Barrier, DeferredPromise, disposableTimeout, isThenable } from 'vs/base/common/async'; import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; import { once } from 'vs/base/common/functional'; +import { Iterable } from 'vs/base/common/iterator'; import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { deepFreeze } from 'vs/base/common/objects'; import { isDefined } from 'vs/base/common/types'; @@ -18,24 +19,29 @@ import { ExtHostTestingResource, ExtHostTestingShape, MainContext, MainThreadTes import { ExtHostDocumentData } from 'vs/workbench/api/common/extHostDocumentData'; import { IExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; +import { ExtHostTestItemEventType, getPrivateApiFor } from 'vs/workbench/api/common/extHostTestingPrivateApi'; import * as Convert from 'vs/workbench/api/common/extHostTypeConverters'; -import { Disposable, TestItem as TestItemImpl, TestItemHookProperty } from 'vs/workbench/api/common/extHostTypes'; +import { Disposable, TestItemImpl } from 'vs/workbench/api/common/extHostTypes'; import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace'; import { OwnedTestCollection, SingleUseTestCollection, TestPosition } from 'vs/workbench/contrib/testing/common/ownedTestCollection'; -import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, RunTestForProviderRequest, TestDiffOpType, TestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestItem, RunTestForProviderRequest, TestDiffOpType, TestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import type * as vscode from 'vscode'; const getTestSubscriptionKey = (resource: ExtHostTestingResource, uri: URI) => `${resource}:${uri.toString()}`; export class ExtHostTesting implements ExtHostTestingShape { private readonly resultsChangedEmitter = new Emitter(); - private readonly providers = new Map(); + private readonly controllers = new Map + }>(); private readonly proxy: MainThreadTestingShape; private readonly ownedTests = new OwnedTestCollection(); - private readonly testSubscriptions = new Map void; + subscribeFn: (id: string, provider: vscode.TestController) => void; }>(); private workspaceObservers: WorkspaceFolderTestObserverFactory; @@ -46,6 +52,7 @@ export class ExtHostTesting implements ExtHostTestingShape { constructor(@IExtHostRpcService rpc: IExtHostRpcService, @IExtHostDocumentsAndEditors private readonly documents: IExtHostDocumentsAndEditors, @IExtHostWorkspace private readonly workspace: IExtHostWorkspace) { this.proxy = rpc.getProxy(MainContext.MainThreadTesting); + this.runQueue = new TestRunQueue(this.proxy); this.workspaceObservers = new WorkspaceFolderTestObserverFactory(this.proxy); this.textDocumentObservers = new TextDocumentTestObserverFactory(this.proxy, documents); } @@ -53,22 +60,22 @@ export class ExtHostTesting implements ExtHostTestingShape { /** * Implements vscode.test.registerTestProvider */ - public registerTestProvider(provider: vscode.TestProvider): vscode.Disposable { - const providerId = generateUuid(); - this.providers.set(providerId, provider); - this.proxy.$registerTestProvider(providerId); + public registerTestController(extensionId: string, controller: vscode.TestController): vscode.Disposable { + const controllerId = generateUuid(); + this.controllers.set(controllerId, { instance: controller, extensionId }); + this.proxy.$registerTestController(controllerId); // give the ext a moment to register things rather than synchronously invoking within activate() - const toSubscribe = [...this.testSubscriptions.keys()]; + const toSubscribe = [...this.testControllers.keys()]; setTimeout(() => { for (const subscription of toSubscribe) { - this.testSubscriptions.get(subscription)?.subscribeFn(providerId, provider); + this.testControllers.get(subscription)?.subscribeFn(controllerId, controller); } }, 0); return new Disposable(() => { - this.providers.delete(providerId); - this.proxy.$unregisterTestProvider(providerId); + this.controllers.delete(controllerId); + this.proxy.$unregisterTestController(controllerId); }); } @@ -89,8 +96,8 @@ export class ExtHostTesting implements ExtHostTestingShape { /** * Implements vscode.test.runTests */ - public async runTests(req: vscode.TestRunRequest, token = CancellationToken.None) { - const testListToProviders = (tests: ReadonlyArray) => + public async runTests(req: vscode.TestRunRequest, token = CancellationToken.None) { + const testListToProviders = (tests: ReadonlyArray>) => tests .map(this.getInternalTestForReference, this) .filter(isDefined) @@ -104,10 +111,10 @@ export class ExtHostTesting implements ExtHostTestingShape { } /** - * Implements vscode.test.publishTestResults + * Implements vscode.test.createTestRunTask */ - public publishExtensionProvidedResults(results: vscode.TestRunResult, persist: boolean): void { - this.proxy.$publishExtensionProvidedResults(Convert.TestResults.from(generateUuid(), results), persist); + public createTestRunTask(extensionId: string, request: vscode.TestRunRequest, name: string | undefined, persist = true): vscode.TestRunTask { + return this.runQueue.createTestRunTask(extensionId, request, name, persist); } /** @@ -133,12 +140,12 @@ export class ExtHostTesting implements ExtHostTestingShape { public async $subscribeToTests(resource: ExtHostTestingResource, uriComponents: UriComponents) { const uri = URI.revive(uriComponents); const subscriptionKey = getTestSubscriptionKey(resource, uri); - if (this.testSubscriptions.has(subscriptionKey)) { + if (this.testControllers.has(subscriptionKey)) { return; } const cancellation = new CancellationTokenSource(); - let method: undefined | ((p: vscode.TestProvider) => vscode.ProviderResult); + let method: undefined | ((p: vscode.TestController) => vscode.ProviderResult>); if (resource === ExtHostTestingResource.TextDocument) { let document = this.documents.getDocument(uri); @@ -157,14 +164,14 @@ export class ExtHostTesting implements ExtHostTestingShape { if (document) { const folder = await this.workspace.getWorkspaceFolder2(uri, false); - method = p => p.provideDocumentTestRoot - ? p.provideDocumentTestRoot(document!.document, cancellation.token) + method = p => p.createDocumentTestRoot + ? p.createDocumentTestRoot(document!.document, cancellation.token) : createDefaultDocumentTestRoot(p, document!.document, folder, cancellation.token); } } else { const folder = await this.workspace.getWorkspaceFolder2(uri, false); if (folder) { - method = p => p.provideWorkspaceTestRoot(folder, cancellation.token); + method = p => p.createWorkspaceTestRoot(folder, cancellation.token); } } @@ -172,7 +179,7 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } - const subscribeFn = async (id: string, provider: vscode.TestProvider) => { + const subscribeFn = async (id: string, provider: vscode.TestController) => { try { const root = await method!(provider); if (root) { @@ -187,15 +194,15 @@ export class ExtHostTesting implements ExtHostTestingShape { const collection = disposable.add(this.ownedTests.createForHierarchy( diff => this.proxy.$publishDiff(resource, uriComponents, diff))); disposable.add(toDisposable(() => cancellation.dispose(true))); - for (const [id, provider] of this.providers) { - subscribeFn(id, provider); + for (const [id, controller] of this.controllers) { + subscribeFn(id, controller.instance); } // note: we don't increment the root count initially -- this is done by the // main thread, incrementing once per extension host. We just push the // diff to signal that roots have been discovered. collection.pushDiff([TestDiffOpType.DeltaRootsComplete, -1]); - this.testSubscriptions.set(subscriptionKey, { store: disposable, collection, subscribeFn }); + this.testControllers.set(subscriptionKey, { store: disposable, collection, subscribeFn }); } /** @@ -204,7 +211,7 @@ export class ExtHostTesting implements ExtHostTestingShape { * @override */ public async $expandTest(test: TestIdWithSrc, levels: number) { - const sub = mapFind(this.testSubscriptions.values(), s => s.collection.treeId === test.src.tree ? s : undefined); + const sub = mapFind(this.testControllers.values(), s => s.collection.treeId === test.src.tree ? s : undefined); await sub?.collection.expand(test.testId, levels < 0 ? Infinity : levels); this.flushCollectionDiffs(); } @@ -216,8 +223,8 @@ export class ExtHostTesting implements ExtHostTestingShape { public $unsubscribeFromTests(resource: ExtHostTestingResource, uriComponents: UriComponents) { const uri = URI.revive(uriComponents); const subscriptionKey = getTestSubscriptionKey(resource, uri); - this.testSubscriptions.get(subscriptionKey)?.store.dispose(); - this.testSubscriptions.delete(subscriptionKey); + this.testControllers.get(subscriptionKey)?.store.dispose(); + this.testControllers.delete(subscriptionKey); } /** @@ -238,14 +245,14 @@ export class ExtHostTesting implements ExtHostTestingShape { * providers to be run. * @override */ - public async $runTestsForProvider(req: RunTestForProviderRequest, cancellation: CancellationToken): Promise { - const provider = this.providers.get(req.tests[0].src.provider); - if (!provider) { + public async $runTestsForProvider(req: RunTestForProviderRequest, token: CancellationToken): Promise { + const controller = this.controllers.get(req.tests[0].src.controller); + if (!controller) { return; } const includeTests = req.tests - .map(({ testId, src }) => this.ownedTests.getTestById(testId, src.tree)) + .map(({ testId, src }) => this.ownedTests.getTestById(testId, src?.tree)) .filter(isDefined) .map(([_tree, test]) => test); @@ -260,50 +267,19 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } - const isExcluded = (test: vscode.TestItem) => { - // for test providers that don't support excluding natively, - // make sure not to report excluded result otherwise summaries will be off. - for (const [tree, exclude] of excludeTests) { - const e = tree.comparePositions(exclude, test.id); - if (e === TestPosition.IsChild || e === TestPosition.IsSame) { - return true; - } - } - - return false; + const publicReq: vscode.TestRunRequest = { + tests: includeTests.map(t => TestItemFilteredWrapper.unwrap(t.actual)), + exclude: excludeTests.map(([, t]) => TestItemFilteredWrapper.unwrap(t.actual)), + debug: req.debug, }; - try { - await provider.runTests({ - appendOutput: message => { - this.proxy.$appendOutputToRun(req.runId, VSBuffer.fromString(message)); - }, - appendMessage: (test, message) => { - if (!isExcluded(test)) { - this.flushCollectionDiffs(); - this.proxy.$appendTestMessageInRun(req.runId, test.id, Convert.TestMessage.from(message)); - } - }, - setState: (test, state, duration) => { - if (!isExcluded(test)) { - this.flushCollectionDiffs(); - this.proxy.$updateTestStateInRun(req.runId, test.id, state, duration); - } - }, - tests: includeTests.map(t => TestItemFilteredWrapper.unwrap(t.actual)), - exclude: excludeTests.map(([, t]) => TestItemFilteredWrapper.unwrap(t.actual)), - debug: req.debug, - }, cancellation); - - for (const { collection } of this.testSubscriptions.values()) { - collection.flushDiff(); // ensure all states are updated - } - - return; - } catch (e) { - console.error(e); // so it appears to attached debuggers - throw e; - } + await this.runQueue.enqueueRun({ + dto: TestRunDto.fromInternal(req), + token, + extensionId: controller.extensionId, + req: publicReq, + doRun: () => controller!.instance.runTests(publicReq, token) + }); } public $lookupTest(req: TestIdWithSrc): Promise { @@ -321,7 +297,7 @@ export class ExtHostTesting implements ExtHostTestingShape { * main thread is updated. */ private flushCollectionDiffs() { - for (const { collection } of this.testSubscriptions.values()) { + for (const { collection } of this.testControllers.values()) { collection.flushDiff(); } } @@ -329,18 +305,236 @@ export class ExtHostTesting implements ExtHostTestingShape { /** * Gets the internal test item associated with the reference from the extension. */ - private getInternalTestForReference(test: vscode.TestItem) { + private getInternalTestForReference(test: vscode.TestItem) { // Find workspace items first, then owned tests, then document tests. // If a test instance exists in both the workspace and document, prefer // the workspace because it's less ephemeral. return this.workspaceObservers.getMirroredTestDataByReference(test) - ?? mapFind(this.testSubscriptions.values(), c => c.collection.getTestByReference(test)) + ?? mapFind(this.testControllers.values(), c => c.collection.getTestByReference(test)) ?? this.textDocumentObservers.getMirroredTestDataByReference(test); } } -export const createDefaultDocumentTestRoot = async ( - provider: vscode.TestProvider, +/** + * Queues runs for a single extension and provides the currently-executing + * run so that `createTestRunTask` can be properly correlated. + */ +class TestRunQueue { + private readonly state = new Map, + factory: (name: string | undefined) => TestRunTask, + }, + queue: (() => (Promise | void))[]; + }>(); + + constructor(private readonly proxy: MainThreadTestingShape) { } + + /** + * Registers and enqueues a test run. `doRun` will be called when an + * invokation to {@link TestController.runTests} should be called. + */ + public enqueueRun(opts: { + extensionId: string, + req: vscode.TestRunRequest, + dto: TestRunDto, + token: CancellationToken, + doRun: () => Thenable | void, + }, + ) { + let record = this.state.get(opts.extensionId); + if (!record) { + record = { queue: [], current: undefined as any }; + this.state.set(opts.extensionId, record); + } + + const deferred = new DeferredPromise(); + const runner = () => { + const tasks: TestRunTask[] = []; + const shared = new Set(); + record!.current = { + publicReq: opts.req, + factory: name => { + const task = new TestRunTask(name, opts.dto, shared, this.proxy); + tasks.push(task); + opts.token.onCancellationRequested(() => task.end()); + return task; + }, + }; + + this.invokeRunner(opts.extensionId, opts.dto.id, opts.doRun, tasks).finally(() => deferred.complete()); + }; + + record.queue.push(runner); + if (record.queue.length === 1) { + runner(); + } + + return deferred.p; + } + + /** + * Implements the public `createTestRunTask` API. + */ + public createTestRunTask(extensionId: string, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRunTask { + const state = this.state.get(extensionId); + // If the request is for the currently-executing `runTests`, then correlate + // it to that existing run. Otherwise return a new, detached run. + if (state?.current.publicReq === request) { + return state.current.factory(name); + } + + const dto = TestRunDto.fromPublic(request); + const task = new TestRunTask(name, dto, new Set(), this.proxy); + this.proxy.$startedExtensionTestRun({ + debug: request.debug, + exclude: request.exclude?.map(t => t.id) ?? [], + id: dto.id, + tests: request.tests.map(t => t.id), + persist: persist + }); + task.onEnd.wait().then(() => this.proxy.$finishedExtensionTestRun(dto.id)); + return task; + } + + private invokeRunner(extensionId: string, runId: string, fn: () => Thenable | void, tasks: TestRunTask[]): Promise { + try { + const res = fn(); + if (isThenable(res)) { + return res + .then(() => this.handleInvokeResult(extensionId, runId, tasks, undefined)) + .catch(err => this.handleInvokeResult(extensionId, runId, tasks, err)); + } else { + return this.handleInvokeResult(extensionId, runId, tasks, undefined); + } + } catch (e) { + return this.handleInvokeResult(extensionId, runId, tasks, e); + } + } + + private async handleInvokeResult(extensionId: string, runId: string, tasks: TestRunTask[], error?: Error) { + const record = this.state.get(extensionId); + if (!record) { + return; + } + + record.queue.shift(); + if (record.queue.length > 0) { + record.queue[0](); + } else { + this.state.delete(extensionId); + } + + await Promise.all(tasks.map(t => t.onEnd.wait())); + } +} + +class TestRunDto { + public static fromPublic(request: vscode.TestRunRequest) { + return new TestRunDto( + generateUuid(), + new Set(request.tests.map(t => t.id)), + new Set(request.exclude?.map(t => t.id) ?? Iterable.empty()), + ); + } + + public static fromInternal(request: RunTestForProviderRequest) { + return new TestRunDto( + request.runId, + new Set(request.tests.map(t => t.testId)), + new Set(request.excludeExtIds), + ); + } + + constructor( + public readonly id: string, + private readonly include: ReadonlySet, + private readonly exclude: ReadonlySet, + ) { } + + public isIncluded(test: vscode.TestItem) { + for (let t: vscode.TestItem | undefined = test; t; t = t.parent) { + if (this.include.has(t.id)) { + return true; + } else if (this.exclude.has(t.id)) { + return false; + } + } + + return true; + } +} + +class TestRunTask implements vscode.TestRunTask { + readonly #proxy: MainThreadTestingShape; + readonly #req: TestRunDto; + readonly #taskId = generateUuid(); + readonly #sharedIds: Set; + public readonly onEnd = new Barrier(); + + constructor( + public readonly name: string | undefined, + dto: TestRunDto, + sharedTestIds: Set, + proxy: MainThreadTestingShape, + ) { + this.#proxy = proxy; + this.#req = dto; + this.#sharedIds = sharedTestIds; + proxy.$startedTestRunTask(dto.id, { id: this.#taskId, name, running: true }); + } + + setState(test: vscode.TestItem, state: vscode.TestResultState, duration?: number): void { + if (this.#req.isIncluded(test)) { + this.ensureTestIsKnown(test); + this.#proxy.$updateTestStateInRun(this.#req.id, this.#taskId, test.id, state, duration); + } + } + + appendMessage(test: vscode.TestItem, message: vscode.TestMessage): void { + if (this.#req.isIncluded(test)) { + this.ensureTestIsKnown(test); + this.#proxy.$appendTestMessageInRun(this.#req.id, this.#taskId, test.id, Convert.TestMessage.from(message)); + } + } + + appendOutput(output: string): void { + this.#proxy.$appendOutputToRun(this.#req.id, this.#taskId, VSBuffer.fromString(output)); + } + + end(): void { + this.#proxy.$finishedTestRunTask(this.#req.id, this.#taskId); + this.onEnd.open(); + } + + private ensureTestIsKnown(test: vscode.TestItem) { + const sent = this.#sharedIds; + if (sent.has(test.id)) { + return; + } + + const chain: ITestItem[] = []; + while (true) { + chain.unshift(Convert.TestItem.from(test)); + + if (sent.has(test.id)) { + break; + } + + sent.add(test.id); + if (!test.parent) { + break; + } + + test = test.parent; + } + + this.#proxy.$addTestsToRun(this.#req.id, chain); + } +} + +export const createDefaultDocumentTestRoot = async ( + provider: vscode.TestController, document: vscode.TextDocument, folder: vscode.WorkspaceFolder | undefined, token: CancellationToken, @@ -349,7 +543,7 @@ export const createDefaultDocumentTestRoot = async ( return; } - const root = await provider.provideWorkspaceTestRoot(folder, token); + const root = await provider.createWorkspaceTestRoot(folder, token); if (!root) { return; } @@ -365,25 +559,26 @@ export const createDefaultDocumentTestRoot = async ( * A class which wraps a vscode.TestItem that provides the ability to filter a TestItem's children * to only the children that are located in a certain vscode.Uri. */ -export class TestItemFilteredWrapper extends TestItemImpl { - private static wrapperMap = new WeakMap>(); +export class TestItemFilteredWrapper extends TestItemImpl { + private static wrapperMap = new WeakMap, TestItemFilteredWrapper>>(); + public static removeFilter(document: vscode.TextDocument): void { this.wrapperMap.delete(document); } // Wraps the TestItem specified in a TestItemFilteredWrapper and pulls from a cache if it already exists. - public static getWrapperForTestItem( - item: T, + public static getWrapperForTestItem( + item: vscode.TestItem, filterDocument: vscode.TextDocument, - parent?: TestItemFilteredWrapper, - ): TestItemFilteredWrapper { + parent?: TestItemFilteredWrapper, + ): TestItemFilteredWrapper { let innerMap = this.wrapperMap.get(filterDocument); if (innerMap?.has(item)) { - return innerMap.get(item) as TestItemFilteredWrapper; + return innerMap.get(item) as TestItemFilteredWrapper; } if (!innerMap) { - innerMap = new WeakMap(); + innerMap = new WeakMap(); this.wrapperMap.set(filterDocument, innerMap); } @@ -396,8 +591,8 @@ export class TestItemFilteredWrapper(item: vscode.TestItem | TestItemFilteredWrapper) { + return item instanceof TestItemFilteredWrapper ? item.actual as vscode.TestItem : item; } private _cachedMatchesFilter: boolean | undefined; @@ -414,21 +609,37 @@ export class TestItemFilteredWrapper, private filterDocument: vscode.TextDocument, - public readonly parent?: TestItemFilteredWrapper, + public readonly actualParent?: TestItemFilteredWrapper, ) { - super(actual.id, actual.label, actual.uri, actual.expandable); + super(actual.id, actual.label, actual.uri, undefined); if (!(actual instanceof TestItemImpl)) { throw new Error(`TestItems provided to the VS Code API must extend \`vscode.TestItem\`, but ${actual.id} did not`); } - (actual as TestItemImpl)[TestItemHookProperty] = { - setProp: (key, value) => (this as Record)[key] = value, - created: child => TestItemFilteredWrapper.getWrapperForTestItem(child, this.filterDocument, this).refreshMatch(), - invalidate: () => this.invalidate(), - delete: child => this.children.delete(child), - }; + this.debuggable = actual.debuggable; + this.runnable = actual.runnable; + this.description = actual.description; + this.error = actual.error; + this.status = actual.status; + + const wrapperApi = getPrivateApiFor(this); + const actualApi = getPrivateApiFor(actual); + actualApi.bus.event(evt => { + switch (evt[0]) { + case ExtHostTestItemEventType.SetProp: + (this as Record)[evt[1]] = evt[2]; + break; + case ExtHostTestItemEventType.NewChild: + const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(evt[1], this.filterDocument, this); + getPrivateApiFor(wrapper).parent = actual; + wrapper.refreshMatch(); + break; + default: + wrapperApi.bus.fire(evt); + } + }); } /** @@ -441,12 +652,12 @@ export class TestItemFilteredWrapper; depth: number; } @@ -562,7 +773,7 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection) { - let output: vscode.TestItem[] = []; + let output: vscode.TestItem[] = []; for (const itemId of itemIds) { const item = this.items.get(itemId); if (item) { @@ -584,7 +795,7 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection) { return this.items.get(item.id); } @@ -595,7 +806,7 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection, depth: parent ? parent.depth + 1 : 0, children: new Set(), }; @@ -644,7 +855,7 @@ abstract class AbstractTestObserverFactory { /** * Gets the internal test data by its reference, in any observer. */ - public getMirroredTestDataByReference(ref: vscode.TestItem) { + public getMirroredTestDataByReference(ref: vscode.TestItem) { for (const { tests } of this.resources.values()) { const v = tests.getMirroredTestDataByReference(ref); if (v) { diff --git a/src/vs/workbench/api/common/extHostTestingPrivateApi.ts b/src/vs/workbench/api/common/extHostTestingPrivateApi.ts new file mode 100644 index 00000000000..66394266b4b --- /dev/null +++ b/src/vs/workbench/api/common/extHostTestingPrivateApi.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; +import { TestItemImpl } from 'vs/workbench/api/common/extHostTypes'; +import * as vscode from 'vscode'; + +export const enum ExtHostTestItemEventType { + NewChild, + Disposed, + Invalidated, + SetProp, +} + +export type ExtHostTestItemEvent = + | [evt: ExtHostTestItemEventType.NewChild, item: TestItemImpl] + | [evt: ExtHostTestItemEventType.Disposed] + | [evt: ExtHostTestItemEventType.Invalidated] + | [evt: ExtHostTestItemEventType.SetProp, key: keyof vscode.TestItem, value: any]; + +export interface IExtHostTestItemApi { + children: Map; + parent?: TestItemImpl; + bus: Emitter; +} + +const eventPrivateApis = new WeakMap(); + +/** + * Gets the private API for a test item implementation. This implementation + * is a managed object, but we keep a weakmap to avoid exposing any of the + * internals to extensions. + */ +export const getPrivateApiFor = (impl: TestItemImpl) => { + let api = eventPrivateApis.get(impl); + if (!api) { + api = { children: new Map(), bus: new Emitter() }; + eventPrivateApis.set(impl, api); + } + + return api; +}; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index e360f85de1a..f9e8d73a13a 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -29,7 +29,7 @@ import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebo import { EditorGroupColumn, SaveReason } from 'vs/workbench/common/editor'; import * as notebooks from 'vs/workbench/contrib/notebook/common/notebookCommon'; import * as search from 'vs/workbench/contrib/search/common/search'; -import { ISerializedTestResults, ITestItem, ITestMessage, SerializedTestResultItem, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ISerializedTestResults, ITestItem, ITestMessage, SerializedTestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import type * as vscode from 'vscode'; import * as types from './extHostTypes'; @@ -1695,9 +1695,9 @@ export namespace TestMessage { } export namespace TestItem { - export type Raw = vscode.TestItem; + export type Raw = vscode.TestItem; - export function from(item: vscode.TestItem): ITestItem { + export function from(item: vscode.TestItem): ITestItem { return { extId: item.id, label: item.label, @@ -1706,7 +1706,6 @@ export namespace TestItem { debuggable: item.debuggable ?? false, description: item.description, runnable: item.runnable ?? true, - expandable: item.expandable, }; } @@ -1719,25 +1718,27 @@ export namespace TestItem { debuggable: false, description: item.description, runnable: true, - expandable: true, }; } - export function toPlain(item: ITestItem): Omit { + export function toPlain(item: ITestItem): Omit, 'children' | 'invalidate' | 'discoverChildren'> { return { id: item.extId, label: item.label, uri: URI.revive(item.uri), range: Range.to(item.range), - expandable: item.expandable, + addChild: () => undefined, + dispose: () => undefined, + status: types.TestItemStatus.Pending, + data: undefined as never, debuggable: item.debuggable, description: item.description, runnable: item.runnable, }; } - export function to(item: ITestItem): types.TestItem { - const testItem = new types.TestItem(item.extId, item.label, URI.revive(item.uri), item.expandable); + export function to(item: ITestItem): types.TestItemImpl { + const testItem = new types.TestItemImpl(item.extId, item.label, URI.revive(item.uri), undefined); testItem.range = Range.to(item.range); testItem.debuggable = item.debuggable; testItem.description = item.description; @@ -1747,52 +1748,13 @@ export namespace TestItem { } export namespace TestResults { - export function from(id: string, results: vscode.TestRunResult): ISerializedTestResults { - const serialized: ISerializedTestResults = { - completedAt: results.completedAt, - id, - output: results.output, - items: [], - }; - - const queue: [parent: SerializedTestResultItem | null, children: Iterable][] = [ - [null, results.results], - ]; - - while (queue.length) { - const [parent, children] = queue.pop()!; - for (const item of children) { - const serializedItem: SerializedTestResultItem = { - children: item.children?.map(c => c.id) ?? [], - computedState: item.state, - item: TestItem.fromResultSnapshot(item), - state: { - state: item.state, - duration: item.duration, - messages: item.messages.map(TestMessage.from), - }, - retired: undefined, - expand: TestItemExpandState.Expanded, - parent: parent?.item.extId ?? null, - src: { provider: '', tree: -1 }, - direct: !parent, - }; - - serialized.items.push(serializedItem); - if (item.children) { - queue.push([serializedItem, item.children]); - } - } - } - - return serialized; - } - const convertTestResultItem = (item: SerializedTestResultItem, byInternalId: Map): vscode.TestResultSnapshot => ({ ...TestItem.toPlain(item.item), - state: item.state.state, - duration: item.state.duration, - messages: item.state.messages.map(TestMessage.to), + taskStates: item.tasks.map(t => ({ + state: t.state, + duration: t.duration, + messages: t.messages.map(TestMessage.to), + })), children: item.children .map(c => byInternalId.get(c)) .filter(isDefined) diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 3d510c2f430..4157a949b15 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -7,12 +7,13 @@ import { coalesceInPlace, equals } from 'vs/base/common/arrays'; import { illegalArgument } from 'vs/base/common/errors'; import { IRelativePattern } from 'vs/base/common/glob'; import { isMarkdownString, MarkdownString as BaseMarkdownString } from 'vs/base/common/htmlContent'; -import { ResourceMap } from 'vs/base/common/map'; +import { ReadonlyMapView, ResourceMap } from 'vs/base/common/map'; import { isStringArray } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import { FileSystemProviderErrorCode, markAsFileSystemProviderError } from 'vs/platform/files/common/files'; import { RemoteAuthorityResolverErrorCode } from 'vs/platform/remote/common/remoteAuthorityResolver'; +import { getPrivateApiFor, ExtHostTestItemEventType, IExtHostTestItemApi } from 'vs/workbench/api/common/extHostTestingPrivateApi'; import { CellEditType, ICellEditOperation } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import type * as vscode from 'vscode'; @@ -3236,16 +3237,17 @@ export enum TestMessageSeverity { Hint = 3 } -export const TestItemHookProperty = Symbol('TestItemHookProperty'); - -export interface ITestItemHook { - created(item: vscode.TestItem): void; - setProp(key: K, value: vscode.TestItem[K]): void; - invalidate(id: string): void; - delete(id: string): void; +export enum TestItemStatus { + Pending = 0, + Resolved = 1, } -const testItemPropAccessor = (item: TestItem, key: K, defaultValue: vscode.TestItem[K]) => { +const testItemPropAccessor = >( + api: IExtHostTestItemApi, + key: K, + defaultValue: vscode.TestItem[K], + equals: (a: vscode.TestItem[K], b: vscode.TestItem[K]) => boolean +) => { let value = defaultValue; return { enumerable: true, @@ -3253,80 +3255,41 @@ const testItemPropAccessor = (item: TestItem, k get() { return value; }, - set(newValue: vscode.TestItem[K]) { - item[TestItemHookProperty]?.setProp(key, newValue); - value = newValue; + set(newValue: vscode.TestItem[K]) { + if (!equals(value, newValue)) { + value = newValue; + api.bus.fire([ExtHostTestItemEventType.SetProp, key, newValue]); + } }, }; }; -export class TestChildrenCollection implements vscode.TestChildrenCollection { - #map = new Map(); - #hookRef: () => ITestItemHook | undefined; +const strictEqualComparator = (a: T, b: T) => a === b; +const rangeComparator = (a: vscode.Range | undefined, b: vscode.Range | undefined) => { + if (a === b) { return true; } + if (!a || !b) { return false; } + return a.isEqual(b); +}; - public get size() { - return this.#map.size; - } +export class TestItemImpl implements vscode.TestItem { + public readonly id!: string; + public readonly uri!: vscode.Uri; + public readonly children!: ReadonlyMap; + public readonly parent!: TestItemImpl | undefined; - constructor(hookRef: () => ITestItemHook | undefined) { - this.#hookRef = hookRef; - } - - public add(child: vscode.TestItem) { - const map = this.#map; - const hook = this.#hookRef(); - - const existing = map.get(child.id); - if (existing === child) { - return; - } - - if (existing) { - hook?.delete(child.id); - } - - map.set(child.id, child); - hook?.created(child); - } - - public get(id: string) { - return this.#map.get(id); - } - - public clear() { - for (const key of this.#map.keys()) { - this.delete(key); - } - } - - public delete(childOrId: vscode.TestItem | string) { - const id = typeof childOrId === 'string' ? childOrId : childOrId.id; - if (this.#map.has(id)) { - this.#map.delete(id); - this.#hookRef()?.delete(id); - } - } - - public toJSON() { - return [...this.#map.values()]; - } - - public [Symbol.iterator]() { - return this.#map.values(); - } -} - -export class TestItem implements vscode.TestItem { - public id!: string; public range!: vscode.Range | undefined; public description!: string | undefined; public runnable!: boolean; public debuggable!: boolean; - public children!: TestChildrenCollection; - public uri!: vscode.Uri; - public [TestItemHookProperty]!: ITestItemHook | undefined; + public error!: string | vscode.MarkdownString; + public status!: vscode.TestItemStatus; + + /** Extension-owned resolve handler */ + public resolveHandler?: (token: vscode.CancellationToken) => void; + + constructor(id: string, public label: string, uri: vscode.Uri, public data: unknown) { + const api = getPrivateApiFor(this); - constructor(id: string, public label: string, uri: vscode.Uri, public expandable: boolean) { Object.defineProperties(this, { id: { value: id, @@ -3338,32 +3301,52 @@ export class TestItem implements vscode.TestItem { enumerable: true, writable: false, }, + parent: { + enumerable: false, + get: () => api.parent, + }, children: { - value: new TestChildrenCollection(() => this[TestItemHookProperty]), + value: new ReadonlyMapView(api.children), enumerable: true, writable: false, }, - [TestItemHookProperty]: { - enumerable: false, - writable: true, - configurable: false, - }, - range: testItemPropAccessor(this, 'range', undefined), - description: testItemPropAccessor(this, 'description', undefined), - runnable: testItemPropAccessor(this, 'runnable', true), - debuggable: testItemPropAccessor(this, 'debuggable', true), + range: testItemPropAccessor(api, 'range', undefined, rangeComparator), + description: testItemPropAccessor(api, 'description', undefined, strictEqualComparator), + runnable: testItemPropAccessor(api, 'runnable', true, strictEqualComparator), + debuggable: testItemPropAccessor(api, 'debuggable', true, strictEqualComparator), + status: testItemPropAccessor(api, 'status', TestItemStatus.Resolved, strictEqualComparator), }); } public invalidate() { - this[TestItemHookProperty]?.invalidate(this.id); + getPrivateApiFor(this).bus.fire([ExtHostTestItemEventType.Invalidated]); } - public discoverChildren(progress: vscode.Progress<{ busy: boolean }>, _token: vscode.CancellationToken) { - progress.report({ busy: false }); + public dispose() { + const api = getPrivateApiFor(this); + if (api.parent) { + getPrivateApiFor(api.parent).children.delete(this.id); + } + + api.bus.fire([ExtHostTestItemEventType.Disposed]); + } + + public addChild(child: vscode.TestItem) { + if (!(child instanceof TestItemImpl)) { + throw new Error('Test child must be created through vscode.test.createTestItem()'); + } + + const api = getPrivateApiFor(this); + if (api.children.has(child.id)) { + throw new Error(`Attempted to insert a duplicate test item ID ${child.id}`); + } + + api.children.set(child.id, child); + api.bus.fire([ExtHostTestItemEventType.NewChild, child]); } } + export class TestMessage implements vscode.TestMessage { public severity = TestMessageSeverity.Error; public expectedOutput?: string; diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts index 5db66fff1c6..ebc4021f651 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts @@ -68,7 +68,6 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes for (const inTree of [...this.items.values()].sort((a, b) => b.depth - a.depth)) { const lookup = this.results.getStateById(inTree.test.item.extId)?.[1]; - inTree.ownState = lookup?.state.state ?? TestResultState.Unset; const computed = lookup?.computedState ?? TestResultState.Unset; if (computed !== inTree.state) { inTree.state = computed; @@ -84,7 +83,6 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes this._register(results.onTestChanged(({ item: result }) => { const item = this.items.get(result.item.extId); if (item) { - item.ownState = result.state.state; item.retired = result.retired; refreshComputedState(computedStateAccessor, item, this.addUpdated, result.computedState); this.addUpdated(item); @@ -270,7 +268,6 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes const prevState = this.results.getStateById(treeElement.test.item.extId)?.[1]; if (prevState) { - treeElement.ownState = prevState.state.state; treeElement.retired = prevState.retired; refreshComputedState(computedStateAccessor, treeElement, this.addUpdated, prevState.computedState); } diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts index 41a5824a3a5..8d62b8ecc89 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts @@ -140,7 +140,7 @@ export class HierarchicalByNameProjection extends HierarchicalByLocationProjecti const parent = this.getOrCreateFolderElement(folder); const actualParent = item.parent ? this.items.get(item.parent) as HierarchicalByNameElement : undefined; for (const testRoot of parent.children) { - if (testRoot.test.src.provider === item.src.provider) { + if (testRoot.test.src.controller === item.src.controller) { return new HierarchicalByNameElement(item, testRoot, r => this.changes.addedOrRemoved(r), actualParent); } } diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts index 323b6424b5d..324e28dff1f 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts @@ -55,7 +55,6 @@ export class HierarchicalElement implements ITestTreeElement { public state = TestResultState.Unset; public retired = false; - public ownState = TestResultState.Unset; constructor(public readonly test: InternalTestItem, public readonly parentItem: HierarchicalFolder | HierarchicalElement) { this.test = { ...test, item: { ...test.item } }; // clone since we Object.assign updatese @@ -97,7 +96,6 @@ export class HierarchicalFolder implements ITestTreeElement { public retired = false; public state = TestResultState.Unset; - public ownState = TestResultState.Unset; constructor(public readonly folder: IWorkspaceFolder) { } diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts index a456d777b8b..e076f828940 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts @@ -112,7 +112,6 @@ export interface ITestTreeElement { */ readonly retired: boolean; - readonly ownState: TestResultState; readonly label: string; readonly parentItem: ITestTreeElement | null; } diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 02669c967ec..f9d69a0298a 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -72,10 +72,8 @@ .monaco-action-bar .action-item > .action-label { - width: 16px; - height: 100%; - line-height: 22px; - margin-right: 8px; + padding: 2px; + margin-right: 2px; } .monaco-workbench .part > .title > .title-actions .action-label.codicon-testing-autorun::after { diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index 3e60eb44ced..d208ba4b30b 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -440,8 +440,9 @@ export class ShowMostRecentOutputAction extends Action2 { constructor() { super({ id: 'testing.showMostRecentOutput', - title: localize('testing.showMostRecentOutput', "Show Most Recent Output"), - f1: false, + title: localize('testing.showMostRecentOutput', "Show Output"), + f1: true, + category, icon: Codicon.terminal, menu: { id: MenuId.ViewTitle, @@ -878,7 +879,7 @@ abstract class RunOrDebugFailedTests extends RunOrDebugExtsById { const resultSet = results[i]; for (const test of resultSet.tests) { const path = this.getPathForTest(test, resultSet).join(sep); - if (isFailedState(test.state.state)) { + if (isFailedState(test.ownComputedState)) { paths.add(path); } else { paths.delete(path); diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts index fae2288cb23..c7305a1a8b0 100644 --- a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -185,17 +185,21 @@ export class TestingDecorations extends Disposable implements IEditorContributio continue; // do not show decorations for outdated tests } - for (let i = 0; i < stateItem.state.messages.length; i++) { - const m = stateItem.state.messages[i]; - if (!this.invalidatedMessages.has(m) && hasValidLocation(uri, m)) { - const uri = buildTestUri({ - type: TestUriType.ResultActualOutput, - messageIndex: i, - resultId: result.id, - testExtId: stateItem.item.extId, - }); + for (let taskId = 0; taskId < stateItem.tasks.length; taskId++) { + const state = stateItem.tasks[taskId]; + for (let i = 0; i < state.messages.length; i++) { + const m = state.messages[i]; + if (!this.invalidatedMessages.has(m) && hasValidLocation(uri, m)) { + const uri = buildTestUri({ + type: TestUriType.ResultActualOutput, + messageIndex: i, + taskIndex: taskId, + resultId: result.id, + testExtId: stateItem.item.extId, + }); - newDecorations.push(this.instantiationService.createInstance(TestMessageDecoration, m, uri, m.location, this.editor)); + newDecorations.push(this.instantiationService.createInstance(TestMessageDecoration, m, uri, m.location, this.editor)); + } } } } @@ -275,7 +279,7 @@ class RunTestDecoration extends Disposable implements ITestDecoration { : test.children.size > 0 ? testingRunAllIcon : testingRunIcon; const hoverMessage = new MarkdownString('', true).appendText(localize('failedHoverMessage', '{0} has failed. ', test.item.label)); - if (stateItem?.state.messages.length) { + if (stateItem?.tasks.some(s => s.messages.length > 0)) { const args = encodeURIComponent(JSON.stringify([test.item.extId])); hoverMessage.appendMarkdown(`[${localize('failedPeekAction', 'Peek Error')}](command:vscode.peekTestError?${args})`); } diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 542763578e0..803ee309d85 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -475,7 +475,7 @@ export class TestingExplorerViewModel extends Disposable { */ private async tryPeekError(item: ITestTreeElement) { const lookup = item.test && this.testResults.getStateById(item.test.item.extId); - return lookup && isFailedState(lookup[1].state.state) + return lookup && lookup[1].tasks.some(s => isFailedState(s.state)) ? this.peekOpener.tryPeekFirstError(lookup[0], lookup[1], { preserveFocus: true }) : false; } @@ -638,9 +638,9 @@ class TestsFilter implements ITreeFilter { case TestExplorerStateFilter.All: return FilterResult.Include; case TestExplorerStateFilter.OnlyExecuted: - return element.ownState !== TestResultState.Unset ? FilterResult.Include : FilterResult.Inherit; + return element.state !== TestResultState.Unset ? FilterResult.Include : FilterResult.Inherit; case TestExplorerStateFilter.OnlyFailed: - return isFailedState(element.ownState) ? FilterResult.Include : FilterResult.Inherit; + return isFailedState(element.state) ? FilterResult.Include : FilterResult.Inherit; } } diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 53a647c5267..e6b54d9e2c0 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -31,7 +31,7 @@ import { EditorModel } from 'vs/workbench/common/editor'; 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 { ITestItem, ITestMessage, ITestState, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestItem, ITestMessage, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; import { buildTestUri, parseTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; @@ -42,7 +42,7 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic interface ITestDto { test: ITestItem, messageIndex: number; - state: ITestState; + messages: ITestMessage[]; expectedUri: URI; actualUri: URI; messageUri: URI; @@ -78,12 +78,12 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener * @returns a boolean if a peek was opened */ public async tryPeekFirstError(result: ITestResult, test: TestResultItem, options?: Partial) { - const index = test.state.messages.findIndex(m => !!m.location); - if (index === -1) { + const candidate = this.getCandidateMessage(test); + if (!candidate) { return false; } - const message = test.state.messages[index]; + const message = candidate.message; const pane = await this.editorService.openEditor({ resource: message.location!.uri, options: { selection: message.location!.range, revealIfOpened: true, ...options } @@ -96,7 +96,8 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener TestingOutputPeekController.get(control).show(buildTestUri({ type: TestUriType.ResultMessage, - messageIndex: index, + taskIndex: candidate.taskId, + messageIndex: candidate.index, resultId: result.id, testExtId: test.item.extId, })); @@ -112,7 +113,8 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener return; } - if (!isFailedState(evt.item.state.state) || !evt.item.state.messages.length) { + const candidate = this.getCandidateMessage(evt.item); + if (!candidate) { return; } @@ -137,6 +139,24 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener this.tryPeekFirstError(evt.result, evt.item); } + + private getCandidateMessage(test: TestResultItem) { + for (let taskId = 0; taskId < test.tasks.length; taskId++) { + const { messages, state } = test.tasks[taskId]; + if (!isFailedState(state)) { + continue; + } + + const index = messages.findIndex(m => !!m.location); + if (index === -1) { + continue; + } + + return { taskId, index, message: messages[index] }; + } + + return undefined; + } } /** @@ -205,7 +225,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo return; } - const message = dto.state.messages[dto.messageIndex]; + const message = dto.messages[dto.messageIndex]; if (!message?.location) { return; } @@ -253,7 +273,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo * else, then clear the peek. */ private closePeekOnTestChange(evt: TestResultItemChange) { - if (evt.reason !== TestResultItemChangeReason.OwnStateChange || evt.previous === evt.item.state.state) { + if (evt.reason !== TestResultItemChangeReason.OwnStateChange || evt.previous === evt.item.ownComputedState) { return; } @@ -273,9 +293,13 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo } const test = this.testResults.getResult(parts.resultId)?.getStateById(parts.testExtId); + if (!test || !test.tasks[parts.taskIndex]) { + return; + } + return test && { test: test.item, - state: test.state, + messages: test.tasks[parts.taskIndex].messages, messageIndex: parts.messageIndex, expectedUri: buildTestUri({ ...parts, type: TestUriType.ResultExpectedOutput }), actualUri: buildTestUri({ ...parts, type: TestUriType.ResultActualOutput }), @@ -382,8 +406,8 @@ class TestingDiffOutputPeek extends TestingOutputPeek { /** * @override */ - public async setModel({ test, state, messageIndex, expectedUri, actualUri }: ITestDto) { - const message = state.messages[messageIndex]; + public async setModel({ test, messages, messageIndex, expectedUri, actualUri }: ITestDto) { + const message = messages[messageIndex]; if (!message?.location) { return; } @@ -440,8 +464,8 @@ class TestingMessageOutputPeek extends TestingOutputPeek { /** * @override */ - public async setModel({ state, test, messageIndex, messageUri }: ITestDto) { - const message = state.messages[messageIndex]; + public async setModel({ messages, test, messageIndex, messageUri }: ITestDto) { + const message = messages[messageIndex]; if (!message?.location) { return; } diff --git a/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts b/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts index 3d96e10cdbc..0e5cd72e71b 100644 --- a/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts +++ b/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts @@ -7,8 +7,10 @@ import { mapFind } from 'vs/base/common/arrays'; import { DeferredPromise, isThenable, RunOnceScheduler } from 'vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { IDisposable, IReference } from 'vs/base/common/lifecycle'; +import { assertNever } from 'vs/base/common/types'; +import { ExtHostTestItemEvent, ExtHostTestItemEventType, getPrivateApiFor } from 'vs/workbench/api/common/extHostTestingPrivateApi'; import * as Convert from 'vs/workbench/api/common/extHostTypeConverters'; -import { TestItem as TestItemImpl, TestItemHookProperty } from 'vs/workbench/api/common/extHostTypes'; +import { TestItemImpl, TestItemStatus } from 'vs/workbench/api/common/extHostTypes'; import { applyTestItemUpdate, InternalTestItem, TestDiffOpType, TestItemExpandState, TestsDiff, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testCollection'; type TestItemRaw = Convert.TestItem.Raw; @@ -209,8 +211,8 @@ export class SingleUseTestCollection implements IDisposable { /** * Adds a new root node to the collection. */ - public addRoot(item: TestItemRaw, providerId: string) { - this.addItem(item, providerId, null); + public addRoot(item: TestItemRaw, controllerId: string) { + this.addItem(item, controllerId, null); } /** @@ -290,7 +292,7 @@ export class SingleUseTestCollection implements IDisposable { public dispose() { for (const item of this.testItemToInternal.values()) { item.discoverCts?.dispose(true); - (item.actual as TestItemImpl)[TestItemHookProperty] = undefined; + getPrivateApiFor(item.actual).bus.dispose(); } this.diff = []; @@ -298,7 +300,42 @@ export class SingleUseTestCollection implements IDisposable { this.debounceSendDiff.dispose(); } - private addItem(actual: TestItemRaw, providerId: string, parent: OwnedCollectionTestItem | null) { + private onTestItemEvent(internal: OwnedCollectionTestItem, evt: ExtHostTestItemEvent) { + const extId = internal?.actual.id; + + switch (evt[0]) { + case ExtHostTestItemEventType.Invalidated: + this.pushDiff([TestDiffOpType.Retire, extId]); + break; + + case ExtHostTestItemEventType.Disposed: + this.removeItem(internal); + break; + + case ExtHostTestItemEventType.NewChild: + this.addItem(evt[1], internal.src.controller, internal); + break; + + case ExtHostTestItemEventType.SetProp: + const [_, key, value] = evt; + switch (key) { + case 'status': + this.updateExpandability(internal); + break; + case 'range': + this.pushDiff([TestDiffOpType.Update, { extId, item: { range: Convert.Range.from(value) }, }]); + break; + default: + this.pushDiff([TestDiffOpType.Update, { extId, item: { [key]: value } }]); + break; + } + break; + default: + assertNever(evt[0]); + } + } + + private addItem(actual: TestItemRaw, controllerId: string, parent: OwnedCollectionTestItem | null) { if (!(actual instanceof TestItemImpl)) { throw new Error(`TestItems provided to the VS Code API must extend \`vscode.TestItem\`, but ${actual.id} did not`); } @@ -312,15 +349,15 @@ export class SingleUseTestCollection implements IDisposable { } const parentId = parent ? parent.item.extId : null; - const expand = actual.expandable ? TestItemExpandState.Expandable : TestItemExpandState.NotExpandable; + const expand = actual.resolveHandler ? TestItemExpandState.Expandable : TestItemExpandState.NotExpandable; const pExpandLvls = parent?.expandLevels; - const src = { provider: providerId, tree: this.testIdToInternal.object.id }; + const src = { controller: controllerId, tree: this.testIdToInternal.object.id }; const internal: OwnedCollectionTestItem = { actual, parent: parentId, item: Convert.TestItem.from(actual), - expandLevels: pExpandLvls && expand === TestItemExpandState.Expandable ? pExpandLvls - 1 : undefined, - expand, + expandLevels: pExpandLvls /* intentionally undefined or 0 */ ? pExpandLvls - 1 : undefined, + expand: TestItemExpandState.NotExpandable, // updated by `updateExpandability` down below src, }; @@ -328,19 +365,49 @@ export class SingleUseTestCollection implements IDisposable { this.testItemToInternal.set(actual, internal); this.pushDiff([TestDiffOpType.Add, { parent: parentId, src, expand, item: internal.item }]); - actual[TestItemHookProperty] = { - created: item => this.addItem(item, providerId, internal!), - delete: id => this.removeItembyId(id), - invalidate: item => this.pushDiff([TestDiffOpType.Retire, item]), - setProp: (key, value) => this.pushDiff([TestDiffOpType.Update, { - extId: actual.id, - item: { [key]: key === 'range' ? Convert.Range.from(value as any) : value }, - }]) - }; + const api = getPrivateApiFor(actual); + api.parent = parent?.actual; + api.bus.event(this.onTestItemEvent.bind(this, internal)); + + // important that this comes after binding the event bus otherwise we + // might miss a synchronous discovery completion + this.updateExpandability(internal); // Discover any existing children that might have already been added - for (const child of actual.children) { - this.addItem(child, providerId, internal); + for (const child of api.children.values()) { + if (!this.testItemToInternal.has(child)) { + this.addItem(child, controllerId, internal); + } + } + } + + /** + * Updates the `expand` state of the item. Should be called whenever the + * resolved state of the item changes. Can automatically expand the item + * if requested by a consumer. + */ + private updateExpandability(internal: OwnedCollectionTestItem) { + let newState: TestItemExpandState; + if (!internal.actual.resolveHandler) { + newState = TestItemExpandState.NotExpandable; + } else if (internal.actual.status === TestItemStatus.Pending) { + newState = internal.discoverCts + ? TestItemExpandState.BusyExpanding + : TestItemExpandState.Expandable; + } else { + internal.initialExpand?.complete(); + newState = TestItemExpandState.Expanded; + } + + if (newState === internal.expand) { + return; + } + + internal.expand = newState; + this.pushDiff([TestDiffOpType.Update, { extId: internal.actual.id, expand: newState }]); + + if (newState === TestItemExpandState.Expandable && internal.expandLevels !== undefined) { + this.refreshChildren(internal); } } @@ -354,8 +421,8 @@ export class SingleUseTestCollection implements IDisposable { return; } - const asyncChildren = [...internal.actual.children] - .map(c => this.expand(c.id, levels - 1)) + const asyncChildren = [...internal.actual.children.values()] + .map(c => this.expand(c.id, levels)) .filter(isThenable); if (asyncChildren.length) { @@ -371,37 +438,30 @@ export class SingleUseTestCollection implements IDisposable { internal.discoverCts.dispose(true); } + if (!internal.actual.resolveHandler) { + const p = new DeferredPromise(); + p.complete(); + return p; + } + internal.expand = TestItemExpandState.BusyExpanding; internal.discoverCts = new CancellationTokenSource(); this.pushExpandStateUpdate(internal); - const updateComplete = new DeferredPromise(); - internal.initialExpand = updateComplete; + internal.initialExpand = new DeferredPromise(); + internal.actual.resolveHandler(internal.discoverCts.token); - internal.actual.discoverChildren({ - report: event => { - if (!event.busy) { - internal.expand = TestItemExpandState.Expanded; - if (!updateComplete.isSettled) { updateComplete.complete(); } - this.pushExpandStateUpdate(internal); - } else { - internal.expand = TestItemExpandState.BusyExpanding; - this.pushExpandStateUpdate(internal); - } - } - }, internal.discoverCts.token); - - return updateComplete; + return internal.initialExpand; } private pushExpandStateUpdate(internal: OwnedCollectionTestItem) { this.pushDiff([TestDiffOpType.Update, { extId: internal.actual.id, expand: internal.expand }]); } - private removeItembyId(id: string) { - this.pushDiff([TestDiffOpType.Remove, id]); + private removeItem(internal: OwnedCollectionTestItem) { + this.pushDiff([TestDiffOpType.Remove, internal.actual.id]); - const queue = [this.testIdToInternal.object.get(id)]; + const queue: (OwnedCollectionTestItem | undefined)[] = [internal]; while (queue.length) { const item = queue.pop(); if (!item) { @@ -411,11 +471,12 @@ export class SingleUseTestCollection implements IDisposable { item.discoverCts?.dispose(true); this.testIdToInternal.object.delete(item.item.extId); this.testItemToInternal.delete(item.actual); - for (const child of item.actual.children) { + for (const child of item.actual.children.values()) { queue.push(this.testIdToInternal.object.get(child.id)); } } } + public flushDiff() { const diff = this.collectDiff(); if (diff.length) { diff --git a/src/vs/workbench/contrib/testing/common/testCollection.ts b/src/vs/workbench/contrib/testing/common/testCollection.ts index 7923e668bd4..eaf56c459fb 100644 --- a/src/vs/workbench/contrib/testing/common/testCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -9,9 +9,11 @@ import { IRange, Range } from 'vs/editor/common/core/range'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; import { TestMessageSeverity, TestResultState } from 'vs/workbench/api/common/extHostTypes'; -export interface TestIdWithSrc { +export type TestIdWithSrc = Required; + +export interface TestIdWithMaybeSrc { testId: string; - src: { provider: string; tree: number }; + src?: { controller: string; tree: number }; } /** @@ -24,14 +26,25 @@ export type TestIdPath = string[]; * Request to the main thread to run a set of tests. */ export interface RunTestsRequest { - tests: TestIdWithSrc[]; + tests: TestIdWithMaybeSrc[]; exclude?: string[]; debug: boolean; isAutoRun?: boolean; } /** - * Request from the main thread to run tests for a single provider. + * Request to the main thread to run a set of tests. + */ +export interface ExtensionRunTestsRequest { + id: string; + tests: string[]; + exclude: string[]; + debug: boolean; + persist: boolean; +} + +/** + * Request from the main thread to run tests for a single controller. */ export interface RunTestForProviderRequest { runId: string; @@ -56,17 +69,23 @@ export interface ITestMessage { location: IRichLocation | undefined; } -export interface ITestState { +export interface ITestTaskState { state: TestResultState; duration: number | undefined; messages: ITestMessage[]; } +export interface ITestRunTask { + id: string; + name: string | undefined; + running: boolean; +} + /** * The TestItem from .d.ts, as a plain object without children. */ export interface ITestItem { - /** ID of the test given by the test provider */ + /** ID of the test given by the test controller */ extId: string; label: string; children?: never; @@ -75,7 +94,6 @@ export interface ITestItem { description: string | undefined; runnable: boolean; debuggable: boolean; - expandable: boolean; } export const enum TestItemExpandState { @@ -89,7 +107,7 @@ export const enum TestItemExpandState { * TestItem-like shape, butm with an ID and children as strings. */ export interface InternalTestItem { - src: { provider: string; tree: number }; + src: { controller: string; tree: number }; expand: TestItemExpandState; parent: string | null; item: ITestItem; @@ -116,9 +134,15 @@ export const applyTestItemUpdate = (internal: InternalTestItem | ITestItemUpdate /** * Test result item used in the main thread. */ -export interface TestResultItem extends IncrementalTestCollectionItem { - /** Current state of this test */ - state: ITestState; +export interface TestResultItem { + /** Parent ID, if any */ + parent: string | null; + /** Raw test item properties */ + item: ITestItem; + /** State of this test in various tasks */ + tasks: ITestTaskState[]; + /** State of this test as a computation of its tasks */ + ownComputedState: TestResultState; /** Computed state based on children */ computedState: TestResultState; /** True if the test is outdated */ @@ -142,6 +166,8 @@ export interface ISerializedTestResults { output?: string; /** Subset of test result items */ items: SerializedTestResultItem[]; + /** Tasks involved in the run. */ + tasks: ITestRunTask[]; } export const enum TestDiffOpType { @@ -151,7 +177,7 @@ export const enum TestDiffOpType { Update, /** Removes a test (and all its children) */ Remove, - /** Changes the number of providers who are yet to publish their collection roots. */ + /** Changes the number of controllers who are yet to publish their collection roots. */ DeltaRootsComplete, /** Retires a test/result */ Retire, @@ -227,9 +253,9 @@ export abstract class AbstractIncrementalTestCollection(); /** - * Number of 'busy' providers. + * Number of 'busy' controllers. */ - protected busyProviderCount = 0; + protected busyControllerCount = 0; /** * Number of pending roots. @@ -260,7 +286,7 @@ export abstract class AbstractIncrementalTestCollection; + /** + * List of this result's subtasks. + */ + tasks: ReadonlyArray; + /** * Gets the state of the test by its extension-assigned ID. */ @@ -166,77 +170,20 @@ export class LiveOutputController { } } +interface TestResultItemWithChildren extends TestResultItem { + /** Children in the run */ + children: TestResultItemWithChildren[]; +} -const itemToNode = ( - item: IncrementalTestCollectionItem, - byExtId: Map, -): TestResultItem => { - const n: TestResultItem = { - ...item, - // shallow-clone the test to take a 'snapshot' of it at the point in time where tests run - item: { ...item.item }, - children: new Set(item.children), - state: { - duration: undefined, - messages: [], - state: TestResultState.Unset - }, - computedState: TestResultState.Unset, - retired: false, - }; - - byExtId.set(n.item.extId, n); - - return n; -}; - -const makeParents = ( - collection: IMainThreadTestCollection, - child: IncrementalTestCollectionItem, - byExtId: Map, -) => { - const parent = child.parent && collection.getNodeById(child.parent); - if (!parent) { - return; - } - - let parentResultItem = byExtId.get(parent.item.extId); - if (parentResultItem) { - parentResultItem.children.add(child.item.extId); - return; // no need to recurse, all parents already in result - } - - parentResultItem = itemToNode(parent, byExtId); - parentResultItem.children = new Set([child.item.extId]); - makeParents(collection, parent, byExtId); -}; - -const makeNodeAndChildren = ( - collection: IMainThreadTestCollection, - test: IncrementalTestCollectionItem, - excluded: ReadonlySet, - byExtId: Map, - isExecutedDirectly = true, -): TestResultItem => { - const existing = byExtId.get(test.item.extId); - if (existing) { - return existing; - } - - const mapped = itemToNode(test, byExtId); - if (isExecutedDirectly) { - mapped.direct = true; - } - - for (const childId of test.children) { - const child = collection.getNodeById(childId); - if (child && !excluded.has(childId)) { - makeNodeAndChildren(collection, child, excluded, byExtId, false); - } - } - - return mapped; -}; +const itemToNode = (item: ITestItem, parent: string | null): TestResultItemWithChildren => ({ + parent, + item: { ...item }, + children: [], + tasks: [], + ownComputedState: TestResultState.Unset, + computedState: TestResultState.Unset, + retired: false, +}); export const enum TestResultItemChangeReason { Retired, @@ -255,39 +202,29 @@ export type TestResultItemChange = { item: TestResultItem; result: ITestResult } * and marked as "complete" when the run finishes. */ export class LiveTestResult implements ITestResult { - /** - * Creates a new TestResult, pulling tests from the associated list - * of collections. - */ - public static from( - resultId: string, - collections: ReadonlyArray, - output: LiveOutputController, - req: RunTestsRequest, - ) { - const testByExtId = new Map(); - const excludeSet = new Set(req.exclude); - for (const test of req.tests) { - for (const collection of collections) { - const node = collection.getNodeById(test.testId); - if (!node) { - continue; - } - - makeNodeAndChildren(collection, node, excludeSet, testByExtId); - makeParents(collection, node, testByExtId); - } - } - - return new LiveTestResult(resultId, collections, testByExtId, excludeSet, output, !!req.isAutoRun); - } - private readonly completeEmitter = new Emitter(); private readonly changeEmitter = new Emitter(); + private readonly testById = new Map(); private _completedAt?: number; public readonly onChange = this.changeEmitter.event; public readonly onComplete = this.completeEmitter.event; + public readonly tasks: ITestRunTask[] = []; + + /** + * 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 @@ -308,21 +245,11 @@ export class LiveTestResult implements ITestResult { return this.testById.values(); } - private readonly computedStateAccessor: IComputedStateAccessor = { - getOwnState: i => i.state.state, + private readonly computedStateAccessor: IComputedStateAccessor = { + getOwnState: i => i.ownComputedState, getCurrentComputedState: i => i.computedState, setComputedState: (i, s) => i.computedState = s, - getChildren: i => { - const { testById: testByExtId } = this; - return (function* () { - for (const childId of i.children) { - const child = testByExtId.get(childId); - if (child) { - yield child; - } - } - })(); - }, + getChildren: i => i.children[Symbol.iterator](), getParents: i => { const { testById: testByExtId } = this; return (function* () { @@ -341,13 +268,12 @@ export class LiveTestResult implements ITestResult { constructor( public readonly id: string, - private readonly collections: ReadonlyArray, - private readonly testById: Map, - private readonly excluded: ReadonlySet, public readonly output: LiveOutputController, - public readonly isAutoRun: boolean, + private readonly req: ExtensionRunTestsRequest | RunTestsRequest, ) { - this.counts[TestResultState.Unset] = testById.size; + 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); } /** @@ -358,47 +284,71 @@ export class LiveTestResult implements ITestResult { } /** - * Updates all tests in the collection to the given state. + * Adds a new run task to the results. */ - public setAllToState(state: TestResultState, when: (_t: TestResultItem) => boolean) { - for (const test of this.testById.values()) { - if (when(test)) { - this.fireUpdateAndRefresh(test, state); - } + public addTask(task: ITestRunTask) { + const index = this.tasks.length; + this.tasks.push(task); + + for (const test of this.tests) { + test.tasks.push({ duration: undefined, messages: [], state: TestResultState.Unset }); + this.fireUpdateAndRefresh(test, index, TestResultState.Queued); } } + /** + * Add the chain of tests to the run. The first test in the chain should + * be either a test root, or a previously-known test. + */ + public addTestChainToRun(chain: ReadonlyArray) { + let parent = this.testById.get(chain[0].extId); + if (!parent) { // must be a test root + parent = this.addTestToRun(chain[0], null); + } + + for (let i = 1; i < chain.length; i++) { + parent = this.addTestToRun(chain[i], parent.item.extId); + } + + for (let i = 0; i < this.tasks.length; i++) { + this.fireUpdateAndRefresh(parent, i, TestResultState.Queued); + } + + return undefined; + } + /** * Updates the state of the test by its internal ID. */ - public updateState(testId: string, state: TestResultState, duration?: number) { - const entry = this.testById.get(testId) ?? this.addTestToRun(testId); + public updateState(testId: string, taskId: string, state: TestResultState, duration?: number) { + const entry = this.testById.get(testId); if (!entry) { return; } + const index = this.mustGetTaskIndex(taskId); if (duration !== undefined) { - entry.state.duration = duration; + entry.tasks[index].duration = duration; } - this.fireUpdateAndRefresh(entry, state); + this.fireUpdateAndRefresh(entry, index, state); } /** * Appends a message for the test in the run. */ - public appendMessage(testId: string, message: ITestMessage) { - const entry = this.testById.get(testId) ?? this.addTestToRun(testId); + public appendMessage(testId: string, taskId: string, message: ITestMessage) { + const entry = this.testById.get(testId); if (!entry) { return; } - entry.state.messages.push(message); + entry.tasks[this.mustGetTaskIndex(taskId)].messages.push(message); this.changeEmitter.fire({ item: entry, result: this, reason: TestResultItemChangeReason.OwnStateChange, - previous: entry.state.state, + previous: entry.ownComputedState, }); } @@ -409,24 +359,6 @@ export class LiveTestResult implements ITestResult { return this.output.read(); } - private fireUpdateAndRefresh(entry: TestResultItem, newState: TestResultState) { - const previous = entry.state.state; - if (newState === previous) { - return; - } - - entry.state.state = newState; - this.counts[previous]--; - this.counts[newState]++; - refreshComputedState(this.computedStateAccessor, entry, t => - this.changeEmitter.fire( - t === entry - ? { item: entry, result: this, reason: TestResultItemChangeReason.OwnStateChange, previous } - : { item: t, result: this, reason: TestResultItemChangeReason.ComputedStateChange } - ), - ); - } - /** * Marks a test as retired. This can trigger it to be rerun in live mode. */ @@ -436,11 +368,10 @@ export class LiveTestResult implements ITestResult { return; } - const queue: Iterable[] = [[root.item.extId]]; + const queue = [[root]]; while (queue.length) { - for (const id of queue.pop()!) { - const entry = this.testById.get(id); - if (entry && !entry.retired) { + for (const entry of queue.pop()!) { + if (!entry.retired) { entry.retired = true; queue.push(entry.children); this.changeEmitter.fire({ @@ -456,23 +387,15 @@ export class LiveTestResult implements ITestResult { } /** - * Adds a test, by its ID, to the test run. This can end up being called - * if tests were started while discovery was still happening, so initially - * we didn't serialize/capture the test. + * Marks the task in the test run complete. */ - private addTestToRun(testId: string) { - for (const collection of this.collections) { - let test = collection.getNodeById(testId); - if (test) { - const originalSize = this.testById.size; - makeParents(collection, test, this.testById); - const node = makeNodeAndChildren(collection, test, this.excluded, this.testById, false); - this.counts[TestResultState.Unset] += this.testById.size - originalSize; - return node; - } - } - - return undefined; + public markTaskComplete(taskId: string) { + this.tasks[this.mustGetTaskIndex(taskId)].running = false; + this.setAllToState( + TestResultState.Unset, + taskId, + t => t.state === TestResultState.Queued || t.state === TestResultState.Running, + ); } /** @@ -483,11 +406,11 @@ export class LiveTestResult implements ITestResult { throw new Error('cannot complete a test result multiple times'); } - // un-queue any tests that weren't explicitly updated - this.setAllToState( - TestResultState.Unset, - t => t.state.state === TestResultState.Queued || t.state.state === TestResultState.Running, - ); + for (const task of this.tasks) { + if (task.running) { + this.markTaskComplete(task.id); + } + } this._completedAt = Date.now(); this.completeEmitter.fire(); @@ -497,16 +420,80 @@ export class LiveTestResult implements ITestResult { * @inheritdoc */ public toJSON(): ISerializedTestResults | undefined { - return this.completedAt ? this.doSerialize.getValue() : undefined; + return this.completedAt && !('persist' in this.req && this.req.persist === false) + ? this.doSerialize.getValue() + : undefined; + } + + /** + * Updates all tests in the collection to the given state. + */ + protected setAllToState(state: TestResultState, taskId: string, when: (task: ITestTaskState, item: TestResultItem) => boolean) { + const index = this.mustGetTaskIndex(taskId); + for (const test of this.testById.values()) { + if (when(test.tasks[index], test)) { + this.fireUpdateAndRefresh(test, index, state); + } + } + } + + private fireUpdateAndRefresh(entry: TestResultItem, taskIndex: number, newState: TestResultState) { + const previousOwnComputed = entry.ownComputedState; + entry.tasks[taskIndex].state = newState; + const newOwnComputed = maxPriority(...entry.tasks.map(t => t.state)); + if (newOwnComputed === previousOwnComputed) { + return; + } + + entry.ownComputedState = newOwnComputed; + this.counts[previousOwnComputed]--; + this.counts[newOwnComputed]++; + refreshComputedState(this.computedStateAccessor, entry, t => + this.changeEmitter.fire( + t === entry + ? { item: entry, result: this, reason: TestResultItemChangeReason.OwnStateChange, previous: previousOwnComputed } + : { item: t, result: this, reason: TestResultItemChangeReason.ComputedStateChange } + ), + ); + } + + private addTestToRun(item: ITestItem, parent: string | null) { + const node = itemToNode(item, parent); + node.direct = this.includedIds.has(item.extId); + this.testById.set(item.extId, node); + this.counts[TestResultState.Unset]++; + + if (parent) { + this.testById.get(parent)?.children.push(node); + } + + if (this.tasks.length) { + for (let i = 0; i < this.tasks.length; i++) { + node.tasks.push({ duration: undefined, messages: [], state: TestResultState.Queued }); + } + } + + return node; + } + + private mustGetTaskIndex(taskId: string) { + const index = this.tasks.findIndex(t => t.id === taskId); + if (index === -1) { + throw new Error(`Unknown task ${taskId} in updateState`); + } + + return index; } private readonly doSerialize = new Lazy((): ISerializedTestResults => ({ id: this.id, completedAt: this.completedAt!, + tasks: this.tasks, items: [...this.testById.values()].map(entry => ({ ...entry, retired: undefined, - children: [...entry.children], + src: undefined, + children: [...entry.children.map(c => c.item.extId)], })), })); } @@ -530,6 +517,11 @@ export class HydratedTestResult implements ITestResult { */ public readonly completedAt: number; + /** + * @inheritdoc + */ + public readonly tasks: ITestRunTask[]; + /** * @inheritdoc */ @@ -546,19 +538,22 @@ export class HydratedTestResult implements ITestResult { ) { this.id = serialized.id; this.completedAt = serialized.completedAt; + this.tasks = serialized.tasks; for (const item of serialized.items) { - const cast: TestResultItem = { ...item, retired: true, children: new Set(item.children) }; + const cast: TestResultItem = { ...item, retired: true }; cast.item.uri = URI.revive(cast.item.uri); - for (const message of cast.state.messages) { - if (message.location) { - message.location.uri = URI.revive(message.location.uri); - message.location.range = Range.lift(message.location.range); + for (const task of cast.tasks) { + for (const message of task.messages) { + if (message.location) { + message.location.uri = URI.revive(message.location.uri); + message.location.range = Range.lift(message.location.range); + } } } - this.counts[item.state.state]++; + this.counts[item.ownComputedState]++; this.testById.set(item.item.extId, cast); } } diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index 8db013579e6..2657bbed62e 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -7,17 +7,14 @@ import { findFirstInSorted } from 'vs/base/common/arrays'; import { RunOnceScheduler } from 'vs/base/common/async'; import { Emitter, Event } from 'vs/base/common/event'; import { once } from 'vs/base/common/functional'; -import { Iterable } from 'vs/base/common/iterator'; -import { equals } from 'vs/base/common/objects'; 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 { RunTestsRequest, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ExtensionRunTestsRequest, RunTestsRequest, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; 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'; -import { IMainThreadTestCollection } from 'vs/workbench/contrib/testing/common/testService'; export type ResultChangeEvent = | { completed: LiveTestResult } @@ -50,7 +47,7 @@ export interface ITestResultService { /** * Creates a new, live test result. */ - createLiveResult(collections: ReadonlyArray, req: RunTestsRequest): LiveTestResult; + createLiveResult(req: RunTestsRequest | ExtensionRunTestsRequest): LiveTestResult; /** * Adds a new test result to the collection. @@ -70,14 +67,6 @@ export interface ITestResultService { export const ITestResultService = createDecorator('testResultService'); -/** - * Returns if the tests in the results are exactly equal. Check the counts - * first as a cheap check before starting to iterate. - */ -const resultsEqual = (a: ITestResult, b: ITestResult) => - a.completedAt === b.completedAt && equals(a.counts, b.counts) && Iterable.equals(a.tests, b.tests, - (at, bt) => equals(at.state, bt.state) && equals(at.item, bt.item)); - export class TestResultService implements ITestResultService { declare _serviceBrand: undefined; private changeResultEmitter = new Emitter(); @@ -135,9 +124,13 @@ export class TestResultService implements ITestResultService { /** * @inheritdoc */ - public createLiveResult(collections: ReadonlyArray, req: RunTestsRequest) { - const id = generateUuid(); - return this.push(LiveTestResult.from(id, collections, this.storage.getOutputController(id), req)); + public createLiveResult(req: RunTestsRequest | ExtensionRunTestsRequest) { + if ('id' in req) { + return this.push(new LiveTestResult(req.id, this.storage.getOutputController(req.id), req)); + } else { + const id = generateUuid(); + return this.push(new LiveTestResult(id, this.storage.getOutputController(id), req)); + } } /** @@ -148,11 +141,6 @@ export class TestResultService implements ITestResultService { this.results.unshift(result); } else { const index = findFirstInSorted(this.results, r => r.completedAt !== undefined && r.completedAt <= result.completedAt!); - const prev = this.results[index]; - if (prev && resultsEqual(result, prev)) { - return result; - } - this.results.splice(index, 0, result); this.persistScheduler.schedule(); } @@ -166,7 +154,6 @@ export class TestResultService implements ITestResultService { result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter); this.isRunning.set(true); this.changeResultEmitter.fire({ started: result }); - result.setAllToState(TestResultState.Queued, () => true); } else { this.changeResultEmitter.fire({ inserted: result }); // If this is not a new result, go through each of its tests. For each diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index a4f609925b5..850e17a2cd4 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { groupBy } from 'vs/base/common/arrays'; +import { groupBy, mapFind } from 'vs/base/common/arrays'; import { disposableTimeout } from 'vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable, IReference, toDisposable } from 'vs/base/common/lifecycle'; +import { isDefined } from 'vs/base/common/types'; import { URI, UriComponents } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -74,7 +75,7 @@ export class TestService extends Disposable implements ITestService { * @inheritdoc */ public async expandTest(test: TestIdWithSrc, levels: number) { - await this.testControllers.get(test.src.provider)?.expandTest(test, levels); + await this.testControllers.get(test.src.controller)?.expandTest(test, levels); } /** @@ -159,7 +160,7 @@ export class TestService extends Disposable implements ITestService { } } - return this.testControllers.get(test.src.provider)?.lookupTest(test); + return this.testControllers.get(test.src.controller)?.lookupTest(test); } /** @@ -193,18 +194,27 @@ export class TestService extends Disposable implements ITestService { req.exclude = [...this.excludeTests.value]; } - const subscriptions = [...this.testSubscriptions.values()] - .filter(v => req.tests.some(t => v.collection.getNodeById(t.testId))) - .map(s => this.subscribeToDiffs(s.ident.resource, s.ident.uri)); - const result = this.testResults.createLiveResult(subscriptions.map(s => s.object), req); + const result = this.testResults.createLiveResult(req); + const testsWithIds = req.tests.map(test => { + if (test.src) { + return test as TestIdWithSrc; + } + + const subscribed = mapFind(this.testSubscriptions.values(), s => s.collection.getNodeById(test.testId)); + if (!subscribed) { + return undefined; + } + + return { testId: test.testId, src: subscribed.src }; + }).filter(isDefined); try { - const tests = groupBy(req.tests, (a, b) => a.src.provider === b.src.provider ? 0 : 1); + const tests = groupBy(testsWithIds, (a, b) => a.src.controller === b.src.controller ? 0 : 1); const cancelSource = new CancellationTokenSource(token); this.runningTests.set(req, cancelSource); const requests = tests.map( - group => this.testControllers.get(group[0].src.provider)?.runTests( + group => this.testControllers.get(group[0].src.controller)?.runTests( { runId: result.id, debug: req.debug, @@ -221,7 +231,6 @@ export class TestService extends Disposable implements ITestService { return result; } finally { this.runningTests.delete(req); - subscriptions.forEach(s => s.dispose()); result.markComplete(); } } @@ -372,7 +381,7 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection< * @inheritdoc */ public get busyProviders() { - return this.busyProviderCount; + return this.busyControllerCount; } /** @@ -457,12 +466,12 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection< * Applies the diff to the collection. */ public override apply(diff: TestsDiff) { - let prevBusy = this.busyProviderCount; + let prevBusy = this.busyControllerCount; let prevPendingRoots = this.pendingRootCount; super.apply(diff); - if (prevBusy !== this.busyProviderCount) { - this.busyProvidersChangeEmitter.fire(this.busyProviderCount); + if (prevBusy !== this.busyControllerCount) { + this.busyProvidersChangeEmitter.fire(this.busyControllerCount); } if (prevPendingRoots !== this.pendingRootCount) { this.pendingRootChangeEmitter.fire(this.pendingRootCount); diff --git a/src/vs/workbench/contrib/testing/common/testStubs.ts b/src/vs/workbench/contrib/testing/common/testStubs.ts index 5c0e5241df6..d159f66d391 100644 --- a/src/vs/workbench/contrib/testing/common/testStubs.ts +++ b/src/vs/workbench/contrib/testing/common/testStubs.ts @@ -3,28 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from 'vs/base/common/cancellation'; import { URI } from 'vs/base/common/uri'; -import { IProgress } from 'vs/platform/progress/common/progress'; -import { TestItem, TestResultState } from 'vs/workbench/api/common/extHostTypes'; +import { TestItemImpl, TestItemStatus, TestResultState } from 'vs/workbench/api/common/extHostTypes'; -export class StubTestItem extends TestItem { - parent: StubTestItem | undefined; +export { TestItemImpl, TestResultState } from 'vs/workbench/api/common/extHostTypes'; +export * as Convert from 'vs/workbench/api/common/extHostTypeConverters'; - constructor(id: string, label: string, private readonly pendingChildren: StubTestItem[]) { - super(id, label, URI.file('/'), pendingChildren.length > 0); +export const stubTest = (label: string, idPrefix = 'id-', children: TestItemImpl[] = []): TestItemImpl => { + const item = new TestItemImpl(idPrefix + label, label, URI.file('/'), undefined); + if (children.length) { + item.status = TestItemStatus.Pending; + item.resolveHandler = () => { + for (const child of children) { + item.addChild(child); + } + + item.status = TestItemStatus.Resolved; + }; } - public override discoverChildren(progress: IProgress<{ busy: boolean }>) { - for (const child of this.pendingChildren) { - this.children.add(child); + return item; +}; + +export const testStubsChain = (stub: TestItemImpl, path: string[], slice = 0) => { + const tests = [stub]; + for (const segment of path) { + if (stub.status !== TestItemStatus.Resolved) { + stub.resolveHandler!(CancellationToken.None); } - progress.report({ busy: false }); - } -} + stub = stub.children.get(segment)!; + if (!stub) { + throw new Error(`missing child ${segment}`); + } -export const stubTest = (label: string, idPrefix = 'id-', children: StubTestItem[] = []): StubTestItem => { - return new StubTestItem(idPrefix + label, label, children); + tests.push(stub); + } + + return tests.slice(slice); }; export const testStubs = { diff --git a/src/vs/workbench/contrib/testing/common/testingAutoRun.ts b/src/vs/workbench/contrib/testing/common/testingAutoRun.ts index 36a375b2a11..1e44ba1ba3f 100644 --- a/src/vs/workbench/contrib/testing/common/testingAutoRun.ts +++ b/src/vs/workbench/contrib/testing/common/testingAutoRun.ts @@ -10,7 +10,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 { InternalTestItem, TestDiffOpType, TestIdWithSrc } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestDiffOpType, TestIdWithMaybeSrc } 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 { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; @@ -67,7 +67,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { */ private makeRunner() { let isRunning = false; - const rerunIds = new Map(); + const rerunIds = new Map(); const store = new DisposableStore(); const cts = new CancellationTokenSource(); store.add(toDisposable(() => cts.dispose(true))); @@ -91,8 +91,8 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { } }, delay)); - const addToRerun = (test: InternalTestItem) => { - rerunIds.set(`${test.item.extId}/${test.src.provider}`, ({ testId: test.item.extId, src: test.src })); + const addToRerun = (test: TestIdWithMaybeSrc) => { + rerunIds.set(`${test.testId}/${test.src?.controller}`, test); if (!isRunning) { scheduler.schedule(delay); } @@ -100,7 +100,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { store.add(this.results.onTestChanged(evt => { if (evt.reason === TestResultItemChangeReason.Retired) { - addToRerun(evt.item); + addToRerun({ testId: evt.item.item.extId }); } })); @@ -113,7 +113,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { for (const [, collection] of sub.workspaceFolderCollections) { for (const rootId of collection.rootIds) { const root = collection.getNodeById(rootId); - if (root) { addToRerun(root); } + if (root) { addToRerun({ testId: root.item.extId, src: root.src }); } } } } @@ -122,7 +122,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { store.add(sub.onDiff(([, diff]) => { for (const entry of diff) { if (entry[0] === TestDiffOpType.Add) { - addToRerun(entry[1]); + addToRerun({ testId: entry[1].item.extId, src: entry[1].src }); } } })); diff --git a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts index 49a8b21e2ec..38930d4c833 100644 --- a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts +++ b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts @@ -47,13 +47,13 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode let text: string | undefined; switch (parsed.type) { case TestUriType.ResultActualOutput: - text = test.state.messages[parsed.messageIndex]?.actualOutput; + text = test.tasks[parsed.taskIndex].messages[parsed.messageIndex]?.actualOutput; break; case TestUriType.ResultExpectedOutput: - text = test.state.messages[parsed.messageIndex]?.expectedOutput; + text = test.tasks[parsed.taskIndex].messages[parsed.messageIndex]?.expectedOutput; break; case TestUriType.ResultMessage: - text = test.state.messages[parsed.messageIndex]?.message.toString(); + text = test.tasks[parsed.taskIndex].messages[parsed.messageIndex]?.message.toString(); break; } diff --git a/src/vs/workbench/contrib/testing/common/testingStates.ts b/src/vs/workbench/contrib/testing/common/testingStates.ts index 2d7f9bd0db8..977b6867261 100644 --- a/src/vs/workbench/contrib/testing/common/testingStates.ts +++ b/src/vs/workbench/contrib/testing/common/testingStates.ts @@ -34,7 +34,25 @@ export const stateNodes = Object.entries(statePriority).reduce( export const cmpPriority = (a: TestResultState, b: TestResultState) => statePriority[b] - statePriority[a]; -export const maxPriority = (a: TestResultState, b: TestResultState) => statePriority[a] > statePriority[b] ? a : b; +export const maxPriority = (...states: TestResultState[]) => { + switch (states.length) { + case 0: + return TestResultState.Unset; + case 1: + return states[0]; + case 2: + return statePriority[states[0]] > statePriority[states[1]] ? states[0] : states[1]; + default: + let max = states[0]; + for (let i = 1; i < states.length; i++) { + if (statePriority[max] < statePriority[states[i]]) { + max = states[i]; + } + } + + return max; + } +}; export const statesInOrder = Object.keys(statePriority).map(s => Number(s) as TestResultState).sort(cmpPriority); diff --git a/src/vs/workbench/contrib/testing/common/testingUri.ts b/src/vs/workbench/contrib/testing/common/testingUri.ts index 9503c99e152..7f19b9db3ec 100644 --- a/src/vs/workbench/contrib/testing/common/testingUri.ts +++ b/src/vs/workbench/contrib/testing/common/testingUri.ts @@ -15,6 +15,7 @@ export const enum TestUriType { interface IResultTestUri { resultId: string; + taskIndex: number; testExtId: string; } @@ -46,17 +47,18 @@ export const parseTestUri = (uri: URI): ParsedTestUri | undefined => { const [locationId, ...request] = uri.path.slice(1).split('/'); if (request[0] === TestUriParts.Messages) { - const index = Number(request[1]); - const part = request[2]; + const taskIndex = Number(request[1]); + const index = Number(request[2]); + const part = request[3]; const testExtId = uri.query; if (type === TestUriParts.Results) { switch (part) { case TestUriParts.Text: - return { resultId: locationId, testExtId, messageIndex: index, type: TestUriType.ResultMessage }; + return { resultId: locationId, taskIndex, testExtId, messageIndex: index, type: TestUriType.ResultMessage }; case TestUriParts.ActualOutput: - return { resultId: locationId, testExtId, messageIndex: index, type: TestUriType.ResultActualOutput }; + return { resultId: locationId, taskIndex, testExtId, messageIndex: index, type: TestUriType.ResultActualOutput }; case TestUriParts.ExpectedOutput: - return { resultId: locationId, testExtId, messageIndex: index, type: TestUriType.ResultExpectedOutput }; + return { resultId: locationId, taskIndex, testExtId, messageIndex: index, type: TestUriType.ResultExpectedOutput }; } } } @@ -69,20 +71,20 @@ export const buildTestUri = (parsed: ParsedTestUri): URI => { scheme: TEST_DATA_SCHEME, authority: TestUriParts.Results }; - const msgRef = (locationId: string, index: number, ...remaining: string[]) => + const msgRef = (locationId: string, ...remaining: (string | number)[]) => URI.from({ ...uriParts, query: parsed.testExtId, - path: ['', locationId, TestUriParts.Messages, index, ...remaining].join('/'), + path: ['', locationId, TestUriParts.Messages, ...remaining].join('/'), }); switch (parsed.type) { case TestUriType.ResultActualOutput: - return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.ActualOutput); + return msgRef(parsed.resultId, parsed.taskIndex, parsed.messageIndex, TestUriParts.ActualOutput); case TestUriType.ResultExpectedOutput: - return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.ExpectedOutput); + return msgRef(parsed.resultId, parsed.taskIndex, parsed.messageIndex, TestUriParts.ExpectedOutput); case TestUriType.ResultMessage: - return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.Text); + return msgRef(parsed.resultId, parsed.taskIndex, parsed.messageIndex, TestUriParts.Text); default: throw new Error('Invalid test uri'); } diff --git a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts index 7548ab2d7db..64f8c3471df 100644 --- a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts +++ b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts @@ -96,7 +96,7 @@ suite('Workbench - Testing Explorer Hierarchal by Location Projection', () => { { e: 'b' } ]); - tests.children.get('id-a')!.children.add(testStubs.test('ac')); + tests.children.get('id-a')!.addChild(testStubs.test('ac')); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'a', children: [{ e: 'aa' }, { e: 'ab' }, { e: 'ac' }] }, @@ -110,7 +110,7 @@ suite('Workbench - Testing Explorer Hierarchal by Location Projection', () => { harness.flush(folder1); harness.tree.expand(harness.projection.getElementByTestId('id-a')!); - tests.children.get('id-a')!.children.delete('id-ab'); + tests.children.get('id-a')!.children.get('id-ab')!.dispose(); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'a', children: [{ e: 'aa' }] }, 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 c1292616584..afc857561e6 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 @@ -68,7 +68,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children.get('id-a')!.children.add(testStubs.test('ac')); + tests.children.get('id-a')!.addChild(testStubs.test('ac')); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, @@ -83,7 +83,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children.get('id-a')!.children.delete('id-ab'); + tests.children.get('id-a')!.children.get('id-ab')!.dispose(); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, @@ -96,7 +96,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children.get('id-b')!.children.add(testStubs.test('ba')); + tests.children.get('id-b')!.addChild(testStubs.test('ba')); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, @@ -111,7 +111,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { harness.flush(folder1); const child = testStubs.test('ba'); - tests.children.get('id-b')!.children.add(child); + tests.children.get('id-b')!.addChild(child); harness.flush(folder1); child.runnable = false; 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 63bad953408..089e459cbc9 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -9,11 +9,11 @@ import { bufferToStream, newWriteableBufferStream, VSBuffer } from 'vs/base/comm 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 { InternalTestItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ITestTaskState, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { HydratedTestResult, LiveOutputController, LiveTestResult, makeEmptyCounts, 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'; -import { ReExportedTestRunState as TestRunState } from 'vs/workbench/contrib/testing/common/testStubs'; +import { Convert, ReExportedTestRunState as TestRunState, TestItemImpl, TestResultState, testStubs, testStubsChain } from 'vs/workbench/contrib/testing/common/testStubs'; import { getInitializedMainTestCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; @@ -23,34 +23,60 @@ export const emptyOutputController = () => new LiveOutputController( ); suite('Workbench - Test Results Service', () => { - const getLabelsIn = (it: Iterable) => [...it].map(t => t.item.label).sort(); + const getLabelsIn = (it: Iterable) => [...it].map(t => t.item.label).sort(); const getChangeSummary = () => [...changed] .map(c => ({ reason: c.reason, label: c.item.item.label })) .sort((a, b) => a.label.localeCompare(b.label)); - let r: LiveTestResult; + let r: TestLiveTestResult; let changed = new Set(); + let tests: TestItemImpl; + + const defaultOpts = { + exclude: [], + debug: false, + id: 'x', + persist: true, + }; + + class TestLiveTestResult extends LiveTestResult { + public setAllToState(state: TestResultState, taskId: string, when: (task: ITestTaskState, item: TestResultItem) => boolean) { + super.setAllToState(state, taskId, when); + } + } setup(async () => { changed = new Set(); - r = LiveTestResult.from( + r = new TestLiveTestResult( 'foo', - [await getInitializedMainTestCollection()], emptyOutputController(), - { tests: [{ src: { provider: 'provider', tree: 0 }, testId: 'id-a' }], debug: false }, + { ...defaultOpts, tests: ['id-a'] }, ); r.onChange(e => changed.add(e)); + r.addTask({ id: 't', name: undefined, running: true }); + + tests = testStubs.nested(); + r.addTestChainToRun(testStubsChain(tests, ['id-a', 'id-aa']).map(Convert.TestItem.from)); + r.addTestChainToRun(testStubsChain(tests, ['id-a', 'id-ab'], 1).map(Convert.TestItem.from)); }); suite('LiveTestResult', () => { - test('is empty if no tests are requesteed', async () => { - const r = LiveTestResult.from('', [await getInitializedMainTestCollection()], emptyOutputController(), { tests: [], debug: false }); - assert.deepStrictEqual(getLabelsIn(r.tests), []); + test('is empty if no tests are yet present', async () => { + assert.deepStrictEqual(getLabelsIn(new TestLiveTestResult( + 'foo', + emptyOutputController(), + { ...defaultOpts, tests: ['id-a'] }, + ).tests), []); }); - test('does not change or retire initially', () => { - assert.deepStrictEqual(0, changed.size); + test('initially queues with update', () => { + assert.deepStrictEqual(getChangeSummary(), [ + { label: 'a', reason: TestResultItemChangeReason.ComputedStateChange }, + { label: 'aa', reason: TestResultItemChangeReason.OwnStateChange }, + { label: 'ab', reason: TestResultItemChangeReason.OwnStateChange }, + { label: 'root', reason: TestResultItemChangeReason.ComputedStateChange }, + ]); }); test('initializes with the subtree of requested tests', () => { @@ -60,19 +86,29 @@ suite('Workbench - Test Results Service', () => { test('initializes with valid counts', () => { assert.deepStrictEqual(r.counts, { ...makeEmptyCounts(), - [TestRunState.Unset]: 4 + [TestRunState.Queued]: 2, + [TestRunState.Unset]: 2, }); }); test('setAllToState', () => { - r.setAllToState(TestRunState.Queued, t => t.item.label !== 'root'); + changed.clear(); + r.setAllToState(TestRunState.Queued, 't', (_, t) => t.item.label !== 'root'); assert.deepStrictEqual(r.counts, { ...makeEmptyCounts(), [TestRunState.Unset]: 1, [TestRunState.Queued]: 3, }); - assert.deepStrictEqual(r.getStateById('id-a')?.state.state, TestRunState.Queued); + r.setAllToState(TestRunState.Passed, 't', (_, t) => t.item.label !== 'root'); + assert.deepStrictEqual(r.counts, { + ...makeEmptyCounts(), + [TestRunState.Unset]: 1, + [TestRunState.Passed]: 3, + }); + + assert.deepStrictEqual(r.getStateById('id-a')?.ownComputedState, TestRunState.Passed); + assert.deepStrictEqual(r.getStateById('id-a')?.tasks[0].state, TestRunState.Passed); assert.deepStrictEqual(getChangeSummary(), [ { label: 'a', reason: TestResultItemChangeReason.OwnStateChange }, { label: 'aa', reason: TestResultItemChangeReason.OwnStateChange }, @@ -82,22 +118,26 @@ suite('Workbench - Test Results Service', () => { }); test('updateState', () => { - r.updateState('id-a', TestRunState.Running); + changed.clear(); + r.updateState('id-aa', 't', TestRunState.Running); assert.deepStrictEqual(r.counts, { ...makeEmptyCounts(), + [TestRunState.Unset]: 2, [TestRunState.Running]: 1, - [TestRunState.Unset]: 3, + [TestRunState.Queued]: 1, }); - assert.deepStrictEqual(r.getStateById('id-a')?.state.state, TestRunState.Running); + assert.deepStrictEqual(r.getStateById('id-aa')?.ownComputedState, TestRunState.Running); // update computed state: assert.deepStrictEqual(r.getStateById('id-root')?.computedState, TestRunState.Running); assert.deepStrictEqual(getChangeSummary(), [ - { label: 'a', reason: TestResultItemChangeReason.OwnStateChange }, + { label: 'a', reason: TestResultItemChangeReason.ComputedStateChange }, + { label: 'aa', reason: TestResultItemChangeReason.OwnStateChange }, { label: 'root', reason: TestResultItemChangeReason.ComputedStateChange }, ]); }); test('retire', () => { + changed.clear(); r.retire('id-a'); assert.deepStrictEqual(getChangeSummary(), [ { label: 'a', reason: TestResultItemChangeReason.Retired }, @@ -110,21 +150,20 @@ suite('Workbench - Test Results Service', () => { assert.strictEqual(changed.size, 0); }); - test('addTestToRun', () => { - r.updateState('id-b', TestRunState.Running); + test('ignores outside run', () => { + changed.clear(); + r.updateState('id-b', 't', TestRunState.Running); assert.deepStrictEqual(r.counts, { ...makeEmptyCounts(), - [TestRunState.Running]: 1, - [TestRunState.Unset]: 4, + [TestRunState.Queued]: 2, + [TestRunState.Unset]: 2, }); - assert.deepStrictEqual(r.getStateById('id-b')?.state.state, TestRunState.Running); - // update computed state: - assert.deepStrictEqual(r.getStateById('id-root')?.computedState, TestRunState.Running); + assert.deepStrictEqual(r.getStateById('id-b'), undefined); }); test('markComplete', () => { - r.setAllToState(TestRunState.Queued, () => true); - r.updateState('id-aa', TestRunState.Passed); + r.setAllToState(TestRunState.Queued, 't', () => true); + r.updateState('id-aa', 't', TestRunState.Passed); changed.clear(); r.markComplete(); @@ -135,8 +174,8 @@ suite('Workbench - Test Results Service', () => { [TestRunState.Unset]: 3, }); - assert.deepStrictEqual(r.getStateById('id-root')?.state.state, TestRunState.Unset); - assert.deepStrictEqual(r.getStateById('id-aa')?.state.state, TestRunState.Passed); + assert.deepStrictEqual(r.getStateById('id-root')?.ownComputedState, TestRunState.Unset); + assert.deepStrictEqual(r.getStateById('id-aa')?.ownComputedState, TestRunState.Passed); }); }); @@ -160,7 +199,7 @@ suite('Workbench - Test Results Service', () => { test('serializes and re-hydrates', async () => { results.push(r); - r.updateState('id-aa', TestRunState.Passed); + r.updateState('id-aa', 't', TestRunState.Passed); r.markComplete(); await timeout(0); // allow persistImmediately async to happen @@ -175,12 +214,12 @@ suite('Workbench - Test Results Service', () => { const [rehydrated, actual] = results.getStateById('id-root')!; const expected: any = { ...r.getStateById('id-root')! }; - delete expected.state.duration; // delete undefined props that don't survive serialization + delete expected.tasks[0].duration; // delete undefined props that don't survive serialization delete expected.item.range; delete expected.item.description; expected.item.uri = actual.item.uri; - assert.deepStrictEqual(actual, { ...expected, retired: true }); + assert.deepStrictEqual(actual, { ...expected, src: undefined, retired: true, children: ['id-a'] }); assert.deepStrictEqual(rehydrated.counts, r.counts); assert.strictEqual(typeof rehydrated.completedAt, 'number'); }); @@ -189,11 +228,10 @@ suite('Workbench - Test Results Service', () => { results.push(r); r.markComplete(); - const r2 = results.push(LiveTestResult.from( + const r2 = results.push(new LiveTestResult( '', - [await getInitializedMainTestCollection()], emptyOutputController(), - { tests: [{ src: { provider: 'provider', tree: 0 }, testId: '1' }], debug: false } + { ...defaultOpts, tests: [] } )); results.clear(); @@ -202,11 +240,10 @@ suite('Workbench - Test Results Service', () => { test('keeps ongoing tests on top', async () => { results.push(r); - const r2 = results.push(LiveTestResult.from( + const r2 = results.push(new LiveTestResult( '', - [await getInitializedMainTestCollection()], emptyOutputController(), - { tests: [{ src: { provider: 'provider', tree: 0 }, testId: '1' }], debug: false } + { ...defaultOpts, tests: [] } )); assert.deepStrictEqual(results.results, [r2, r]); @@ -219,10 +256,12 @@ suite('Workbench - Test Results Service', () => { const makeHydrated = async (completedAt = 42, state = TestRunState.Passed) => new HydratedTestResult({ completedAt, id: 'some-id', + tasks: [{ id: 't', running: false, name: undefined }], items: [{ ...(await getInitializedMainTestCollection()).getNodeById('id-a')!, - state: { state, duration: 0, messages: [] }, + tasks: [{ state, duration: 0, messages: [] }], computedState: state, + ownComputedState: state, retired: undefined, children: [], }] @@ -235,16 +274,14 @@ suite('Workbench - Test Results Service', () => { assert.deepStrictEqual(results.results, [r, hydrated]); }); - test('deduplicates identical results', async () => { + test('inserts in correct order', async () => { results.push(r); const hydrated1 = await makeHydrated(); results.push(hydrated1); - const hydrated2 = await makeHydrated(); - results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated1]); }); - test('does not deduplicate if different completedAt', async () => { + test('inserts in correct order 2', async () => { results.push(r); const hydrated1 = await makeHydrated(); results.push(hydrated1); @@ -252,14 +289,5 @@ suite('Workbench - Test Results Service', () => { results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated1, hydrated2]); }); - - test('does not deduplicate if different tests', async () => { - results.push(r); - const hydrated1 = await makeHydrated(); - results.push(hydrated1); - const hydrated2 = await makeHydrated(undefined, TestRunState.Failed); - results.push(hydrated2); - assert.deepStrictEqual(results.results, [r, hydrated2, hydrated1]); - }); }); }); 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 33237417545..1d2d205854d 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts @@ -8,24 +8,32 @@ import { range } from 'vs/base/common/arrays'; import { NullLogService } from 'vs/platform/log/common/log'; import { ITestResult, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { InMemoryResultStorage, RETAIN_MAX_RESULTS } from 'vs/workbench/contrib/testing/common/testResultStorage'; -import { MainThreadTestCollection } from 'vs/workbench/contrib/testing/common/testServiceImpl'; -import { getInitializedMainTestCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; +import { Convert, testStubs, testStubsChain } from 'vs/workbench/contrib/testing/common/testStubs'; import { emptyOutputController } from 'vs/workbench/contrib/testing/test/common/testResultService.test'; import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; suite('Workbench - Test Result Storage', () => { let storage: InMemoryResultStorage; - let collection: MainThreadTestCollection; const makeResult = (addMessage?: string) => { - const t = LiveTestResult.from( + const t = new LiveTestResult( '', - [collection], emptyOutputController(), - { tests: [{ src: { provider: 'provider', tree: 0 }, testId: 'id-a' }], debug: false } + { + tests: [], + exclude: [], + debug: false, + id: 'x', + persist: true, + } ); + + t.addTask({ id: 't', name: undefined, running: true }); + const tests = testStubs.nested(); + t.addTestChainToRun(testStubsChain(tests, ['id-a', 'id-aa']).map(Convert.TestItem.from)); + if (addMessage) { - t.appendMessage('id-a', { + t.appendMessage('id-a', 't', { message: addMessage, actualOutput: undefined, expectedOutput: undefined, @@ -41,7 +49,6 @@ suite('Workbench - Test Result Storage', () => { assert.deepStrictEqual((await storage.read()).map(r => r.id), stored.map(s => s.id)); setup(async () => { - collection = await getInitializedMainTestCollection(); storage = new InMemoryResultStorage(new TestStorageService(), new NullLogService()); }); @@ -68,7 +75,7 @@ suite('Workbench - Test Result Storage', () => { test('limits stored result by budget', async () => { const r = range(100).map(() => makeResult('a'.repeat(2048))); await storage.persist(r); - await assertStored(r.slice(0, 41)); + await assertStored(r.slice(0, 46)); }); test('always stores the min number of results', async () => { diff --git a/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts b/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts index e9c1d60412e..b2e1bdf7112 100644 --- a/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts @@ -9,9 +9,9 @@ import { buildTestUri, ParsedTestUri, parseTestUri, TestUriType } from 'vs/workb suite('Workbench - Testing URIs', () => { test('round trip', () => { const uris: ParsedTestUri[] = [ - { type: TestUriType.ResultActualOutput, messageIndex: 42, resultId: 'r', testExtId: 't' }, - { type: TestUriType.ResultExpectedOutput, messageIndex: 42, resultId: 'r', testExtId: 't' }, - { type: TestUriType.ResultMessage, messageIndex: 42, resultId: 'r', testExtId: 't' }, + { type: TestUriType.ResultActualOutput, taskIndex: 1, messageIndex: 42, resultId: 'r', testExtId: 't' }, + { type: TestUriType.ResultExpectedOutput, taskIndex: 1, messageIndex: 42, resultId: 'r', testExtId: 't' }, + { type: TestUriType.ResultMessage, taskIndex: 1, messageIndex: 42, resultId: 'r', testExtId: 't' }, ]; for (const uri of uris) { diff --git a/src/vs/workbench/test/browser/api/extHostTesting.test.ts b/src/vs/workbench/test/browser/api/extHostTesting.test.ts index cb245d0f3b1..1236700012e 100644 --- a/src/vs/workbench/test/browser/api/extHostTesting.test.ts +++ b/src/vs/workbench/test/browser/api/extHostTesting.test.ts @@ -10,7 +10,7 @@ import { stubTest, testStubs } from 'vs/workbench/contrib/testing/common/testStu import { TestOwnedTestCollection, TestSingleUseCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; import { TestItem } from 'vscode'; -const simplify = (item: TestItem) => ({ +const simplify = (item: TestItem) => ({ id: item.id, label: item.label, uri: item.uri, @@ -19,13 +19,21 @@ const simplify = (item: TestItem) => ({ debuggable: item.debuggable, }); -const assertTreesEqual = (a: TestItem, b: TestItem) => { +const assertTreesEqual = (a: TestItem | undefined, b: TestItem | undefined) => { + if (!a) { + throw new assert.AssertionError({ message: 'Expected a to be defined', actual: a }); + } + + if (!b) { + throw new assert.AssertionError({ message: 'Expected b to be defined', actual: b }); + } + assert.deepStrictEqual(simplify(a), simplify(b)); - const aChildren = [...a.children].slice().sort(); - const bChildren = [...b.children].slice().sort(); + const aChildren = [...a.children.keys()].slice().sort(); + const bChildren = [...b.children.keys()].slice().sort(); assert.strictEqual(aChildren.length, bChildren.length, `expected ${a.label}.children.length == ${b.label}.children.length`); - aChildren.forEach((_, i) => assertTreesEqual(aChildren[i], bChildren[i])); + aChildren.forEach(key => assertTreesEqual(a.children.get(key), b.children.get(key))); }; // const assertTreeListEqual = (a: ReadonlyArray, b: ReadonlyArray) => { @@ -67,36 +75,32 @@ suite('ExtHost Testing', () => { assert.deepStrictEqual(single.collectDiff(), [ [ TestDiffOpType.Add, - { src: { tree: 0, provider: 'pid' }, parent: null, expand: TestItemExpandState.BusyExpanding, item: { ...convert.TestItem.from(stubTest('root')), expandable: true } } + { src: { tree: 0, controller: 'pid' }, parent: null, expand: TestItemExpandState.BusyExpanding, item: { ...convert.TestItem.from(stubTest('root')) } } ], [ TestDiffOpType.Add, - { src: { tree: 0, provider: 'pid' }, parent: 'id-root', expand: TestItemExpandState.Expandable, item: { ...convert.TestItem.from(stubTest('a')), expandable: true } } + { src: { tree: 0, controller: 'pid' }, parent: 'id-root', expand: TestItemExpandState.BusyExpanding, item: { ...convert.TestItem.from(stubTest('a')) } } ], [ TestDiffOpType.Add, - { src: { tree: 0, provider: 'pid' }, parent: 'id-root', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('b')) } - ], - [ - TestDiffOpType.Update, - { extId: 'id-root', expand: TestItemExpandState.Expanded } - ], - [ - TestDiffOpType.Update, - { extId: 'id-a', expand: TestItemExpandState.BusyExpanding } + { src: { tree: 0, controller: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('aa')) } ], [ TestDiffOpType.Add, - { src: { tree: 0, provider: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('aa')) } - ], - [ - TestDiffOpType.Add, - { src: { tree: 0, provider: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('ab')) } + { src: { tree: 0, controller: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('ab')) } ], [ TestDiffOpType.Update, { extId: 'id-a', expand: TestItemExpandState.Expanded } ], + [ + TestDiffOpType.Add, + { src: { tree: 0, controller: 'pid' }, parent: 'id-root', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('b')) } + ], + [ + TestDiffOpType.Update, + { extId: 'id-root', expand: TestItemExpandState.Expanded } + ], ]); }); @@ -126,7 +130,7 @@ suite('ExtHost Testing', () => { single.addRoot(tests, 'pid'); single.expand('id-root', Infinity); single.collectDiff(); - tests.children.delete('id-a'); + tests.children.get('id-a')!.dispose(); assert.deepStrictEqual(single.collectDiff(), [ [TestDiffOpType.Remove, 'id-a'], @@ -141,11 +145,11 @@ suite('ExtHost Testing', () => { single.expand('id-root', Infinity); single.collectDiff(); const child = stubTest('ac'); - tests.children.get('id-a')!.children!.add(child); + tests.children.get('id-a')!.addChild(child); assert.deepStrictEqual(single.collectDiff(), [ [TestDiffOpType.Add, { - src: { tree: 0, provider: 'pid' }, + src: { tree: 0, controller: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(child),