From 808266d4dec579f4a4356005228e43969d8f8c43 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 22 Mar 2021 18:08:13 -0700 Subject: [PATCH] Allow tests to be loaded asynchronously (#119537) * initial wip in the extension host * wip * wip * wip * continued progress * update api from discussion * update for new proposal * wip * update to lastest testing api, almost everything working Refs https://github.com/microsoft/vscode/issues/115089 Design https://gist.github.com/connor4312/73f1883d720654834b7fd40550d3b6e0 * re-wire retirement state * update actions to new async test structure * minor cleanup * remove unused es2018 that failed build --- .eslintrc.json | 2 + src/vs/base/browser/ui/tree/asyncDataTree.ts | 2 +- src/vs/base/common/types.ts | 7 + src/vs/vscode.proposed.d.ts | 170 +++-- .../api/browser/mainThreadTesting.ts | 27 +- .../workbench/api/common/extHost.protocol.ts | 6 +- src/vs/workbench/api/common/extHostTesting.ts | 336 ++++----- .../api/common/extHostTypeConverters.ts | 33 +- src/vs/workbench/api/common/extHostTypes.ts | 127 +++- .../hierarchalByLocation.ts | 59 +- .../explorerProjections/hierarchalByName.ts | 28 +- .../explorerProjections/hierarchalNodes.ts | 26 +- .../browser/explorerProjections/index.ts | 16 +- .../browser/explorerProjections/nodeHelper.ts | 6 +- .../testing/browser/testExplorerActions.ts | 142 ++-- .../testing/browser/testing.contribution.ts | 14 +- .../testing/browser/testingDecorations.ts | 39 +- .../testing/browser/testingExplorerFilter.ts | 11 +- .../testing/browser/testingExplorerView.ts | 177 ++--- .../testing/common/ownedTestCollection.ts | 311 +++++---- .../contrib/testing/common/testCollection.ts | 106 ++- .../testing/common/testResultService.ts | 2 +- .../contrib/testing/common/testService.ts | 106 ++- .../contrib/testing/common/testServiceImpl.ts | 152 +++-- .../contrib/testing/common/testStubs.ts | 23 +- .../contrib/testing/common/testingAutoRun.ts | 6 +- .../hierarchalByLocation.test.ts | 61 +- .../hierarchalByName.test.ts | 34 +- .../testing/test/browser/testObjectTree.ts | 16 +- .../test/common/ownedTestCollection.ts | 11 +- .../test/common/testResultService.test.ts | 48 +- .../test/browser/api/extHostTesting.test.ts | 642 ++++++++++-------- test/automation/package.json | 2 +- 33 files changed, 1619 insertions(+), 1129 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index e21cd93df02..47731490701 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -982,6 +982,7 @@ "allowed": [ "FileSystemProvider", "TreeDataProvider", + "TestProvider", "CustomEditorProvider", "CustomReadonlyEditorProvider", "TerminalLinkProvider", @@ -1015,6 +1016,7 @@ "override", "receive", "register", + "remove", "rename", "save", "send", diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index d9bb4c2e8b6..731137df0a6 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -616,7 +616,7 @@ export class AsyncDataTree implements IDisposable return this.tree.isCollapsible(this.getDataNode(element)); } - isCollapsed(element: T): boolean { + isCollapsed(element: TInput | T): boolean { return this.tree.isCollapsed(this.getDataNode(element)); } diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts index a91a3cfd174..983d30c3df7 100644 --- a/src/vs/base/common/types.ts +++ b/src/vs/base/common/types.ts @@ -50,6 +50,13 @@ export function isNumber(obj: unknown): obj is number { return (typeof obj === 'number' && !isNaN(obj)); } +/** + * @returns whether the provided parameter is an Iterable, casting to the given generic + */ +export function isIterable(obj: unknown): obj is Iterable { + return !!obj && typeof (obj as any)[Symbol.iterator] === 'function'; +} + /** * @returns whether the provided parameter is a JavaScript Boolean or not. */ diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 0b90f75a65f..d78a87836d1 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2185,33 +2185,6 @@ declare module 'vscode' { * in {@link onDidChangeTest} when a test is added or removed. */ readonly root: T; - - /** - * An event that fires when an existing test `root` changes. This can be - * a result of a property update, or an update to its children. Changes - * made to tests will not be visible to {@link TestObserver} instances - * until this event is fired. - * - * When a change is signalled, VS Code will check for any new or removed - * direct children of the changed ite, For example, firing the event with - * the {@link testRoot} will detect any new children in `root.children`. - */ - readonly onDidChangeTest: Event; - - /** - * Promise that should be resolved when all tests that are initially - * defined have been discovered. The provider should continue to watch for - * changes and fire `onDidChangeTest` until the hierarchy is disposed. - */ - readonly discoveredInitialTests?: Thenable; - - /** - * An event that fires when a test becomes outdated, as a result of - * file changes, for example. In "auto run" mode, tests that are outdated - * will be automatically re-run after a short delay. Firing a test - * with children will mark the entire subtree as outdated. - */ - readonly onDidInvalidateTest?: Event; } /** @@ -2233,8 +2206,9 @@ declare module 'vscode' { * * @param workspace The workspace 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 */ - provideWorkspaceTestHierarchy(workspace: WorkspaceFolder, token: CancellationToken): ProviderResult>; + provideWorkspaceTestRoot(workspace: WorkspaceFolder, token: CancellationToken): ProviderResult; /** * Requests that tests be provided for the given document. This will be @@ -2246,18 +2220,21 @@ declare module 'vscode' { * saved, if possible. * * 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, VS - * Code will request and use the information from the workspace hierarchy. + * per-file basis, this method may not be implemented. In that case, the + * editor will request and use the information from the workspace tree. * * @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 */ - provideDocumentTestHierarchy?(document: TextDocument, token: CancellationToken): ProviderResult>; + provideDocumentTestRoot?(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. This should cause {@link onDidChangeTest} to * fire with update test states during the run. - * @todo this will eventually need to be able to return a summary report, coverage for example. * @param options Options for this test run * @param cancellationToken Token that signals the used asked to abort the test run. */ @@ -2305,11 +2282,42 @@ declare module 'vscode' { setState(test: T, state: TestState): void; } + export interface TestChildrenCollection extends Iterable { + /** + * Gets the number of children in the collection. + */ + readonly size: number; + + /** + * Gets an existing TestItem by its ID, if it exists. + * @param id ID of the test. + * @returns the TestItem instance if it exists. + */ + get(id: string): T | undefined; + + /** + * Adds a new child test item. No-ops if the test was already a child. + * @param child The test item to add. + */ + add(child: T): void; + + /** + * Removes the child test item by reference or ID from the collection. + * @param child Child ID or instance to remove. + */ + delete(child: T | string): void; + + /** + * Removes all children from the collection. + */ + clear(): void; + } + /** * 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 class TestItem { /** * Unique identifier for the TestItem. This is used to correlate * test results and tests in the document with those in the workspace @@ -2317,6 +2325,12 @@ declare module 'vscode' { */ readonly id: string; + /** + * A set of children this item has. You can add new children to it, which + * will propagate to the editor UI. + */ + readonly children: TestChildrenCollection; + /** * Display name describing the test case. */ @@ -2327,6 +2341,12 @@ declare module 'vscode' { */ description?: string; + /** + * Location of the test in the workspace. This is used to show line + * decorations and code lenses for the test. + */ + location?: Location; + /** * Whether this test item can be run individually, defaults to `true`. * @@ -2344,22 +2364,53 @@ declare module 'vscode' { debuggable: boolean; /** - * Location of the test in the workspace. This is used to show line - * decorations and code lenses for the test. - */ - location?: Location; - - /** - * Optional list of nested tests for this item. + * Whether this test item can be expanded in the tree view, implying it + * has (or may have) children. If this is given, the item may be + * passed to the {@link TestHierarchy.getChildren} method. */ - children: TestItem[]; + expandable: boolean; /** * Creates a new TestItem instance. * @param id Value of the "id" property * @param label Value of the "label" property. + * @param parent Parent of this item. This should only be defined for the + * test root. + */ + constructor(id: string, label: string, expandable: boolean); + + /** + * Marks the test as outdated. This can happen as a result of file changes, + * for example. In "auto run" mode, tests that are outdated will be + * automatically re-run after a short delay. Invoking this on a + * test with children will mark the entire subtree as outdated. + * + * Extensions should generally not override this method. + */ + invalidate(): void; + + /** + * Requests the children of the test item. Extensions should override this + * method for any test that can discover children. + * + * 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. + * + * 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 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 + * existing, uncancelled discovery for an item. + * + * @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 */ - constructor(id: string, label: string); + discoverChildren(progress: Progress<{ busy: boolean }>, token: CancellationToken): void; } /** @@ -2483,23 +2534,46 @@ declare module 'vscode' { * List of test results. The items in this array are the items that * were passed in the {@link test.runTests} method. */ - results: ReadonlyArray>; + results: ReadonlyArray>; } /** - * A {@link TestItem} with an associated result, which appear or can be - * provided in {@link TestResult} interfaces. + * A {@link TestItem}-like interface with an associated result, which appear + * or can be provided in {@link TestResult} interfaces. */ - export interface TestItemWithResults extends TestItem { + export interface TestResultSnapshot { + /** + * Unique identifier that matches that of the associated TestItem. + * This is used to correlate test results and tests in the document with + * those in the workspace (test explorer). + */ + readonly id: string; + + /** + * Display name describing the test case. + */ + readonly label: string; + + /** + * Optional description that appears next to the label. + */ + readonly description?: string; + + /** + * Location of the test in the workspace. This is used to show line + * decorations and code lenses for the test. + */ + readonly location?: Location; + /** * Current result of the test. */ - result: TestState; + readonly result: TestState; /** * Optional list of nested tests for this item. */ - children: Readonly[]; + readonly children: Readonly[]; } //#endregion diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 0bfb150cb02..23b3dc5b6e7 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -11,14 +11,14 @@ import { Range } from 'vs/editor/common/core/range'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { getTestSubscriptionKey, ISerializedTestResults, ITestState, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { HydratedTestResult, ITestResultService, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResultService'; -import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; +import { ITestRootProvider, ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { ExtHostContext, ExtHostTestingResource, ExtHostTestingShape, IExtHostContext, MainContext, MainThreadTestingShape } from '../common/extHost.protocol'; const reviveDiff = (diff: TestsDiff) => { for (const entry of diff) { if (entry[0] === TestDiffOpType.Add || entry[0] === TestDiffOpType.Update) { const item = entry[1]; - if (item.item.location) { + if (item.item?.location) { item.item.location.uri = URI.revive(item.item.location.uri); item.item.location.range = Range.lift(item.item.location.range); } @@ -27,7 +27,7 @@ const reviveDiff = (diff: TestsDiff) => { }; @extHostNamedCustomer(MainContext.MainThreadTesting) -export class MainThreadTesting extends Disposable implements MainThreadTestingShape { +export class MainThreadTesting extends Disposable implements MainThreadTestingShape, ITestRootProvider { private readonly proxy: ExtHostTestingShape; private readonly testSubscriptions = new Map(); private readonly testProviderRegistrations = new Map(); @@ -56,7 +56,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh } })); - testService.updateRootProviderCount(1); + this._register(testService.registerRootProvider(this)); for (const { resource, uri } of this.testService.subscriptions) { this.proxy.$subscribeToTests(resource, uri); @@ -66,25 +66,14 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void { + public $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void { this.resultService.push(new HydratedTestResult(results, persist)); } /** * @inheritdoc */ - $retireTest(extId: string): void { - for (const result of this.resultService.results) { - if (result instanceof LiveTestResult) { - result.retire(extId); - } - } - } - - /** - * @inheritdoc - */ - $updateTestStateInRun(runId: string, testId: string, state: ITestState): void { + public $updateTestStateInRun(runId: string, testId: string, state: ITestState): void { const r = this.resultService.getResult(runId); if (r && r instanceof LiveTestResult) { for (const message of state.messages) { @@ -105,6 +94,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh const disposable = this.testService.registerTestController(id, { runTests: (req, token) => this.proxy.$runTestsForProvider(req, token), lookupTest: test => this.proxy.$lookupTest(test), + expandTest: (src, levels) => this.proxy.$expandTest(src, isFinite(levels) ? levels : -1), }); this.testProviderRegistrations.set(id, disposable); @@ -121,7 +111,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - $subscribeToDiffs(resource: ExtHostTestingResource, uriComponents: UriComponents): void { + public $subscribeToDiffs(resource: ExtHostTestingResource, uriComponents: UriComponents): void { const uri = URI.revive(uriComponents); const disposable = this.testService.subscribeToDiffs(resource, uri, diff => this.proxy.$acceptDiff(resource, uriComponents, diff)); @@ -152,7 +142,6 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh public dispose() { super.dispose(); - this.testService.updateRootProviderCount(-1); for (const subscription of this.testSubscriptions.values()) { subscription.dispose(); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 59f972d8493..b59617130d8 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -56,7 +56,7 @@ import { Dto } from 'vs/base/common/types'; import { DebugConfigurationProviderTriggerKind, WorkspaceTrustState } from 'vs/workbench/api/common/extHostTypes'; import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility'; import { IExtensionIdWithVersion } from 'vs/platform/userDataSync/common/extensionsStorageSync'; -import { InternalTestItem, ITestState, RunTestForProviderRequest, RunTestsRequest, TestIdWithProvider, TestsDiff, ISerializedTestResults } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, ITestState, RunTestForProviderRequest, RunTestsRequest, TestIdWithSrc, TestsDiff, ISerializedTestResults } from 'vs/workbench/contrib/testing/common/testCollection'; import { CandidatePort } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { WorkspaceTrustRequest, WorkspaceTrustStateChangeEvent } from 'vs/platform/workspace/common/workspaceTrust'; import { ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable'; @@ -1919,9 +1919,10 @@ export interface ExtHostTestingShape { $runTestsForProvider(req: RunTestForProviderRequest, token: CancellationToken): Promise; $subscribeToTests(resource: ExtHostTestingResource, uri: UriComponents): void; $unsubscribeFromTests(resource: ExtHostTestingResource, uri: UriComponents): void; - $lookupTest(test: TestIdWithProvider): Promise; + $lookupTest(test: TestIdWithSrc): Promise; $acceptDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void; $publishTestResults(results: ISerializedTestResults[]): void; + $expandTest(src: TestIdWithSrc, levels: number): Promise; } export interface MainThreadTestingShape { @@ -1933,7 +1934,6 @@ export interface MainThreadTestingShape { $updateTestStateInRun(runId: string, testId: string, state: ITestState): void; $runTests(req: RunTestsRequest, token: CancellationToken): Promise; $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void; - $retireTest(extId: string): void; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 30ba5179ba9..5ac5508a131 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -18,10 +18,10 @@ 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 * as Convert from 'vs/workbench/api/common/extHostTypeConverters'; -import { Disposable } from 'vs/workbench/api/common/extHostTypes'; +import { Disposable, TestItem as TestItemImpl, TestItemHookProperty } 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, TestIdWithProvider, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, 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()}`; @@ -93,7 +93,7 @@ export class ExtHostTesting implements ExtHostTestingShape { tests .map(this.getInternalTestForReference, this) .filter(isDefined) - .map(t => ({ providerId: t.providerId, testId: t.item.extId })); + .map(t => ({ src: t.src, testId: t.item.extId })); await this.proxy.$runTests({ exclude: req.exclude ? testListToProviders(req.exclude).map(t => t.testId) : undefined, @@ -137,7 +137,7 @@ export class ExtHostTesting implements ExtHostTestingShape { } const cancellation = new CancellationTokenSource(); - let method: undefined | ((p: vscode.TestProvider) => vscode.ProviderResult>); + let method: undefined | ((p: vscode.TestProvider) => vscode.ProviderResult); if (resource === ExtHostTestingResource.TextDocument) { let document = this.documents.getDocument(uri); @@ -156,14 +156,14 @@ export class ExtHostTesting implements ExtHostTestingShape { if (document) { const folder = await this.workspace.getWorkspaceFolder2(uri, false); - method = p => p.provideDocumentTestHierarchy - ? p.provideDocumentTestHierarchy(document!.document, cancellation.token) - : this.createDefaultDocumentTestHierarchy(p, document!.document, folder, cancellation.token); + method = p => p.provideDocumentTestRoot + ? p.provideDocumentTestRoot(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.provideWorkspaceTestHierarchy(folder, cancellation.token); + method = p => p.provideWorkspaceTestRoot(folder, cancellation.token); } } @@ -173,25 +173,10 @@ export class ExtHostTesting implements ExtHostTestingShape { const subscribeFn = async (id: string, provider: vscode.TestProvider) => { try { - collection.pushDiff([TestDiffOpType.DeltaDiscoverComplete, 1]); - - const hierarchy = await method!(provider); - if (!hierarchy) { - collection.pushDiff([TestDiffOpType.DeltaDiscoverComplete, -1]); - return; + const root = await method!(provider); + if (root) { + collection.addRoot(root, id); } - - collection.addRoot(hierarchy.root, id); - Promise.resolve(hierarchy.discoveredInitialTests).then(() => collection.pushDiff([TestDiffOpType.DeltaDiscoverComplete, -1])); - hierarchy.onDidChangeTest(e => collection.onItemChange(e, id)); - hierarchy.onDidInvalidateTest?.(e => { - const internal = collection.getTestByReference(e); - if (!internal) { - console.warn(`Received a TestProvider.onDidInvalidateTest for a test that does not currently exist.`); - } else { - this.proxy.$retireTest(internal.item.extId); - } - }); } catch (e) { console.error(e); } @@ -212,6 +197,17 @@ export class ExtHostTesting implements ExtHostTestingShape { this.testSubscriptions.set(subscriptionKey, { store: disposable, collection, subscribeFn }); } + /** + * Expands the nodes in the test tree. If levels is less than zero, it will + * be treated as infinite. + * @override + */ + public async $expandTest(test: TestIdWithSrc, levels: number) { + const sub = mapFind(this.testSubscriptions.values(), s => s.collection.treeId === test.src.tree ? s : undefined); + await sub?.collection.expand(test.testId, levels < 0 ? Infinity : levels); + this.flushCollectionDiffs(); + } + /** * Disposes of a previous subscription to tests. * @override @@ -242,12 +238,16 @@ export class ExtHostTesting implements ExtHostTestingShape { * @override */ public async $runTestsForProvider(req: RunTestForProviderRequest, cancellation: CancellationToken): Promise { - const provider = this.providers.get(req.providerId); + const provider = this.providers.get(req.tests[0].src.provider); if (!provider) { return; } - const includeTests = req.ids.map(id => this.ownedTests.getTestById(id)?.[1]).filter(isDefined); + const includeTests = req.tests + .map(({ testId, src }) => this.ownedTests.getTestById(testId, src.tree)) + .filter(isDefined) + .map(([_tree, test]) => test); + const excludeTests = req.excludeExtIds .map(id => this.ownedTests.getTestById(id)) .filter(isDefined) @@ -290,13 +290,13 @@ export class ExtHostTesting implements ExtHostTestingShape { } } - public $lookupTest(req: TestIdWithProvider): Promise { + public $lookupTest(req: TestIdWithSrc): Promise { const owned = this.ownedTests.getTestById(req.testId); if (!owned) { return Promise.resolve(undefined); } - const { actual, previousChildren, previousEquals, ...item } = owned[1]; + const { actual, discoverCts, expandLevels, ...item } = owned[1]; return Promise.resolve(item); } @@ -321,101 +321,54 @@ export class ExtHostTesting implements ExtHostTestingShape { ?? mapFind(this.testSubscriptions.values(), c => c.collection.getTestByReference(test)) ?? this.textDocumentObservers.getMirroredTestDataByReference(test); } +} - private async createDefaultDocumentTestHierarchy( - provider: vscode.TestProvider, - document: vscode.TextDocument, - folder: vscode.WorkspaceFolder | undefined, - token: CancellationToken, - ): Promise | undefined> { - if (!folder) { - return; - } - - const workspaceHierarchy = await provider.provideWorkspaceTestHierarchy(folder, token); - if (!workspaceHierarchy) { - return; - } - - const onDidInvalidateTest = new Emitter(); - workspaceHierarchy.onDidInvalidateTest?.(node => { - const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(node, document); - if (wrapper.hasNodeMatchingFilter) { - onDidInvalidateTest.fire(wrapper); - } - }); - - const onDidChangeTest = new Emitter(); - workspaceHierarchy.onDidChangeTest(node => { - const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(node, document); - const previouslySeen = wrapper.hasNodeMatchingFilter; - - if (previouslySeen) { - // reset cache and get whether you can currently see the TestItem. - wrapper.reset(); - const currentlySeen = wrapper.hasNodeMatchingFilter; - - if (currentlySeen) { - onDidChangeTest.fire(wrapper); - return; - } - - // Fire the event to say that the current visible parent has changed. - onDidChangeTest.fire(wrapper.visibleParent); - return; - } - - const previousParent = wrapper.visibleParent; - wrapper.reset(); - const currentlySeen = wrapper.hasNodeMatchingFilter; - - // It wasn't previously seen and isn't currently seen so - // nothing has actually changed. - if (!currentlySeen) { - return; - } +export const createDefaultDocumentTestRoot = async ( + provider: vscode.TestProvider, + document: vscode.TextDocument, + folder: vscode.WorkspaceFolder | undefined, + token: CancellationToken, +) => { + if (!folder) { + return; + } - // The test is now visible so we need to refresh the cache - // of the previous visible parent and fire that it has changed. - previousParent.reset(); - onDidChangeTest.fire(previousParent); - }); + const root = await provider.provideWorkspaceTestRoot(folder, token); + if (!root) { + return; + } - token.onCancellationRequested(() => { - TestItemFilteredWrapper.removeFilter(document); - onDidChangeTest.dispose(); - }); + token.onCancellationRequested(() => { + TestItemFilteredWrapper.removeFilter(document); + }); - return { - root: TestItemFilteredWrapper.getWrapperForTestItem(workspaceHierarchy.root, document), - discoveredInitialTests: workspaceHierarchy.discoveredInitialTests, - onDidInvalidateTest: onDidInvalidateTest.event, - onDidChangeTest: onDidChangeTest.event - }; - } -} + return TestItemFilteredWrapper.getWrapperForTestItem(root, document); +}; /* * 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 implements vscode.TestItem { +export class TestItemFilteredWrapper extends TestItemImpl { private static wrapperMap = new WeakMap>(); 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: vscode.TestItem, filterDocument: vscode.TextDocument, parent?: TestItemFilteredWrapper): TestItemFilteredWrapper { + public static getWrapperForTestItem( + item: T, + filterDocument: vscode.TextDocument, + parent?: TestItemFilteredWrapper, + ): TestItemFilteredWrapper { let innerMap = this.wrapperMap.get(filterDocument); if (innerMap?.has(item)) { - return innerMap.get(item)!; + return innerMap.get(item) as TestItemFilteredWrapper; } if (!innerMap) { innerMap = new WeakMap(); this.wrapperMap.set(filterDocument, innerMap); - } const w = new TestItemFilteredWrapper(item, filterDocument, parent); @@ -423,75 +376,77 @@ export class TestItemFilteredWrapper implements vscode.TestItem { return w; } + /** + * If the TestItem is wrapped, returns the unwrapped item provided + * by the extension. + */ public static unwrap(item: vscode.TestItem) { return item instanceof TestItemFilteredWrapper ? item.actual : item; } - public get id() { - return this.actual.id; - } - - public get label() { - return this.actual.label; - } - - public get debuggable() { - return this.actual.debuggable; - } + private _cachedMatchesFilter: boolean | undefined; - public get description() { - return this.actual.description; - } - - public get location() { - return this.actual.location; - } - - public get runnable() { - return this.actual.runnable; + /** + * Gets whether this node, or any of its children, match the document filter. + */ + public get hasNodeMatchingFilter(): boolean { + if (this._cachedMatchesFilter === undefined) { + return this.refreshMatch(); + } else { + return this._cachedMatchesFilter; + } } - public get children() { - // We only want children that match the filter. - return this.getWrappedChildren().filter(child => child.hasNodeMatchingFilter); - } + private constructor( + public readonly actual: T, + private filterDocument: vscode.TextDocument, + public readonly parent?: TestItemFilteredWrapper, + ) { + super(actual.id, actual.label, actual.expandable); + if (!(actual instanceof TestItemImpl)) { + throw new Error(`TestItems provided to the VS Code API must extend \`vscode.TestItem\`, but ${actual.id} did not`); + } - public get visibleParent(): TestItemFilteredWrapper { - return this.hasNodeMatchingFilter ? this : this.parent!.visibleParent; + (actual as TestItemImpl)[TestItemHookProperty] = { + setProp: (key, value) => { + (this as Record)[key] = value; + if (key === 'location') { + this.refreshMatch(); + } + }, + created: child => TestItemFilteredWrapper.getWrapperForTestItem(child, this.filterDocument, this).refreshMatch(), + invalidate: () => this.invalidate(), + delete: child => this.children.delete(child), + }; } - private matchesFilter: boolean | undefined; + /** + * Refreshes the `hasNodeMatchingFilter` state for this item. It matches + * if the test itself has a location that matches, or if any of its + * children do. + */ + private refreshMatch() { + const didMatch = this._cachedMatchesFilter; - // Determines if the TestItem matches the filter. This would be true if: - // 1. We don't have a parent (because the root is the workspace root node) - // 2. The URI of the current node matches the filter URI - // 3. Some child of the current node matches the filter URI - public get hasNodeMatchingFilter(): boolean { - if (this.matchesFilter === undefined) { - this.matchesFilter = !this.parent - || this.actual.location?.uri.toString() === this.filterDocument.uri.toString() - || this.getWrappedChildren().some(child => child.hasNodeMatchingFilter); + // The `children` of the wrapper only include the children who match the + // filter. Synchronize them. + for (const rawChild of this.actual.children) { + const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(rawChild, this.filterDocument, this); + if (wrapper.hasNodeMatchingFilter) { + this.children.add(wrapper); + } else { + this.children.delete(wrapper); + } } - return this.matchesFilter; - } + const nowMatches = this.children.size > 0 || this.actual.location?.uri.toString() === this.filterDocument.uri.toString(); + this._cachedMatchesFilter = nowMatches; - // Reset the cache of whether or not you can see a node from a particular node - // up to it's visible parent. - public reset(): void { - if (this !== this.visibleParent) { - this.parent?.reset(); + if (nowMatches !== didMatch) { + this.parent?.refreshMatch(); } - this.matchesFilter = undefined; - } - - private constructor(public readonly actual: vscode.TestItem, private filterDocument: vscode.TextDocument, private readonly parent?: TestItemFilteredWrapper) { - this.getWrappedChildren(); - } - - private getWrappedChildren() { - return this.actual.children?.map(t => TestItemFilteredWrapper.getWrapperForTestItem(t, this.filterDocument, this)) || []; + return this._cachedMatchesFilter; } } @@ -499,9 +454,8 @@ export class TestItemFilteredWrapper implements vscode.TestItem { * @private */ interface MirroredCollectionTestItem extends IncrementalTestCollectionItem { - revived: Omit; + revived: vscode.TestItem; depth: number; - wrapped?: TestItemFromMirror; } class MirroredChangeCollector extends IncrementalChangeCollector { @@ -515,7 +469,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector) { + constructor(private readonly emitter: Emitter) { super(); } @@ -530,7 +484,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector n.revived); }, + get updated() { return [...updated].map(n => n.revived); }, + get removed() { return [...removed].map(n => n.revived); }, }; } @@ -601,7 +555,7 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection (c as TestItemFromMirror).toJSON()), - - providerId: this.#internal.providerId, - testId: this.id, - }; - - return serialized; - } -} interface IObserverData { observers: number; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 6876415daef..8b33c1fcf05 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -32,7 +32,7 @@ import { RenderLineNumbersType } from 'vs/editor/common/config/editorOptions'; import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook'; import * as notebooks from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { ISerializedTestResults, ITestItem, ITestMessage, ITestState, SerializedTestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { ISerializedTestResults, ITestItem, ITestMessage, ITestState, SerializedTestResultItem, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; export interface PositionLike { line: number; @@ -1661,10 +1661,23 @@ export namespace TestItem { debuggable: item.debuggable ?? false, description: item.description, runnable: item.runnable ?? true, + expandable: item.expandable, }; } - export function toPlainShallow(item: ITestItem): Omit { + export function fromResultSnapshot(item: vscode.TestResultSnapshot): ITestItem { + return { + extId: item.id, + label: item.label, + location: item.location ? location.from(item.location) as any : undefined, + debuggable: false, + description: item.description, + runnable: true, + expandable: true, + }; + } + + export function toPlain(item: ITestItem): Omit { return { id: item.extId, label: item.label, @@ -1672,14 +1685,15 @@ export namespace TestItem { range: item.location.range, uri: URI.revive(item.location.uri) }), + expandable: item.expandable, debuggable: item.debuggable, description: item.description, runnable: item.runnable, }; } - export function toShallow(item: ITestItem): Omit { - const testItem = new types.TestItem(item.extId, item.label); + export function to(item: ITestItem): types.TestItem { + const testItem = new types.TestItem(item.extId, item.label, item.expandable); if (item.location) { testItem.location = location.to({ range: item.location.range, @@ -1702,7 +1716,7 @@ export namespace TestResults { items: [], }; - const queue: [parent: SerializedTestResultItem | null, children: Iterable][] = [ + const queue: [parent: SerializedTestResultItem | null, children: Iterable][] = [ [null, results.results], ]; @@ -1712,11 +1726,12 @@ export namespace TestResults { const serializedItem: SerializedTestResultItem = { children: item.children?.map(c => c.id) ?? [], computedState: item.result.state, - item: TestItem.from(item), + item: TestItem.fromResultSnapshot(item), state: TestState.from(item.result), retired: undefined, + expand: TestItemExpandState.Expanded, parent: parent?.item.extId ?? null, - providerId: '', + src: { provider: '', tree: -1 }, direct: !parent, }; @@ -1730,8 +1745,8 @@ export namespace TestResults { return serialized; } - const convertTestResultItem = (item: SerializedTestResultItem, byInternalId: Map): vscode.TestItemWithResults => ({ - ...TestItem.toPlainShallow(item.item), + const convertTestResultItem = (item: SerializedTestResultItem, byInternalId: Map): vscode.TestResultSnapshot => ({ + ...TestItem.toPlain(item.item), result: TestState.to(item.state), children: item.children .map(c => byInternalId.get(c)) diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 8b23d243e99..73138dc99af 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3240,21 +3240,126 @@ 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; +} + +const testItemPropAccessor = (item: TestItem, key: K, defaultValue: vscode.TestItem[K]) => { + let value = defaultValue; + return { + enumerable: true, + configurable: false, + get() { + return value; + }, + set(newValue: vscode.TestItem[K]) { + item[TestItemHookProperty]?.setProp(key, newValue); + value = newValue; + }, + }; +}; + +export class TestChildrenCollection implements vscode.TestChildrenCollection { + #map = new Map(); + #hookRef: () => ITestItemHook | undefined; + + public get size() { + return this.#map.size; + } + + 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 location?: Location; - public description?: string; - public runnable = true; - public debuggable = false; - public children: vscode.TestItem[] = []; - - constructor(id: string, public label: string) { - Object.defineProperty(this, 'id', { - value: id, - enumerable: true, - writable: false, + public location!: Location | undefined; + public description!: string | undefined; + public runnable!: boolean; + public debuggable!: boolean; + public children!: TestChildrenCollection; + public [TestItemHookProperty]!: ITestItemHook | undefined; + + constructor(id: string, public label: string, public expandable: boolean) { + Object.defineProperties(this, { + id: { + value: id, + enumerable: true, + writable: false, + }, + children: { + value: new TestChildrenCollection(() => this[TestItemHookProperty]), + enumerable: true, + writable: false, + }, + [TestItemHookProperty]: { + enumerable: false, + writable: true, + configurable: false, + }, + location: testItemPropAccessor(this, 'location', undefined), + description: testItemPropAccessor(this, 'description', undefined), + runnable: testItemPropAccessor(this, 'runnable', true), + debuggable: testItemPropAccessor(this, 'debuggable', true), }); } + + public invalidate() { + this[TestItemHookProperty]?.invalidate(this.id); + } + + public discoverChildren(progress: vscode.Progress<{ busy: boolean }>, _token: vscode.CancellationToken) { + progress.report({ busy: false }); + } } export class TestState implements vscode.TestState { diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts index 0e3fa440dd8..e97b5fe41fb 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts @@ -13,10 +13,10 @@ import { IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from 'vs/platform/work import { TestResult } from 'vs/workbench/api/common/extHostTypes'; import { ITestTreeElement, ITestTreeProjection } from 'vs/workbench/contrib/testing/browser/explorerProjections'; import { HierarchicalElement, HierarchicalFolder } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes'; -import { locationsEqual, TestLocationStore } from 'vs/workbench/contrib/testing/browser/explorerProjections/locationStore'; +import { TestLocationStore } from 'vs/workbench/contrib/testing/browser/explorerProjections/locationStore'; import { NodeChangeList, NodeRenderDirective, NodeRenderFn, peersHaveChildren } from 'vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper'; import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; -import { InternalTestItem, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, TestDiffOpType, TestItemExpandState, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { TestSubscriptionListener } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; @@ -37,9 +37,14 @@ const computedStateAccessor: IComputedStateAccessor = { */ export class HierarchicalByLocationProjection extends Disposable implements ITestTreeProjection { private readonly updateEmitter = new Emitter(); - private readonly changes = new NodeChangeList(); + protected readonly changes = new NodeChangeList(); private readonly locations = new TestLocationStore(); + /** + * Depth of root children which are expanded automatically. + */ + protected rootRevealDepth = 0; + /** * Map of test IDs to test item objects. */ @@ -55,7 +60,7 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes */ public readonly onUpdate = this.updateEmitter.event; - constructor(listener: TestSubscriptionListener, @ITestResultService private readonly results: ITestResultService) { + constructor(private readonly listener: TestSubscriptionListener, @ITestResultService private readonly results: ITestResultService) { super(); this._register(listener.onDiff(([folder, diff]) => this.applyDiff(folder, diff))); this._register(listener.onFolderChange(this.applyFolderChange, this)); @@ -99,7 +104,7 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes } for (const folder of this.folders.values()) { - this.changes.added(folder); + this.changes.addedOrRemoved(folder); } } @@ -115,7 +120,7 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes const existing = this.folders.get(folder.uri.toString()); if (existing) { this.folders.delete(folder.uri.toString()); - this.changes.removed(existing); + this.changes.addedOrRemoved(existing); } this.updateEmitter.fire(); } @@ -144,20 +149,20 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes case TestDiffOpType.Add: { const item = this.createItem(op[1], folder); this.storeItem(item); - this.changes.added(item); + this.changes.addedOrRemoved(item); break; } case TestDiffOpType.Update: { - const internalTest = op[1]; - const existing = this.items.get(internalTest.item.extId); + const patch = op[1]; + const existing = this.items.get(patch.extId); if (!existing) { break; } - const locationChanged = !locationsEqual(existing.location, internalTest.item.location); + const locationChanged = !!patch.item?.location; if (locationChanged) { this.locations.remove(existing); } - existing.update(internalTest); + existing.update(patch); if (locationChanged) { this.locations.add(existing); } this.addUpdated(existing); break; @@ -169,7 +174,7 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes break; } - this.changes.removed(toRemove); + this.changes.addedOrRemoved(toRemove); const queue: Iterable[] = [[toRemove]]; while (queue.length) { @@ -193,6 +198,23 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes this.changes.applyTo(tree, this.renderNode, () => this.folders.values()); } + /** + * @inheritdoc + */ + public expandElement(element: ITestTreeElement, depth: number): void { + if (!(element instanceof HierarchicalElement)) { + return; + } + + if (element.test.expand !== TestItemExpandState.Expandable) { + return; + } + + const folder = element.folder; + const collection = this.listener.workspaceFolderCollections.find(([f]) => f.folder === folder); + collection?.[1].expand(element.test.item.extId, depth); + } + protected createItem(item: InternalTestItem, folder: IWorkspaceFolder): HierarchicalElement { const parent = item.parent ? this.items.get(item.parent)! : this.getOrCreateFolderElement(folder); return new HierarchicalElement(item, parent); @@ -202,7 +224,7 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes let f = this.folders.get(folder.uri.toString()); if (!f) { f = new HierarchicalFolder(folder); - this.changes.added(f); + this.changes.addedOrRemoved(f); this.folders.set(folder.uri.toString(), f); } @@ -219,7 +241,12 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes return NodeRenderDirective.Concat; } - return { element: node, incompressible: true, children: recurse(node.children) }; + return { + element: node, + collapsible: node.expandable !== TestItemExpandState.NotExpandable, + collapsed: node.expandable === TestItemExpandState.Expandable ? true : undefined, + children: recurse(node.children), + }; }; protected unstoreItem(treeElement: HierarchicalElement) { @@ -234,6 +261,10 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes this.items.set(treeElement.test.item.extId, treeElement); this.locations.add(treeElement); + if (treeElement.depth === 1) { + this.expandElement(treeElement, this.rootRevealDepth); + } + const prevState = this.results.getStateById(treeElement.test.item.extId)?.[1]; if (prevState) { treeElement.ownState = prevState.state.state; diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts index 8ff8d568112..fc8e7a28781 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName.ts @@ -5,11 +5,10 @@ import { Iterable } from 'vs/base/common/iterator'; import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; -import { ITestTreeElement } from 'vs/workbench/contrib/testing/browser/explorerProjections'; import { HierarchicalByLocationProjection as HierarchicalByLocationProjection } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation'; import { HierarchicalElement, HierarchicalFolder } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes'; import { NodeRenderDirective } from 'vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper'; -import { InternalTestItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, ITestItemUpdate, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { TestSubscriptionListener } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; @@ -35,6 +34,10 @@ export class HierarchicalByNameElement extends HierarchicalElement { public readonly isTestRoot = !this.actualParent; public readonly actualChildren = new Set(); + public get expandable() { + return TestItemExpandState.NotExpandable; + } + public get description() { let description: string | undefined; for (let parent = this.actualParent; parent && !parent.isTestRoot; parent = parent.actualParent) { @@ -54,7 +57,7 @@ export class HierarchicalByNameElement extends HierarchicalElement { constructor( internal: InternalTestItem, parentItem: HierarchicalFolder | HierarchicalElement, - private readonly addUpdated: (n: ITestTreeElement) => void, + private readonly addedOrRemoved: (n: HierarchicalByNameElement) => void, private readonly actualParent?: HierarchicalByNameElement, ) { super(internal, parentItem); @@ -65,11 +68,10 @@ export class HierarchicalByNameElement extends HierarchicalElement { /** * @override */ - public update(actual: InternalTestItem) { - const wasRunnable = this.test.item.runnable; - super.update(actual); + public update(patch: ITestItemUpdate) { + super.update(patch); - if (this.test.item.runnable !== wasRunnable) { + if (patch.item?.runnable !== undefined) { this.updateLeafTestState(); } } @@ -105,7 +107,7 @@ export class HierarchicalByNameElement extends HierarchicalElement { if (newType !== this.elementType) { this.elementType = newType; - this.addUpdated(this); + this.addedOrRemoved(this); } this.actualParent?.updateLeafTestState(); @@ -118,6 +120,10 @@ export class HierarchicalByNameElement extends HierarchicalElement { * test root rather than the heirarchal parent. */ export class HierarchicalByNameProjection extends HierarchicalByLocationProjection { + protected rootRevealDepth = Infinity; + + private readonly addedOrRemoved = (node: HierarchicalByNameElement) => this.changes.addedOrRemoved(node); + constructor(listener: TestSubscriptionListener, @ITestResultService results: ITestResultService) { super(listener, results); @@ -138,12 +144,12 @@ export class HierarchicalByNameProjection extends HierarchicalByLocationProjecti const parent = this.getOrCreateFolderElement(folder); const actualParent = item.parent ? this.items.get(item.parent) as HierarchicalByNameElement : undefined; for (const testRoot of parent.children) { - if (testRoot.test.providerId === item.providerId) { - return new HierarchicalByNameElement(item, testRoot, this.addUpdated, actualParent); + if (testRoot.test.src.provider === item.src.provider) { + return new HierarchicalByNameElement(item, testRoot, this.addedOrRemoved, actualParent); } } - return new HierarchicalByNameElement(item, parent, this.addUpdated); + return new HierarchicalByNameElement(item, parent, this.addedOrRemoved); } /** diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts index f1237486a52..2b4073443e7 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalNodes.ts @@ -8,7 +8,7 @@ import { generateUuid } from 'vs/base/common/uuid'; import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { TestResult } from 'vs/workbench/api/common/extHostTypes'; import { ITestTreeElement } from 'vs/workbench/contrib/testing/browser/explorerProjections'; -import { InternalTestItem, TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection'; +import { applyTestItemUpdate, InternalTestItem, ITestItemUpdate, TestIdWithSrc, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; /** * Test tree element element that groups be hierarchy. @@ -29,18 +29,26 @@ export class HierarchicalElement implements ITestTreeElement { return this.test.item.location; } - public get runnable(): Iterable { + public get runnable(): Iterable { return this.test.item.runnable - ? [{ providerId: this.test.providerId, testId: this.test.item.extId }] + ? [{ src: this.test.src, testId: this.test.item.extId }] : Iterable.empty(); } public get debuggable() { return this.test.item.debuggable - ? [{ providerId: this.test.providerId, testId: this.test.item.extId }] + ? [{ src: this.test.src, testId: this.test.item.extId }] : Iterable.empty(); } + public get expandable() { + return this.test.expand; + } + + public get folder(): IWorkspaceFolder { + return this.parentItem.folder; + } + public state = TestResult.Unset; public retired = false; public ownState = TestResult.Unset; @@ -49,8 +57,8 @@ export class HierarchicalElement implements ITestTreeElement { this.test = { ...test, item: { ...test.item } }; // clone since we Object.assign updatese } - public update(actual: InternalTestItem) { - Object.assign(this.test, actual); + public update(patch: ITestItemUpdate) { + applyTestItemUpdate(this.test, patch); } } @@ -75,11 +83,15 @@ export class HierarchicalFolder implements ITestTreeElement { return Iterable.concatNested(Iterable.map(this.children, c => c.debuggable)); } + public get expandable() { + return TestItemExpandState.Expanded; + } + public retired = false; public state = TestResult.Unset; public ownState = TestResult.Unset; - constructor(private readonly folder: IWorkspaceFolder) { } + constructor(public readonly folder: IWorkspaceFolder) { } public get label() { return this.folder.name; diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts index a67f77da061..1be1e0a4caa 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts @@ -11,7 +11,7 @@ import { URI } from 'vs/base/common/uri'; import { Position } from 'vs/editor/common/core/position'; import { ITextEditorSelection } from 'vs/platform/editor/common/editor'; import { TestResult } from 'vs/workbench/api/common/extHostTypes'; -import { InternalTestItem, TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, TestIdWithSrc, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; /** * Describes a rendering of tests in the explorer view. Different @@ -26,6 +26,11 @@ export interface ITestTreeProjection extends IDisposable { */ onUpdate: Event; + /** + * Fired when an element in the tree is expanded. + */ + expandElement(element: ITestTreeElement, depth: number): void; + /** * Gets an element by its extension-assigned ID. */ @@ -80,12 +85,17 @@ export interface ITestTreeElement { /** * Tests that can be run using this tree item. */ - readonly runnable: Iterable; + readonly runnable: Iterable; /** * Tests that can be run using this tree item. */ - readonly debuggable: Iterable; + readonly debuggable: Iterable; + + /** + * Expand state of the test. + */ + readonly expandable: TestItemExpandState; /** * Element state to display. diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts index 7500b77cfda..a7e2ecb6636 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts @@ -76,11 +76,7 @@ export class NodeChangeList this.updatedNodes.add(node); } - public removed(node: T) { - this.added(node); - } - - public added(node: T) { + public addedOrRemoved(node: T) { this.changedParents.add(this.getNearestNotOmittedParent(node)); } diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index b8ff392b1e7..e964b6c14b2 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Action } from 'vs/base/common/actions'; +import { flatten } from 'vs/base/common/arrays'; import { Codicon } from 'vs/base/common/codicons'; +import { Iterable } from 'vs/base/common/iterator'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { isDefined } from 'vs/base/common/types'; import { localize } from 'vs/nls'; @@ -23,12 +25,12 @@ import { IExtensionsViewPaneContainer, VIEWLET_ID as EXTENSIONS_VIEWLET_ID } fro import * as icons from 'vs/workbench/contrib/testing/browser/icons'; import { TestingExplorerView, TestingExplorerViewModel } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { TestExplorerViewMode, TestExplorerViewSorting, Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { IncrementalTestCollectionItem, InternalTestItem, TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, TestIdPath, TestIdWithSrc, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestingAutoRun } from 'vs/workbench/contrib/testing/common/testingAutoRun'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; import { ITestResult, ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; -import { ITestService, waitForAllRoots, waitForAllTests } from 'vs/workbench/contrib/testing/common/testService'; +import { getAllTestsInHierarchy, getTestByPath, ITestService, waitForAllRoots } from 'vs/workbench/contrib/testing/common/testService'; import { IWorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; @@ -70,7 +72,7 @@ export class HideOrShowTestAction extends Action { export class DebugAction extends Action { constructor( - private readonly tests: Iterable, + private readonly tests: Iterable, isRunning: boolean, @ITestService private readonly testService: ITestService ) { @@ -95,7 +97,7 @@ export class DebugAction extends Action { export class RunAction extends Action { constructor( - private readonly tests: Iterable, + private readonly tests: Iterable, isRunning: boolean, @ITestService private readonly testService: ITestService ) { @@ -145,19 +147,19 @@ abstract class RunOrDebugSelectedAction extends ViewAction private getActionableTests(testCollection: IWorkspaceTestCollectionService, viewModel: TestingExplorerViewModel) { const selected = viewModel.getSelectedTests(); - const tests: TestIdWithProvider[] = []; + const tests: TestIdWithSrc[] = []; if (!selected.length) { for (const folder of testCollection.workspaceFolders()) { for (const child of folder.getChildren()) { if (this.filter(child)) { - tests.push({ testId: child.item.extId, providerId: child.providerId }); + tests.push({ testId: child.item.extId, src: child.src }); } } } } else { for (const treeElement of selected) { if (treeElement?.test && this.filter(treeElement.test)) { - tests.push({ testId: treeElement.test.item.extId, providerId: treeElement.test.providerId }); + tests.push({ testId: treeElement.test.item.extId, src: treeElement.test.src }); } } } @@ -244,7 +246,7 @@ abstract class RunOrDebugAllAllAction extends Action2 { const notifications = accessor.get(INotificationService); const progress = accessor.get(IProgressService); - const tests: TestIdWithProvider[] = []; + const tests: TestIdWithSrc[] = []; const todo = workspace.getWorkspace().folders.map(async (folder) => { const ref = testService.subscribeToDiffs(ExtHostTestingResource.Workspace, folder.uri); try { @@ -252,7 +254,7 @@ abstract class RunOrDebugAllAllAction extends Action2 { for (const root of ref.object.rootIds) { const node = ref.object.getNodeById(root); if (node && (this.debug ? node.item.debuggable : node.item.runnable)) { - tests.push({ testId: node.item.extId, providerId: node.providerId }); + tests.push({ testId: node.item.extId, src: node.src }); } } } finally { @@ -578,7 +580,7 @@ abstract class RunOrDebugAtCursor extends Action2 { let bestNode: InternalTestItem | undefined; try { - await showDiscoveringWhile(accessor.get(IProgressService), waitForAllTests(collection.object)); + await showDiscoveringWhile(accessor.get(IProgressService), getAllTestsInHierarchy(collection.object)); const queue: [depth: number, nodes: Iterable][] = [[0, collection.object.rootIds]]; while (queue.length > 0) { @@ -626,7 +628,7 @@ export class RunAtCursor extends RunOrDebugAtCursor { protected runTest(service: ITestService, internalTest: InternalTestItem): Promise { return service.runTests({ debug: false, - tests: [{ testId: internalTest.item.extId, providerId: internalTest.providerId }], + tests: [{ testId: internalTest.item.extId, src: internalTest.src }], }); } } @@ -648,7 +650,7 @@ export class DebugAtCursor extends RunOrDebugAtCursor { protected runTest(service: ITestService, internalTest: InternalTestItem): Promise { return service.runTests({ debug: true, - tests: [{ testId: internalTest.item.extId, providerId: internalTest.providerId }], + tests: [{ testId: internalTest.item.extId, src: internalTest.src }], }); } } @@ -668,33 +670,16 @@ abstract class RunOrDebugCurrentFile extends Action2 { const testService = accessor.get(ITestService); const collection = testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, model.uri); - // the gather function builds the first nodes in the tree who have a - // location in the file. Ideally we could just request to run the roots, - // but in the case where they're polyfilled from a workspace heirarchy, - // the "root" test actually refers to the workspace root. - const expectedUri = model.uri.toString(); - const tests: IncrementalTestCollectionItem[] = []; - const gather = (testIds: Iterable) => { - for (const id of testIds) { - const node = collection.object.getNodeById(id); - if (!node) { - // no-op - } else if (node.item.location?.uri.toString() === expectedUri) { - if (this.filter(node)) { - tests.push(node); - } - } else if (node.children) { - gather(node.children); - } - } - }; - try { - await showDiscoveringWhile(accessor.get(IProgressService), waitForAllTests(collection.object)); - gather(collection.object.rootIds); + await waitForAllRoots(collection.object); - if (tests.length) { - await this.runTest(testService, tests); + const roots = [...collection.object.rootIds] + .map(r => collection.object.getNodeById(r)) + .filter(isDefined) + .filter(n => this.filter(n)); + + if (roots.length) { + await this.runTest(testService, roots); } } finally { collection.dispose(); @@ -723,7 +708,7 @@ export class RunCurrentFile extends RunOrDebugCurrentFile { protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ debug: false, - tests: internalTests.map(t => ({ testId: t.item.extId, providerId: t.providerId })), + tests: internalTests.map(t => ({ testId: t.item.extId, src: t.src })), }); } } @@ -745,92 +730,99 @@ export class DebugCurrentFile extends RunOrDebugCurrentFile { protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ debug: true, - tests: internalTests.map(t => ({ testId: t.item.extId, providerId: t.providerId })) + tests: internalTests.map(t => ({ testId: t.item.extId, src: t.src })) }); } } -abstract class RunOrDebugTestResults extends Action2 { +abstract class RunOrDebugExtsById extends Action2 { /** * @override */ public async run(accessor: ServicesAccessor) { const testService = accessor.get(ITestService); - const extIds = this.getTestExtIdsToRun(accessor); - if (extIds.size === 0) { + const paths = [...this.getTestExtIdsToRun(accessor)]; + if (paths.length === 0) { return; } const workspaceTests = accessor.get(IWorkspaceTestCollectionService).subscribeToWorkspaceTests(); try { - const todo = Promise.all(workspaceTests.workspaceFolderCollections.map(([, c]) => waitForAllTests(c))); - await showDiscoveringWhile(accessor.get(IProgressService), todo); - - const toRun: InternalTestItem[] = []; - for (const [, collection] of workspaceTests.workspaceFolderCollections) { - for (const node of collection.all) { - if (extIds.has(node.item.extId) && this.filter(node)) { - toRun.push(node); - extIds.delete(node.item.extId); - } - } - } + const todo = Promise.all(workspaceTests.workspaceFolderCollections.map( + ([, c]) => Promise.all(paths.map(p => getTestByPath(c, p))), + )); - if (toRun.length) { - await this.runTest(testService, toRun); + const tests = flatten(await showDiscoveringWhile(accessor.get(IProgressService), todo)).filter(isDefined); + if (tests.length) { + await this.runTest(testService, tests); } } finally { workspaceTests.dispose(); } } - protected abstract getTestExtIdsToRun(accessor: ServicesAccessor): Set; + protected abstract getTestExtIdsToRun(accessor: ServicesAccessor): Iterable; protected abstract filter(node: InternalTestItem): boolean; protected abstract runTest(service: ITestService, node: InternalTestItem[]): Promise; + + protected getPathForTest(test: TestResultItem, results: ITestResult) { + const path = [test]; + while (true) { + const parentId = path[0].parent; + const parent = parentId && results.getStateById(parentId); + if (!parent) { + break; + } + + path.unshift(parent); + } + + return path.map(t => t.item.extId); + } } -abstract class RunOrDebugFailedTests extends RunOrDebugTestResults { +abstract class RunOrDebugFailedTests extends RunOrDebugExtsById { /** * @inheritdoc */ - protected getTestExtIdsToRun(accessor: ServicesAccessor): Set { + protected getTestExtIdsToRun(accessor: ServicesAccessor): Iterable { const { results } = accessor.get(ITestResultService); - const extIds = new Set(); + const paths = new Set(); + const sep = '$$TEST SEP$$'; for (let i = results.length - 1; i >= 0; i--) { - for (const test of results[i].tests) { + const resultSet = results[i]; + for (const test of resultSet.tests) { + const path = this.getPathForTest(test, resultSet).join(sep); if (isFailedState(test.state.state)) { - extIds.add(test.item.extId); + paths.add(path); } else { - extIds.delete(test.item.extId); + paths.delete(path); } } } - return extIds; + return Iterable.map(paths, p => p.split(sep)); } } -abstract class RunOrDebugLastRun extends RunOrDebugTestResults { +abstract class RunOrDebugLastRun extends RunOrDebugExtsById { /** * @inheritdoc */ - protected getTestExtIdsToRun(accessor: ServicesAccessor): Set { + protected *getTestExtIdsToRun(accessor: ServicesAccessor): Iterable { const lastResult = accessor.get(ITestResultService).results[0]; - const extIds = new Set(); if (!lastResult) { - return extIds; + return; } for (const test of lastResult.tests) { if (test.direct) { - extIds.add(test.item.extId); + yield this.getPathForTest(test, lastResult); } } - - return extIds; } } @@ -851,7 +843,7 @@ export class ReRunFailedTests extends RunOrDebugFailedTests { protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ debug: false, - tests: internalTests.map(t => ({ testId: t.item.extId, providerId: t.providerId })), + tests: internalTests.map(t => ({ testId: t.item.extId, src: t.src })), }); } } @@ -873,7 +865,7 @@ export class DebugFailedTests extends RunOrDebugFailedTests { protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ debug: true, - tests: internalTests.map(t => ({ testId: t.item.extId, providerId: t.providerId })), + tests: internalTests.map(t => ({ testId: t.item.extId, src: t.src })), }); } } @@ -895,7 +887,7 @@ export class ReRunLastRun extends RunOrDebugLastRun { protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ debug: false, - tests: internalTests.map(t => ({ testId: t.item.extId, providerId: t.providerId })), + tests: internalTests.map(t => ({ testId: t.item.extId, src: t.src })), }); } } @@ -917,7 +909,7 @@ export class DebugLastRun extends RunOrDebugLastRun { protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { return service.runTests({ debug: true, - tests: internalTests.map(t => ({ testId: t.item.extId, providerId: t.providerId })), + tests: internalTests.map(t => ({ testId: t.item.extId, src: t.src })), }); } } diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index 0be711424df..6b8eec7dbde 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -24,7 +24,7 @@ import { ITestingProgressUiService, TestingProgressUiService } from 'vs/workbenc import { TestingViewPaneContainer } from 'vs/workbench/contrib/testing/browser/testingViewPaneContainer'; import { testingConfiguation } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestIdPath, TestIdWithSrc } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestingAutoRun, TestingAutoRun } from 'vs/workbench/contrib/testing/common/testingAutoRun'; import { TestingContentProvider } from 'vs/workbench/contrib/testing/common/testingContentProvider'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; @@ -119,24 +119,24 @@ registerEditorContribution(Testing.DecorationsContributionId, TestingDecorations CommandsRegistry.registerCommand({ id: 'vscode.runTests', - handler: async (accessor: ServicesAccessor, tests: TestIdWithProvider[]) => { + handler: async (accessor: ServicesAccessor, tests: TestIdWithSrc[]) => { const testService = accessor.get(ITestService); - testService.runTests({ debug: false, tests: tests.filter(t => t.providerId && t.testId) }); + testService.runTests({ debug: false, tests: tests.filter(t => t.src && t.testId) }); } }); CommandsRegistry.registerCommand({ id: 'vscode.debugTests', - handler: async (accessor: ServicesAccessor, tests: TestIdWithProvider[]) => { + handler: async (accessor: ServicesAccessor, tests: TestIdWithSrc[]) => { const testService = accessor.get(ITestService); - testService.runTests({ debug: true, tests: tests.filter(t => t.providerId && t.testId) }); + testService.runTests({ debug: true, tests: tests.filter(t => t.src && t.testId) }); } }); CommandsRegistry.registerCommand({ id: 'vscode.revealTestInExplorer', - handler: async (accessor: ServicesAccessor, extId: string) => { - accessor.get(ITestExplorerFilterState).reveal.value = extId; + handler: async (accessor: ServicesAccessor, pathToTest: TestIdPath) => { + accessor.get(ITestExplorerFilterState).reveal.value = pathToTest; accessor.get(IViewsService).openView(Testing.ExplorerViewId); } }); diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts index 3745541bb66..f4ddd718331 100644 --- a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -27,7 +27,7 @@ import { BREAKPOINT_EDITOR_CONTRIBUTION_ID, IBreakpointEditorContribution } from import { testingRunAllIcon, testingRunIcon, testingStatesToIcons } from 'vs/workbench/contrib/testing/browser/icons'; import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; import { testMessageSeverityColors } from 'vs/workbench/contrib/testing/browser/theme'; -import { IncrementalTestCollectionItem, IRichLocation, ITestMessage, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { IncrementalTestCollectionItem, IRichLocation, ITestMessage, TestDiffOpType, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { IMainThreadTestCollection, ITestService } from 'vs/workbench/contrib/testing/common/testService'; @@ -125,7 +125,20 @@ export class TestingDecorations extends Disposable implements IEditorContributio return; } - this.collection.value = this.testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, uri, () => this.setDecorations(uri!)); + const collection = this.collection.value = this.testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, uri, diff => { + this.setDecorations(uri!); + + for (const op of diff) { + if (op[0] === TestDiffOpType.Add && !op[1].parent) { + collection.object?.expand(op[1].item.extId, Infinity); + } + } + }); + + for (const root of collection.object.rootIds) { + collection.object.expand(root, Infinity); + } + this.setDecorations(uri); } @@ -141,7 +154,7 @@ export class TestingDecorations extends Disposable implements IEditorContributio const stateLookup = this.results.getStateById(test.item.extId); if (hasValidLocation(uri, test.item)) { newDecorations.push(this.instantiationService.createInstance( - RunTestDecoration, test, test.item.location, this.editor, stateLookup?.[1])); + RunTestDecoration, test, ref.object, test.item.location, this.editor, stateLookup?.[1])); } if (!stateLookup) { @@ -227,6 +240,7 @@ class RunTestDecoration extends Disposable implements ITestDecoration { constructor( private readonly test: IncrementalTestCollectionItem, + private readonly collection: IMainThreadTestCollection, private readonly location: IRichLocation, private readonly editor: ICodeEditor, stateItem: TestResultItem | undefined, @@ -282,7 +296,7 @@ class RunTestDecoration extends Disposable implements ITestDecoration { } else { // todo: customize click behavior this.testService.runTests({ - tests: [{ testId: this.test.item.extId, providerId: this.test.providerId }], + tests: [{ testId: this.test.item.extId, src: this.test.src }], debug: false, }); } @@ -304,19 +318,30 @@ class RunTestDecoration extends Disposable implements ITestDecoration { if (this.test.item.runnable) { testActions.push(new Action('testing.run', localize('run test', 'Run Test'), undefined, undefined, () => this.testService.runTests({ debug: false, - tests: [{ providerId: this.test.providerId, testId: this.test.item.extId }], + tests: [{ src: this.test.src, testId: this.test.item.extId }], }))); } if (this.test.item.debuggable) { testActions.push(new Action('testing.debug', localize('debug test', 'Debug Test'), undefined, undefined, () => this.testService.runTests({ debug: true, - tests: [{ providerId: this.test.providerId, testId: this.test.item.extId }], + tests: [{ src: this.test.src, testId: this.test.item.extId }], }))); } testActions.push(new Action('testing.reveal', localize('reveal test', 'Reveal in Test Explorer'), undefined, undefined, async () => { - await this.commandService.executeCommand('vscode.revealTestInExplorer', this.test.item.extId); + const path = [this.test]; + while (true) { + const parentId = path[0].parent; + const parent = parentId && this.collection.getNodeById(parentId); + if (!parent) { + break; + } + + path.unshift(parent); + } + + await this.commandService.executeCommand('vscode.revealTestInExplorer', path.map(t => t.item.extId)); })); const breakpointActions = this.editor diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts index 9feebd491c4..4e6b78e8eee 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -27,14 +27,19 @@ import { testingFilterIcon } from 'vs/workbench/contrib/testing/browser/icons'; import { TestExplorerStateFilter, Testing } from 'vs/workbench/contrib/testing/common/constants'; import { ObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; +import { TestIdPath } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; export interface ITestExplorerFilterState { _serviceBrand: undefined; readonly text: ObservableValue; - /** Reveal request, the extId of the test to reveal */ - readonly reveal: ObservableValue; + /** + * Reveal request: the path to the test to reveal. The last element of the + * array is the test the user wanted to reveal, and the previous + * items are its parents. + */ + readonly reveal: ObservableValue; readonly stateFilter: ObservableValue; readonly currentDocumentOnly: ObservableValue; /** Whether excluded test should be shown in the view */ @@ -62,7 +67,7 @@ export class TestExplorerFilterState implements ITestExplorerFilterState { }, this.storage), false); public readonly showExcludedTests = new ObservableValue(false); - public readonly reveal = new ObservableValue(undefined); + public readonly reveal = new ObservableValue(undefined); public readonly onDidRequestInputFocus = this.focusEmitter.event; diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 48c280e37bc..1800815b5be 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -19,12 +19,11 @@ import { FuzzyScore } from 'vs/base/common/filters'; import { splitGlobAware } from 'vs/base/common/glob'; import { Iterable } from 'vs/base/common/iterator'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, dispose, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/testing'; -import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { localize } from 'vs/nls'; import { createAndFillInActionBarActions, MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; @@ -56,6 +55,7 @@ import { ITestExplorerFilterState, TestExplorerFilterState, TestingExplorerFilte import { ITestingPeekOpener, TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; import { ITestingProgressUiService } from 'vs/workbench/contrib/testing/browser/testingProgressUiService'; import { TestExplorerStateFilter, TestExplorerViewMode, TestExplorerViewSorting, Testing, testStateNames } from 'vs/workbench/contrib/testing/common/constants'; +import { TestIdPath, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { cmpPriority, isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; @@ -305,13 +305,19 @@ export class TestingExplorerViewModel extends Disposable { { simpleKeyboardNavigation: true, identityProvider: instantiationService.createInstance(IdentityProvider), - hideTwistiesOfChildlessElements: true, + hideTwistiesOfChildlessElements: false, sorter: instantiationService.createInstance(TreeSorter, this), keyboardNavigationLabelProvider: instantiationService.createInstance(TreeKeyboardNavigationLabelProvider), accessibilityProvider: instantiationService.createInstance(ListAccessibilityProvider), filter: this.filter, }) as WorkbenchObjectTree; + this._register(this.tree.onDidChangeCollapseState(evt => { + if (evt.node.element) { + this.projection.value?.expandElement(evt.node.element, evt.deep ? Infinity : 0); + } + })); + this._register(filterState.currentDocumentOnly.onDidChange(() => { if (!filterState.currentDocumentOnly.value) { this.filter.filterToUri(undefined); @@ -351,7 +357,7 @@ export class TestingExplorerViewModel extends Disposable { } })); - this._register(filterState.reveal.onDidChange(this.revealByExtId, this)); + this._register(filterState.reveal.onDidChange(this.revealByIdPath, this)); this._register(onDidChangeVisibility(visible => { if (visible) { @@ -369,15 +375,6 @@ export class TestingExplorerViewModel extends Disposable { } })); - const tracker = this._register(this.instantiationService.createInstance(CodeEditorTracker, this)); - this._register(onDidChangeVisibility(visible => { - if (visible) { - tracker.activate(); - } else { - tracker.deactivate(); - } - })); - this._register(testResults.onResultsChanged(() => { this.tree.resort(null); })); @@ -399,63 +396,70 @@ export class TestingExplorerViewModel extends Disposable { } /** - * Reveals and moves focus to the item. + * Tries to reveal by extension ID. Queues the request if the extension + * ID is not currently available. */ - public async revealItem(item: ITestTreeElement, reveal = true): Promise { - if (!this.tree.hasElement(item)) { + private revealByIdPath(idPath: TestIdPath | undefined) { + if (!idPath) { + this.hasPendingReveal = false; return; } - const chain: ITestTreeElement[] = []; - for (let parent = item.parentItem; parent; parent = parent.parentItem) { - chain.push(parent); + if (!this.projection.value) { + return; } - for (const parent of chain.reverse()) { - try { - this.tree.expand(parent); - } catch { - // ignore if not present + // If the item itself is visible in the tree, show it. Otherwise, expand + // its closest parent. + let expandToLevel = 0; + for (let i = idPath.length - 1; i >= expandToLevel; i--) { + const element = this.projection.value.getElementByTestId(idPath[i]); + // Skip all elements that aren't in the tree. + if (!element || !this.tree.hasElement(element)) { + continue; + } + + // If this 'if' is true, we're at the clostest-visible parent to the node + // we want to expand. Expand that, and then start the loop again because + // we might already have children for it. + if (i < idPath.length - 1) { + this.tree.expand(element); + expandToLevel = i + 1; // avoid an infinite loop if the test does not exist + i = idPath.length - 1; // restart the loop since new children may now be visible + continue; } - } - if (reveal === true && this.tree.getRelativeTop(item) === null) { - // Don't scroll to the item if it's already visible, or if set not to. - this.tree.reveal(item, 0.5); - } + // Otherwise, we've arrived! - this.tree.setFocus([item]); - this.tree.setSelection([item]); - } + // If the node or any of its children are exlcuded, flip on the 'show + // excluded tests' checkbox automatically. + for (let n: ITestTreeElement | null = element; n; n = n.parentItem) { + if (n.test && this.testService.excludeTests.value.has(n.test.item.extId)) { + this.filterState.showExcludedTests.value = true; + break; + } + } - /** - * Tries to reveal by extension ID. Queues the request if the extension - * ID is not currently available. - */ - private revealByExtId(testExtId: string | undefined) { - if (!testExtId) { + this.filterState.reveal.value = undefined; this.hasPendingReveal = false; - return; - } + this.tree.domFocus(); - const item = testExtId && this.projection.value?.getElementByTestId(testExtId); - if (!item) { - this.hasPendingReveal = true; - return; - } + setTimeout(() => { + // Don't scroll to the item if it's already visible + if (this.tree.getRelativeTop(element) === null) { + this.tree.reveal(element, 0.5); + } - // reveal the test if it's hidden, #117481 - for (let n: ITestTreeElement | null = item; n; n = n.parentItem) { - if (n.test && this.testService.excludeTests.value.has(n.test.item.extId)) { - this.filterState.showExcludedTests.value = true; - break; - } + this.tree.setFocus([element]); + this.tree.setSelection([element]); + }, 1); + + return; } - setTimeout(() => this.revealItem(item, true), 1); - this.filterState.reveal.value = undefined; - this.hasPendingReveal = false; - this.tree.domFocus(); + // If here, we've expanded all parents we can. Waiting on data to come + // in to possibly show the revealed test. + this.hasPendingReveal = true; } /** @@ -537,7 +541,7 @@ export class TestingExplorerViewModel extends Disposable { if (toRun.length) { this.testService.runTests({ debug: false, - tests: toRun.map(t => ({ providerId: t.providerId, testId: t.item.extId })), + tests: toRun.map(t => ({ src: t.src, testId: t.item.extId })), }); } } @@ -576,7 +580,7 @@ export class TestingExplorerViewModel extends Disposable { this.projection.value?.applyTo(this.tree); if (this.hasPendingReveal) { - this.revealByExtId(this.filterState.reveal.value); + this.revealByIdPath(this.filterState.reveal.value); } } @@ -588,61 +592,6 @@ export class TestingExplorerViewModel extends Disposable { } } -class CodeEditorTracker { - private store = new DisposableStore(); - private lastRevealed?: ITestTreeElement; - - constructor( - private readonly model: TestingExplorerViewModel, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, - ) { - } - - public activate() { - const editorStores = new Set(); - this.store.add(toDisposable(() => { - for (const store of editorStores) { - store.dispose(); - } - })); - - const register = (editor: ICodeEditor) => { - const store = new DisposableStore(); - editorStores.add(store); - - store.add(editor.onDidChangeCursorPosition(evt => { - const uri = editor.getModel()?.uri; - if (!uri) { - return; - } - - const test = this.model.projection.value?.getTestAtPosition(uri, evt.position); - if (test && test !== this.lastRevealed) { - this.model.revealItem(test); - this.lastRevealed = test; - } - })); - - editor.onDidDispose(() => { - store.dispose(); - editorStores.delete(store); - }); - }; - - this.store.add(this.codeEditorService.onCodeEditorAdd(register)); - this.codeEditorService.listCodeEditors().forEach(register); - } - - public deactivate() { - this.store.dispose(); - this.store = new DisposableStore(); - } - - public dispose() { - this.store.dispose(); - } -} - const enum FilterResult { Exclude, Inherit, @@ -890,7 +839,7 @@ class TestsRenderer extends Disposable implements ITreeRenderer | AsyncIterable | undefined | null; +} /** * @private */ export class OwnedTestCollection { - protected readonly testIdsToInternal = new Set>(); + protected readonly testIdsToInternal = new Map>(); /** * Gets test information by ID, if it was defined and still exists in this * extension host. */ - public getTestById(id: string) { - return mapFind(this.testIdsToInternal, t => { + public getTestById(id: string, preferTree?: number): undefined | [ + tree: TestTree, + test: OwnedCollectionTestItem, + ] { + if (preferTree !== undefined) { + const tree = this.testIdsToInternal.get(preferTree); + const test = tree?.get(id); + if (test) { + return [tree!, test]; + } + } + return mapFind(this.testIdsToInternal.values(), t => { const owned = t.get(id); - return owned && [t, owned] as const; + return owned && [t, owned]; }); } @@ -32,22 +47,26 @@ export class OwnedTestCollection { * or document observation. */ public createForHierarchy(publishDiff: (diff: TestsDiff) => void = () => undefined) { - return new SingleUseTestCollection(this.createIdMap(), publishDiff); + return new SingleUseTestCollection(this.createIdMap(treeIdCounter++), publishDiff); } - protected createIdMap(): IReference> { - const tree = new TestTree(); - this.testIdsToInternal.add(tree); - return { object: tree, dispose: () => this.testIdsToInternal.delete(tree) }; + protected createIdMap(id: number): IReference> { + const tree = new TestTree(id); + this.testIdsToInternal.set(tree.id, tree); + return { object: tree, dispose: () => this.testIdsToInternal.delete(tree.id) }; } } /** * @private */ export interface OwnedCollectionTestItem extends InternalTestItem { - actual: TestItem.Raw; - previousChildren: Set; - previousEquals: (v: TestItem.Raw) => boolean; + actual: TestItemImpl; + /** + * Number of levels of items below this one that are expanded. May be infinite. + */ + expandLevels?: number; + initialExpand?: DeferredPromise; + discoverCts?: CancellationTokenSource; } /** @@ -65,6 +84,8 @@ export const enum TestPosition { IsSame, } +let treeIdCounter = 0; + /** * Test tree is (or will be after debt week 2020-03) the standard collection * for test trees. Internally it indexes tests by their extension ID in @@ -75,6 +96,8 @@ export class TestTree { private readonly _roots = new Set(); public readonly roots: ReadonlySet = this._roots; + constructor(public readonly id: number) { } + /** * Gets the size of the tree. */ @@ -170,14 +193,11 @@ export class TestTree { export class SingleUseTestCollection implements IDisposable { protected readonly testItemToInternal = new Map(); protected diff: TestsDiff = []; - private disposed = false; + private readonly debounceSendDiff = new RunOnceScheduler(() => this.flushDiff(), 200); - /** - * Debouncer for sending diffs. We use both a throttle and a debounce here, - * so that tests that all change state simultenously are effected together, - * but so we don't send hundreds of test updates per second to the main thread. - */ - private readonly debounceSendDiff = new RunOnceScheduler(() => this.throttleSendDiff(), 2); + public get treeId() { + return this.testIdToInternal.object.id; + } constructor( private readonly testIdToInternal: IReference>, @@ -189,7 +209,6 @@ export class SingleUseTestCollection implements IDisposable { */ public addRoot(item: TestItem.Raw, providerId: string) { this.addItem(item, providerId, null); - this.debounceSendDiff.schedule(); } /** @@ -200,22 +219,6 @@ export class SingleUseTestCollection implements IDisposable { return this.testItemToInternal.get(item); } - /** - * Should be called when an item change is fired on the test provider. - */ - public onItemChange(item: TestItem.Raw, providerId: string) { - const existing = this.testItemToInternal.get(item); - if (!existing) { - if (!this.disposed) { - console.warn(`Received a TestProvider.onDidChangeTest for a test that wasn't seen before as a child.`); - } - return; - } - - this.addItem(item, providerId, existing.parent); - this.debounceSendDiff.schedule(); - } - /** * Gets a diff of all changes that have been made, and clears the diff queue. */ @@ -229,72 +232,169 @@ export class SingleUseTestCollection implements IDisposable { * Pushes a new diff entry onto the collected diff list. */ public pushDiff(diff: TestsDiffOp) { + // Try to merge updates, since they're invoked per-property + const last = this.diff[this.diff.length - 1]; + if (last && diff[0] === TestDiffOpType.Update) { + if (last[0] === TestDiffOpType.Update && last[1].extId === diff[1].extId) { + applyTestItemUpdate(last[1], diff[1]); + return; + } + + if (last[0] === TestDiffOpType.Add && last[1].item.extId === diff[1].extId) { + applyTestItemUpdate(last[1], diff[1]); + return; + } + } + this.diff.push(diff); - this.debounceSendDiff.schedule(); + + if (!this.debounceSendDiff.isScheduled()) { + this.debounceSendDiff.schedule(); + } } + /** + * Expands the test and the given number of `levels` of children. If levels + * is < 0, then all children will be expanded. If it's 0, then only this + * item will be expanded. + */ + public expand(testId: string, levels: number): Promise | void { + const internal = this.testIdToInternal.object.get(testId); + if (!internal) { + return; + } + + if (internal.expandLevels === undefined || levels > internal.expandLevels) { + internal.expandLevels = levels; + } + + // try to avoid awaiting things if the provider returns synchronously in + // order to keep everything in a single diff and DOM update. + if (internal.expand === TestItemExpandState.Expandable) { + const r = this.refreshChildren(internal); + return !r.isSettled + ? r.p.then(() => this.expandChildren(internal, levels - 1)) + : this.expandChildren(internal, levels - 1); + } else if (internal.expand === TestItemExpandState.Expanded) { + return internal.initialExpand?.isSettled === false + ? internal.initialExpand.p.then(() => this.expandChildren(internal, levels - 1)) + : this.expandChildren(internal, levels - 1); + } + } + + /** + * @inheritdoc + */ public dispose() { - this.testIdToInternal.dispose(); + for (const item of this.testItemToInternal.values()) { + item.discoverCts?.dispose(true); + (item.actual as TestItemImpl)[TestItemHookProperty] = undefined; + } + this.diff = []; - this.disposed = true; + this.testIdToInternal.dispose(); + this.debounceSendDiff.dispose(); } - private addItem(actual: TestItem.Raw, providerId: string, parent: string | null) { - let internal = this.testItemToInternal.get(actual); - if (!internal) { - if (this.testIdToInternal.object.has(actual.id)) { - throw new Error(`Attempted to insert a duplicate test item ID ${actual.id}`); - } + private addItem(actual: TestItem.Raw, 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`); + } - internal = { - actual, - parent, - item: TestItem.from(actual), - providerId, - previousChildren: new Set(), - previousEquals: itemEqualityComparator(actual), - }; - - this.testIdToInternal.object.add(internal); - this.testItemToInternal.set(actual, internal); - this.diff.push([TestDiffOpType.Add, { parent, providerId, item: internal.item }]); - } else if (!internal.previousEquals(actual)) { - internal.item = TestItem.from(actual); - internal.previousEquals = itemEqualityComparator(actual); - this.diff.push([TestDiffOpType.Update, { parent, providerId, item: internal.item }]); + if (this.testItemToInternal.has(actual)) { + throw new Error(`Attempted to add a single TestItem ${actual.id} multiple times to the tree`); } - // If there are children, track which ones are deleted - // and recursively and/update them. - if (actual.children) { - const deletedChildren = internal.previousChildren; - const currentChildren = new Set(); - for (const child of actual.children) { - // If a child was recreated, delete the old object before calling - // addItem() anew. - const previous = this.testIdToInternal.object.get(child.id); - if (previous && previous.actual !== child) { - this.removeItembyId(child.id); - } + if (this.testIdToInternal.object.has(actual.id)) { + throw new Error(`Attempted to insert a duplicate test item ID ${actual.id}`); + } - const c = this.addItem(child, providerId, internal.item.extId); - deletedChildren.delete(c.item.extId); - currentChildren.add(c.item.extId); - } + const parentId = parent ? parent.item.extId : null; + const expand = actual.expandable ? TestItemExpandState.Expandable : TestItemExpandState.NotExpandable; + const pExpandLvls = parent?.expandLevels; + const src = { provider: providerId, tree: this.testIdToInternal.object.id }; + const internal: OwnedCollectionTestItem = { + actual, + parent: parentId, + item: TestItem.from(actual), + expandLevels: pExpandLvls && expand === TestItemExpandState.Expandable ? pExpandLvls - 1 : undefined, + expand, + src, + }; + + this.testIdToInternal.object.add(internal); + 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]: value } }]) + }; + + // Discover any existing children that might have already been added + for (const child of actual.children) { + this.addItem(child, providerId, internal); + } + } - for (const child of deletedChildren) { - this.removeItembyId(child); - } + /** + * 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 + * will be expanded. If it's <0, it's a no-op. + */ + private expandChildren(internal: OwnedCollectionTestItem, levels: number): Promise | void { + if (levels < 0) { + return; + } + + const asyncChildren = [...internal.actual.children] + .map(c => this.expand(c.id, levels - 1)) + .filter(isThenable); - internal.previousChildren = currentChildren; + if (asyncChildren.length) { + return Promise.all(asyncChildren).then(() => { }); + } + } + + /** + * Calls `discoverChildren` on the item, refreshing all its tests. + */ + private refreshChildren(internal: OwnedCollectionTestItem) { + if (internal.discoverCts) { + internal.discoverCts.dispose(true); } + internal.expand = TestItemExpandState.BusyExpanding; + internal.discoverCts = new CancellationTokenSource(); + this.pushExpandStateUpdate(internal); + + const updateComplete = new DeferredPromise(); + internal.initialExpand = updateComplete; + + 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 internal; + return updateComplete; + } + + private pushExpandStateUpdate(internal: OwnedCollectionTestItem) { + this.pushDiff([TestDiffOpType.Update, { extId: internal.actual.id, expand: internal.expand }]); } private removeItembyId(id: string) { - this.diff.push([TestDiffOpType.Remove, id]); + this.pushDiff([TestDiffOpType.Remove, id]); const queue = [this.testIdToInternal.object.get(id)]; while (queue.length) { @@ -303,19 +403,14 @@ export class SingleUseTestCollection implements IDisposable { continue; } + item.discoverCts?.dispose(true); this.testIdToInternal.object.delete(item.item.extId); this.testItemToInternal.delete(item.actual); - for (const child of item.previousChildren) { - queue.push(this.testIdToInternal.object.get(child)); + for (const child of item.actual.children) { + queue.push(this.testIdToInternal.object.get(child.id)); } } } - - @throttle(200) - protected throttleSendDiff() { - this.flushDiff(); - } - public flushDiff() { const diff = this.collectDiff(); if (diff.length) { @@ -323,31 +418,3 @@ export class SingleUseTestCollection implements IDisposable { } } } - -const keyMap: { [K in keyof Omit]: null } = { - id: null, - label: null, - location: null, - debuggable: null, - description: null, - runnable: null -}; - -const simpleProps = Object.keys(keyMap) as ReadonlyArray; - -const itemEqualityComparator = (a: TestItem.Raw) => { - const values: unknown[] = []; - for (const prop of simpleProps) { - values.push(a[prop]); - } - - return (b: TestItem.Raw) => { - for (let i = 0; i < simpleProps.length; i++) { - if (values[i] !== b[simpleProps[i]]) { - return false; - } - } - - return true; - }; -}; diff --git a/src/vs/workbench/contrib/testing/common/testCollection.ts b/src/vs/workbench/contrib/testing/common/testCollection.ts index 9c871a14e3e..f667a2e918b 100644 --- a/src/vs/workbench/contrib/testing/common/testCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -9,16 +9,22 @@ import { Range } from 'vs/editor/common/core/range'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; import { TestMessageSeverity, TestResult } from 'vs/workbench/api/common/extHostTypes'; -export interface TestIdWithProvider { +export interface TestIdWithSrc { testId: string; - providerId: string; + src: { provider: string; tree: number }; } +/** + * Defines the path to a test, as a list of test IDs. The last element of the + * array is the test ID, and the predecessors are its parents, in order. + */ +export type TestIdPath = string[]; + /** * Request to the main thread to run a set of tests. */ export interface RunTestsRequest { - tests: TestIdWithProvider[]; + tests: TestIdWithSrc[]; exclude?: string[]; debug: boolean; isAutoRun?: boolean; @@ -30,8 +36,7 @@ export interface RunTestsRequest { export interface RunTestForProviderRequest { runId: string; excludeExtIds: string[]; - providerId: string; - ids: string[]; + tests: TestIdWithSrc[]; debug: boolean; } @@ -69,17 +74,44 @@ export interface ITestItem { description: string | undefined; runnable: boolean; debuggable: boolean; + expandable: boolean; +} + +export const enum TestItemExpandState { + NotExpandable, + Expandable, + BusyExpanding, + Expanded, } /** * TestItem-like shape, butm with an ID and children as strings. */ export interface InternalTestItem { - providerId: string; + src: { provider: string; tree: number }; + expand: TestItemExpandState; parent: string | null; item: ITestItem; } +/** + * A partial update made to an existing InternalTestItem. + */ +export interface ITestItemUpdate { + extId: string; + expand?: TestItemExpandState; + item?: Partial; +} + +export const applyTestItemUpdate = (internal: InternalTestItem | ITestItemUpdate, patch: ITestItemUpdate) => { + if (patch.expand !== undefined) { + internal.expand = patch.expand; + } + if (patch.item !== undefined) { + Object.assign(internal.item, patch.item); + } +}; + /** * Test result item used in the main thread. */ @@ -94,7 +126,8 @@ export interface TestResultItem extends IncrementalTestCollectionItem { direct?: boolean; } -export type SerializedTestResultItem = Omit & { children: string[], retired: undefined }; +export type SerializedTestResultItem = Omit + & { children: string[], retired: undefined }; /** * Test results serialized for transport and storage. @@ -115,17 +148,18 @@ export const enum TestDiffOpType { Update, /** Removes a test (and all its children) */ Remove, - /** Changes the number of providers running initial test discovery. */ - DeltaDiscoverComplete, /** Changes the number of providers who are yet to publish their collection roots. */ DeltaRootsComplete, + /** Retires a test/result */ + Retire, } export type TestsDiffOp = | [op: TestDiffOpType.Add, item: InternalTestItem] - | [op: TestDiffOpType.Update, item: InternalTestItem] + | [op: TestDiffOpType.Update, item: ITestItemUpdate] | [op: TestDiffOpType.Remove, itemId: string] - | [op: TestDiffOpType.DeltaDiscoverComplete | TestDiffOpType.DeltaRootsComplete, amount: number]; + | [op: TestDiffOpType.Retire, itemId: string] + | [op: TestDiffOpType.DeltaRootsComplete, amount: number]; /** * Utility function to get a unique string for a subscription to a resource, @@ -189,6 +223,16 @@ export abstract class AbstractIncrementalTestCollection(); + /** + * Number of 'busy' providers. + */ + protected busyProviderCount = 0; + + /** + * Number of pending roots. + */ + protected pendingRootCount = 0; + /** * Applies the diff to the collection. */ @@ -211,15 +255,24 @@ export abstract class AbstractIncrementalTestCollection('testService'); export interface MainTestController { - lookupTest(test: TestIdWithProvider): Promise; + expandTest(src: TestIdWithSrc, levels: number): Promise; + lookupTest(test: TestIdWithSrc): Promise; runTests(request: RunTestForProviderRequest, token: CancellationToken): Promise; } @@ -51,6 +52,12 @@ export interface IMainThreadTestCollection extends AbstractIncrementalTestCollec */ getNodeById(id: string): IncrementalTestCollectionItem | undefined; + /** + * Requests that children be revealed for the given test. "Levels" may + * be infinite. + */ + expand(testId: string, levels: number): Promise; + /** * Gets a diff that adds all items currently in the tree to a new collection, * allowing it to fully hydrate. @@ -75,25 +82,69 @@ export const waitForAllRoots = (collection: IMainThreadTestCollection, ct = Canc }).finally(() => disposable.dispose()); }; -export const waitForAllTests = async (collection: IMainThreadTestCollection, ct = CancellationToken.None) => { +/** + * Ensures the test with the given path exists in the collection, if possible. + * If cancellation is requested, or the test cannot be found, it will return + * undefined. + */ +export const getTestByPath = async (collection: IMainThreadTestCollection, idPath: TestIdPath, ct = CancellationToken.None) => { + await waitForAllRoots(collection, ct); + + // Expand all direct children since roots might well have different IDs, but + // children should start matching. + await Promise.all([...collection.rootIds].map(r => collection.expand(r, 0))); + + if (ct.isCancellationRequested) { + return undefined; + } + + let expandToLevel = 0; + for (let i = idPath.length - 1; !ct.isCancellationRequested && i >= expandToLevel;) { + const id = idPath[i]; + const existing = collection.getNodeById(id); + if (!existing) { + i--; + continue; + } + + if (i === idPath.length - 1) { + return existing; + } + + await collection.expand(id, 0); + expandToLevel = i + 1; // avoid an infinite loop if the test does not exist + i = idPath.length - 1; + } + return undefined; +}; + +/** + * Waits for all test in the hierarchy to be fulfilled before returning. + * If cancellation is requested, it will return early. + */ +export const getAllTestsInHierarchy = async (collection: IMainThreadTestCollection, ct = CancellationToken.None) => { await waitForAllRoots(collection, ct); - if (collection.busyProviders === 0 || ct.isCancellationRequested) { + if (ct.isCancellationRequested) { return; } - const disposable = new DisposableStore(); - return new Promise(resolve => { - disposable.add(collection.onBusyProvidersChange(count => { - if (count === 0) { - resolve(); - } - })); + let l: IDisposable; - disposable.add(ct.onCancellationRequested(() => resolve())); - }).finally(() => disposable.dispose()); + await Promise.race([ + Promise.all([...collection.rootIds].map(r => collection.expand(r, Infinity))), + new Promise(r => { l = ct.onCancellationRequested(r); }), + ]).finally(() => l?.dispose()); }; +/** + * An instance of the RootProvider should be registered for each extension + * host. + */ +export interface ITestRootProvider { + // todo: nothing, yet +} + export interface ITestService { readonly _serviceBrand: undefined; readonly onShouldSubscribe: Event<{ resource: ExtHostTestingResource, uri: URI; }>; @@ -118,24 +169,37 @@ export interface ITestService { */ clearExcludedTests(): void; - registerTestController(id: string, controller: MainTestController): IDisposable; - runTests(req: RunTestsRequest, token?: CancellationToken): Promise; - cancelTestRun(req: RunTestsRequest): void; - publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void; - subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference; - /** * Updates the number of sources who provide test roots when subscription * is requested. This is equal to the number of extension hosts, and used * with `TestDiffOpType.DeltaRootsComplete` to signal when all roots * are available. */ - updateRootProviderCount(delta: number): void; + registerRootProvider(provider: ITestRootProvider): IDisposable; + + /** + * Registers an interface that runs tests for the given provider ID. + */ + registerTestController(providerId: string, controller: MainTestController): IDisposable; + + /** + * Requests that tests be executed. + */ + runTests(req: RunTestsRequest, token?: CancellationToken): Promise; + + /** + * Cancels an ongoign test run request. + */ + cancelTestRun(req: RunTestsRequest): void; + + publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void; + subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference; + /** * Looks up a test, by a request to extension hosts. */ - lookupTest(test: TestIdWithProvider): Promise; + lookupTest(test: TestIdWithSrc): Promise; /** * Requests to resubscribe to all active subscriptions, discarding old tests. diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 8e6e601e9fe..808fd5f07c8 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -16,10 +16,10 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; import { ObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; -import { AbstractIncrementalTestCollection, getTestSubscriptionKey, IncrementalTestCollectionItem, InternalTestItem, RunTestsRequest, TestDiffOpType, TestIdWithProvider, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; +import { AbstractIncrementalTestCollection, getTestSubscriptionKey, IncrementalTestCollectionItem, InternalTestItem, RunTestsRequest, TestDiffOpType, TestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestResult, ITestResultService, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResultService'; -import { IMainThreadTestCollection, ITestService, MainTestController, TestDiffListener } from 'vs/workbench/contrib/testing/common/testService'; +import { IMainThreadTestCollection, ITestRootProvider, ITestService, MainTestController, TestDiffListener } from 'vs/workbench/contrib/testing/common/testService'; type TestLocationIdent = { resource: ExtHostTestingResource, uri: URI }; @@ -45,10 +45,10 @@ export class TestService extends Disposable implements ITestService { private readonly hasRunnable: IContextKey; private readonly hasDebuggable: IContextKey; private readonly runningTests = new Map(); - private rootProviderCount = 0; + private readonly rootProviders = new Set(); public readonly excludeTests = ObservableValue.stored(new StoredValue>({ - key: 'excludedTestItes', + key: 'excludedTestItems', scope: StorageScope.WORKSPACE, target: StorageTarget.USER, serialization: { @@ -69,6 +69,13 @@ export class TestService extends Disposable implements ITestService { this.hasRunnable = TestingContextKeys.hasRunnableTests.bindTo(contextKeyService); } + /** + * @inheritdoc + */ + public async expandTest(test: TestIdWithSrc, levels: number) { + await this.testControllers.get(test.src.provider)?.expandTest(test, levels); + } + /** * @inheritdoc */ @@ -143,7 +150,7 @@ export class TestService extends Disposable implements ITestService { /** * @inheritdoc */ - public async lookupTest(test: TestIdWithProvider) { + public async lookupTest(test: TestIdWithSrc) { for (const { collection } of this.testSubscriptions.values()) { const node = collection.getNodeById(test.testId); if (node) { @@ -151,17 +158,29 @@ export class TestService extends Disposable implements ITestService { } } - return this.testControllers.get(test.providerId)?.lookupTest(test); + return this.testControllers.get(test.src.provider)?.lookupTest(test); } /** * @inheritdoc */ - public updateRootProviderCount(delta: number) { - this.rootProviderCount += delta; + public registerRootProvider(provider: ITestRootProvider) { + if (this.rootProviders.has(provider)) { + return toDisposable(() => { }); + } + + this.rootProviders.add(provider); for (const { collection } of this.testSubscriptions.values()) { - collection.updatePendingRoots(delta); + collection.updatePendingRoots(1); } + + return toDisposable(() => { + if (this.rootProviders.delete(provider)) { + for (const { collection } of this.testSubscriptions.values()) { + collection.updatePendingRoots(-1); + } + } + }); } @@ -179,26 +198,23 @@ export class TestService extends Disposable implements ITestService { const result = this.testResults.push(LiveTestResult.from(subscriptions.map(s => s.object), req)); try { - const tests = groupBy(req.tests, (a, b) => a.providerId === b.providerId ? 0 : 1); + const tests = groupBy(req.tests, (a, b) => a.src.provider === b.src.provider ? 0 : 1); const cancelSource = new CancellationTokenSource(token); this.runningTests.set(req, cancelSource); - const requests = tests.map(group => { - const providerId = group[0].providerId; - const controller = this.testControllers.get(providerId); - return controller?.runTests( + const requests = tests.map( + group => this.testControllers.get(group[0].src.provider)?.runTests( { runId: result.id, - providerId, debug: req.debug, excludeExtIds: req.exclude ?? [], - ids: group.map(t => t.testId), + tests: group, }, cancelSource.token, ).catch(err => { this.notificationService.error(localize('testError', 'An error occurred attempting to run tests: {0}', err.message)); - }); - }); + }) + ); await Promise.all(requests); return result; @@ -230,10 +246,22 @@ export class TestService extends Disposable implements ITestService { if (!subscription) { subscription = { ident: { resource, uri }, - collection: new MainThreadTestCollection(this.rootProviderCount), + collection: new MainThreadTestCollection( + this.rootProviders.size, + this.expandTest.bind(this), + ), listeners: 0, onDiff: new Emitter(), }; + + subscription.collection.onDidRetireTest(testId => { + for (const result of this.testResults.results) { + if (result instanceof LiveTestResult) { + result.retire(testId); + } + } + }); + this.subscribeEmitter.fire({ resource, uri }); this.testSubscriptions.set(subscriptionKey, subscription); } else if (subscription.disposeTimeout) { @@ -316,20 +344,25 @@ export class TestService extends Disposable implements ITestService { export class MainThreadTestCollection extends AbstractIncrementalTestCollection implements IMainThreadTestCollection { private pendingRootChangeEmitter = new Emitter(); private busyProvidersChangeEmitter = new Emitter(); - private _busyProviders = 0; + private retireTestEmitter = new Emitter(); + private expandPromises = new WeakMap; + }>(); /** * @inheritdoc */ public get pendingRootProviders() { - return this._pendingRootProviders; + return this.pendingRootCount; } /** * @inheritdoc */ public get busyProviders() { - return this._busyProviders; + return this.busyProviderCount; } /** @@ -346,12 +379,37 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection< return this.getIterator(); } - public readonly onPendingRootProvidersChange = this.pendingRootChangeEmitter.event; public readonly onBusyProvidersChange = this.busyProvidersChangeEmitter.event; + public readonly onDidRetireTest = this.retireTestEmitter.event; - constructor(private _pendingRootProviders: number) { + constructor(pendingRootProviders: number, private readonly expandActual: (src: TestIdWithSrc, levels: number) => Promise) { super(); + this.pendingRootCount = pendingRootProviders; + } + + /** + * @inheritdoc + */ + public expand(testId: string, levels: number): Promise { + const test = this.items.get(testId); + if (!test) { + return Promise.resolve(); + } + + // simple cache to avoid duplicate/unnecessary expansion calls + const existing = this.expandPromises.get(test); + if (existing && existing.pendingLvl >= levels) { + return existing.prom; + } + + const prom = this.expandActual({ src: test.src, testId: test.item.extId }, levels); + const record = { doneLvl: existing ? existing.doneLvl : -1, pendingLvl: levels, prom }; + this.expandPromises.set(test, record); + + return prom.then(() => { + record.doneLvl = levels; + }); } /** @@ -365,16 +423,18 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection< * @inheritdoc */ public getReviverDiff() { - const ops: TestsDiff = [ - [TestDiffOpType.DeltaDiscoverComplete, this._busyProviders], - [TestDiffOpType.DeltaRootsComplete, this._pendingRootProviders], - ]; + const ops: TestsDiff = [[TestDiffOpType.DeltaRootsComplete, this.pendingRootCount]]; const queue = [this.roots]; while (queue.length) { for (const child of queue.pop()!) { const item = this.items.get(child)!; - ops.push([TestDiffOpType.Add, { providerId: item.providerId, item: item.item, parent: item.parent }]); + ops.push([TestDiffOpType.Add, { + src: item.src, + expand: item.expand, + item: item.item, + parent: item.parent, + }]); queue.push(item.children); } } @@ -382,6 +442,23 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection< return ops; } + + /** + * Applies the diff to the collection. + */ + public apply(diff: TestsDiff) { + let prevBusy = this.busyProviderCount; + let prevPendingRoots = this.pendingRootCount; + super.apply(diff); + + if (prevBusy !== this.busyProviderCount) { + this.busyProvidersChangeEmitter.fire(this.busyProviderCount); + } + if (prevPendingRoots !== this.pendingRootCount) { + this.pendingRootChangeEmitter.fire(this.pendingRootCount); + } + } + /** * Clears everything from the collection, and returns a diff that applies * that action. @@ -401,24 +478,15 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection< /** * @override */ - protected updateBusyProviders(delta: number) { - this._busyProviders += delta; - this.busyProvidersChangeEmitter.fire(this._busyProviders); - } - - /** - * @override - */ - public updatePendingRoots(delta: number) { - this._pendingRootProviders = Math.max(0, this._pendingRootProviders + delta); - this.pendingRootChangeEmitter.fire(this._pendingRootProviders); + protected createItem(internal: InternalTestItem): IncrementalTestCollectionItem { + return { ...internal, children: new Set() }; } /** * @override */ - protected createItem(internal: InternalTestItem): IncrementalTestCollectionItem { - return { ...internal, children: new Set() }; + protected retireTest(testId: string) { + this.retireTestEmitter.fire(testId); } private *getIterator() { diff --git a/src/vs/workbench/contrib/testing/common/testStubs.ts b/src/vs/workbench/contrib/testing/common/testStubs.ts index e88a67404ef..9c771bde997 100644 --- a/src/vs/workbench/contrib/testing/common/testStubs.ts +++ b/src/vs/workbench/contrib/testing/common/testStubs.ts @@ -3,12 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IProgress } from 'vs/platform/progress/common/progress'; import { TestItem, TestResult } from 'vs/workbench/api/common/extHostTypes'; -export const stubTest = (label: string, idPrefix = 'id-', children: TestItem[] = []): TestItem => { - const t = new TestItem(idPrefix + label, label); - t.children = children; - return t; +export class StubTestItem extends TestItem { + parent: StubTestItem | undefined; + + constructor(id: string, label: string, private readonly pendingChildren: StubTestItem[]) { + super(id, label, pendingChildren.length > 0); + } + + public 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); }; export const testStubs = { diff --git a/src/vs/workbench/contrib/testing/common/testingAutoRun.ts b/src/vs/workbench/contrib/testing/common/testingAutoRun.ts index 65de1849885..f43532e54b9 100644 --- a/src/vs/workbench/contrib/testing/common/testingAutoRun.ts +++ b/src/vs/workbench/contrib/testing/common/testingAutoRun.ts @@ -10,7 +10,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { AutoRunMode, getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; -import { InternalTestItem, TestDiffOpType, TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection'; +import { InternalTestItem, TestDiffOpType, TestIdWithSrc } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestResultService, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResultService'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; @@ -66,7 +66,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { */ private makeRunner() { let isRunning = false; - const rerunIds = new Map(); + const rerunIds = new Map(); const store = new DisposableStore(); const cts = new CancellationTokenSource(); store.add(toDisposable(() => cts.dispose(true))); @@ -91,7 +91,7 @@ export class TestingAutoRun extends Disposable implements ITestingAutoRun { }, delay)); const addToRerun = (test: InternalTestItem) => { - rerunIds.set(`${test.item.extId}/${test.providerId}`, ({ testId: test.item.extId, providerId: test.providerId })); + rerunIds.set(`${test.item.extId}/${test.src.provider}`, ({ testId: test.item.extId, src: test.src })); if (!isRunning) { scheduler.schedule(delay); } 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 cd80ecd830e..7548ab2d7db 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 @@ -8,12 +8,18 @@ import { HierarchicalByLocationProjection } from 'vs/workbench/contrib/testing/b import { testStubs } from 'vs/workbench/contrib/testing/common/testStubs'; import { makeTestWorkspaceFolder, TestTreeTestHarness } from 'vs/workbench/contrib/testing/test/browser/testObjectTree'; +class TestHierarchicalByLocationProjection extends HierarchicalByLocationProjection { + public get folderNodes() { + return [...this.folders.values()]; + } +} + suite('Workbench - Testing Explorer Hierarchal by Location Projection', () => { - let harness: TestTreeTestHarness; + let harness: TestTreeTestHarness; const folder1 = makeTestWorkspaceFolder('f1'); const folder2 = makeTestWorkspaceFolder('f2'); setup(() => { - harness = new TestTreeTestHarness(l => new HierarchicalByLocationProjection(l, { + harness = new TestTreeTestHarness([folder1, folder2], l => new TestHierarchicalByLocationProjection(l, { onResultsChanged: () => undefined, onTestChanged: () => undefined, getStateById: () => ({ state: { state: 0 }, computedState: 0 }), @@ -24,52 +30,73 @@ suite('Workbench - Testing Explorer Hierarchal by Location Projection', () => { harness.dispose(); }); - test('renders initial tree', () => { + test('renders initial tree', async () => { harness.c.addRoot(testStubs.nested(), 'a'); - assert.deepStrictEqual(harness.flush(folder1), [ + harness.flush(folder1); + assert.deepStrictEqual(harness.tree.getRendered(), [ + { e: 'a' }, { e: 'b' } + ]); + }); + + test('expands children', async () => { + harness.c.addRoot(testStubs.nested(), 'a'); + harness.flush(folder1); + harness.tree.expand(harness.projection.getElementByTestId('id-a')!); + assert.deepStrictEqual(harness.flush(), [ { e: 'a', children: [{ e: 'aa' }, { e: 'ab' }] }, { e: 'b' } ]); }); - test('updates render if a second folder is added', () => { + test('updates render if a second folder is added', async () => { harness.c.addRoot(testStubs.nested('id1-'), 'a'); harness.flush(folder1); harness.c.addRoot(testStubs.nested('id2-'), 'a'); harness.flush(folder2); - assert.deepStrictEqual(harness.flush(folder1), [ + assert.deepStrictEqual(harness.tree.getRendered(), [ + { e: 'f1', children: [{ e: 'a' }, { e: 'b' }] }, + { e: 'f2', children: [{ e: 'a' }, { e: 'b' }] }, + ]); + + harness.tree.expand(harness.projection.getElementByTestId('id1-a')!); + assert.deepStrictEqual(harness.flush(), [ { e: 'f1', children: [{ e: 'a', children: [{ e: 'aa' }, { e: 'ab' }] }, { e: 'b' }] }, - { e: 'f2', children: [{ e: 'a', children: [{ e: 'aa' }, { e: 'ab' }] }, { e: 'b' }] }, + { e: 'f2', children: [{ e: 'a' }, { e: 'b' }] }, ]); }); - test('updates render if second folder is removed', () => { + test('updates render if second folder is removed', async () => { harness.c.addRoot(testStubs.nested('id1-'), 'a'); harness.flush(folder1); harness.c.addRoot(testStubs.nested('id2-'), 'a'); harness.flush(folder2); harness.onFolderChange.fire({ added: [], changed: [], removed: [folder1] }); assert.deepStrictEqual(harness.flush(folder1), [ - { e: 'a', children: [{ e: 'aa' }, { e: 'ab' }] }, { e: 'b' }, + { e: 'a' }, { e: 'b' }, ]); }); - test('updates render if second test provider appears', () => { + test('updates render if second test provider appears', async () => { harness.c.addRoot(testStubs.nested(), 'a'); harness.flush(folder1); harness.c.addRoot(testStubs.test('root2', undefined, [testStubs.test('c')]), 'b'); assert.deepStrictEqual(harness.flush(folder1), [ - { e: 'root', children: [{ e: 'a', children: [{ e: 'aa' }, { e: 'ab' }] }, { e: 'b' }] }, + { e: 'root', children: [{ e: 'a' }, { e: 'b' }] }, { e: 'root2', children: [{ e: 'c' }] }, ]); }); - test('updates nodes if they add children', () => { + test('updates nodes if they add children', async () => { const tests = testStubs.nested(); harness.c.addRoot(tests, 'a'); harness.flush(folder1); + harness.tree.expand(harness.projection.getElementByTestId('id-a')!); + + assert.deepStrictEqual(harness.flush(folder1), [ + { e: 'a', children: [{ e: 'aa' }, { e: 'ab' }] }, + { e: 'b' } + ]); - tests.children[0].children?.push(testStubs.test('ac')); - harness.c.onItemChange(tests.children[0], 'a'); + tests.children.get('id-a')!.children.add(testStubs.test('ac')); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'a', children: [{ e: 'aa' }, { e: 'ab' }, { e: 'ac' }] }, @@ -77,13 +104,13 @@ suite('Workbench - Testing Explorer Hierarchal by Location Projection', () => { ]); }); - test('updates nodes if they remove children', () => { + test('updates nodes if they remove children', async () => { const tests = testStubs.nested(); harness.c.addRoot(tests, 'a'); harness.flush(folder1); + harness.tree.expand(harness.projection.getElementByTestId('id-a')!); - tests.children[0].children?.pop(); - harness.c.onItemChange(tests.children[0], 'a'); + tests.children.get('id-a')!.children.delete('id-ab'); 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 f7812e7ce4f..c1292616584 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 @@ -13,7 +13,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { const folder1 = makeTestWorkspaceFolder('f1'); const folder2 = makeTestWorkspaceFolder('f2'); setup(() => { - harness = new TestTreeTestHarness(l => new HierarchicalByNameProjection(l, { + harness = new TestTreeTestHarness([folder1, folder2], l => new HierarchicalByNameProjection(l, { onResultsChanged: () => undefined, onTestChanged: () => undefined, getStateById: () => ({ state: { state: 0 }, computedState: 0 }), @@ -24,14 +24,14 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { harness.dispose(); }); - test('renders initial tree', () => { + test('renders initial tree', async () => { harness.c.addRoot(testStubs.nested(), 'a'); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, { e: 'ab' }, { e: 'b' } ]); }); - test('updates render if a second folder is added', () => { + test('updates render if a second folder is added', async () => { harness.c.addRoot(testStubs.nested('id1-'), 'a'); harness.flush(folder1); harness.c.addRoot(testStubs.nested('id2-'), 'a'); @@ -42,7 +42,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { ]); }); - test('updates render if second folder is removed', () => { + test('updates render if second folder is removed', async () => { harness.c.addRoot(testStubs.nested('id1-'), 'a'); harness.flush(folder1); harness.c.addRoot(testStubs.nested('id2-'), 'a'); @@ -53,7 +53,7 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { ]); }); - test('updates render if second test provider appears', () => { + test('updates render if second test provider appears', async () => { harness.c.addRoot(testStubs.nested(), 'a'); harness.flush(folder1); harness.c.addRoot(testStubs.test('root2', undefined, [testStubs.test('c')]), 'b'); @@ -63,13 +63,12 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { ]); }); - test('updates nodes if they add children', () => { + test('updates nodes if they add children', async () => { const tests = testStubs.nested(); harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children[0].children?.push(testStubs.test('ac')); - harness.c.onItemChange(tests.children[0], 'a'); + tests.children.get('id-a')!.children.add(testStubs.test('ac')); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, @@ -79,13 +78,12 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { ]); }); - test('updates nodes if they remove children', () => { + test('updates nodes if they remove children', async () => { const tests = testStubs.nested(); harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children[0].children?.pop(); - harness.c.onItemChange(tests.children[0], 'a'); + tests.children.get('id-a')!.children.delete('id-ab'); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, @@ -93,13 +91,12 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { ]); }); - test('swaps when node is no longer leaf', () => { + test('swaps when node is no longer leaf', async () => { const tests = testStubs.nested(); harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children[1].children = [testStubs.test('ba')]; - harness.c.onItemChange(tests.children[1], 'a'); + tests.children.get('id-b')!.children.add(testStubs.test('ba')); assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, @@ -108,17 +105,16 @@ suite('Workbench - Testing Explorer Hierarchal by Name Projection', () => { ]); }); - test('swaps when node is no longer runnable', () => { + test('swaps when node is no longer runnable', async () => { const tests = testStubs.nested(); harness.c.addRoot(tests, 'a'); harness.flush(folder1); - tests.children[1].children = [testStubs.test('ba')]; - harness.c.onItemChange(tests.children[0], 'a'); + const child = testStubs.test('ba'); + tests.children.get('id-b')!.children.add(child); harness.flush(folder1); - tests.children[1].children[0].runnable = false; - harness.c.onItemChange(tests.children[1].children[0], 'a'); + child.runnable = false; assert.deepStrictEqual(harness.flush(folder1), [ { e: 'aa' }, diff --git a/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts b/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts index a7c1201b3a2..1dbedfe0e0f 100644 --- a/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts +++ b/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts @@ -86,14 +86,26 @@ export class TestTreeTestHarness; - constructor(makeTree: (listener: TestSubscriptionListener) => T) { + constructor(folders: IWorkspaceFolderData[], makeTree: (listener: TestSubscriptionListener) => T) { super(); this.projection = this._register(makeTree({ - workspaceFolderCollections: [], + workspaceFolderCollections: folders.map(folder => [{ folder }, { + expand: (testId: string, levels: number) => { + this.c.expand(testId, levels); + this.onDiff.fire([folder, this.c.collectDiff()]); + return Promise.resolve(); + }, + all: [], + }]), onDiff: this.onDiff.event, onFolderChange: this.onFolderChange.event, } as any)); this.tree = this._register(new TestObjectTree(t => t.label)); + this._register(this.tree.onDidChangeCollapseState(evt => { + if (evt.node.element) { + this.projection.expandElement(evt.node.element, evt.deep ? Infinity : 0); + } + })); } public flush(folder?: IWorkspaceFolderData) { diff --git a/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts b/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts index f2297cc125b..87b248eab5e 100644 --- a/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts +++ b/src/vs/workbench/contrib/testing/test/common/ownedTestCollection.ts @@ -25,11 +25,11 @@ export class TestSingleUseCollection extends SingleUseTestCollection { export class TestOwnedTestCollection extends OwnedTestCollection { public get idToInternal() { - return Iterable.first(this.testIdsToInternal)!; + return Iterable.first(this.testIdsToInternal.values())!; } public createForHierarchy(publishDiff: (diff: TestsDiff) => void = () => undefined) { - return new TestSingleUseCollection(this.createIdMap(), publishDiff); + return new TestSingleUseCollection(this.createIdMap(0), publishDiff); } } @@ -37,10 +37,11 @@ export class TestOwnedTestCollection extends OwnedTestCollection { * Gets a main thread test collection initialized with the given set of * roots/stubs. */ -export const getInitializedMainTestCollection = (root = testStubs.nested()) => { - const c = new MainThreadTestCollection(0); - const singleUse = new TestSingleUseCollection({ object: new TestTree(), dispose: () => undefined }, () => undefined); +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); singleUse.addRoot(root, 'provider'); + await singleUse.expand('id-root', Infinity); c.apply(singleUse.collectDiff()); return c; }; diff --git a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts index 17fc8f2a78d..8cdb2ec4308 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -20,19 +20,19 @@ suite('Workbench - Test Results Service', () => { let r: LiveTestResult; let changed = new Set(); - setup(() => { + setup(async () => { changed = new Set(); r = LiveTestResult.from( - [getInitializedMainTestCollection()], - { tests: [{ providerId: 'provider', testId: 'id-a' }], debug: false } + [await getInitializedMainTestCollection()], + { tests: [{ src: { provider: 'provider', tree: 0 }, testId: 'id-a' }], debug: false } ); r.onChange(e => changed.add(e)); }); suite('LiveTestResult', () => { - test('is empty if no tests are requesteed', () => { - const r = LiveTestResult.from([getInitializedMainTestCollection()], { tests: [], debug: false }); + test('is empty if no tests are requesteed', async () => { + const r = LiveTestResult.from([await getInitializedMainTestCollection()], { tests: [], debug: false }); assert.deepStrictEqual(getLabelsIn(r.tests), []); }); @@ -165,24 +165,24 @@ suite('Workbench - Test Results Service', () => { assert.strictEqual(typeof rehydrated.completedAt, 'number'); }); - test('clears results but keeps ongoing tests', () => { + test('clears results but keeps ongoing tests', async () => { results.push(r); r.markComplete(); const r2 = results.push(LiveTestResult.from( - [getInitializedMainTestCollection()], - { tests: [{ providerId: 'provider', testId: '1' }], debug: false } + [await getInitializedMainTestCollection()], + { tests: [{ src: { provider: 'provider', tree: 0 }, testId: '1' }], debug: false } )); results.clear(); assert.deepStrictEqual(results.results, [r2]); }); - test('keeps ongoing tests on top', () => { + test('keeps ongoing tests on top', async () => { results.push(r); const r2 = results.push(LiveTestResult.from( - [getInitializedMainTestCollection()], - { tests: [{ providerId: 'provider', testId: '1' }], debug: false } + [await getInitializedMainTestCollection()], + { tests: [{ src: { provider: 'provider', tree: 0 }, testId: '1' }], debug: false } )); assert.deepStrictEqual(results.results, [r2, r]); @@ -192,11 +192,11 @@ suite('Workbench - Test Results Service', () => { assert.deepStrictEqual(results.results, [r, r2]); }); - const makeHydrated = (completedAt = 42, state = TestRunState.Passed) => new HydratedTestResult({ + const makeHydrated = async (completedAt = 42, state = TestRunState.Passed) => new HydratedTestResult({ completedAt, id: 'some-id', items: [{ - ...getInitializedMainTestCollection().getNodeById('id-a')!, + ...(await getInitializedMainTestCollection()).getNodeById('id-a')!, state: { state, duration: 0, messages: [] }, computedState: state, retired: undefined, @@ -204,36 +204,36 @@ suite('Workbench - Test Results Service', () => { }] }); - test('pushes hydrated results', () => { + test('pushes hydrated results', async () => { results.push(r); - const hydrated = makeHydrated(); + const hydrated = await makeHydrated(); results.push(hydrated); assert.deepStrictEqual(results.results, [r, hydrated]); }); - test('deduplicates identical results', () => { + test('deduplicates identical results', async () => { results.push(r); - const hydrated1 = makeHydrated(); + const hydrated1 = await makeHydrated(); results.push(hydrated1); - const hydrated2 = makeHydrated(); + const hydrated2 = await makeHydrated(); results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated1]); }); - test('does not deduplicate if different completedAt', () => { + test('does not deduplicate if different completedAt', async () => { results.push(r); - const hydrated1 = makeHydrated(); + const hydrated1 = await makeHydrated(); results.push(hydrated1); - const hydrated2 = makeHydrated(30); + const hydrated2 = await makeHydrated(30); results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated1, hydrated2]); }); - test('does not deduplicate if different tests', () => { + test('does not deduplicate if different tests', async () => { results.push(r); - const hydrated1 = makeHydrated(); + const hydrated1 = await makeHydrated(); results.push(hydrated1); - const hydrated2 = makeHydrated(undefined, TestRunState.Failed); + const hydrated2 = await makeHydrated(undefined, TestRunState.Failed); results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated2, hydrated1]); }); diff --git a/src/vs/workbench/test/browser/api/extHostTesting.test.ts b/src/vs/workbench/test/browser/api/extHostTesting.test.ts index 89fcfb56a13..1a62bedc264 100644 --- a/src/vs/workbench/test/browser/api/extHostTesting.test.ts +++ b/src/vs/workbench/test/browser/api/extHostTesting.test.ts @@ -4,16 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { MirroredTestCollection, TestItemFilteredWrapper } from 'vs/workbench/api/common/extHostTesting'; import * as convert from 'vs/workbench/api/common/extHostTypeConverters'; -import { TestItem as TestItemImpl } from 'vs/workbench/api/common/extHostTypes'; -import { TestDiffOpType } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestDiffOpType, TestItemExpandState } from 'vs/workbench/contrib/testing/common/testCollection'; import { stubTest, testStubs } from 'vs/workbench/contrib/testing/common/testStubs'; import { TestOwnedTestCollection, TestSingleUseCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; -import { TestChangeEvent, TestItem, TextDocument } from 'vscode'; -import { URI } from 'vs/base/common/uri'; -import { Location } from 'vs/editor/common/modes'; -import { Range } from 'vs/editor/common/core/range'; +import { TestItem } from 'vscode'; const simplify = (item: TestItem) => ({ id: item.id, @@ -26,29 +21,29 @@ const simplify = (item: TestItem) => ({ const assertTreesEqual = (a: TestItem, b: TestItem) => { assert.deepStrictEqual(simplify(a), simplify(b)); - const aChildren = (a.children ?? []).slice().sort(); - const bChildren = (b.children ?? []).slice().sort(); + const aChildren = [...a.children].slice().sort(); + const bChildren = [...b.children].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])); }; -const assertTreeListEqual = (a: ReadonlyArray, b: ReadonlyArray) => { - assert.strictEqual(a.length, b.length, `expected a.length == n.length`); - a.forEach((_, i) => assertTreesEqual(a[i], b[i])); -}; +// const assertTreeListEqual = (a: ReadonlyArray, b: ReadonlyArray) => { +// assert.strictEqual(a.length, b.length, `expected a.length == n.length`); +// a.forEach((_, i) => assertTreesEqual(a[i], b[i])); +// }; -class TestMirroredCollection extends MirroredTestCollection { - public changeEvent!: TestChangeEvent; +// class TestMirroredCollection extends MirroredTestCollection { +// public changeEvent!: TestChangeEvent; - constructor() { - super(); - this.onDidChangeTests(evt => this.changeEvent = evt); - } +// constructor() { +// super(); +// this.onDidChangeTests(evt => this.changeEvent = evt); +// } - public get length() { - return this.items.size; - } -} +// public get length() { +// return this.items.size; +// } +// } suite('ExtHost Testing', () => { let single: TestSingleUseCollection; @@ -67,12 +62,40 @@ suite('ExtHost Testing', () => { test('adds a root recursively', () => { const tests = testStubs.nested(); single.addRoot(tests, 'pid'); + single.expand('id-root', Infinity); assert.deepStrictEqual(single.collectDiff(), [ - [TestDiffOpType.Add, { providerId: 'pid', parent: null, item: convert.TestItem.from(stubTest('root')) }], - [TestDiffOpType.Add, { providerId: 'pid', parent: 'id-root', item: convert.TestItem.from(stubTest('a')) }], - [TestDiffOpType.Add, { providerId: 'pid', parent: 'id-a', item: convert.TestItem.from(stubTest('aa')) }], - [TestDiffOpType.Add, { providerId: 'pid', parent: 'id-a', item: convert.TestItem.from(stubTest('ab')) }], - [TestDiffOpType.Add, { providerId: 'pid', parent: 'id-root', item: convert.TestItem.from(stubTest('b')) }], + [ + TestDiffOpType.Add, + { src: { tree: 0, provider: 'pid' }, parent: null, expand: TestItemExpandState.BusyExpanding, item: { ...convert.TestItem.from(stubTest('root')), expandable: true } } + ], + [ + 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 } + ], + [ + TestDiffOpType.Add, + { src: { tree: 0, provider: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('aa')) } + ], + [ + TestDiffOpType.Add, + { src: { tree: 0, provider: 'pid' }, parent: 'id-a', expand: TestItemExpandState.NotExpandable, item: convert.TestItem.from(stubTest('ab')) } + ], + [ + TestDiffOpType.Update, + { extId: 'id-a', expand: TestItemExpandState.Expanded } + ], ]); }); @@ -86,26 +109,23 @@ suite('ExtHost Testing', () => { test('watches property mutations', () => { const tests = testStubs.nested(); single.addRoot(tests, 'pid'); + single.expand('id-root', Infinity); single.collectDiff(); - tests.children![0].description = 'Hello world'; /* item a */ - single.onItemChange(tests, 'pid'); + tests.children.get('id-a')!.description = 'Hello world'; /* item a */ - const expected = stubTest('a'); - expected.description = 'Hello world'; assert.deepStrictEqual(single.collectDiff(), [ - [TestDiffOpType.Update, { parent: 'id-root', providerId: 'pid', item: convert.TestItem.from(expected) }], + [ + TestDiffOpType.Update, + { extId: 'id-a', item: { description: 'Hello world' } }], ]); - - single.onItemChange(tests, 'pid'); - assert.deepStrictEqual(single.collectDiff(), []); }); test('removes children', () => { const tests = testStubs.nested(); single.addRoot(tests, 'pid'); + single.expand('id-root', Infinity); single.collectDiff(); - tests.children!.splice(0, 1); - single.onItemChange(tests, 'pid'); + tests.children.delete('id-a'); assert.deepStrictEqual(single.collectDiff(), [ [TestDiffOpType.Remove, 'id-a'], @@ -117,13 +137,18 @@ suite('ExtHost Testing', () => { test('adds new children', () => { const tests = testStubs.nested(); single.addRoot(tests, 'pid'); + single.expand('id-root', Infinity); single.collectDiff(); const child = stubTest('ac'); - tests.children![0].children!.push(child); - single.onItemChange(tests, 'pid'); + tests.children.get('id-a')!.children!.add(child); assert.deepStrictEqual(single.collectDiff(), [ - [TestDiffOpType.Add, { providerId: 'pid', parent: 'id-a', item: convert.TestItem.from(child) }], + [TestDiffOpType.Add, { + src: { tree: 0, provider: 'pid' }, + parent: 'id-a', + expand: TestItemExpandState.NotExpandable, + item: convert.TestItem.from(child), + }], ]); assert.deepStrictEqual( [...owned.idToInternal].map(n => n.item.extId).sort(), @@ -133,260 +158,285 @@ suite('ExtHost Testing', () => { }); }); - suite('MirroredTestCollection', () => { - let m: TestMirroredCollection; - setup(() => m = new TestMirroredCollection()); - - test('mirrors creation of the root', () => { - const tests = testStubs.nested(); - single.addRoot(tests, 'pid'); - m.apply(single.collectDiff()); - assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); - assert.strictEqual(m.length, single.itemToInternal.size); - }); - - test('mirrors node deletion', () => { - const tests = testStubs.nested(); - single.addRoot(tests, 'pid'); - m.apply(single.collectDiff()); - tests.children!.splice(0, 1); - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - - assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); - assert.strictEqual(m.length, single.itemToInternal.size); - }); - - test('mirrors node addition', () => { - const tests = testStubs.nested(); - single.addRoot(tests, 'pid'); - m.apply(single.collectDiff()); - tests.children![0].children!.push(stubTest('ac')); - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - - assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); - assert.strictEqual(m.length, single.itemToInternal.size); - }); - - test('mirrors node update', () => { - const tests = testStubs.nested(); - single.addRoot(tests, 'pid'); - m.apply(single.collectDiff()); - tests.children![0].description = 'Hello world'; /* item a */ - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); - }); - - suite('MirroredChangeCollector', () => { - let tests = testStubs.nested(); - setup(() => { - tests = testStubs.nested(); - single.addRoot(tests, 'pid'); - m.apply(single.collectDiff()); - }); - - test('creates change for root', () => { - assertTreeListEqual(m.changeEvent.added, [ - tests, - tests.children[0], - tests.children![0].children![0], - tests.children![0].children![1], - tests.children[1], - ]); - assertTreeListEqual(m.changeEvent.removed, []); - assertTreeListEqual(m.changeEvent.updated, []); - }); - - test('creates change for delete', () => { - const rm = tests.children.shift()!; - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - - assertTreeListEqual(m.changeEvent.added, []); - assertTreeListEqual(m.changeEvent.removed, [ - { ...rm, children: [] }, - { ...rm.children![0], children: [] }, - { ...rm.children![1], children: [] }, - ]); - assertTreeListEqual(m.changeEvent.updated, []); - }); - - test('creates change for update', () => { - tests.children[0].label = 'updated!'; - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - - assertTreeListEqual(m.changeEvent.added, []); - assertTreeListEqual(m.changeEvent.removed, []); - assertTreeListEqual(m.changeEvent.updated, [tests.children[0]]); - }); - - test('is a no-op if a node is added and removed', () => { - const nested = testStubs.nested('id2-'); - tests.children.push(nested); - single.onItemChange(tests, 'pid'); - tests.children.pop(); - single.onItemChange(tests, 'pid'); - const previousEvent = m.changeEvent; - m.apply(single.collectDiff()); - assert.strictEqual(m.changeEvent, previousEvent); - }); - - test('is a single-op if a node is added and changed', () => { - const child = stubTest('c'); - tests.children.push(child); - single.onItemChange(tests, 'pid'); - child.label = 'd'; - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - - assertTreeListEqual(m.changeEvent.added, [child]); - assertTreeListEqual(m.changeEvent.removed, []); - assertTreeListEqual(m.changeEvent.updated, []); - }); - - test('gets the common ancestor (1)', () => { - tests.children![0].children![0].label = 'za'; - tests.children![0].children![1].label = 'zb'; - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - - }); - - test('gets the common ancestor (2)', () => { - tests.children![0].children![0].label = 'za'; - tests.children![1].label = 'ab'; - single.onItemChange(tests, 'pid'); - m.apply(single.collectDiff()); - }); - }); - - suite('TestItemFilteredWrapper', () => { - const stubTestWithLocation = (label: string, location: Location, children: TestItemImpl[] = []) => { - const t = stubTest(label, undefined, children); - t.location = location as any; - return t; - }; - - const location1: Location = { - range: new Range(0, 0, 0, 0), - uri: URI.parse('file:///foo.ts') - }; - - const location2: Location = { - range: new Range(0, 0, 0, 0), - uri: URI.parse('file:///bar.ts') - }; - - const location3: Location = { - range: new Range(0, 0, 0, 0), - uri: URI.parse('file:///baz.ts') - }; - - const textDocumentFilter = { - uri: location1.uri - } as TextDocument; - - let testsWithLocation: TestItem; - setup(() => { - testsWithLocation = - stubTest('root', undefined, [ - stubTestWithLocation('a', location1, [stubTestWithLocation('aa', location1), stubTestWithLocation('ab', location1)]), - stubTestWithLocation('b', location2, [stubTestWithLocation('ba', location2), stubTestWithLocation('bb', location2)]), - stubTestWithLocation('b', location3), - ]); - }); - - teardown(() => { - TestItemFilteredWrapper.removeFilter(textDocumentFilter); - }); - - test('gets all actual properties', () => { - const testItem: TestItem = stubTest('test1'); - const wrapper: TestItemFilteredWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testItem, textDocumentFilter); - - assert.strictEqual(testItem.debuggable, wrapper.debuggable); - assert.strictEqual(testItem.description, wrapper.description); - assert.strictEqual(testItem.label, wrapper.label); - assert.strictEqual(testItem.location, wrapper.location); - assert.strictEqual(testItem.runnable, wrapper.runnable); - }); - - test('gets no children if nothing matches Uri filter', () => { - let tests: TestItem = testStubs.nested(); - const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(tests, textDocumentFilter); - assert.strictEqual(wrapper.children.length, 0); - }); - - test('filter is applied to children', () => { - const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - assert.strictEqual(wrapper.label, 'root'); - assert.strictEqual(wrapper.children.length, 1); - assert.strictEqual(wrapper.children[0] instanceof TestItemFilteredWrapper, true); - assert.strictEqual(wrapper.children[0].label, 'a'); - }); - - test('can get if node has matching filter', () => { - const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - - const invisible = testsWithLocation.children![1]; - const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); - const visible = testsWithLocation.children![0]; - const visibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(visible, textDocumentFilter); - - // The root is always visible - assert.strictEqual(rootWrapper.hasNodeMatchingFilter, true); - assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, false); - assert.strictEqual(visibleWrapper.hasNodeMatchingFilter, true); - }); - - test('can get visible parent', () => { - const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - - const invisible = testsWithLocation.children![1]; - const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); - const visible = testsWithLocation.children![0]; - const visibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(visible, textDocumentFilter); - - // The root is always visible - assert.strictEqual(rootWrapper.visibleParent, rootWrapper); - assert.strictEqual(invisibleWrapper.visibleParent, rootWrapper); - assert.strictEqual(visibleWrapper.visibleParent, visibleWrapper); - }); - - test('can reset cached value of hasNodeMatchingFilter', () => { - TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - - const invisible = testsWithLocation.children![1]; - const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); - - assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, false); - invisible.location = location1 as any; - assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, false); - invisibleWrapper.reset(); - assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, true); - }); - - test('can reset cached value of hasNodeMatchingFilter of parents up to visible parent', () => { - const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); - - const invisibleParent = testsWithLocation.children![1]; - const invisibleParentWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisibleParent, textDocumentFilter); - const invisible = invisibleParent.children![1]; - const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); - - assert.strictEqual(invisibleParentWrapper.hasNodeMatchingFilter, false); - invisible.location = location1 as any; - assert.strictEqual(invisibleParentWrapper.hasNodeMatchingFilter, false); - invisibleWrapper.reset(); - assert.strictEqual(invisibleParentWrapper.hasNodeMatchingFilter, true); - - // the root should be undefined due to the reset. - assert.strictEqual((rootWrapper as any).matchesFilter, undefined); - }); - }); - }); + // todo@connor4312: re-renable when we figure out what observing looks like we async children + // suite('MirroredTestCollection', () => { + // let m: TestMirroredCollection; + // setup(() => m = new TestMirroredCollection()); + + // test('mirrors creation of the root', () => { + // const tests = testStubs.nested(); + // single.addRoot(tests, 'pid'); + // single.expand('id-root', Infinity); + // m.apply(single.collectDiff()); + // assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); + // assert.strictEqual(m.length, single.itemToInternal.size); + // }); + + // test('mirrors node deletion', () => { + // const tests = testStubs.nested(); + // single.addRoot(tests, 'pid'); + // m.apply(single.collectDiff()); + // single.expand('id-root', Infinity); + // tests.children!.splice(0, 1); + // single.onItemChange(tests, 'pid'); + // single.expand('id-root', Infinity); + // m.apply(single.collectDiff()); + + // assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); + // assert.strictEqual(m.length, single.itemToInternal.size); + // }); + + // test('mirrors node addition', () => { + // const tests = testStubs.nested(); + // single.addRoot(tests, 'pid'); + // m.apply(single.collectDiff()); + // tests.children![0].children!.push(stubTest('ac')); + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + + // assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); + // assert.strictEqual(m.length, single.itemToInternal.size); + // }); + + // test('mirrors node update', () => { + // const tests = testStubs.nested(); + // single.addRoot(tests, 'pid'); + // m.apply(single.collectDiff()); + // tests.children![0].description = 'Hello world'; /* item a */ + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + + // assertTreesEqual(m.rootTestItems[0], owned.getTestById('id-root')![1].actual); + // }); + + // suite('MirroredChangeCollector', () => { + // let tests = testStubs.nested(); + // setup(() => { + // tests = testStubs.nested(); + // single.addRoot(tests, 'pid'); + // m.apply(single.collectDiff()); + // }); + + // test('creates change for root', () => { + // assertTreeListEqual(m.changeEvent.added, [ + // tests, + // tests.children[0], + // tests.children![0].children![0], + // tests.children![0].children![1], + // tests.children[1], + // ]); + // assertTreeListEqual(m.changeEvent.removed, []); + // assertTreeListEqual(m.changeEvent.updated, []); + // }); + + // test('creates change for delete', () => { + // const rm = tests.children.shift()!; + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + + // assertTreeListEqual(m.changeEvent.added, []); + // assertTreeListEqual(m.changeEvent.removed, [ + // { ...rm }, + // { ...rm.children![0] }, + // { ...rm.children![1] }, + // ]); + // assertTreeListEqual(m.changeEvent.updated, []); + // }); + + // test('creates change for update', () => { + // tests.children[0].label = 'updated!'; + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + + // assertTreeListEqual(m.changeEvent.added, []); + // assertTreeListEqual(m.changeEvent.removed, []); + // assertTreeListEqual(m.changeEvent.updated, [tests.children[0]]); + // }); + + // test('is a no-op if a node is added and removed', () => { + // const nested = testStubs.nested('id2-'); + // tests.children.push(nested); + // single.onItemChange(tests, 'pid'); + // tests.children.pop(); + // single.onItemChange(tests, 'pid'); + // const previousEvent = m.changeEvent; + // m.apply(single.collectDiff()); + // assert.strictEqual(m.changeEvent, previousEvent); + // }); + + // test('is a single-op if a node is added and changed', () => { + // const child = stubTest('c'); + // tests.children.push(child); + // single.onItemChange(tests, 'pid'); + // child.label = 'd'; + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + + // assertTreeListEqual(m.changeEvent.added, [child]); + // assertTreeListEqual(m.changeEvent.removed, []); + // assertTreeListEqual(m.changeEvent.updated, []); + // }); + + // test('gets the common ancestor (1)', () => { + // tests.children![0].children![0].label = 'za'; + // tests.children![0].children![1].label = 'zb'; + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + + // }); + + // test('gets the common ancestor (2)', () => { + // tests.children![0].children![0].label = 'za'; + // tests.children![1].label = 'ab'; + // single.onItemChange(tests, 'pid'); + // m.apply(single.collectDiff()); + // }); + // }); + + // suite('TestItemFilteredWrapper', () => { + // const stubTestWithLocation = (label: string, location: Location, children: StubTestItem[] = []) => { + // const t = stubTest(label, undefined, children); + // t.location = location as any; + // return t; + // }; + + // const location1: Location = { + // range: new Range(0, 0, 0, 0), + // uri: URI.parse('file:///foo.ts') + // }; + + // const location2: Location = { + // range: new Range(0, 0, 0, 0), + // uri: URI.parse('file:///bar.ts') + // }; + + // const location3: Location = { + // range: new Range(0, 0, 0, 0), + // uri: URI.parse('file:///baz.ts') + // }; + + // const textDocumentFilter = { + // uri: location1.uri + // } as TextDocument; + + // let testsWithLocation: StubTestItem; + // let hierarchy: TestHierarchy; + // setup(async () => { + // testsWithLocation = + // stubTest('root', undefined, [ + // stubTestWithLocation('a', location1, [stubTestWithLocation('aa', location1), stubTestWithLocation('ab', location1)]), + // stubTestWithLocation('b', location2, [stubTestWithLocation('ba', location2), stubTestWithLocation('bb', location2)]), + // stubTestWithLocation('b', location3), + // ]); + + // hierarchy = (await createDefaultDocumentTestHierarchy( + // { + // provideWorkspaceTestHierarchy: () => ({ + // getChildren.getChildren, + // getParent.getParent, + // onDidChangeTest: new Emitter().event, + // root: testsWithLocation + // }), + // runTests() { + // throw new Error('no implemented'); + // } + // }, + // textDocumentFilter, + // undefined, + // CancellationToken.None + // ))!; + // }); + + // teardown(() => { + // TestItemFilteredWrapper.removeFilter(textDocumentFilter); + // }); + + // test('gets all actual properties', () => { + // const testItem: TestItem = stubTest('test1'); + // const wrapper: TestItemFilteredWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testItem, textDocumentFilter); + + // assert.strictEqual(testItem.debuggable, wrapper.debuggable); + // assert.strictEqual(testItem.description, wrapper.description); + // assert.strictEqual(testItem.label, wrapper.label); + // assert.strictEqual(testItem.location, wrapper.location); + // assert.strictEqual(testItem.runnable, wrapper.runnable); + // }); + + // test('gets no children if nothing matches Uri filter', () => { + // let tests: TestItem = testStubs.nested(); + // const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(tests, textDocumentFilter); + // const children = hierarchy.getChildren(wrapper, CancellationToken.None) as TestItemFilteredWrapper[]; + // assert.strictEqual(children.length, 0); + // }); + + // test('filter is applied to children', () => { + // const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + // assert.strictEqual(wrapper.label, 'root'); + // const children = hierarchy.getChildren(wrapper, CancellationToken.None) as TestItemFilteredWrapper[]; + // assert.strictEqual(children.length, 1); + // assert.strictEqual(children[0] instanceof TestItemFilteredWrapper, true); + // assert.strictEqual(children[0].label, 'a'); + // }); + + // test('can get if node has matching filter', () => { + // const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + + // const invisible = testsWithLocation.children![1]; + // const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); + // const visible = testsWithLocation.children![0]; + // const visibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(visible, textDocumentFilter); + + // // The root is always visible + // assert.strictEqual(rootWrapper.hasNodeMatchingFilter, true); + // assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, false); + // assert.strictEqual(visibleWrapper.hasNodeMatchingFilter, true); + // }); + + // test('can get visible parent', () => { + // const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + + // const invisible = testsWithLocation.children![1]; + // const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); + // const visible = testsWithLocation.children![0]; + // const visibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(visible, textDocumentFilter); + + // // The root is always visible + // assert.strictEqual(rootWrapper.visibleParent, rootWrapper); + // assert.strictEqual(invisibleWrapper.visibleParent, rootWrapper); + // assert.strictEqual(visibleWrapper.visibleParent, visibleWrapper); + // }); + + // test('can reset cached value of hasNodeMatchingFilter', () => { + // TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + + // const invisible = testsWithLocation.children![1]; + // const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); + + // assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, false); + // invisible.location = location1 as any; + // assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, false); + // invisibleWrapper.reset(); + // assert.strictEqual(invisibleWrapper.hasNodeMatchingFilter, true); + // }); + + // test('can reset cached value of hasNodeMatchingFilter of parents up to visible parent', () => { + // const rootWrapper = TestItemFilteredWrapper.getWrapperForTestItem(testsWithLocation, textDocumentFilter); + + // const invisibleParent = testsWithLocation.children![1]; + // const invisibleParentWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisibleParent, textDocumentFilter); + // const invisible = invisibleParent.children![1]; + // const invisibleWrapper = TestItemFilteredWrapper.getWrapperForTestItem(invisible, textDocumentFilter); + + // assert.strictEqual(invisibleParentWrapper.hasNodeMatchingFilter, false); + // invisible.location = location1 as any; + // assert.strictEqual(invisibleParentWrapper.hasNodeMatchingFilter, false); + // invisibleWrapper.reset(); + // assert.strictEqual(invisibleParentWrapper.hasNodeMatchingFilter, true); + + // // the root should be undefined due to the reset. + // assert.strictEqual((rootWrapper as any).matchesFilter, undefined); + // }); + // }); + // }); }); diff --git a/test/automation/package.json b/test/automation/package.json index 47f249d9e6d..94006ad95e6 100644 --- a/test/automation/package.json +++ b/test/automation/package.json @@ -35,4 +35,4 @@ "vscode-uri": "^2.0.3", "watch": "^1.0.2" } -} \ No newline at end of file +} -- GitLab