testing: initial api implementation
* wip * wip * wip * wip * wip * wip
This commit is contained in:
parent
ff1887be3e
commit
d1280418d7
|
@ -983,6 +983,7 @@
|
|||
"collapse",
|
||||
"create",
|
||||
"delete",
|
||||
"discover",
|
||||
"dispose",
|
||||
"edit",
|
||||
"end",
|
||||
|
|
|
@ -588,3 +588,17 @@ export function asArray<T>(x: T | T[]): T[] {
|
|||
export function getRandomElement<T>(arr: T[]): T | undefined {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first mapped value of the array which is not undefined.
|
||||
*/
|
||||
export function mapFind<T, R>(array: Iterable<T>, mapFn: (value: T) => R | undefined): R | undefined {
|
||||
for (const value of array) {
|
||||
const mapped = mapFn(value);
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
289
src/vs/vscode.proposed.d.ts
vendored
289
src/vs/vscode.proposed.d.ts
vendored
|
@ -2149,4 +2149,293 @@ declare module 'vscode' {
|
|||
notebook: NotebookDocument | undefined;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region https://github.com/microsoft/vscode/issues/107467
|
||||
/*
|
||||
General activation events:
|
||||
- `onLanguage:*` most test extensions will want to activate when their
|
||||
language is opened to provide code lenses.
|
||||
- `onTests:*` new activation event very simiular to `workspaceContains`,
|
||||
but only fired when the user wants to run tests or opens the test explorer.
|
||||
*/
|
||||
export namespace test {
|
||||
/**
|
||||
* Registers a provider that discovers tests for the given document
|
||||
* selectors. It is activated when either tests need to be enumerated, or
|
||||
* a document matching the selector is opened.
|
||||
*/
|
||||
export function registerTestProvider<T extends TestItem>(testProvider: TestProvider<T>): Disposable;
|
||||
|
||||
/**
|
||||
* Runs tests with the given options. If no options are given, then
|
||||
* all tests are run. Returns the resulting test run.
|
||||
*/
|
||||
export function runTests<T extends TestItem>(options: TestRunOptions<T>): Thenable<void>;
|
||||
|
||||
/**
|
||||
* Returns an observer that retrieves tests in the given workspace folder.
|
||||
*/
|
||||
export function createWorkspaceTestObserver(workspaceFolder: WorkspaceFolder): TestObserver;
|
||||
|
||||
/**
|
||||
* Returns an observer that retrieves tests in the given text document.
|
||||
*/
|
||||
export function createDocumentTestObserver(document: TextDocument): TestObserver;
|
||||
}
|
||||
|
||||
export interface TestObserver {
|
||||
/**
|
||||
* List of tests returned by test provider for files in the workspace.
|
||||
*/
|
||||
readonly tests: ReadonlyArray<TestItem>;
|
||||
|
||||
/**
|
||||
* An event that fires when an existing test in the collection changes, or
|
||||
* null if a top-level test was added or removed. When fired, the consumer
|
||||
* should check the test item and all its children for changes.
|
||||
*/
|
||||
readonly onDidChangeTest: Event<TestItem | null>;
|
||||
|
||||
/**
|
||||
* An event the fires when all test providers have signalled that the tests
|
||||
* the observer references have been discovered. Providers may continue to
|
||||
* watch for changes and cause {@link onDidChangeTest} to fire as files
|
||||
* change, until the observer is disposed.
|
||||
*
|
||||
* @todo as below
|
||||
*/
|
||||
readonly onDidDiscoverInitialTests: Event<void>;
|
||||
|
||||
/**
|
||||
* Dispose of the observer, allowing VS Code to eventually tell test
|
||||
* providers that they no longer need to update tests.
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree of tests returned from the provide methods in the {@link TestProvider}.
|
||||
*/
|
||||
export interface TestHierarchy<T extends TestItem> {
|
||||
/**
|
||||
* Root node for tests. The `testRoot` instance must not be replaced over
|
||||
* the lifespan of the TestHierarchy, since you will need to reference it
|
||||
* in `onDidChangeTest` when a test is added or removed.
|
||||
*/
|
||||
readonly root: T;
|
||||
|
||||
/**
|
||||
* An event that fires when an existing test under the `root` changes.
|
||||
* This can be a result of a state change in a test run, 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.
|
||||
*
|
||||
* This will signal a change recursively to all children of the given node.
|
||||
* For example, firing the event with the {@link testRoot} will refresh
|
||||
* all tests.
|
||||
*/
|
||||
readonly onDidChangeTest: Event<T>;
|
||||
|
||||
/**
|
||||
* An event that should be fired when all tests that are currently defined
|
||||
* have been discovered. The provider should continue to watch for changes
|
||||
* and fire `onDidChangeTest` until the hierarchy is disposed.
|
||||
*
|
||||
* @todo can this be covered by existing progress apis? Or return a promise
|
||||
*/
|
||||
readonly onDidDiscoverInitialTests: Event<void>;
|
||||
|
||||
/**
|
||||
* Dispose will be called when there are no longer observers interested
|
||||
* in the hierarchy.
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and provides tests. It's expected that the TestProvider will
|
||||
* ambiently listen to {@link vscode.window.onDidChangeVisibleTextEditors} to
|
||||
* provide test information about the open files for use in code lenses and
|
||||
* other file-specific UI.
|
||||
*
|
||||
* Additionally, the UI may request it to discover tests for the workspace
|
||||
* via `addWorkspaceTests`.
|
||||
*
|
||||
* @todo rename from provider
|
||||
*/
|
||||
export interface TestProvider<T extends TestItem = TestItem> {
|
||||
/**
|
||||
* Requests that tests be provided for the given workspace. This will
|
||||
* generally be called when tests need to be enumerated for the
|
||||
* workspace.
|
||||
*
|
||||
* It's guaranteed that this method will not be called again while
|
||||
* there is a previous undisposed watcher for the given workspace folder.
|
||||
*/
|
||||
createWorkspaceTestHierarchy?(workspace: WorkspaceFolder): TestHierarchy<T>;
|
||||
|
||||
/**
|
||||
* Requests that tests be provided for the given document. This will
|
||||
* be called when tests need to be enumerated for a single open file,
|
||||
* for instance by code lens UI.
|
||||
*/
|
||||
createDocumentTestHierarchy?(document: TextDocument): TestHierarchy<T>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
runTests?(options: TestRunOptions<T>, cancellationToken: CancellationToken): ProviderResult<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options given to `TestProvider.runTests`
|
||||
*/
|
||||
export interface TestRunOptions<T extends TestItem = TestItem> {
|
||||
/**
|
||||
* Array of specific tests to run. The {@link TestProvider.testRoot} may
|
||||
* be provided as an indication to run all tests.
|
||||
*/
|
||||
tests: T[];
|
||||
|
||||
/**
|
||||
* Whether or not tests in this run should be debugged.
|
||||
*/
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 interface TestItem {
|
||||
/**
|
||||
* Display name describing the test case.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Optional description that appears next to the label.
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Whether this test item can be run individually, defaults to `true`
|
||||
* if not provided.
|
||||
*
|
||||
* In some cases, like Go's tests, test can have children but these
|
||||
* children cannot be run independently.
|
||||
*/
|
||||
runnable?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this test item can be debugged.
|
||||
*/
|
||||
debuggable?: boolean;
|
||||
|
||||
/**
|
||||
* VS Code location.
|
||||
*/
|
||||
location?: Location;
|
||||
|
||||
/**
|
||||
* Optional list of nested tests for this item.
|
||||
*/
|
||||
children?: TestItem[];
|
||||
|
||||
/**
|
||||
* Test run state. Will generally be {@link TestRunState.Unset} by
|
||||
* default.
|
||||
*/
|
||||
state: TestState;
|
||||
}
|
||||
|
||||
export enum TestRunState {
|
||||
// Initial state
|
||||
Unset = 0,
|
||||
// Test is currently running
|
||||
Running = 1,
|
||||
// Test run has passed
|
||||
Passed = 2,
|
||||
// Test run has failed (on an assertion)
|
||||
Failed = 3,
|
||||
// Test run has been skipped
|
||||
Skipped = 4,
|
||||
// Test run failed for some other reason (compilation error, timeout, etc)
|
||||
Errored = 5
|
||||
}
|
||||
|
||||
/**
|
||||
* TestState includes a test and its run state. This is included in the
|
||||
* {@link TestItem} and is immutable; it should be replaced in th TestItem
|
||||
* in order to update it. This allows consumers to quickly and easily check
|
||||
* for changes via object identity.
|
||||
*/
|
||||
export class TestState {
|
||||
/**
|
||||
* Current state of the test.
|
||||
*/
|
||||
readonly runState: TestRunState;
|
||||
|
||||
/**
|
||||
* Optional duration of the test run, in milliseconds.
|
||||
*/
|
||||
readonly duration?: number;
|
||||
|
||||
/**
|
||||
* Associated test run message. Can, for example, contain assertion
|
||||
* failure information if the test fails.
|
||||
*/
|
||||
readonly messages: ReadonlyArray<Readonly<TestMessage>>;
|
||||
|
||||
/**
|
||||
* @param state Run state to hold in the test state
|
||||
* @param messages List of associated messages for the test
|
||||
* @param duration Length of time the test run took, if appropriate.
|
||||
*/
|
||||
constructor(runState: TestRunState, messages?: TestMessage[], duration?: number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the severity of test messages.
|
||||
*/
|
||||
export enum TestMessageSeverity {
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Information = 2,
|
||||
Hint = 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Message associated with the test state. Can be linked to a specific
|
||||
* source range -- useful for assertion failures, for example.
|
||||
*/
|
||||
export interface TestMessage {
|
||||
/**
|
||||
* Human-readable message text to display.
|
||||
*/
|
||||
message: string | MarkdownString;
|
||||
|
||||
/**
|
||||
* Message severity. Defaults to "Error", if not provided.
|
||||
*/
|
||||
severity?: TestMessageSeverity;
|
||||
|
||||
/**
|
||||
* Expected test output. If given with `actual`, a diff view will be shown.
|
||||
*/
|
||||
expectedOutput?: string;
|
||||
|
||||
/**
|
||||
* Actual test output. If given with `actual`, a diff view will be shown.
|
||||
*/
|
||||
actualOutput?: string;
|
||||
|
||||
/**
|
||||
* Associated file location.
|
||||
*/
|
||||
location?: Location;
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
|
|
@ -64,6 +64,7 @@ import './mainThreadLabelService';
|
|||
import './mainThreadTunnelService';
|
||||
import './mainThreadAuthentication';
|
||||
import './mainThreadTimeline';
|
||||
import './mainThreadTesting';
|
||||
import 'vs/workbench/api/common/apiCommands';
|
||||
|
||||
export class ExtensionPoints implements IWorkbenchContribution {
|
||||
|
|
77
src/vs/workbench/api/browser/mainThreadTesting.ts
Normal file
77
src/vs/workbench/api/browser/mainThreadTesting.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { getTestSubscriptionKey, RunTestsRequest, RunTestsResult, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
|
||||
import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers';
|
||||
import { ExtHostContext, ExtHostTestingResource, ExtHostTestingShape, IExtHostContext, MainContext, MainThreadTestingShape } from '../common/extHost.protocol';
|
||||
import { URI, UriComponents } from 'vs/base/common/uri';
|
||||
|
||||
@extHostNamedCustomer(MainContext.MainThreadTesting)
|
||||
export class MainThreadTesting extends Disposable implements MainThreadTestingShape {
|
||||
private readonly proxy: ExtHostTestingShape;
|
||||
private readonly testSubscriptions = new Map<string, IDisposable>();
|
||||
|
||||
constructor(
|
||||
extHostContext: IExtHostContext,
|
||||
@ITestService private readonly testService: ITestService,
|
||||
) {
|
||||
super();
|
||||
this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostTesting);
|
||||
this._register(this.testService.onShouldSubscribe(args => this.proxy.$subscribeToTests(args.resource, args.uri)));
|
||||
this._register(this.testService.onShouldUnsubscribe(args => this.proxy.$unsubscribeFromTests(args.resource, args.uri)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public $registerTestProvider(id: string) {
|
||||
this.testService.registerTestController(id, {
|
||||
runTests: req => this.proxy.$runTestsForProvider(req),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public $unregisterTestProvider(id: string) {
|
||||
this.testService.unregisterTestController(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
$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));
|
||||
this.testSubscriptions.set(getTestSubscriptionKey(resource, uri), disposable);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public $unsubscribeFromDiffs(resource: ExtHostTestingResource, uriComponents: UriComponents): void {
|
||||
const key = getTestSubscriptionKey(resource, URI.revive(uriComponents));
|
||||
this.testSubscriptions.get(key)?.dispose();
|
||||
this.testSubscriptions.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public $publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void {
|
||||
this.testService.publishDiff(resource, URI.revive(uri), diff);
|
||||
}
|
||||
|
||||
public $runTests(req: RunTestsRequest): Promise<RunTestsResult> {
|
||||
return this.testService.runTests(req);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
// no-op
|
||||
}
|
||||
}
|
|
@ -81,6 +81,7 @@ import { ExtHostCustomEditors } from 'vs/workbench/api/common/extHostCustomEdito
|
|||
import { ExtHostWebviewPanels } from 'vs/workbench/api/common/extHostWebviewPanels';
|
||||
import { ExtHostBulkEdits } from 'vs/workbench/api/common/extHostBulkEdits';
|
||||
import { IExtHostFileSystemInfo } from 'vs/workbench/api/common/extHostFileSystemInfo';
|
||||
import { ExtHostTesting } from 'vs/workbench/api/common/extHostTesting';
|
||||
|
||||
export interface IExtensionApiFactory {
|
||||
(extension: IExtensionDescription, registry: ExtensionDescriptionRegistry, configProvider: ExtHostConfigProvider): typeof vscode;
|
||||
|
@ -152,6 +153,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
|
|||
const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace));
|
||||
const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels));
|
||||
const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews));
|
||||
const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, new ExtHostTesting(rpcProtocol, extHostDocumentsAndEditors, extHostWorkspace));
|
||||
|
||||
// Check that no named customers are missing
|
||||
const expected: ProxyIdentifier<any>[] = values(ExtHostContext);
|
||||
|
@ -333,6 +335,25 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
|
|||
? extHostTypes.ExtensionKind.Workspace
|
||||
: extHostTypes.ExtensionKind.UI;
|
||||
|
||||
const test: typeof vscode.test = {
|
||||
registerTestProvider(provider) {
|
||||
checkProposedApiEnabled(extension);
|
||||
return extHostTesting.registerTestProvider(provider);
|
||||
},
|
||||
createDocumentTestObserver(document) {
|
||||
checkProposedApiEnabled(extension);
|
||||
return extHostTesting.createTextDocumentTestObserver(document);
|
||||
},
|
||||
createWorkspaceTestObserver(workspaceFolder) {
|
||||
checkProposedApiEnabled(extension);
|
||||
return extHostTesting.createWorkspaceTestObserver(workspaceFolder);
|
||||
},
|
||||
runTests(provider) {
|
||||
checkProposedApiEnabled(extension);
|
||||
return extHostTesting.runTests(provider);
|
||||
},
|
||||
};
|
||||
|
||||
// namespace: extensions
|
||||
const extensions: typeof vscode.extensions = {
|
||||
getExtension(extensionId: string): Extension<any> | undefined {
|
||||
|
@ -1072,6 +1093,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
|
|||
extensions,
|
||||
languages,
|
||||
scm,
|
||||
test,
|
||||
comment,
|
||||
comments,
|
||||
tasks,
|
||||
|
@ -1197,7 +1219,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
|
|||
NotebookEditorRevealType: extHostTypes.NotebookEditorRevealType,
|
||||
NotebookCellOutput: extHostTypes.NotebookCellOutput,
|
||||
NotebookCellOutputItem: extHostTypes.NotebookCellOutputItem,
|
||||
OnTypeRenameRanges: extHostTypes.OnTypeRenameRanges
|
||||
OnTypeRenameRanges: extHostTypes.OnTypeRenameRanges,
|
||||
TestRunState: extHostTypes.TestRunState,
|
||||
TestMessageSeverity: extHostTypes.TestMessageSeverity,
|
||||
TestState: extHostTypes.TestState,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ import { ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib
|
|||
import { DebugConfigurationProviderTriggerKind } from 'vs/workbench/api/common/extHostTypes';
|
||||
import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility';
|
||||
import { IExtensionIdWithVersion } from 'vs/platform/userDataSync/common/extensionsStorageSync';
|
||||
import { RunTestForProviderRequest, RunTestsRequest, RunTestsResult, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
|
||||
export interface IEnvironment {
|
||||
isExtensionDevelopmentDebug: boolean;
|
||||
|
@ -1750,6 +1751,28 @@ export interface ExtHostTimelineShape {
|
|||
$getTimeline(source: string, uri: UriComponents, options: TimelineOptions, token: CancellationToken, internalOptions?: InternalTimelineOptions): Promise<Timeline | undefined>;
|
||||
}
|
||||
|
||||
export const enum ExtHostTestingResource {
|
||||
Workspace,
|
||||
TextDocument
|
||||
}
|
||||
|
||||
export interface ExtHostTestingShape {
|
||||
$runTestsForProvider(req: RunTestForProviderRequest): Promise<RunTestsResult>;
|
||||
$subscribeToTests(resource: ExtHostTestingResource, uri: UriComponents): void;
|
||||
$unsubscribeFromTests(resource: ExtHostTestingResource, uri: UriComponents): void;
|
||||
|
||||
$acceptDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void;
|
||||
}
|
||||
|
||||
export interface MainThreadTestingShape {
|
||||
$registerTestProvider(id: string): void;
|
||||
$unregisterTestProvider(id: string): void;
|
||||
$subscribeToDiffs(resource: ExtHostTestingResource, uri: UriComponents): void;
|
||||
$unsubscribeFromDiffs(resource: ExtHostTestingResource, uri: UriComponents): void;
|
||||
$publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void;
|
||||
$runTests(req: RunTestsRequest): Promise<RunTestsResult>;
|
||||
}
|
||||
|
||||
// --- proxy identifiers
|
||||
|
||||
export const MainContext = {
|
||||
|
@ -1799,7 +1822,8 @@ export const MainContext = {
|
|||
MainThreadNotebook: createMainId<MainThreadNotebookShape>('MainThreadNotebook'),
|
||||
MainThreadTheming: createMainId<MainThreadThemingShape>('MainThreadTheming'),
|
||||
MainThreadTunnelService: createMainId<MainThreadTunnelServiceShape>('MainThreadTunnelService'),
|
||||
MainThreadTimeline: createMainId<MainThreadTimelineShape>('MainThreadTimeline')
|
||||
MainThreadTimeline: createMainId<MainThreadTimelineShape>('MainThreadTimeline'),
|
||||
MainThreadTesting: createMainId<MainThreadTestingShape>('MainThreadTesting'),
|
||||
};
|
||||
|
||||
export const ExtHostContext = {
|
||||
|
@ -1842,5 +1866,6 @@ export const ExtHostContext = {
|
|||
ExtHostTheming: createMainId<ExtHostThemingShape>('ExtHostTheming'),
|
||||
ExtHostTunnelService: createMainId<ExtHostTunnelServiceShape>('ExtHostTunnelService'),
|
||||
ExtHostAuthentication: createMainId<ExtHostAuthenticationShape>('ExtHostAuthentication'),
|
||||
ExtHostTimeline: createMainId<ExtHostTimelineShape>('ExtHostTimeline')
|
||||
ExtHostTimeline: createMainId<ExtHostTimelineShape>('ExtHostTimeline'),
|
||||
ExtHostTesting: createMainId<ExtHostTestingShape>('ExtHostTesting'),
|
||||
};
|
||||
|
|
623
src/vs/workbench/api/common/extHostTesting.ts
Normal file
623
src/vs/workbench/api/common/extHostTesting.ts
Normal file
|
@ -0,0 +1,623 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { mapFind } from 'vs/base/common/arrays';
|
||||
import { disposableTimeout } from 'vs/base/common/async';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { throttle } from 'vs/base/common/decorators';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { once } from 'vs/base/common/functional';
|
||||
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { isDefined } from 'vs/base/common/types';
|
||||
import { URI, UriComponents } from 'vs/base/common/uri';
|
||||
import { generateUuid } from 'vs/base/common/uuid';
|
||||
import { ExtHostTestingResource, ExtHostTestingShape, MainContext, MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
|
||||
import { IExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
|
||||
import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService';
|
||||
import { TestItem } from 'vs/workbench/api/common/extHostTypeConverters';
|
||||
import { Disposable } from 'vs/workbench/api/common/extHostTypes';
|
||||
import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace';
|
||||
import { AbstractIncrementalTestCollection, EMPTY_TEST_RESULT, IncrementalTestCollectionItem, InternalTestItem, RunTestForProviderRequest, RunTestsResult, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
import type * as vscode from 'vscode';
|
||||
|
||||
const getTestSubscriptionKey = (resource: ExtHostTestingResource, uri: URI) => `${resource}:${uri.toString()}`;
|
||||
|
||||
export class ExtHostTesting implements ExtHostTestingShape {
|
||||
private readonly providers = new Map<string, vscode.TestProvider>();
|
||||
private readonly proxy: MainThreadTestingShape;
|
||||
private readonly ownedTests = new OwnedTestCollection();
|
||||
private readonly testSubscriptions = new Map<string, { collection: SingleUseTestCollection, store: IDisposable }>();
|
||||
|
||||
private workspaceObservers: WorkspaceFolderTestObserverFactory;
|
||||
private textDocumentObservers: TextDocumentTestObserverFactory;
|
||||
|
||||
constructor(@IExtHostRpcService rpc: IExtHostRpcService, @IExtHostDocumentsAndEditors private readonly documents: IExtHostDocumentsAndEditors, @IExtHostWorkspace private readonly workspace: IExtHostWorkspace) {
|
||||
this.proxy = rpc.getProxy(MainContext.MainThreadTesting);
|
||||
this.workspaceObservers = new WorkspaceFolderTestObserverFactory(this.proxy);
|
||||
this.textDocumentObservers = new TextDocumentTestObserverFactory(this.proxy, documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements vscode.test.registerTestProvider
|
||||
*/
|
||||
public registerTestProvider<T extends vscode.TestItem>(provider: vscode.TestProvider<T>): vscode.Disposable {
|
||||
const providerId = generateUuid();
|
||||
this.providers.set(providerId, provider);
|
||||
this.proxy.$registerTestProvider(providerId);
|
||||
|
||||
return new Disposable(() => {
|
||||
this.providers.delete(providerId);
|
||||
this.proxy.$unregisterTestProvider(providerId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements vscode.test.createTextDocumentTestObserver
|
||||
*/
|
||||
public createTextDocumentTestObserver(document: vscode.TextDocument) {
|
||||
return this.textDocumentObservers.checkout(document.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements vscode.test.createWorkspaceTestObserver
|
||||
*/
|
||||
public createWorkspaceTestObserver(workspaceFolder: vscode.WorkspaceFolder) {
|
||||
return this.workspaceObservers.checkout(workspaceFolder.uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements vscode.test.runTests
|
||||
*/
|
||||
public async runTests(req: vscode.TestRunOptions<vscode.TestItem>) {
|
||||
await this.proxy.$runTests({
|
||||
tests: req.tests
|
||||
// Find workspace items first, then owned tests, then document tests.
|
||||
// If a test instance exists in both the workspace and document, prefer
|
||||
// the workspace because it's less ephemeral.
|
||||
.map(test => this.workspaceObservers.getMirroredTestDataByReference(test)
|
||||
?? mapFind(this.testSubscriptions.values(), c => c.collection.getTestByReference(test))
|
||||
?? this.textDocumentObservers.getMirroredTestDataByReference(test))
|
||||
.filter(isDefined)
|
||||
.map(item => ({ providerId: item.providerId, testId: item.id })),
|
||||
debug: req.debug
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a request to read tests for a file, or workspace.
|
||||
* @override
|
||||
*/
|
||||
public $subscribeToTests(resource: ExtHostTestingResource, uriComponents: UriComponents) {
|
||||
const uri = URI.revive(uriComponents);
|
||||
const subscriptionKey = getTestSubscriptionKey(resource, uri);
|
||||
if (this.testSubscriptions.has(subscriptionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let method: undefined | ((p: vscode.TestProvider) => vscode.TestHierarchy<vscode.TestItem> | undefined);
|
||||
if (resource === ExtHostTestingResource.TextDocument) {
|
||||
const document = this.documents.getDocument(uri);
|
||||
if (document) {
|
||||
method = p => p.createDocumentTestHierarchy?.(document.document);
|
||||
}
|
||||
} else {
|
||||
const folder = this.workspace.getWorkspaceFolder(uri, false);
|
||||
if (folder) {
|
||||
method = p => p.createWorkspaceTestHierarchy?.(folder);
|
||||
}
|
||||
}
|
||||
|
||||
if (!method) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disposable = new DisposableStore();
|
||||
const collection = disposable.add(this.ownedTests.createForHierarchy(diff => this.proxy.$publishDiff(resource, uriComponents, diff)));
|
||||
for (const [id, provider] of this.providers) {
|
||||
try {
|
||||
const hierarchy = method(provider);
|
||||
if (!hierarchy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
disposable.add(hierarchy);
|
||||
collection.addRoot(hierarchy.root, id);
|
||||
hierarchy.onDidChangeTest(e => collection.onItemChange(e, id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.testSubscriptions.set(subscriptionKey, { store: disposable, collection });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of a previous subscription to tests.
|
||||
* @override
|
||||
*/
|
||||
public $unsubscribeFromTests(resource: ExtHostTestingResource, uriComponents: UriComponents) {
|
||||
const uri = URI.revive(uriComponents);
|
||||
const subscriptionKey = getTestSubscriptionKey(resource, uri);
|
||||
this.testSubscriptions.get(subscriptionKey)?.store.dispose();
|
||||
this.testSubscriptions.delete(subscriptionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a test update from the main thread. Called (eventually) whenever
|
||||
* tests change.
|
||||
* @override
|
||||
*/
|
||||
public $acceptDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void {
|
||||
if (resource === ExtHostTestingResource.TextDocument) {
|
||||
this.textDocumentObservers.acceptDiff(URI.revive(uri), diff);
|
||||
} else {
|
||||
this.workspaceObservers.acceptDiff(URI.revive(uri), diff);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs tests with the given set of IDs. Allows for test from multiple
|
||||
* providers to be run.
|
||||
* @override
|
||||
*/
|
||||
public async $runTestsForProvider(req: RunTestForProviderRequest): Promise<RunTestsResult> {
|
||||
const provider = this.providers.get(req.providerId);
|
||||
if (!provider || !provider.runTests) {
|
||||
return EMPTY_TEST_RESULT;
|
||||
}
|
||||
|
||||
const tests = req.ids.map(id => this.ownedTests.getTestById(id)?.actual).filter(isDefined);
|
||||
if (!tests.length) {
|
||||
return EMPTY_TEST_RESULT;
|
||||
}
|
||||
|
||||
await provider.runTests({ tests, debug: req.debug }, CancellationToken.None);
|
||||
return EMPTY_TEST_RESULT;
|
||||
}
|
||||
}
|
||||
|
||||
const keyMap: { [K in keyof Omit<Required<vscode.TestItem>, 'children'>]: null } = {
|
||||
label: null,
|
||||
location: null,
|
||||
state: null,
|
||||
debuggable: null,
|
||||
description: null,
|
||||
runnable: null
|
||||
};
|
||||
|
||||
const simpleProps = Object.keys(keyMap) as ReadonlyArray<keyof typeof keyMap>;
|
||||
|
||||
const itemEqualityComparator = (a: vscode.TestItem) => {
|
||||
const values: unknown[] = [];
|
||||
for (const prop of simpleProps) {
|
||||
values.push(a[prop]);
|
||||
}
|
||||
|
||||
return (b: vscode.TestItem) => {
|
||||
for (let i = 0; i < simpleProps.length; i++) {
|
||||
if (values[i] !== b[simpleProps[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export interface OwnedCollectionTestItem extends InternalTestItem {
|
||||
actual: vscode.TestItem;
|
||||
previousChildren: Set<string>;
|
||||
previousEquals: (v: vscode.TestItem) => boolean;
|
||||
}
|
||||
|
||||
export class OwnedTestCollection {
|
||||
protected readonly testIdToInternal = new Map<string, OwnedCollectionTestItem>();
|
||||
|
||||
/**
|
||||
* Gets test information by ID, if it was defined and still exists in this
|
||||
* extension host.
|
||||
*/
|
||||
public getTestById(id: string) {
|
||||
return this.testIdToInternal.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new test collection for a specific hierarchy for a workspace
|
||||
* or document observation.
|
||||
*/
|
||||
public createForHierarchy(publishDiff: (diff: TestsDiff) => void = () => undefined) {
|
||||
return new SingleUseTestCollection(this.testIdToInternal, publishDiff);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains tests created and registered for a single set of hierarchies
|
||||
* for a workspace or document.
|
||||
* @private
|
||||
*/
|
||||
export class SingleUseTestCollection implements IDisposable {
|
||||
protected readonly testItemToInternal = new Map<vscode.TestItem, OwnedCollectionTestItem>();
|
||||
protected diff: TestsDiff = [];
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly testIdToInternal: Map<string, OwnedCollectionTestItem>, private readonly publishDiff: (diff: TestsDiff) => void) { }
|
||||
|
||||
/**
|
||||
* Adds a new root node to the collection.
|
||||
*/
|
||||
public addRoot(item: vscode.TestItem, providerId: string) {
|
||||
this.addItem(item, providerId, null);
|
||||
this.throttleSendDiff();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets test information by its reference, if it was defined and still exists
|
||||
* in this extension host.
|
||||
*/
|
||||
public getTestByReference(item: vscode.TestItem) {
|
||||
return this.testItemToInternal.get(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when an item change is fired on the test provider.
|
||||
*/
|
||||
public onItemChange(item: vscode.TestItem, 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.throttleSendDiff();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a diff of all changes that have been made, and clears the diff queue.
|
||||
*/
|
||||
public collectDiff() {
|
||||
const diff = this.diff;
|
||||
this.diff = [];
|
||||
return diff;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
for (const item of this.testItemToInternal.values()) {
|
||||
this.testIdToInternal.delete(item.id);
|
||||
}
|
||||
|
||||
this.testIdToInternal.clear();
|
||||
this.diff = [];
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
protected getId(): string {
|
||||
return generateUuid();
|
||||
}
|
||||
|
||||
private addItem(actual: vscode.TestItem, providerId: string, parent: string | null) {
|
||||
let internal = this.testItemToInternal.get(actual);
|
||||
if (!internal) {
|
||||
internal = {
|
||||
actual,
|
||||
id: this.getId(),
|
||||
parent,
|
||||
item: TestItem.from(actual),
|
||||
providerId,
|
||||
previousChildren: new Set(),
|
||||
previousEquals: itemEqualityComparator(actual),
|
||||
};
|
||||
|
||||
this.testItemToInternal.set(actual, internal);
|
||||
this.testIdToInternal.set(internal.id, internal);
|
||||
this.diff.push([TestDiffOpType.Add, { id: internal.id, parent, providerId, item: internal.item }]);
|
||||
} else if (!internal.previousEquals(actual)) {
|
||||
internal.item = TestItem.from(actual);
|
||||
internal.previousEquals = itemEqualityComparator(actual);
|
||||
this.diff.push([TestDiffOpType.Update, { id: internal.id, parent, providerId, item: internal.item }]);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const c = this.addItem(child, providerId, internal.id);
|
||||
deletedChildren.delete(c.id);
|
||||
currentChildren.add(c.id);
|
||||
}
|
||||
|
||||
for (const child of deletedChildren) {
|
||||
this.removeItembyId(child);
|
||||
}
|
||||
|
||||
internal.previousChildren = currentChildren;
|
||||
}
|
||||
|
||||
|
||||
return internal;
|
||||
}
|
||||
|
||||
private removeItembyId(id: string) {
|
||||
this.diff.push([TestDiffOpType.Remove, id]);
|
||||
|
||||
const queue = [this.testIdToInternal.get(id)];
|
||||
while (queue.length) {
|
||||
const item = queue.pop();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.testIdToInternal.delete(item.id);
|
||||
this.testItemToInternal.delete(item.actual);
|
||||
for (const child of item.previousChildren) {
|
||||
queue.push(this.testIdToInternal.get(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@throttle(200)
|
||||
protected throttleSendDiff() {
|
||||
const diff = this.collectDiff();
|
||||
if (diff.length) {
|
||||
this.publishDiff(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
interface MirroredCollectionTestItem extends IncrementalTestCollectionItem {
|
||||
revived: vscode.TestItem;
|
||||
wrapped?: vscode.TestItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains tests in this extension host sent from the main thread.
|
||||
* @private
|
||||
*/
|
||||
export class MirroredTestCollection extends AbstractIncrementalTestCollection<MirroredCollectionTestItem> {
|
||||
private changeEmitter = new Emitter<vscode.TestItem | null>();
|
||||
|
||||
/**
|
||||
* Change emitter that fires with the same sematics as `TestObserver.onDidChangeTests`.
|
||||
*/
|
||||
public readonly onDidChangeTests = this.changeEmitter.event;
|
||||
|
||||
/**
|
||||
* Mapping of mirrored test items to their underlying ID. Given here to avoid
|
||||
* exposing them to extensions.
|
||||
*/
|
||||
protected readonly mirroredTestIds = new WeakMap<vscode.TestItem, string>();
|
||||
|
||||
/**
|
||||
* Gets a list of root test items.
|
||||
*/
|
||||
public get rootTestItems() {
|
||||
return this.getAllAsTestItem([...this.roots]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the item IDs to TestItems for exposure to extensions.
|
||||
*/
|
||||
public getAllAsTestItem(itemIds: ReadonlyArray<string>): vscode.TestItem[] {
|
||||
return itemIds.map(itemId => {
|
||||
const item = this.items.get(itemId);
|
||||
return item && this.createCollectionItemWrapper(item);
|
||||
}).filter(isDefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the test item is a mirrored test item, returns its underlying ID.
|
||||
*/
|
||||
public getMirroredTestDataByReference(item: vscode.TestItem) {
|
||||
const itemId = this.mirroredTestIds.get(item);
|
||||
return itemId ? this.items.get(itemId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
protected createItem(item: InternalTestItem): MirroredCollectionTestItem {
|
||||
return { ...item, revived: TestItem.to(item.item), children: new Set() };
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
protected onChange(item: MirroredCollectionTestItem | null) {
|
||||
if (item) {
|
||||
Object.assign(item.revived, TestItem.to(item.item));
|
||||
}
|
||||
|
||||
this.changeEmitter.fire(item ? this.createCollectionItemWrapper(item) : null);
|
||||
}
|
||||
|
||||
private createCollectionItemWrapper(item: MirroredCollectionTestItem): vscode.TestItem {
|
||||
if (!item.wrapped) {
|
||||
item.wrapped = createMirroredTestItem(item, this);
|
||||
this.mirroredTestIds.set(item.wrapped, item.id);
|
||||
}
|
||||
|
||||
return item.wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
const createMirroredTestItem = (internal: MirroredCollectionTestItem, collection: MirroredTestCollection): vscode.TestItem => {
|
||||
const obj = {};
|
||||
|
||||
Object.defineProperty(obj, 'children', {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
get: () => collection.getAllAsTestItem([...internal.children])
|
||||
});
|
||||
|
||||
simpleProps.forEach(prop => Object.defineProperty(obj, prop, {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
get: () => internal.revived[prop],
|
||||
}));
|
||||
|
||||
return obj as any;
|
||||
};
|
||||
|
||||
interface IObserverData {
|
||||
observers: number;
|
||||
tests: MirroredTestCollection;
|
||||
listener: IDisposable;
|
||||
pendingDeletion?: IDisposable;
|
||||
}
|
||||
|
||||
abstract class AbstractTestObserverFactory {
|
||||
private readonly resources = new Map<string /* uri */, IObserverData>();
|
||||
|
||||
public checkout(resourceUri: URI): vscode.TestObserver {
|
||||
const resourceKey = resourceUri.toString();
|
||||
const resource = this.resources.get(resourceKey) ?? this.createObserverData(resourceUri);
|
||||
|
||||
resource.observers++;
|
||||
|
||||
return {
|
||||
onDidChangeTest: resource.tests.onDidChangeTests,
|
||||
onDidDiscoverInitialTests: new Emitter<void>().event, // todo@connor4312
|
||||
get tests() {
|
||||
return resource.tests.rootTestItems;
|
||||
},
|
||||
dispose: once(() => {
|
||||
if (!--resource.observers) {
|
||||
resource.pendingDeletion = this.eventuallyDispose(resourceUri);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the internal test data by its reference, in any observer.
|
||||
*/
|
||||
public getMirroredTestDataByReference(ref: vscode.TestItem) {
|
||||
for (const { tests } of this.resources.values()) {
|
||||
const v = tests.getMirroredTestDataByReference(ref);
|
||||
if (v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when no observers are listening for the resource any more. Should
|
||||
* defer unlistening on the resource, and return a disposiable
|
||||
* to halt the process in case new listeners come in.
|
||||
*/
|
||||
protected eventuallyDispose(resourceUri: URI) {
|
||||
return disposableTimeout(() => this.unlisten(resourceUri), 10 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening to test information for the given resource.
|
||||
*/
|
||||
protected abstract listen(resourceUri: URI, onDiff: (diff: TestsDiff) => void): Disposable;
|
||||
|
||||
private createObserverData(resourceUri: URI): IObserverData {
|
||||
const tests = new MirroredTestCollection();
|
||||
const listener = this.listen(resourceUri, diff => tests.apply(diff));
|
||||
const data: IObserverData = { observers: 0, tests, listener };
|
||||
this.resources.set(resourceUri.toString(), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a resource is no longer in use.
|
||||
*/
|
||||
protected unlisten(resourceUri: URI) {
|
||||
const key = resourceUri.toString();
|
||||
const resource = this.resources.get(key);
|
||||
if (resource) {
|
||||
resource.observers = -1;
|
||||
resource.pendingDeletion?.dispose();
|
||||
resource.listener.dispose();
|
||||
this.resources.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WorkspaceFolderTestObserverFactory extends AbstractTestObserverFactory {
|
||||
private diffListeners = new Map<string, (diff: TestsDiff) => void>();
|
||||
|
||||
constructor(private readonly proxy: MainThreadTestingShape) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishees the diff for the workspace folder with the given uri.
|
||||
*/
|
||||
public acceptDiff(resourceUri: URI, diff: TestsDiff) {
|
||||
this.diffListeners.get(resourceUri.toString())?.(diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
public listen(resourceUri: URI, onDiff: (diff: TestsDiff) => void) {
|
||||
this.proxy.$subscribeToDiffs(ExtHostTestingResource.Workspace, resourceUri);
|
||||
|
||||
const uriString = resourceUri.toString();
|
||||
this.diffListeners.set(uriString, onDiff);
|
||||
|
||||
return new Disposable(() => {
|
||||
this.proxy.$unsubscribeFromDiffs(ExtHostTestingResource.Workspace, resourceUri);
|
||||
this.diffListeners.delete(uriString);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TextDocumentTestObserverFactory extends AbstractTestObserverFactory {
|
||||
private diffListeners = new Map<string, (diff: TestsDiff) => void>();
|
||||
|
||||
constructor(private readonly proxy: MainThreadTestingShape, private documents: IExtHostDocumentsAndEditors) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishees the diff for the document with the given uri.
|
||||
*/
|
||||
public acceptDiff(resourceUri: URI, diff: TestsDiff) {
|
||||
this.diffListeners.get(resourceUri.toString())?.(diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
public listen(resourceUri: URI, onDiff: (diff: TestsDiff) => void) {
|
||||
const document = this.documents.getDocument(resourceUri);
|
||||
if (!document) {
|
||||
return new Disposable(() => undefined);
|
||||
}
|
||||
|
||||
const uriString = resourceUri.toString();
|
||||
this.diffListeners.set(uriString, onDiff);
|
||||
|
||||
const disposeListener = this.documents.onDidRemoveDocuments(evt => {
|
||||
if (evt.some(delta => delta.document.uri.toString() === uriString)) {
|
||||
this.unlisten(resourceUri);
|
||||
}
|
||||
});
|
||||
|
||||
this.proxy.$subscribeToDiffs(ExtHostTestingResource.TextDocument, resourceUri);
|
||||
return new Disposable(() => {
|
||||
this.proxy.$unsubscribeFromDiffs(ExtHostTestingResource.TextDocument, resourceUri);
|
||||
disposeListener.dispose();
|
||||
this.diffListeners.delete(uriString);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -32,6 +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 { CellOutputKind, IDisplayOutput, INotebookDecorationRenderOptions } from 'vs/workbench/contrib/notebook/common/notebookCommon';
|
||||
import { ITestItem, ITestState } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
|
||||
export interface PositionLike {
|
||||
line: number;
|
||||
|
@ -1396,3 +1397,64 @@ export namespace NotebookDecorationRenderOptions {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TestState {
|
||||
export function from(item: vscode.TestState): ITestState {
|
||||
return {
|
||||
runState: item.runState,
|
||||
duration: item.duration,
|
||||
messages: item.messages.map(message => ({
|
||||
message: MarkdownString.fromStrict(message.message) || '',
|
||||
severity: message.severity,
|
||||
expectedOutput: message.expectedOutput,
|
||||
actualOutput: message.actualOutput,
|
||||
location: message.location ? location.from(message.location) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function to(item: ITestState): vscode.TestState {
|
||||
return new types.TestState(
|
||||
item.runState,
|
||||
item.messages.map(message => ({
|
||||
message: typeof message.message === 'string' ? message.message : MarkdownString.to(message.message),
|
||||
severity: message.severity,
|
||||
expectedOutput: message.expectedOutput,
|
||||
actualOutput: message.actualOutput,
|
||||
location: message.location && location.to({
|
||||
range: message.location.range,
|
||||
uri: URI.revive(message.location.uri)
|
||||
}),
|
||||
})),
|
||||
item.duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export namespace TestItem {
|
||||
export function from(item: vscode.TestItem): ITestItem {
|
||||
return {
|
||||
label: item.label,
|
||||
location: item.location ? location.from(item.location) : undefined,
|
||||
debuggable: item.debuggable,
|
||||
description: item.description,
|
||||
runnable: item.runnable,
|
||||
state: TestState.from(item.state),
|
||||
};
|
||||
}
|
||||
|
||||
export function to(item: ITestItem): vscode.TestItem {
|
||||
return {
|
||||
label: item.label,
|
||||
location: item.location && location.to({
|
||||
range: item.location.range,
|
||||
uri: URI.revive(item.location.uri)
|
||||
}),
|
||||
debuggable: item.debuggable,
|
||||
description: item.description,
|
||||
runnable: item.runnable,
|
||||
state: TestState.to(item.state),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2897,3 +2897,47 @@ export class OnTypeRenameRanges {
|
|||
constructor(public readonly ranges: Range[], public readonly wordPattern?: RegExp) {
|
||||
}
|
||||
}
|
||||
|
||||
//#region Testing
|
||||
export enum TestRunState {
|
||||
Unset = 0,
|
||||
Running = 1,
|
||||
Passed = 2,
|
||||
Failed = 3,
|
||||
Skipped = 4,
|
||||
Errored = 5
|
||||
}
|
||||
|
||||
export enum TestMessageSeverity {
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Information = 2,
|
||||
Hint = 3
|
||||
}
|
||||
|
||||
@es5ClassCompat
|
||||
export class TestState {
|
||||
#runState: TestRunState;
|
||||
#duration?: number;
|
||||
#messages: ReadonlyArray<Readonly<vscode.TestMessage>>;
|
||||
|
||||
public get runState() {
|
||||
return this.#runState;
|
||||
}
|
||||
|
||||
public get duration() {
|
||||
return this.#duration;
|
||||
}
|
||||
|
||||
public get messages() {
|
||||
return this.#messages;
|
||||
}
|
||||
|
||||
constructor(runState: TestRunState, messages: vscode.TestMessage[] = [], duration?: number) {
|
||||
this.#runState = runState;
|
||||
this.#messages = Object.freeze(messages.map(m => Object.freeze(m)));
|
||||
this.#duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
|
||||
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
|
||||
import { TestService } from 'vs/workbench/contrib/testing/common/testServiceImpl';
|
||||
|
||||
registerSingleton(ITestService, TestService);
|
197
src/vs/workbench/contrib/testing/common/testCollection.ts
Normal file
197
src/vs/workbench/contrib/testing/common/testCollection.ts
Normal file
|
@ -0,0 +1,197 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IMarkdownString } from 'vs/base/common/htmlContent';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { Location as ModeLocation } from 'vs/editor/common/modes';
|
||||
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
|
||||
import { TestMessageSeverity, TestRunState } from 'vs/workbench/api/common/extHostTypes';
|
||||
|
||||
/**
|
||||
* Request to them main thread to run a set of tests.
|
||||
*/
|
||||
export interface RunTestsRequest {
|
||||
tests: { testId: string; providerId: string }[];
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request from the main thread to run tests for a single provider.
|
||||
*/
|
||||
export interface RunTestForProviderRequest {
|
||||
providerId: string;
|
||||
ids: string[];
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response to a {@link RunTestsRequest}
|
||||
*/
|
||||
export interface RunTestsResult {
|
||||
// todo
|
||||
}
|
||||
|
||||
export const EMPTY_TEST_RESULT: RunTestsResult = {};
|
||||
|
||||
export const collectTestResults = (results: ReadonlyArray<RunTestsResult>) => {
|
||||
return results[0] || {}; // todo
|
||||
};
|
||||
|
||||
export interface ITestMessage {
|
||||
message: string | IMarkdownString;
|
||||
severity: TestMessageSeverity | undefined;
|
||||
expectedOutput: string | undefined;
|
||||
actualOutput: string | undefined;
|
||||
location: ModeLocation | undefined;
|
||||
}
|
||||
|
||||
export interface ITestState {
|
||||
runState: TestRunState;
|
||||
duration: number | undefined;
|
||||
messages: ITestMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The TestItem from .d.ts, as a plain object without children.
|
||||
*/
|
||||
export interface ITestItem {
|
||||
label: string;
|
||||
children?: never;
|
||||
location: ModeLocation | undefined;
|
||||
description: string | undefined;
|
||||
runnable: boolean | undefined;
|
||||
debuggable: boolean | undefined;
|
||||
state: ITestState;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestItem-like shape, butm with an ID and children as strings.
|
||||
*/
|
||||
export interface InternalTestItem {
|
||||
id: string;
|
||||
providerId: string;
|
||||
parent: string | null;
|
||||
item: ITestItem;
|
||||
}
|
||||
|
||||
export const enum TestDiffOpType {
|
||||
Add,
|
||||
Update,
|
||||
Remove,
|
||||
}
|
||||
|
||||
export type TestsDiffOp =
|
||||
| [op: TestDiffOpType.Add, item: InternalTestItem]
|
||||
| [op: TestDiffOpType.Update, item: InternalTestItem]
|
||||
| [op: TestDiffOpType.Remove, itemId: string];
|
||||
|
||||
/**
|
||||
* Utility function to get a unique string for a subscription to a resource,
|
||||
* useful to keep maps of document or workspace folder subscription info.
|
||||
*/
|
||||
export const getTestSubscriptionKey = (resource: ExtHostTestingResource, uri: URI) => `${resource}:${uri.toString()}`;
|
||||
|
||||
/**
|
||||
* Request from the ext host or main thread to indicate that tests have
|
||||
* changed. It's assumed that any item upserted *must* have its children
|
||||
* previously also upserted, or upserted as part of the same operation.
|
||||
* Children that no longer exist in an upserted item will be removed.
|
||||
*/
|
||||
export type TestsDiff = TestsDiffOp[];
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export interface IncrementalTestCollectionItem extends InternalTestItem {
|
||||
children: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains tests in this extension host sent from the main thread.
|
||||
*/
|
||||
export abstract class AbstractIncrementalTestCollection<T extends IncrementalTestCollectionItem> {
|
||||
/**
|
||||
* Map of item IDs to test item objects.
|
||||
*/
|
||||
protected readonly items = new Map<string, T>();
|
||||
|
||||
/**
|
||||
* ID of test root items.
|
||||
*/
|
||||
protected readonly roots = new Set<string>();
|
||||
|
||||
/**
|
||||
* Applies the diff to the collection.
|
||||
*/
|
||||
public apply(diff: TestsDiff) {
|
||||
for (const op of diff) {
|
||||
switch (op[0]) {
|
||||
case TestDiffOpType.Add: {
|
||||
const item = op[1];
|
||||
if (!item.parent) {
|
||||
this.roots.add(item.id);
|
||||
this.items.set(item.id, this.createItem(item));
|
||||
this.onChange(null);
|
||||
} else if (this.items.has(item.parent)) {
|
||||
const parent = this.items.get(item.parent)!;
|
||||
parent.children.add(item.id);
|
||||
this.items.set(item.id, this.createItem(item));
|
||||
this.onChange(parent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TestDiffOpType.Update: {
|
||||
const item = op[1];
|
||||
const existing = this.items.get(item.id);
|
||||
if (existing) {
|
||||
Object.assign(existing.item, item.item);
|
||||
this.onChange(existing);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TestDiffOpType.Remove: {
|
||||
const toRemove = this.items.get(op[1]);
|
||||
if (!toRemove) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (toRemove.parent) {
|
||||
this.items.get(toRemove.parent)!.children.delete(toRemove.id);
|
||||
} else {
|
||||
this.roots.delete(toRemove.id);
|
||||
}
|
||||
|
||||
const queue: Iterable<string>[] = [[op[1]]];
|
||||
while (queue.length) {
|
||||
for (const itemId of queue.pop()!) {
|
||||
const existing = this.items.get(itemId);
|
||||
if (existing) {
|
||||
queue.push(existing.children);
|
||||
this.items.delete(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.onChange(toRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an item in the collection changes, with the same semantics
|
||||
* as `onDidChangeTests` in vscode.d.ts.
|
||||
*/
|
||||
protected onChange(item: T | null): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new item for the collection from the internal test item.
|
||||
*/
|
||||
protected abstract createItem(internal: InternalTestItem): T;
|
||||
}
|
30
src/vs/workbench/contrib/testing/common/testService.ts
Normal file
30
src/vs/workbench/contrib/testing/common/testService.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IDisposable } from 'vs/base/common/lifecycle';
|
||||
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 { RunTestForProviderRequest, RunTestsRequest, RunTestsResult, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
|
||||
export const ITestService = createDecorator<ITestService>('testService');
|
||||
|
||||
export interface MainTestController {
|
||||
runTests(request: RunTestForProviderRequest): Promise<RunTestsResult>;
|
||||
}
|
||||
|
||||
export type TestDiffListener = (diff: TestsDiff) => void;
|
||||
|
||||
export interface ITestService {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly onShouldSubscribe: Event<{ resource: ExtHostTestingResource, uri: URI }>;
|
||||
readonly onShouldUnsubscribe: Event<{ resource: ExtHostTestingResource, uri: URI }>;
|
||||
registerTestController(id: string, controller: MainTestController): void;
|
||||
unregisterTestController(id: string): void;
|
||||
runTests(req: RunTestsRequest): Promise<RunTestsResult>;
|
||||
publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void;
|
||||
subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff: TestDiffListener): IDisposable;
|
||||
}
|
128
src/vs/workbench/contrib/testing/common/testServiceImpl.ts
Normal file
128
src/vs/workbench/contrib/testing/common/testServiceImpl.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { groupBy } from 'vs/base/common/arrays';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { isDefined } from 'vs/base/common/types';
|
||||
import { URI, UriComponents } from 'vs/base/common/uri';
|
||||
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
|
||||
import { AbstractIncrementalTestCollection, collectTestResults, getTestSubscriptionKey, IncrementalTestCollectionItem, InternalTestItem, RunTestsRequest, RunTestsResult, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
import { ITestService, MainTestController, TestDiffListener } from 'vs/workbench/contrib/testing/common/testService';
|
||||
|
||||
export class TestService extends Disposable implements ITestService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private testControllers = new Map<string, MainTestController>();
|
||||
private readonly testSubscriptions = new Map<string, {
|
||||
collection: MainThreadTestCollection;
|
||||
onDiff: Emitter<TestsDiff>;
|
||||
listeners: number;
|
||||
}>();
|
||||
private readonly subscribeEmitter = new Emitter<{ resource: ExtHostTestingResource, uri: URI }>();
|
||||
private readonly unsubscribeEmitter = new Emitter<{ resource: ExtHostTestingResource, uri: URI }>();
|
||||
|
||||
/**
|
||||
* Fired when extension hosts should pull events from their test factories.
|
||||
*/
|
||||
public readonly onShouldSubscribe = this.subscribeEmitter.event;
|
||||
|
||||
/**
|
||||
* Fired when extension hosts should stop pulling events from their test factories.
|
||||
*/
|
||||
public readonly onShouldUnsubscribe = this.unsubscribeEmitter.event;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async runTests(req: RunTestsRequest): Promise<RunTestsResult> {
|
||||
const tests = groupBy(req.tests, (a, b) => a.providerId === b.providerId ? 0 : 1);
|
||||
const requests = tests.map(group => {
|
||||
const providerId = group[0].providerId;
|
||||
const controller = this.testControllers.get(providerId);
|
||||
return controller?.runTests({ providerId, debug: req.debug, ids: group.map(t => t.testId) });
|
||||
}).filter(isDefined);
|
||||
|
||||
return collectTestResults(await Promise.all(requests));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff: TestDiffListener) {
|
||||
const subscriptionKey = getTestSubscriptionKey(resource, uri);
|
||||
let subscription = this.testSubscriptions.get(subscriptionKey);
|
||||
if (!subscription) {
|
||||
subscription = { collection: new MainThreadTestCollection(), listeners: 0, onDiff: new Emitter() };
|
||||
this.subscribeEmitter.fire({ resource, uri });
|
||||
this.testSubscriptions.set(subscriptionKey, subscription);
|
||||
}
|
||||
|
||||
subscription.listeners++;
|
||||
|
||||
const revive = subscription.collection.getReviverDiff();
|
||||
if (revive.length) {
|
||||
acceptDiff(revive);
|
||||
}
|
||||
|
||||
const listener = subscription.onDiff.event(acceptDiff);
|
||||
return toDisposable(() => {
|
||||
listener.dispose();
|
||||
|
||||
if (!--subscription!.listeners) {
|
||||
this.unsubscribeEmitter.fire({ resource, uri });
|
||||
this.testSubscriptions.delete(subscriptionKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff) {
|
||||
const sub = this.testSubscriptions.get(getTestSubscriptionKey(resource, URI.revive(uri)));
|
||||
if (sub) {
|
||||
sub.collection.apply(diff);
|
||||
sub.onDiff.fire(diff);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public registerTestController(id: string, controller: MainTestController): void {
|
||||
this.testControllers.set(id, controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public unregisterTestController(id: string): void {
|
||||
this.testControllers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
class MainThreadTestCollection extends AbstractIncrementalTestCollection<IncrementalTestCollectionItem> {
|
||||
/**
|
||||
* Gets a diff that adds all items currently in the tree to a new collection,
|
||||
* allowing it to fully hydrate.
|
||||
*/
|
||||
public getReviverDiff() {
|
||||
const ops: TestsDiff = [];
|
||||
const queue = [this.roots];
|
||||
while (queue.length) {
|
||||
for (const child of queue.pop()!) {
|
||||
const item = this.items.get(child)!;
|
||||
ops.push([TestDiffOpType.Add, { id: item.id, providerId: item.providerId, item: item.item, parent: item.parent }]);
|
||||
queue.push(item.children);
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
protected createItem(internal: InternalTestItem): IncrementalTestCollectionItem {
|
||||
return { ...internal, children: new Set() };
|
||||
}
|
||||
}
|
200
src/vs/workbench/test/browser/api/extHostTesting.test.ts
Normal file
200
src/vs/workbench/test/browser/api/extHostTesting.test.ts
Normal file
|
@ -0,0 +1,200 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { MirroredTestCollection, OwnedTestCollection, SingleUseTestCollection } from 'vs/workbench/api/common/extHostTesting';
|
||||
import * as convert from 'vs/workbench/api/common/extHostTypeConverters';
|
||||
import { TestRunState, TestState } from 'vs/workbench/api/common/extHostTypes';
|
||||
import { TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
||||
import { TestItem } from 'vscode';
|
||||
|
||||
suite('ExtHost Testing', () => {
|
||||
let single: TestSingleUseCollection;
|
||||
let owned: TestOwnedTestCollection;
|
||||
setup(() => {
|
||||
owned = new TestOwnedTestCollection();
|
||||
single = owned.createForHierarchy(d => single.setDiff(d /* don't clear during testing */));
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
single.dispose();
|
||||
assert.deepEqual(owned.idToInternal.size, 0, 'expected owned ids to be empty after dispose');
|
||||
});
|
||||
|
||||
suite('OwnedTestCollection', () => {
|
||||
test('adds a root recursively', () => {
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
assert.deepStrictEqual(single.collectDiff(), [
|
||||
[TestDiffOpType.Add, { id: '0', providerId: 'pid', parent: null, item: convert.TestItem.from(stubTest('root')) }],
|
||||
[TestDiffOpType.Add, { id: '1', providerId: 'pid', parent: '0', item: convert.TestItem.from(stubTest('a')) }],
|
||||
[TestDiffOpType.Add, { id: '2', providerId: 'pid', parent: '1', item: convert.TestItem.from(stubTest('aa')) }],
|
||||
[TestDiffOpType.Add, { id: '3', providerId: 'pid', parent: '1', item: convert.TestItem.from(stubTest('ab')) }],
|
||||
[TestDiffOpType.Add, { id: '4', providerId: 'pid', parent: '0', item: convert.TestItem.from(stubTest('b')) }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('no-ops if items not changed', () => {
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
single.collectDiff();
|
||||
assert.deepStrictEqual(single.collectDiff(), []);
|
||||
});
|
||||
|
||||
test('watches property mutations', () => {
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
single.collectDiff();
|
||||
tests.children![0].description = 'Hello world'; /* item a */
|
||||
single.onItemChange(tests, 'pid');
|
||||
assert.deepStrictEqual(single.collectDiff(), [
|
||||
[TestDiffOpType.Update, { id: '1', parent: '0', providerId: 'pid', item: convert.TestItem.from({ ...stubTest('a'), description: 'Hello world' }) }],
|
||||
]);
|
||||
|
||||
single.onItemChange(tests, 'pid');
|
||||
assert.deepStrictEqual(single.collectDiff(), []);
|
||||
});
|
||||
|
||||
test('removes children', () => {
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
single.collectDiff();
|
||||
tests.children!.splice(0, 1);
|
||||
single.onItemChange(tests, 'pid');
|
||||
|
||||
assert.deepStrictEqual(single.collectDiff(), [
|
||||
[TestDiffOpType.Remove, '1'],
|
||||
]);
|
||||
assert.deepStrictEqual([...owned.idToInternal.keys()].sort(), ['0', '4']);
|
||||
assert.strictEqual(single.itemToInternal.size, 2);
|
||||
});
|
||||
|
||||
test('adds new children', () => {
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
single.collectDiff();
|
||||
const child = stubTest('ac');
|
||||
tests.children![0].children!.push(child);
|
||||
single.onItemChange(tests, 'pid');
|
||||
|
||||
assert.deepStrictEqual(single.collectDiff(), [
|
||||
[TestDiffOpType.Add, { id: '5', providerId: 'pid', parent: '1', item: convert.TestItem.from(child) }],
|
||||
]);
|
||||
assert.deepStrictEqual([...owned.idToInternal.keys()].sort(), ['0', '1', '2', '3', '4', '5']);
|
||||
assert.strictEqual(single.itemToInternal.size, 6);
|
||||
});
|
||||
});
|
||||
|
||||
suite('MirroredTestCollection', () => {
|
||||
test('mirrors creation of the root', () => {
|
||||
const m = new TestMirroredCollection();
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
assertTreesEqual(m.rootTestItems[0], owned.getTestById('0')!.actual);
|
||||
assert.strictEqual(m.length, single.itemToInternal.size);
|
||||
});
|
||||
|
||||
test('mirrors node deletion', () => {
|
||||
const m = new TestMirroredCollection();
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
tests.children!.splice(0, 1);
|
||||
single.onItemChange(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
|
||||
assertTreesEqual(m.rootTestItems[0], owned.getTestById('0')!.actual);
|
||||
assert.strictEqual(m.length, single.itemToInternal.size);
|
||||
});
|
||||
|
||||
test('mirrors node addition', () => {
|
||||
const m = new TestMirroredCollection();
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
tests.children![0].children!.push(stubTest('ac'));
|
||||
single.onItemChange(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
|
||||
assertTreesEqual(m.rootTestItems[0], owned.getTestById('0')!.actual);
|
||||
assert.strictEqual(m.length, single.itemToInternal.size);
|
||||
});
|
||||
|
||||
test('mirrors node update', () => {
|
||||
const m = new TestMirroredCollection();
|
||||
const tests = stubNestedTests();
|
||||
single.addRoot(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
tests.children![0].description = 'Hello world'; /* item a */
|
||||
single.onItemChange(tests, 'pid');
|
||||
m.apply(single.collectDiff());
|
||||
|
||||
assertTreesEqual(m.rootTestItems[0], owned.getTestById('0')!.actual);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const stubTest = (label: string): TestItem => ({
|
||||
label,
|
||||
location: undefined,
|
||||
state: new TestState(TestRunState.Unset),
|
||||
debuggable: true,
|
||||
runnable: true,
|
||||
description: ''
|
||||
});
|
||||
|
||||
const assertTreesEqual = (a: TestItem, b: TestItem) => {
|
||||
assert.deepStrictEqual({ ...a, children: undefined }, { ...b, children: undefined });
|
||||
|
||||
const aChildren = (a.children ?? []).sort();
|
||||
const bChildren = (b.children ?? []).sort();
|
||||
assert.strictEqual(aChildren.length, bChildren.length, `expected ${a.label}.children.length == ${b.label}.children.length`);
|
||||
aChildren.forEach((_, i) => assertTreesEqual(aChildren[i], bChildren[i]));
|
||||
};
|
||||
|
||||
const stubNestedTests = () => ({
|
||||
...stubTest('root'),
|
||||
children: [
|
||||
{ ...stubTest('a'), children: [stubTest('aa'), stubTest('ab')] },
|
||||
stubTest('b'),
|
||||
]
|
||||
});
|
||||
|
||||
class TestOwnedTestCollection extends OwnedTestCollection {
|
||||
public get idToInternal() {
|
||||
return this.testIdToInternal;
|
||||
}
|
||||
|
||||
public createForHierarchy(publishDiff: (diff: TestsDiff) => void = () => undefined) {
|
||||
return new TestSingleUseCollection(this.testIdToInternal, publishDiff);
|
||||
}
|
||||
}
|
||||
|
||||
class TestSingleUseCollection extends SingleUseTestCollection {
|
||||
private idCounter = 0;
|
||||
|
||||
public get itemToInternal() {
|
||||
return this.testItemToInternal;
|
||||
}
|
||||
|
||||
public get currentDiff() {
|
||||
return this.diff;
|
||||
}
|
||||
|
||||
protected getId() {
|
||||
return String(this.idCounter++);
|
||||
}
|
||||
|
||||
public setDiff(diff: TestsDiff) {
|
||||
this.diff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
class TestMirroredCollection extends MirroredTestCollection {
|
||||
public get length() {
|
||||
return this.items.size;
|
||||
}
|
||||
}
|
|
@ -156,6 +156,9 @@ import 'vs/workbench/contrib/performance/browser/performance.contribution';
|
|||
// Notebook
|
||||
import 'vs/workbench/contrib/notebook/browser/notebook.contribution';
|
||||
|
||||
// Testing
|
||||
import 'vs/workbench/contrib/testing/browser/testing.contribution';
|
||||
|
||||
// Logs
|
||||
import 'vs/workbench/contrib/logs/common/logs.contribution';
|
||||
|
||||
|
|
Loading…
Reference in a new issue