From d1280418d7276fb4f950c8caf75b3493a6dfec77 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 20 Nov 2020 08:31:35 -0800 Subject: [PATCH] testing: initial api implementation * wip * wip * wip * wip * wip * wip --- .eslintrc.json | 1 + src/vs/base/common/arrays.ts | 14 + src/vs/vscode.proposed.d.ts | 289 ++++++++ .../api/browser/extensionHost.contribution.ts | 1 + .../api/browser/mainThreadTesting.ts | 77 +++ .../workbench/api/common/extHost.api.impl.ts | 27 +- .../workbench/api/common/extHost.protocol.ts | 29 +- src/vs/workbench/api/common/extHostTesting.ts | 623 ++++++++++++++++++ .../api/common/extHostTypeConverters.ts | 62 ++ src/vs/workbench/api/common/extHostTypes.ts | 44 ++ .../testing/browser/testing.contribution.ts | 10 + .../contrib/testing/common/testCollection.ts | 197 ++++++ .../contrib/testing/common/testService.ts | 30 + .../contrib/testing/common/testServiceImpl.ts | 128 ++++ .../test/browser/api/extHostTesting.test.ts | 200 ++++++ src/vs/workbench/workbench.common.main.ts | 3 + 16 files changed, 1732 insertions(+), 3 deletions(-) create mode 100644 src/vs/workbench/api/browser/mainThreadTesting.ts create mode 100644 src/vs/workbench/api/common/extHostTesting.ts create mode 100644 src/vs/workbench/contrib/testing/browser/testing.contribution.ts create mode 100644 src/vs/workbench/contrib/testing/common/testCollection.ts create mode 100644 src/vs/workbench/contrib/testing/common/testService.ts create mode 100644 src/vs/workbench/contrib/testing/common/testServiceImpl.ts create mode 100644 src/vs/workbench/test/browser/api/extHostTesting.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index 055bc22f8e4..fd72f0e58f1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -983,6 +983,7 @@ "collapse", "create", "delete", + "discover", "dispose", "edit", "end", diff --git a/src/vs/base/common/arrays.ts b/src/vs/base/common/arrays.ts index bc0aa40a685..c4820d9cf6f 100644 --- a/src/vs/base/common/arrays.ts +++ b/src/vs/base/common/arrays.ts @@ -588,3 +588,17 @@ export function asArray(x: T | T[]): T[] { export function getRandomElement(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(array: Iterable, mapFn: (value: T) => R | undefined): R | undefined { + for (const value of array) { + const mapped = mapFn(value); + if (mapped !== undefined) { + return mapped; + } + } + + return undefined; +} diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 606da64d9da..1b4dd21140a 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -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(testProvider: TestProvider): 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(options: TestRunOptions): Thenable; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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 { + /** + * 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; + + /** + * 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; + + /** + * 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 { + /** + * 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; + + /** + * 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; + + /** + * 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, cancellationToken: CancellationToken): ProviderResult; + } + + /** + * Options given to `TestProvider.runTests` + */ + export interface TestRunOptions { + /** + * 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>; + + /** + * @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 } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index a4df8523631..3b4c8a66c27 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -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 { diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts new file mode 100644 index 00000000000..52185f8dd91 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -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(); + + 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 { + return this.testService.runTests(req); + } + + public dispose() { + // no-op + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index e4eee8822f6..ee9a8ef8c3c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -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[] = 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 | 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, }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b4359816ea4..6ca43431728 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -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; } +export const enum ExtHostTestingResource { + Workspace, + TextDocument +} + +export interface ExtHostTestingShape { + $runTestsForProvider(req: RunTestForProviderRequest): Promise; + $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; +} + // --- proxy identifiers export const MainContext = { @@ -1799,7 +1822,8 @@ export const MainContext = { MainThreadNotebook: createMainId('MainThreadNotebook'), MainThreadTheming: createMainId('MainThreadTheming'), MainThreadTunnelService: createMainId('MainThreadTunnelService'), - MainThreadTimeline: createMainId('MainThreadTimeline') + MainThreadTimeline: createMainId('MainThreadTimeline'), + MainThreadTesting: createMainId('MainThreadTesting'), }; export const ExtHostContext = { @@ -1842,5 +1866,6 @@ export const ExtHostContext = { ExtHostTheming: createMainId('ExtHostTheming'), ExtHostTunnelService: createMainId('ExtHostTunnelService'), ExtHostAuthentication: createMainId('ExtHostAuthentication'), - ExtHostTimeline: createMainId('ExtHostTimeline') + ExtHostTimeline: createMainId('ExtHostTimeline'), + ExtHostTesting: createMainId('ExtHostTesting'), }; diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts new file mode 100644 index 00000000000..270f66a8847 --- /dev/null +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -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(); + private readonly proxy: MainThreadTestingShape; + private readonly ownedTests = new OwnedTestCollection(); + private readonly testSubscriptions = new Map(); + + 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(provider: vscode.TestProvider): 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) { + 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 | 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 { + 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, 'children'>]: null } = { + label: null, + location: null, + state: null, + debuggable: null, + description: null, + runnable: null +}; + +const simpleProps = Object.keys(keyMap) as ReadonlyArray; + +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; + previousEquals: (v: vscode.TestItem) => boolean; +} + +export class OwnedTestCollection { + protected readonly testIdToInternal = new Map(); + + /** + * 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(); + protected diff: TestsDiff = []; + private disposed = false; + + constructor(private readonly testIdToInternal: Map, 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(); + 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 { + private changeEmitter = new Emitter(); + + /** + * 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(); + + /** + * 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): 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(); + + 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().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 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 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); + }); + } +} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index f68ee495a27..e82a7c10759 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -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), + }; + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index f216ca35dcd..1013635962d 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -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>; + + 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 diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts new file mode 100644 index 00000000000..4e541bc83e8 --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -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); diff --git a/src/vs/workbench/contrib/testing/common/testCollection.ts b/src/vs/workbench/contrib/testing/common/testCollection.ts new file mode 100644 index 00000000000..398294aaec3 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -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) => { + 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; +} + +/** + * Maintains tests in this extension host sent from the main thread. + */ +export abstract class AbstractIncrementalTestCollection { + /** + * Map of item IDs to test item objects. + */ + protected readonly items = new Map(); + + /** + * ID of test root items. + */ + protected readonly roots = new Set(); + + /** + * 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[] = [[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; +} diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts new file mode 100644 index 00000000000..f4d84d583e0 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -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('testService'); + +export interface MainTestController { + runTests(request: RunTestForProviderRequest): Promise; +} + +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; + publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void; + subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff: TestDiffListener): IDisposable; +} diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts new file mode 100644 index 00000000000..845534dd816 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -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(); + private readonly testSubscriptions = new Map; + 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 { + 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 { + /** + * 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() }; + } +} diff --git a/src/vs/workbench/test/browser/api/extHostTesting.test.ts b/src/vs/workbench/test/browser/api/extHostTesting.test.ts new file mode 100644 index 00000000000..2c784c043d8 --- /dev/null +++ b/src/vs/workbench/test/browser/api/extHostTesting.test.ts @@ -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; + } +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index c5dc396505a..a91d88311ed 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -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';