diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index d26b8fa2b1c..0cbc9207ce9 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -55,7 +55,7 @@ import { values } from 'vs/base/common/collections'; import { ExtHostEditorInsets } from 'vs/workbench/api/common/extHostCodeInsets'; import { ExtHostLabelService } from 'vs/workbench/api/common/extHostLabelService'; import { getRemoteName } from 'vs/platform/remote/common/remoteHosts'; -import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IExtHostDecorations } from 'vs/workbench/api/common/extHostDecorations'; import { IExtHostTask } from 'vs/workbench/api/common/extHostTask'; import { IExtHostDebugService } from 'vs/workbench/api/common/extHostDebugService'; @@ -171,7 +171,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); - const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, new ExtHostTesting(rpcProtocol, extHostDocumentsAndEditors, extHostWorkspace)); + const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, new ExtHostTesting(rpcProtocol, accessor.get(IInstantiationService), extHostDocumentsAndEditors)); const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); // Check that no named customers are missing diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 507f122dbea..ba9c914b463 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -15,6 +15,7 @@ import { deepFreeze } from 'vs/base/common/objects'; import { isDefined } from 'vs/base/common/types'; import { URI, UriComponents } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ExtHostTestingResource, ExtHostTestingShape, MainContext, MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostDocumentData } from 'vs/workbench/api/common/extHostDocumentData'; import { IExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; @@ -27,22 +28,15 @@ import { OwnedTestCollection, SingleUseTestCollection, TestPosition } from 'vs/w 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 controllers = new Map - }>(); + private readonly controllers = new TestControllers(); private readonly proxy: MainThreadTestingShape; private readonly ownedTests = new OwnedTestCollection(); private readonly runTracker: TestRunCoordinator; - private readonly testControllers = new Map) => void; - }>(); + private readonly subscriptions: TestSubscriptions; + private readonly mainThreadSubscriptions = new Map(); private workspaceObservers: WorkspaceFolderTestObserverFactory; private textDocumentObservers: TextDocumentTestObserverFactory; @@ -50,8 +44,14 @@ export class ExtHostTesting implements ExtHostTestingShape { public onResultsChanged = this.resultsChangedEmitter.event; public results: ReadonlyArray = []; - constructor(@IExtHostRpcService rpc: IExtHostRpcService, @IExtHostDocumentsAndEditors private readonly documents: IExtHostDocumentsAndEditors, @IExtHostWorkspace private readonly workspace: IExtHostWorkspace) { + constructor( + @IExtHostRpcService rpc: IExtHostRpcService, + @IInstantiationService instantionService: IInstantiationService, + @IExtHostDocumentsAndEditors documents: IExtHostDocumentsAndEditors, + ) { this.proxy = rpc.getProxy(MainContext.MainThreadTesting); + + this.subscriptions = instantionService.createInstance(TestSubscriptions, this.ownedTests, this.controllers); this.runTracker = new TestRunCoordinator(this.proxy); this.workspaceObservers = new WorkspaceFolderTestObserverFactory(this.proxy); this.textDocumentObservers = new TextDocumentTestObserverFactory(this.proxy, documents); @@ -62,19 +62,11 @@ export class ExtHostTesting implements ExtHostTestingShape { */ public registerTestController(extensionId: string, controller: vscode.TestController): vscode.Disposable { const controllerId = generateUuid(); - this.controllers.set(controllerId, { instance: controller, extensionId }); + const registration = this.controllers.register(controllerId, controller); this.proxy.$registerTestController(controllerId); - // give the ext a moment to register things rather than synchronously invoking within activate() - const toSubscribe = [...this.testControllers.keys()]; - setTimeout(() => { - for (const subscription of toSubscribe) { - this.testControllers.get(subscription)?.subscribeFn(controllerId, controller); - } - }, 0); - return toDisposable(() => { - this.controllers.delete(controllerId); + registration.dispose(); this.proxy.$unregisterTestController(controllerId); }); } @@ -139,71 +131,18 @@ export class ExtHostTesting implements ExtHostTestingShape { */ public async $subscribeToTests(resource: ExtHostTestingResource, uriComponents: UriComponents) { const uri = URI.revive(uriComponents); - const subscriptionKey = getTestSubscriptionKey(resource, uri); - if (this.testControllers.has(subscriptionKey)) { - return; + const { disposable, info } = await this.subscriptions.subscribeToTests(resource, uri); + this.mainThreadSubscriptions.set(getTestSubscriptionKey(resource, uri), disposable); + + if (info) { + this.proxy.$publishDiff(resource, uri, info.collection.reviveDiff()); + info.collection.onDidGenerateDiff(diff => this.proxy.$publishDiff(resource, uri, diff)); + + // note: we don't increment the 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. + info.initialSubscribeP.then(() => info.collection.pushDiff([TestDiffOpType.IncrementPendingExtHosts, -1])); } - - const cancellation = new CancellationTokenSource(); - let method: undefined | ((p: vscode.TestController) => vscode.ProviderResult>); - if (resource === ExtHostTestingResource.TextDocument) { - let document = this.documents.getDocument(uri); - - // we can ask to subscribe to tests before the documents are populated in - // the extension host. Try to wait. - if (!document) { - const store = new DisposableStore(); - document = await new Promise(resolve => { - store.add(disposableTimeout(() => resolve(undefined), 5000)); - store.add(this.documents.onDidAddDocuments(e => { - const data = e.find(data => data.document.uri.toString() === uri.toString()); - if (data) { resolve(data); } - })); - }).finally(() => store.dispose()); - } - - if (document) { - const folder = await this.workspace.getWorkspaceFolder2(uri, false); - 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.createWorkspaceTestRoot(folder, cancellation.token); - } - } - - if (!method) { - return; - } - - const subscribeFn = async (id: string, provider: vscode.TestController) => { - try { - const root = await method!(provider); - if (root) { - collection.addRoot(root, id); - } - } catch (e) { - console.error(e); - } - }; - - const disposable = new DisposableStore(); - const collection = disposable.add(this.ownedTests.createForHierarchy( - diff => this.proxy.$publishDiff(resource, uriComponents, diff))); - disposable.add(toDisposable(() => cancellation.dispose(true))); - const subscribes: Promise[] = []; - for (const [id, controller] of this.controllers) { - subscribes.push(subscribeFn(id, controller.instance)); - } - - // note: we don't increment the 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. - Promise.all(subscribes).then(() => collection.pushDiff([TestDiffOpType.IncrementPendingExtHosts, -1])); - this.testControllers.set(subscriptionKey, { store: disposable, collection, subscribeFn }); } /** @@ -212,9 +151,11 @@ export class ExtHostTesting implements ExtHostTestingShape { * @override */ public async $expandTest(test: TestIdWithSrc, levels: number) { - 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(); + const collection = this.subscriptions.getCollectionById(test.src.tree); + if (collection) { + await collection.expand(test.testId, levels < 0 ? Infinity : levels); + collection.flushDiff(); + } } /** @@ -224,8 +165,8 @@ export class ExtHostTesting implements ExtHostTestingShape { public $unsubscribeFromTests(resource: ExtHostTestingResource, uriComponents: UriComponents) { const uri = URI.revive(uriComponents); const subscriptionKey = getTestSubscriptionKey(resource, uri); - this.testControllers.get(subscriptionKey)?.store.dispose(); - this.testControllers.delete(subscriptionKey); + this.mainThreadSubscriptions.get(subscriptionKey)?.dispose(); + this.mainThreadSubscriptions.delete(subscriptionKey); } /** @@ -277,7 +218,7 @@ export class ExtHostTesting implements ExtHostTestingShape { const tracker = this.runTracker.prepareForMainThreadTestRun(publicReq, TestRunDto.fromInternal(req), token); try { - await controller.instance.runTests(publicReq, token); + await controller.runTests(publicReq, token); } finally { if (tracker.isRunning && !token.isCancellationRequested) { await Event.toPromise(tracker.onEnd); @@ -308,16 +249,6 @@ export class ExtHostTesting implements ExtHostTestingShape { return Promise.resolve(item); } - /** - * Flushes diff information for all collections to ensure state in the - * main thread is updated. - */ - private flushCollectionDiffs() { - for (const { collection } of this.testControllers.values()) { - collection.flushDiff(); - } - } - /** * Gets the internal test item associated with the reference from the extension. */ @@ -326,7 +257,7 @@ export class ExtHostTesting implements ExtHostTestingShape { // 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.testControllers.values(), c => c.collection.getTestByReference(test)) + ?? this.subscriptions.getCollectionTestByReference(test) ?? this.textDocumentObservers.getMirroredTestDataByReference(test); } } @@ -579,30 +510,6 @@ class TestRunImpl implements vscode.TestRun { } } -export const createDefaultDocumentTestRoot = async ( - provider: vscode.TestController, - document: vscode.TextDocument, - folder: vscode.WorkspaceFolder | undefined, - token: CancellationToken, -) => { - if (!folder) { - return; - } - - const root = await provider.createWorkspaceTestRoot(folder, token); - if (!root) { - return; - } - - token.onCancellationRequested(() => { - TestItemFilteredWrapper.removeFilter(document); - }); - - const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(root, document); - wrapper.refreshMatch(); - return wrapper; -}; - /* * 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. @@ -644,6 +551,8 @@ export class TestItemFilteredWrapper extends TestItemImpl { } private _cachedMatchesFilter: boolean | undefined; + private disposed?: boolean; + private readonly disposable = new DisposableStore(); /** * Gets whether this node, or any of its children, match the document filter. @@ -659,24 +568,26 @@ export class TestItemFilteredWrapper extends TestItemImpl { private constructor( public readonly actual: vscode.TestItem, private filterDocument: vscode.TextDocument, - public readonly actualParent?: TestItemFilteredWrapper, + public readonly wrappedParent?: TestItemFilteredWrapper, ) { 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`); } + // the resolveHandler is intentionally omitted here. When creating the test + // root, we ask the collection to expand infinitely. We must not duplicate + // call the resolveHandler again from the wrapper.. this.debuggable = actual.debuggable; this.runnable = actual.runnable; this.description = actual.description; this.error = actual.error; this.status = actual.status; this.range = actual.range; - this.resolveHandler = actual.resolveHandler; const wrapperApi = getPrivateApiFor(this); const actualApi = getPrivateApiFor(actual); - actualApi.bus.event(evt => { + this.disposable.add(actualApi.bus.event(evt => { switch (evt[0]) { case ExtHostTestItemEventType.SetProp: (this as Record)[evt[1]] = evt[2]; @@ -686,10 +597,13 @@ export class TestItemFilteredWrapper extends TestItemImpl { getPrivateApiFor(wrapper).parent = actual; wrapper.refreshMatch(); break; + case ExtHostTestItemEventType.Disposed: + this.dispose(); + break; default: wrapperApi.bus.fire(evt); } - }); + })); } /** @@ -698,6 +612,10 @@ export class TestItemFilteredWrapper extends TestItemImpl { * children do. */ public refreshMatch() { + if (this.disposed) { + return false; + } + const didMatch = this._cachedMatchesFilter; // The `children` of the wrapper only include the children who match the @@ -705,7 +623,7 @@ export class TestItemFilteredWrapper extends TestItemImpl { for (const rawChild of this.actual.children.values()) { const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(rawChild, this.filterDocument, this); if (!wrapper.hasNodeMatchingFilter) { - wrapper.dispose(); + wrapper.hide(); } else if (!this.children.has(wrapper.id)) { this.addChild(wrapper); } @@ -714,20 +632,35 @@ export class TestItemFilteredWrapper extends TestItemImpl { const nowMatches = this.children.size > 0 || this.actual.uri?.toString() === this.filterDocument.uri.toString(); this._cachedMatchesFilter = nowMatches; - if (nowMatches !== didMatch) { - this.actualParent?.refreshMatch(); + if (nowMatches !== didMatch && this.wrappedParent?._cachedMatchesFilter !== undefined) { + this.wrappedParent.refreshMatch(); } return this._cachedMatchesFilter; } - public override dispose() { - if (this.actualParent) { - getPrivateApiFor(this.actualParent).children.delete(this.id); + public hide() { + if (this.wrappedParent) { + getPrivateApiFor(this.wrappedParent).children.delete(this.id); } getPrivateApiFor(this).bus.fire([ExtHostTestItemEventType.Disposed]); } + + public override dispose() { + if (this.disposed) { + return; + } + + this.disposed = true; + this.disposable.dispose(); + this.hide(); + this.wrappedParent?.refreshMatch(); + + for (const child of this.children.values()) { + child.dispose(); + } + } } /** @@ -1024,3 +957,182 @@ class TextDocumentTestObserverFactory extends AbstractTestObserverFactory { }); } } + +const getTestSubscriptionKey = (resource: ExtHostTestingResource, uri: URI) => `${resource}:${uri.toString()}`; + +type SubscribeFunction = (controllerId: string, controller: vscode.TestController) => void; + +class TestControllers extends Disposable { + private readonly registeredEmitter = this._register(new Emitter<{ controllerId: string, controller: vscode.TestController }>()); + private readonly unregisteredEmitter = this._register(new Emitter<{ controllerId: string, controller: vscode.TestController }>()); + private readonly value = new Map>(); + + public readonly onRegistered = this.registeredEmitter.event; + + public register(controllerId: string, controller: vscode.TestController): IDisposable { + this.value.set(controllerId, controller); + this.registeredEmitter.fire({ controller, controllerId }); + return toDisposable(() => { + this.value.delete(controllerId); + this.unregisteredEmitter.fire({ controller, controllerId }); + }); + } + + public get(controllerId: string) { + return this.value.get(controllerId); + } + + [Symbol.iterator]() { + return this.value[Symbol.iterator](); + } +} + +class TestSubscriptions extends Disposable { + private readonly subs = new Map, + subscribeFn: SubscribeFunction; + }>(); + + constructor( + private readonly ownedTests: OwnedTestCollection, + private readonly controllers: TestControllers, + @IExtHostDocumentsAndEditors private readonly documents: IExtHostDocumentsAndEditors, + @IExtHostWorkspace private readonly workspace: IExtHostWorkspace, + ) { + super(); + + this._register(controllers.onRegistered(({ controller, controllerId }) => { + const subs = [...this.subs.values()]; + // give the ext a moment to register things rather than synchronously invoking within activate() + setTimeout(() => { + for (const { subscribeFn } of subs) { + subscribeFn(controllerId, controller); + } + }, 0); + })); + } + + + /** + * Gets the test collection for a controller by ID, if one exists. + */ + public getCollectionById(treeId: number) { + return mapFind(this.subs.values(), s => s.collection.treeId === treeId ? s.collection : undefined); + } + + /** + * Gets an internal test from the collection by reference, if it exists. + */ + public getCollectionTestByReference(test: vscode.TestItem) { + return mapFind(this.subs.values(), c => c.collection.getTestByReference(test)); + } + + private async createDefaultDocumentTestRoot(folder: vscode.WorkspaceFolder | undefined, document: vscode.TextDocument, controllerId: string, token: CancellationToken) { + if (!folder) { + return; + } + + const { disposable, info } = await this.subscribeToTests(ExtHostTestingResource.Workspace, folder.uri); + if (!info) { + return; + } + + const root = Iterable.find(info.collection.roots, r => r.src.controller === controllerId); + if (!root) { + return; + } + + const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(root.actual, document); + info.collection.expand(root.actual.id, Infinity); + wrapper.refreshMatch(); + + token.onCancellationRequested(() => { + TestItemFilteredWrapper.removeFilter(document); + wrapper.dispose(); + disposable.dispose(); + }); + + return wrapper; + } + + public async subscribeToTests(resource: ExtHostTestingResource, uri: URI) { + const subscriptionKey = getTestSubscriptionKey(resource, uri); + const existing = this.subs.get(subscriptionKey); + if (existing) { + existing.listeners++; + return { disposable: this.getUnsubscriber(subscriptionKey), info: existing }; + } + + const cancellation = new CancellationTokenSource(); + let method: undefined | ((p: vscode.TestController, controllerId: string) => vscode.ProviderResult>); + if (resource === ExtHostTestingResource.TextDocument) { + let document = this.documents.getDocument(uri); + + // we can ask to subscribe to tests before the documents are populated in + // the extension host. Try to wait. + if (!document) { + const store = new DisposableStore(); + document = await new Promise(resolve => { + store.add(disposableTimeout(() => resolve(undefined), 5000)); + store.add(this.documents.onDidAddDocuments(e => { + const data = e.find(data => data.document.uri.toString() === uri.toString()); + if (data) { resolve(data); } + })); + }).finally(() => store.dispose()); + } + + if (document) { + const folder = await this.workspace.getWorkspaceFolder2(uri, false); + method = (p, id) => p.createDocumentTestRoot + ? p.createDocumentTestRoot(document!.document, cancellation.token) + : this.createDefaultDocumentTestRoot(folder, document!.document, id, cancellation.token); + } + } else { + const folder = await this.workspace.getWorkspaceFolder2(uri, false); + if (folder) { + method = p => p.createWorkspaceTestRoot(folder, cancellation.token); + } + } + + if (!method) { + return { disposable: toDisposable(() => { }), info: undefined }; + } + + const subscribeFn = async (id: string, provider: vscode.TestController) => { + try { + const root = await method!(provider, id); + if (root) { + collection.addRoot(root, id); + } + } catch (e) { + console.error(e); + } + }; + + const disposable = new DisposableStore(); + const collection = disposable.add(this.ownedTests.createForHierarchy()); + disposable.add(toDisposable(() => cancellation.dispose(true))); + const subscribes: Promise[] = []; + for (const [id, controller] of this.controllers) { + subscribes.push(subscribeFn(id, controller)); + } + + const initialSubscribeP = Promise.all(subscribes).then(() => undefined); + const info = { store: disposable, listeners: 1, initialSubscribeP, collection, subscribeFn }; + this.subs.set(subscriptionKey, info); + return { disposable: this.getUnsubscriber(subscriptionKey), info }; + } + + private getUnsubscriber(key: string) { + return toDisposable(() => { + const sub = this.subs.get(key); + if (sub && --sub.listeners === 0) { + sub.store.dispose(); + this.subs.delete(key); + } + }); + } +} diff --git a/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts b/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts index db884efb473..0d5d757d623 100644 --- a/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts +++ b/src/vs/workbench/contrib/testing/common/ownedTestCollection.ts @@ -6,6 +6,8 @@ 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 { Emitter } from 'vs/base/common/event'; +import { Iterable } from 'vs/base/common/iterator'; 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'; @@ -50,8 +52,8 @@ export class OwnedTestCollection { * Creates a new test collection for a specific hierarchy for a workspace * or document observation. */ - public createForHierarchy(publishDiff: (diff: TestsDiff) => void = () => undefined) { - return new SingleUseTestCollection(this.createIdMap(treeIdCounter++), publishDiff); + public createForHierarchy() { + return new SingleUseTestCollection(this.createIdMap(treeIdCounter++)); } protected createIdMap(id: number): IReference> { @@ -198,14 +200,23 @@ export class SingleUseTestCollection implements IDisposable { protected readonly testItemToInternal = new Map(); protected diff: TestsDiff = []; private readonly debounceSendDiff = new RunOnceScheduler(() => this.flushDiff(), 200); + private readonly diffOpEmitter = new Emitter(); + + /** + * Fires when an operation happens that should result in a diff. + */ + public readonly onDidGenerateDiff = this.diffOpEmitter.event; public get treeId() { return this.testIdToInternal.object.id; } + public get roots() { + return Iterable.filter(this.testItemToInternal.values(), t => t.parent === null); + } + constructor( private readonly testIdToInternal: IReference>, - private readonly publishDiff: (diff: TestsDiff) => void, ) { } /** @@ -480,10 +491,28 @@ export class SingleUseTestCollection implements IDisposable { } } + /** + * Immediately emits any pending diffs on the collection. + */ public flushDiff() { const diff = this.collectDiff(); if (diff.length) { - this.publishDiff(diff); + this.diffOpEmitter.fire(diff); } } + + /** + * Returns a diff sufficient to "revive" the collection to its current + * state. + */ + public reviveDiff() { + this.flushDiff(); // flush to synchronize so we don't later replay unsent data + + const diff: TestsDiff = []; + for (const { parent, src, expand, item } of this.testItemToInternal.values()) { + diff.push([TestDiffOpType.Add, { parent, src, expand, item }]); + } + + return diff; + } } diff --git a/src/vs/workbench/contrib/testing/common/testStubs.ts b/src/vs/workbench/contrib/testing/common/testStubs.ts index 6ca2e15552d..ac98d283bd3 100644 --- a/src/vs/workbench/contrib/testing/common/testStubs.ts +++ b/src/vs/workbench/contrib/testing/common/testStubs.ts @@ -26,6 +26,18 @@ export const stubTest = (label: string, idPrefix = 'id-', children: TestItemImpl return item; }; +export const expandAllStubs = (stub: TestItemImpl) => { + const todo = [[stub]]; + for (const stubs of todo) { + for (const stub of stubs) { + if (stub.status !== TestItemStatus.Resolved) { + stub.resolveHandler!(CancellationToken.None); + todo.push([...stub.children.values()]); + } + } + } +}; + export const testStubsChain = (stub: TestItemImpl, path: string[], slice = 0) => { const tests = [stub]; for (const segment of path) { diff --git a/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts b/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts index 7c5f92d030e..62e54befe2e 100644 --- a/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts +++ b/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts @@ -85,13 +85,14 @@ export class TestTreeTestHarness()); public readonly onFolderChange = this._register(new Emitter()); - public readonly c: TestSingleUseCollection = this._register(this.owned.createForHierarchy(d => this.c.setDiff(d /* don't clear during testing */))); + public readonly c: TestSingleUseCollection = this._register(this.owned.createForHierarchy()); private isProcessingDiff = false; public readonly projection: T; public readonly tree: TestObjectTree; constructor(folders: IWorkspaceFolderData[], makeTree: (listener: TestSubscriptionListener) => T) { super(); + this.c.onDidGenerateDiff(d => this.c.setDiff(d /* don't clear during testing */)); this.projection = this._register(makeTree({ workspaceFolderCollections: folders.map(folder => [{ folder }, { expand: (testId: string, levels: number) => { diff --git a/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts b/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts index 8ac8a8d22ce..b5c0307ba22 100644 --- a/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts +++ b/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts @@ -28,8 +28,8 @@ export class TestOwnedTestCollection extends OwnedTestCollection { return Iterable.first(this.testIdsToInternal.values())!; } - public override createForHierarchy(publishDiff: (diff: TestsDiff) => void = () => undefined) { - return new TestSingleUseCollection(this.createIdMap(0), publishDiff); + public override createForHierarchy() { + return new TestSingleUseCollection(this.createIdMap(0)); } } @@ -39,7 +39,7 @@ export class TestOwnedTestCollection extends OwnedTestCollection { */ export const getInitializedMainTestCollection = async (root = testStubs.nested()) => { const c = new MainThreadTestCollection(0, async (t, l) => singleUse.expand(t.testId, l)); - const singleUse = new TestSingleUseCollection({ object: new TestTree(0), dispose: () => undefined }, () => undefined); + const singleUse = new TestSingleUseCollection({ object: new TestTree(0), dispose: () => undefined }); singleUse.addRoot(root, 'provider'); await singleUse.expand('id-root', Infinity); c.apply(singleUse.collectDiff()); diff --git a/src/vs/workbench/test/browser/api/extHostTesting.test.ts b/src/vs/workbench/test/browser/api/extHostTesting.test.ts index 4cb37deebb5..76470fd5f7d 100644 --- a/src/vs/workbench/test/browser/api/extHostTesting.test.ts +++ b/src/vs/workbench/test/browser/api/extHostTesting.test.ts @@ -5,16 +5,16 @@ import * as assert from 'assert'; import { VSBuffer } from 'vs/base/common/buffer'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Iterable } from 'vs/base/common/iterator'; import { URI } from 'vs/base/common/uri'; import { mockObject, MockObject } from 'vs/base/test/common/mock'; import { MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol'; -import { createDefaultDocumentTestRoot, TestItemFilteredWrapper, TestRunCoordinator, TestRunDto } from 'vs/workbench/api/common/extHostTesting'; +import { TestItemFilteredWrapper, TestRunCoordinator, TestRunDto } from 'vs/workbench/api/common/extHostTesting'; import * as convert from 'vs/workbench/api/common/extHostTypeConverters'; import { TestMessage } from 'vs/workbench/api/common/extHostTypes'; import { TestDiffOpType, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; -import { stubTest, TestItemImpl, TestResultState, testStubs, testStubsChain } from 'vs/workbench/contrib/testing/common/testStubs'; +import { expandAllStubs, stubTest, TestItemImpl, TestResultState, testStubs, testStubsChain } from 'vs/workbench/contrib/testing/common/testStubs'; import { TestOwnedTestCollection, TestSingleUseCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; import type { TestItem, TestRunRequest, TextDocument } from 'vscode'; @@ -67,7 +67,8 @@ suite('ExtHost Testing', () => { let owned: TestOwnedTestCollection; setup(() => { owned = new TestOwnedTestCollection(); - single = owned.createForHierarchy(d => single.setDiff(d /* don't clear during testing */)); + single = owned.createForHierarchy(); + single.onDidGenerateDiff(d => single.setDiff(d /* don't clear during testing */)); }); teardown(() => { @@ -331,18 +332,7 @@ suite('ExtHost Testing', () => { stubTest('c', undefined, undefined, URI.parse('file:///baz.ts')), ]); - // todo: this is not used, don't think it's needed anymore - await createDefaultDocumentTestRoot( - { - createWorkspaceTestRoot: () => testsWithLocation as TestItem, - runTests() { - throw new Error('no implemented'); - } - }, - textDocumentFilter, - undefined, - CancellationToken.None - ); + expandAllStubs(testsWithLocation); }); teardown(() => { @@ -352,6 +342,7 @@ suite('ExtHost Testing', () => { test('gets all actual properties', () => { const testItem = stubTest('test1'); const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(testItem, textDocumentFilter); + wrapper.refreshMatch(); assert.strictEqual(testItem.debuggable, wrapper.debuggable); assert.strictEqual(testItem.description, wrapper.description); @@ -363,14 +354,14 @@ suite('ExtHost Testing', () => { test('gets no children if nothing matches Uri filter', () => { const tests = testStubs.nested(); const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(tests, textDocumentFilter); - wrapper.resolveHandler?.(CancellationToken.None); + wrapper.refreshMatch(); assert.strictEqual(wrapper.children.size, 0); }); test('filter is applied to children', () => { const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + wrapper.refreshMatch(); assert.strictEqual(wrapper.label, 'root'); - wrapper.resolveHandler?.(CancellationToken.None); const children = [...wrapper.children.values()]; assert.strictEqual(children.length, 1); @@ -380,7 +371,7 @@ suite('ExtHost Testing', () => { test('can get if node has matching filter', () => { const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - rootWrapper.resolveHandler?.(CancellationToken.None); + rootWrapper.refreshMatch(); const invisible = testsWithLocation.children.get('id-b')!; const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); @@ -393,9 +384,9 @@ suite('ExtHost Testing', () => { assert.strictEqual(visibleWrapper.hasNodeMatchingFilter, true); }); - test('can reset cached value of hasNodeMatchingFilter', () => { + test('can reset cached value of hasNodeMatchingFilter on new children', () => { const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - wrapper.resolveHandler?.(CancellationToken.None); + wrapper.refreshMatch(); const invisible = testsWithLocation.children.get('id-b')!; const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); @@ -408,6 +399,19 @@ suite('ExtHost Testing', () => { assert.strictEqual(invisibleWrapper.children.size, 1); assert.strictEqual(wrapper.children.get('id-b'), invisibleWrapper); }); + + test('can reset cached value of hasNodeMatchingFilter on children removed', () => { + const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + wrapper.refreshMatch(); + + const itemB = testsWithLocation.children.get('id-b')!; + const visibleChild = stubTest('bc', undefined, undefined, URI.parse('file:///foo.ts')); + itemB.addChild(visibleChild); + assert.strictEqual(!!wrapper.children.get('id-b'), true); + + visibleChild.dispose(); + assert.strictEqual(!!wrapper.children.get('id-b'), false); + }); }); });