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 593e6a3ebc7..730348fea1f 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2134,18 +2134,12 @@ 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 @@ -2153,51 +2147,73 @@ declare module 'vscode' { * that "run" is called, all tests given in the run have their state * automatically set to {@link TestRunState.Queued}. */ - 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 @@ -2223,32 +2239,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 @@ -2261,7 +2275,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 @@ -2269,8 +2283,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 @@ -2278,74 +2292,81 @@ 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 { + /** + * 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. 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. + * 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 @@ -2356,44 +2377,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 @@ -2407,10 +2440,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. @@ -2428,6 +2479,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`. @@ -2441,20 +2499,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, @@ -2467,17 +2515,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 @@ -2485,9 +2534,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; } /** diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index daf1e90856e..8b9bb6a4b71 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -123,7 +123,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @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 +136,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); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index e96ed6fd9b8..5b345dc1fc6 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -335,9 +335,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(provider); }, createDocumentTestObserver(document) { checkProposedApiEnabled(extension); @@ -351,9 +351,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() { checkProposedApiEnabled(extension); - return extHostTesting.publishExtensionProvidedResults(results, persist); + throw new Error('todo'); }, get onDidChangeTestResults() { checkProposedApiEnabled(extension); @@ -1256,7 +1259,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I NotebookCellOutput: extHostTypes.NotebookCellOutput, NotebookCellOutputItem: extHostTypes.NotebookCellOutputItem, 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 201cca1cbd4..b8849cacb41 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1997,8 +1997,8 @@ export interface ExtHostTestingShape { } export interface MainThreadTestingShape { - $registerTestProvider(id: string): void; - $unregisterTestProvider(id: string): void; + $registerTestController(id: string): void; + $unregisterTestController(id: string): void; $subscribeToDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; $unsubscribeFromDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; $publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void; diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 142aa312694..4a165d13ca8 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -18,8 +18,9 @@ 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'; @@ -29,13 +30,13 @@ const getTestSubscriptionKey = (resource: ExtHostTestingResource, uri: URI) => ` 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; @@ -53,22 +54,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(controller: vscode.TestController): vscode.Disposable { + const controllerId = generateUuid(); + this.controllers.set(controllerId, controller); + 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 +90,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) @@ -133,12 +134,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 +158,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 +173,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,7 +188,7 @@ 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) { + for (const [id, provider] of this.controllers) { subscribeFn(id, provider); } @@ -195,7 +196,7 @@ export class ExtHostTesting implements ExtHostTestingShape { // 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 +205,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 +217,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); } /** @@ -239,7 +240,7 @@ export class ExtHostTesting implements ExtHostTestingShape { * @override */ public async $runTestsForProvider(req: RunTestForProviderRequest, cancellation: CancellationToken): Promise { - const provider = this.providers.get(req.tests[0].src.provider); + const provider = this.controllers.get(req.tests[0].src.provider); if (!provider) { return; } @@ -260,7 +261,7 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } - const isExcluded = (test: vscode.TestItem) => { + 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) { @@ -295,7 +296,7 @@ export class ExtHostTesting implements ExtHostTestingShape { debug: req.debug, }, cancellation); - for (const { collection } of this.testSubscriptions.values()) { + for (const { collection } of this.testControllers.values()) { collection.flushDiff(); // ensure all states are updated } @@ -321,7 +322,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 +330,18 @@ 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, +export const createDefaultDocumentTestRoot = async ( + provider: vscode.TestController, document: vscode.TextDocument, folder: vscode.WorkspaceFolder | undefined, token: CancellationToken, @@ -349,7 +350,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 +366,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 +398,8 @@ export class TestItemFilteredWrapper(item: vscode.TestItem | TestItemFilteredWrapper) { + return item instanceof TestItemFilteredWrapper ? item.actual as vscode.TestItem : item; } private _cachedMatchesFilter: boolean | undefined; @@ -414,21 +416,35 @@ 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: + TestItemFilteredWrapper.getWrapperForTestItem(evt[1], this.filterDocument, this).refreshMatch(); + break; + default: + wrapperApi.bus.fire(evt); + } + }); } /** @@ -441,12 +457,12 @@ export class TestItemFilteredWrapper; depth: number; } @@ -562,7 +578,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 +600,7 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection) { return this.items.get(item.id); } @@ -595,7 +611,7 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection, depth: parent ? parent.depth + 1 : 0, children: new Set(), }; @@ -644,7 +660,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 377a1227423..22b33ba5a0f 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -1649,9 +1649,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, @@ -1660,7 +1660,6 @@ export namespace TestItem { debuggable: item.debuggable ?? false, description: item.description, runnable: item.runnable ?? true, - expandable: item.expandable, }; } @@ -1673,25 +1672,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; diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 83b428ae2e4..4bf416b06ac 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'; @@ -3238,16 +3239,16 @@ 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], +) => { let value = defaultValue; return { enumerable: true, @@ -3255,80 +3256,35 @@ 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 (newValue !== value) { + value = newValue; + api.bus.fire([ExtHostTestItemEventType.SetProp, key, newValue]); + } }, }; }; -export class TestChildrenCollection implements vscode.TestChildrenCollection { - #map = new Map(); - #hookRef: () => ITestItemHook | undefined; - 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, @@ -3340,32 +3296,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), + description: testItemPropAccessor(api, 'description', undefined), + runnable: testItemPropAccessor(api, 'runnable', true), + debuggable: testItemPropAccessor(api, 'debuggable', true), + status: testItemPropAccessor(api, 'status', TestItemStatus.Resolved), }); } 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/common/ownedTestCollection.ts b/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts index 3d96e10cdbc..3f9b148ba18 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; @@ -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,6 +300,41 @@ export class SingleUseTestCollection implements IDisposable { this.debounceSendDiff.dispose(); } + 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.provider, 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, providerId: 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 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,22 +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.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) { + for (const child of api.children.values()) { this.addItem(child, providerId, 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); + } + } + /** * Expands all children of the item, "levels" deep. If levels is 0, only * the children will be expanded. If it's 1, the children and their children @@ -354,8 +418,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 +435,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 +468,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..63dff937e30 100644 --- a/src/vs/workbench/contrib/testing/common/testCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -75,7 +75,6 @@ export interface ITestItem { description: string | undefined; runnable: boolean; debuggable: boolean; - expandable: boolean; } export const enum TestItemExpandState { diff --git a/src/vs/workbench/contrib/testing/common/testStubs.ts b/src/vs/workbench/contrib/testing/common/testStubs.ts index 5c0e5241df6..d2b70221e39 100644 --- a/src/vs/workbench/contrib/testing/common/testStubs.ts +++ b/src/vs/workbench/contrib/testing/common/testStubs.ts @@ -4,27 +4,22 @@ *--------------------------------------------------------------------------------------------*/ 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 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); + } - constructor(id: string, label: string, private readonly pendingChildren: StubTestItem[]) { - super(id, label, URI.file('/'), pendingChildren.length > 0); + item.status = TestItemStatus.Resolved; + }; } - public override discoverChildren(progress: IProgress<{ busy: boolean }>) { - for (const child of this.pendingChildren) { - this.children.add(child); - } - - progress.report({ busy: false }); - } -} - -export const stubTest = (label: string, idPrefix = 'id-', children: StubTestItem[] = []): StubTestItem => { - return new StubTestItem(idPrefix + label, label, children); + return item; }; export const testStubs = { 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/test/browser/api/extHostTesting.test.ts b/src/vs/workbench/test/browser/api/extHostTesting.test.ts index cb245d0f3b1..7f6cb0343c2 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,23 +75,11 @@ 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, provider: '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 } } - ], - [ - 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, provider: 'pid' }, parent: 'id-root', expand: TestItemExpandState.BusyExpanding, item: { ...convert.TestItem.from(stubTest('a')) } } ], [ TestDiffOpType.Add, @@ -97,6 +93,14 @@ suite('ExtHost Testing', () => { TestDiffOpType.Update, { extId: 'id-a', expand: TestItemExpandState.Expanded } ], + [ + 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 } + ], ]); }); @@ -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,7 +145,7 @@ 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, {