未验证 提交 808266d4 编写于 作者: C Connor Peet 提交者: GitHub

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
上级 6e2cb85e
......@@ -982,6 +982,7 @@
"allowed": [
"FileSystemProvider",
"TreeDataProvider",
"TestProvider",
"CustomEditorProvider",
"CustomReadonlyEditorProvider",
"TerminalLinkProvider",
......@@ -1015,6 +1016,7 @@
"override",
"receive",
"register",
"remove",
"rename",
"save",
"send",
......
......@@ -616,7 +616,7 @@ export class AsyncDataTree<TInput, T, TFilterData = void> 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));
}
......
......@@ -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<T>(obj: unknown): obj is Iterable<T> {
return !!obj && typeof (obj as any)[Symbol.iterator] === 'function';
}
/**
* @returns whether the provided parameter is a JavaScript Boolean or not.
*/
......
......@@ -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<T>;
/**
* 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<unknown>;
/**
* 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<T>;
}
/**
......@@ -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<TestHierarchy<T>>;
provideWorkspaceTestRoot(workspace: WorkspaceFolder, token: CancellationToken): ProviderResult<T>;
/**
* 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<TestHierarchy<T>>;
provideDocumentTestRoot?(document: TextDocument, token: CancellationToken): ProviderResult<T>;
/**
* @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<T> extends Iterable<T> {
/**
* 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<TChildren = any> {
/**
* 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<TChildren>;
/**
* 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<Readonly<TestItemWithResults>>;
results: ReadonlyArray<Readonly<TestResultSnapshot>>;
}
/**
* 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<TestItemWithResults>[];
readonly children: Readonly<TestResultSnapshot>[];
}
//#endregion
......
......@@ -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<string, IDisposable>();
private readonly testProviderRegistrations = new Map<string, IDisposable>();
......@@ -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();
}
......
......@@ -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<void>;
$subscribeToTests(resource: ExtHostTestingResource, uri: UriComponents): void;
$unsubscribeFromTests(resource: ExtHostTestingResource, uri: UriComponents): void;
$lookupTest(test: TestIdWithProvider): Promise<InternalTestItem | undefined>;
$lookupTest(test: TestIdWithSrc): Promise<InternalTestItem | undefined>;
$acceptDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void;
$publishTestResults(results: ISerializedTestResults[]): void;
$expandTest(src: TestIdWithSrc, levels: number): Promise<void>;
}
export interface MainThreadTestingShape {
......@@ -1933,7 +1934,6 @@ export interface MainThreadTestingShape {
$updateTestStateInRun(runId: string, testId: string, state: ITestState): void;
$runTests(req: RunTestsRequest, token: CancellationToken): Promise<string>;
$publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void;
$retireTest(extId: string): void;
}
// --- proxy identifiers
......
......@@ -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<vscode.TestHierarchy<vscode.TestItem>>);
let method: undefined | ((p: vscode.TestProvider) => vscode.ProviderResult<vscode.TestItem>);
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<void> {
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<InternalTestItem | undefined> {
public $lookupTest(req: TestIdWithSrc): Promise<InternalTestItem | undefined> {
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<vscode.TestHierarchy<vscode.TestItem> | undefined> {
if (!folder) {
return;
}
const workspaceHierarchy = await provider.provideWorkspaceTestHierarchy(folder, token);
if (!workspaceHierarchy) {
return;
}
const onDidInvalidateTest = new Emitter<vscode.TestItem>();
workspaceHierarchy.onDidInvalidateTest?.(node => {
const wrapper = TestItemFilteredWrapper.getWrapperForTestItem(node, document);
if (wrapper.hasNodeMatchingFilter) {
onDidInvalidateTest.fire(wrapper);
}
});
const onDidChangeTest = new Emitter<vscode.TestItem>();
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 <T extends vscode.TestItem>(
provider: vscode.TestProvider<T>,
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<T extends vscode.TestItem = vscode.TestItem> extends TestItemImpl {
private static wrapperMap = new WeakMap<vscode.TextDocument, WeakMap<vscode.TestItem, TestItemFilteredWrapper>>();
public static removeFilter(document: vscode.TextDocument): void {
this.wrapperMap.delete(document);
}
// Wraps the TestItem specified in a TestItemFilteredWrapper and pulls from a cache if it already exists.
public static getWrapperForTestItem(item: vscode.TestItem, filterDocument: vscode.TextDocument, parent?: TestItemFilteredWrapper): TestItemFilteredWrapper {
public static getWrapperForTestItem<T extends vscode.TestItem>(
item: T,
filterDocument: vscode.TextDocument,
parent?: TestItemFilteredWrapper<T>,
): TestItemFilteredWrapper<T> {
let innerMap = this.wrapperMap.get(filterDocument);
if (innerMap?.has(item)) {
return innerMap.get(item)!;
return innerMap.get(item) as TestItemFilteredWrapper<T>;
}
if (!innerMap) {
innerMap = new WeakMap<vscode.TestItem, TestItemFilteredWrapper>();
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<T>,
) {
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<string, unknown>)[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<vscode.TestItem, 'children'>;
revived: vscode.TestItem;
depth: number;
wrapped?: TestItemFromMirror;
}
class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollectionTestItem> {
......@@ -515,7 +469,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
return this.added.size === 0 && this.removed.size === 0 && this.updated.size === 0;
}
constructor(private readonly collection: MirroredTestCollection, private readonly emitter: Emitter<vscode.TestChangeEvent>) {
constructor(private readonly emitter: Emitter<vscode.TestChangeEvent>) {
super();
}
......@@ -530,7 +484,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
* @override
*/
public update(node: MirroredCollectionTestItem): void {
Object.assign(node.revived, Convert.TestItem.toPlainShallow(node.item));
Object.assign(node.revived, Convert.TestItem.toPlain(node.item));
if (!this.added.has(node)) {
this.updated.add(node);
}
......@@ -559,11 +513,11 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
* @override
*/
public getChangeEvent(): vscode.TestChangeEvent {
const { collection, added, updated, removed } = this;
const { added, updated, removed } = this;
return {
get added() { return [...added].map(collection.getPublicTestItem, collection); },
get updated() { return [...updated].map(collection.getPublicTestItem, collection); },
get removed() { return [...removed].map(collection.getPublicTestItem, collection); },
get added() { return [...added].map(n => 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<Mi
for (const itemId of itemIds) {
const item = this.items.get(itemId);
if (item) {
output.push(this.getPublicTestItem(item));
output.push(item.revived);
}
}
......@@ -629,7 +583,8 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection<Mi
protected createItem(item: InternalTestItem, parent?: MirroredCollectionTestItem): MirroredCollectionTestItem {
return {
...item,
revived: Convert.TestItem.toPlainShallow(item.item),
// todo@connor4312: make this work well again with children
revived: Convert.TestItem.toPlain(item.item) as vscode.TestItem,
depth: parent ? parent.depth + 1 : 0,
children: new Set(),
};
......@@ -639,57 +594,10 @@ export class MirroredTestCollection extends AbstractIncrementalTestCollection<Mi
* @override
*/
protected createChangeCollector() {
return new MirroredChangeCollector(this, this.changeEmitter);
}
/**
* Gets the public test item instance for the given mirrored record.
*/
public getPublicTestItem(item: MirroredCollectionTestItem): vscode.TestItem {
if (!item.wrapped) {
item.wrapped = new TestItemFromMirror(item, this);
}
return item.wrapped;
return new MirroredChangeCollector(this.changeEmitter);
}
}
class TestItemFromMirror implements vscode.TestItem {
readonly #internal: MirroredCollectionTestItem;
readonly #collection: MirroredTestCollection;
public get id() { return this.#internal.revived.id!; }
public get label() { return this.#internal.revived.label; }
public get description() { return this.#internal.revived.description; }
public get location() { return this.#internal.revived.location; }
public get runnable() { return this.#internal.revived.runnable ?? true; }
public get debuggable() { return this.#internal.revived.debuggable ?? false; }
public get children() {
return this.#collection.getAllAsTestItem(this.#internal.children);
}
constructor(internal: MirroredCollectionTestItem, collection: MirroredTestCollection) {
this.#internal = internal;
this.#collection = collection;
}
public toJSON() {
const serialized: vscode.TestItem & TestIdWithProvider = {
id: this.id,
label: this.label,
description: this.description,
location: this.location,
runnable: this.runnable,
debuggable: this.debuggable,
children: this.children.map(c => (c as TestItemFromMirror).toJSON()),
providerId: this.#internal.providerId,
testId: this.id,
};
return serialized;
}
}
interface IObserverData {
observers: number;
......
......@@ -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<types.TestItem, 'children'> {
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<vscode.TestItem, 'children' | 'invalidate' | 'discoverChildren'> {
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<types.TestItem, 'children'> {
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<vscode.TestItemWithResults>][] = [
const queue: [parent: SerializedTestResultItem | null, children: Iterable<vscode.TestResultSnapshot>][] = [
[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<string, SerializedTestResultItem>): vscode.TestItemWithResults => ({
...TestItem.toPlainShallow(item.item),
const convertTestResultItem = (item: SerializedTestResultItem, byInternalId: Map<string, SerializedTestResultItem>): vscode.TestResultSnapshot => ({
...TestItem.toPlain(item.item),
result: TestState.to(item.state),
children: item.children
.map(c => byInternalId.get(c))
......
......@@ -3240,21 +3240,126 @@ export enum TestMessageSeverity {
Hint = 3
}
export const TestItemHookProperty = Symbol('TestItemHookProperty');
export interface ITestItemHook {
created(item: vscode.TestItem): void;
setProp<K extends keyof vscode.TestItem>(key: K, value: vscode.TestItem[K]): void;
invalidate(id: string): void;
delete(id: string): void;
}
const testItemPropAccessor = <K extends keyof vscode.TestItem>(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<vscode.TestItem> {
#map = new Map<string, vscode.TestItem>();
#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 {
......
......@@ -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<ITestTreeElement> = {
*/
export class HierarchicalByLocationProjection extends Disposable implements ITestTreeProjection {
private readonly updateEmitter = new Emitter<void>();
private readonly changes = new NodeChangeList<HierarchicalElement | HierarchicalFolder>();
protected readonly changes = new NodeChangeList<HierarchicalElement | HierarchicalFolder>();
private readonly locations = new TestLocationStore<HierarchicalElement>();
/**
* 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<HierarchicalElement>[] = [[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;
......
......@@ -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<HierarchicalByNameElement>();
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);
}
/**
......
......@@ -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<TestIdWithProvider> {
public get runnable(): Iterable<TestIdWithSrc> {
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;
......
......@@ -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<void>;
/**
* 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<TestIdWithProvider>;
readonly runnable: Iterable<TestIdWithSrc>;
/**
* Tests that can be run using this tree item.
*/
readonly debuggable: Iterable<TestIdWithProvider>;
readonly debuggable: Iterable<TestIdWithSrc>;
/**
* Expand state of the test.
*/
readonly expandable: TestItemExpandState;
/**
* Element state to display.
......
......@@ -76,11 +76,7 @@ export class NodeChangeList<T extends ITestTreeElement & { children: Iterable<T>
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));
}
......
......@@ -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<TestIdWithProvider>,
private readonly tests: Iterable<TestIdWithSrc>,
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<TestIdWithProvider>,
private readonly tests: Iterable<TestIdWithSrc>,
isRunning: boolean,
@ITestService private readonly testService: ITestService
) {
......@@ -145,19 +147,19 @@ abstract class RunOrDebugSelectedAction extends ViewAction<TestingExplorerView>
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<string>][] = [[0, collection.object.rootIds]];
while (queue.length > 0) {
......@@ -626,7 +628,7 @@ export class RunAtCursor extends RunOrDebugAtCursor {
protected runTest(service: ITestService, internalTest: InternalTestItem): Promise<ITestResult> {
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<ITestResult> {
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<string>) => {
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<ITestResult> {
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<ITestResult> {
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<string>;
protected abstract getTestExtIdsToRun(accessor: ServicesAccessor): Iterable<TestIdPath>;
protected abstract filter(node: InternalTestItem): boolean;
protected abstract runTest(service: ITestService, node: InternalTestItem[]): Promise<ITestResult>;
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<string> {
protected getTestExtIdsToRun(accessor: ServicesAccessor): Iterable<TestIdPath> {
const { results } = accessor.get(ITestResultService);
const extIds = new Set<string>();
const paths = new Set<string>();
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<string> {
protected *getTestExtIdsToRun(accessor: ServicesAccessor): Iterable<TestIdPath> {
const lastResult = accessor.get(ITestResultService).results[0];
const extIds = new Set<string>();
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<ITestResult> {
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<ITestResult> {
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<ITestResult> {
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<ITestResult> {
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 })),
});
}
}
......
......@@ -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);
}
});
......
......@@ -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
......
......@@ -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<string>;
/** Reveal request, the extId of the test to reveal */
readonly reveal: ObservableValue<string | undefined>;
/**
* 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<TestIdPath | undefined>;
readonly stateFilter: ObservableValue<TestExplorerStateFilter>;
readonly currentDocumentOnly: ObservableValue<boolean>;
/** 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<string | undefined>(undefined);
public readonly reveal = new ObservableValue<TestIdPath | undefined>(undefined);
public readonly onDidRequestInputFocus = this.focusEmitter.event;
......
......@@ -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<ITestTreeElement, FuzzyScore>;
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<void> {
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<DisposableStore>();
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<ITestTreeElement
const testHidden = !!element.test && this.testService.excludeTests.value.has(element.test.item.extId);
data.wrapper.classList.toggle('test-is-hidden', testHidden);
const icon = testingStatesToIcons.get(element.state);
const icon = testingStatesToIcons.get(element.expandable === TestItemExpandState.BusyExpanding ? TestResult.Running : element.state);
data.icon.className = 'computed-state ' + (icon ? ThemeIcon.asClassName(icon) : '');
if (element.retired) {
data.icon.className += ' retired';
......
......@@ -4,26 +4,41 @@
*--------------------------------------------------------------------------------------------*/
import { mapFind } from 'vs/base/common/arrays';
import { RunOnceScheduler } from 'vs/base/common/async';
import { throttle } from 'vs/base/common/decorators';
import { DeferredPromise, isThenable, RunOnceScheduler } from 'vs/base/common/async';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { IDisposable, IReference } from 'vs/base/common/lifecycle';
import { TestItem } from 'vs/workbench/api/common/extHostTypeConverters';
import { InternalTestItem, TestDiffOpType, TestsDiff, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testCollection';
import { TestItem as TestItemImpl, TestItemHookProperty } from 'vs/workbench/api/common/extHostTypes';
import { applyTestItemUpdate, InternalTestItem, TestDiffOpType, TestItemExpandState, TestsDiff, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testCollection';
export interface IHierarchyProvider {
getChildren(node: TestItem.Raw, token: CancellationToken): Iterable<TestItem.Raw> | AsyncIterable<TestItem.Raw> | undefined | null;
}
/**
* @private
*/
export class OwnedTestCollection {
protected readonly testIdsToInternal = new Set<TestTree<OwnedCollectionTestItem>>();
protected readonly testIdsToInternal = new Map<number, TestTree<OwnedCollectionTestItem>>();
/**
* 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<OwnedCollectionTestItem>,
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<TestTree<OwnedCollectionTestItem>> {
const tree = new TestTree<OwnedCollectionTestItem>();
this.testIdsToInternal.add(tree);
return { object: tree, dispose: () => this.testIdsToInternal.delete(tree) };
protected createIdMap(id: number): IReference<TestTree<OwnedCollectionTestItem>> {
const tree = new TestTree<OwnedCollectionTestItem>(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<string>;
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<void>;
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<T extends InternalTestItem> {
private readonly _roots = new Set<T>();
public readonly roots: ReadonlySet<T> = this._roots;
constructor(public readonly id: number) { }
/**
* Gets the size of the tree.
*/
......@@ -170,14 +193,11 @@ export class TestTree<T extends InternalTestItem> {
export class SingleUseTestCollection implements IDisposable {
protected readonly testItemToInternal = new Map<TestItem.Raw, OwnedCollectionTestItem>();
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<TestTree<OwnedCollectionTestItem>>,
......@@ -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> | 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<string>();
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> | 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<void>();
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<TestItem.Raw, 'children'>]: null } = {
id: null,
label: null,
location: null,
debuggable: null,
description: null,
runnable: null
};
const simpleProps = Object.keys(keyMap) as ReadonlyArray<keyof typeof keyMap>;
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;
};
};
......@@ -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<ITestItem>;
}
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<TestResultItem, 'children' | 'retired'> & { children: string[], retired: undefined };
export type SerializedTestResultItem = Omit<TestResultItem, 'children' | 'expandable' | 'retired'>
& { 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<T extends IncrementalTes
*/
protected readonly roots = new Set<string>();
/**
* 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<T extends IncrementalTes
this.items.set(internalTest.item.extId, created);
changes.add(created);
}
if (internalTest.expand === TestItemExpandState.BusyExpanding) {
this.updateBusyProviders(1);
}
break;
}
case TestDiffOpType.Update: {
const internalTest = op[1];
const existing = this.items.get(internalTest.item.extId);
if (existing) {
Object.assign(existing.item, internalTest.item);
changes.update(existing);
const patch = op[1];
const existing = this.items.get(patch.extId);
if (!existing) {
break;
}
applyTestItemUpdate(existing, patch);
changes.update(existing);
if (patch.expand !== undefined && existing.expand === TestItemExpandState.BusyExpanding && patch.expand !== TestItemExpandState.BusyExpanding) {
this.updateBusyProviders(-1);
}
break;
}
......@@ -245,14 +298,18 @@ export abstract class AbstractIncrementalTestCollection<T extends IncrementalTes
queue.push(existing.children);
this.items.delete(itemId);
changes.remove(existing, existing !== toRemove);
if (existing.expand === TestItemExpandState.BusyExpanding) {
this.updateBusyProviders(-1);
}
}
}
}
break;
}
case TestDiffOpType.DeltaDiscoverComplete:
this.updateBusyProviders(op[1]);
case TestDiffOpType.Retire:
this.retireTest(op[1]);
break;
case TestDiffOpType.DeltaRootsComplete:
......@@ -264,11 +321,18 @@ export abstract class AbstractIncrementalTestCollection<T extends IncrementalTes
changes.complete();
}
/**
* Called when the extension signals a test result should be retired.
*/
protected retireTest(testId: string) {
// no-op
}
/**
* Updates the number of providers who are still discovering items.
*/
protected updateBusyProviders(delta: number) {
// no-op
this.busyProviderCount += delta;
}
/**
......@@ -276,8 +340,8 @@ export abstract class AbstractIncrementalTestCollection<T extends IncrementalTes
* the total pending test roots reaches 0, the roots for all providers
* will exist in the collection.
*/
protected updatePendingRoots(delta: number) {
// no-op
public updatePendingRoots(delta: number) {
this.pendingRootCount += delta;
}
/**
......
......@@ -365,7 +365,7 @@ export class LiveTestResult implements ITestResult {
if (test) {
const originalSize = this.testById.size;
makeParents(collection, test, this.testById);
const node = makeNodeAndChildren(collection, test, this.excluded, this.testById);
const node = makeNodeAndChildren(collection, test, this.excluded, this.testById, false);
this.counts[TestResult.Unset] += this.testById.size - originalSize;
return node;
}
......
......@@ -10,13 +10,14 @@ import { URI } from 'vs/base/common/uri';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
import { ObservableValue } from 'vs/workbench/contrib/testing/common/observableValue';
import { AbstractIncrementalTestCollection, IncrementalTestCollectionItem, InternalTestItem, RunTestForProviderRequest, RunTestsRequest, TestIdWithProvider, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { AbstractIncrementalTestCollection, IncrementalTestCollectionItem, InternalTestItem, RunTestForProviderRequest, RunTestsRequest, TestIdPath, TestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { ITestResult } from 'vs/workbench/contrib/testing/common/testResultService';
export const ITestService = createDecorator<ITestService>('testService');
export interface MainTestController {
lookupTest(test: TestIdWithProvider): Promise<InternalTestItem | undefined>;
expandTest(src: TestIdWithSrc, levels: number): Promise<void>;
lookupTest(test: TestIdWithSrc): Promise<InternalTestItem | undefined>;
runTests(request: RunTestForProviderRequest, token: CancellationToken): Promise<void>;
}
......@@ -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<void>;
/**
* 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<void>(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<ITestResult>;
cancelTestRun(req: RunTestsRequest): void;
publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void;
subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference<IMainThreadTestCollection>;
/**
* 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<ITestResult>;
/**
* 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<IMainThreadTestCollection>;
/**
* Looks up a test, by a request to extension hosts.
*/
lookupTest(test: TestIdWithProvider): Promise<InternalTestItem | undefined>;
lookupTest(test: TestIdWithSrc): Promise<InternalTestItem | undefined>;
/**
* Requests to resubscribe to all active subscriptions, discarding old tests.
......
......@@ -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<boolean>;
private readonly hasDebuggable: IContextKey<boolean>;
private readonly runningTests = new Map<RunTestsRequest, CancellationTokenSource>();
private rootProviderCount = 0;
private readonly rootProviders = new Set<ITestRootProvider>();
public readonly excludeTests = ObservableValue.stored(new StoredValue<ReadonlySet<string>>({
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<IncrementalTestCollectionItem> implements IMainThreadTestCollection {
private pendingRootChangeEmitter = new Emitter<number>();
private busyProvidersChangeEmitter = new Emitter<number>();
private _busyProviders = 0;
private retireTestEmitter = new Emitter<string>();
private expandPromises = new WeakMap<IncrementalTestCollectionItem, {
pendingLvl: number;
doneLvl: number;
prom: Promise<void>;
}>();
/**
* @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<void>) {
super();
this.pendingRootCount = pendingRootProviders;
}
/**
* @inheritdoc
*/
public expand(testId: string, levels: number): Promise<void> {
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() {
......
......@@ -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 = {
......
......@@ -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<string, TestIdWithProvider>();
const rerunIds = new Map<string, TestIdWithSrc>();
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);
}
......
......@@ -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<TestHierarchicalByLocationProjection>;
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' }] },
......
......@@ -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' },
......
......@@ -86,14 +86,26 @@ export class TestTreeTestHarness<T extends ITestTreeProjection = ITestTreeProjec
public readonly projection: T;
public readonly tree: TestObjectTree<ITestTreeElement>;
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) {
......
......@@ -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;
};
......@@ -20,19 +20,19 @@ suite('Workbench - Test Results Service', () => {
let r: LiveTestResult;
let changed = new Set<TestResultItemChange>();
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]);
});
......
......@@ -35,4 +35,4 @@
"vscode-uri": "^2.0.3",
"watch": "^1.0.2"
}
}
\ No newline at end of file
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册