diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index fb48c495900e2..7e2a59b051dc1 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -154,7 +154,11 @@ import { TerminalLink, InlayHint, InlayHintKind, - InlayHintLabelPart + InlayHintLabelPart, + TestRunProfileKind, + TestTag, + TestRunRequest, + TestMessage } from './types-impl'; import { AuthenticationExtImpl } from './authentication-ext'; import { SymbolKind } from '../common/plugin-api-rpc-model'; @@ -184,6 +188,12 @@ import { ClipboardExt } from './clipboard-ext'; import { WebviewsExtImpl } from './webviews'; import { ExtHostFileSystemEventService } from './file-system-event-service-ext-impl'; import { LabelServiceExtImpl } from '../plugin/label-service'; +import { + createRunProfile, + createTestRun, + testItemCollection, + createTestItem +} from './stubs/tests-api'; import { TimelineExtImpl } from './timeline'; import { ThemingExtImpl } from './theming'; import { CommentsExtImpl } from './comments'; @@ -792,6 +802,30 @@ export function createAPIFactory( } }; + // Tests API (@stubbed) + // The following implementation is temporarily `@stubbed` and marked as such under `theia.d.ts` + const tests: typeof theia.tests = { + createTestController( + provider, + controllerLabel: string, + refreshHandler?: ( + token: theia.CancellationToken + ) => Thenable | void + ) { + return { + id: provider, + label: controllerLabel, + items: testItemCollection, + refreshHandler, + createRunProfile, + createTestRun, + createTestItem, + dispose: () => undefined, + }; + }, + }; + /* End of Tests API */ + const plugins: typeof theia.plugins = { // eslint-disable-next-line @typescript-eslint/no-explicit-any get all(): theia.Plugin[] { @@ -938,6 +972,7 @@ export function createAPIFactory( debug, tasks, scm, + tests, // Types StatusBarAlignment: StatusBarAlignment, Disposable: Disposable, @@ -1061,7 +1096,11 @@ export function createAPIFactory( InputBoxValidationSeverity, InlayHint, InlayHintKind, - InlayHintLabelPart + InlayHintLabelPart, + TestRunProfileKind, + TestTag, + TestRunRequest, + TestMessage }; }; } diff --git a/packages/plugin-ext/src/plugin/stubs/tests-api.ts b/packages/plugin-ext/src/plugin/stubs/tests-api.ts new file mode 100644 index 0000000000000..cad545bd4152e --- /dev/null +++ b/packages/plugin-ext/src/plugin/stubs/tests-api.ts @@ -0,0 +1,96 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +/* tslint:disable:typedef */ + +import { CancellationToken } from '@theia/core/lib/common/cancellation'; +import type * as theia from '@theia/plugin'; + +export const createRunProfile = ( + label: string, + kind: theia.TestRunProfileKind, + runHandler: ( + request: theia.TestRunRequest, + token: CancellationToken + ) => Thenable | void, + isDefault?: boolean, + tag?: theia.TestTag +) => ({ + label, + kind, + isDefault: isDefault ?? false, + tag, + runHandler, + configureHandler: undefined, + dispose: () => undefined, +}); + +export const createTestRun = ( + request: theia.TestRunRequest, + name?: string, + persist?: boolean +): theia.TestRun => ({ + name, + token: CancellationToken.None, + isPersisted: false, + enqueued: (test: theia.TestItem) => undefined, + started: (test: theia.TestItem) => undefined, + skipped: (test: theia.TestItem) => undefined, + failed: ( + test: theia.TestItem, + message: theia.TestMessage | readonly theia.TestMessage[], + duration?: number + ) => undefined, + errored: ( + test: theia.TestItem, + message: theia.TestMessage | readonly theia.TestMessage[], + duration?: number + ) => undefined, + passed: (test: theia.TestItem, duration?: number) => undefined, + appendOutput: ( + output: string, + location?: theia.Location, + test?: theia.TestItem + ) => undefined, + end: () => undefined, +}); + +export const testItemCollection = { + add: () => { }, + delete: () => { }, + forEach: () => { }, + *[Symbol.iterator]() { }, + get: () => undefined, + replace: () => { }, + size: 0, +}; + +export const createTestItem = ( + id: string, + label: string, + uri?: theia.Uri +): theia.TestItem => ({ + id, + label, + uri, + children: testItemCollection, + parent: undefined, + tags: [], + canResolveChildren: false, + busy: false, + range: undefined, + error: undefined, +}); diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 976817f2f02fe..cf9eea96158cb 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -2706,6 +2706,42 @@ export class LinkedEditingRanges { } } +export enum TestRunProfileKind { + Run = 1, + Debug = 2, + Coverage = 3, +} + +@es5ClassCompat +export class TestTag implements theia.TestTag { + constructor(public readonly id: string) { } +} + +@es5ClassCompat +export class TestRunRequest implements theia.TestRunRequest { + constructor( + public readonly include: theia.TestItem[] | undefined = undefined, + public readonly exclude: theia.TestItem[] | undefined = undefined, + public readonly profile: theia.TestRunProfile | undefined = undefined, + ) { } +} + +@es5ClassCompat +export class TestMessage implements theia.TestMessage { + public expectedOutput?: string; + public actualOutput?: string; + public location?: theia.Location; + + public static diff(message: string | theia.MarkdownString, expected: string, actual: string): theia.TestMessage { + const msg = new TestMessage(message); + msg.expectedOutput = expected; + msg.actualOutput = actual; + return msg; + } + + constructor(public message: string | theia.MarkdownString) { } +} + @es5ClassCompat export class TimelineItem { timestamp: number; diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index b7436cbefd8f1..1de28bef1e37e 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -12713,6 +12713,589 @@ export module '@theia/plugin' { } } +/** + * Namespace for testing functionality. Tests are published by registering + * {@link TestController} instances, then adding {@link TestItem TestItems}. + * Controllers may also describe how to run tests by creating one or more + * {@link TestRunProfile} instances. + */ +export namespace tests { + /** + * Creates a new test controller. + * + * @param id Identifier for the controller, must be globally unique. + * @param label A human-readable label for the controller. + * @returns An instance of the {@link TestController}. + * @stubbed + */ + export function createTestController(id: string, label: string): TestController; +} + +/** + * The kind of executions that {@link TestRunProfile TestRunProfiles} control. + */ +export enum TestRunProfileKind { + Run = 1, + Debug = 2, + Coverage = 3, +} + +/** + * Tags can be associated with {@link TestItem TestItems} and + * {@link TestRunProfile TestRunProfiles}. A profile with a tag can only + * execute tests that include that tag in their {@link TestItem.tags} array. + */ +export class TestTag { + /** + * ID of the test tag. `TestTag` instances with the same ID are considered + * to be identical. + */ + readonly id: string; + + /** + * Creates a new TestTag instance. + * @param id ID of the test tag. + */ + constructor(id: string); +} + +/** + * A TestRunProfile describes one way to execute tests in a {@link TestController}. + */ +export interface TestRunProfile { + /** + * Label shown to the user in the UI. + * + * Note that the label has some significance if the user requests that + * tests be re-run in a certain way. For example, if tests were run + * normally and the user requests to re-run them in debug mode, the editor + * will attempt use a configuration with the same label of the `Debug` + * kind. If there is no such configuration, the default will be used. + * @stubbed + */ + label: string; + + /** + * Configures what kind of execution this profile controls. If there + * are no profiles for a kind, it will not be available in the UI. + * @stubbed + */ + readonly kind: TestRunProfileKind; + + /** + * Controls whether this profile is the default action that will + * be taken when its kind is actioned. For example, if the user clicks + * the generic "run all" button, then the default profile for + * {@link TestRunProfileKind.Run} will be executed, although the + * user can configure this. + * @stubbed + */ + isDefault: boolean; + + /** + * Associated tag for the profile. If this is set, only {@link TestItem} + * instances with the same tag will be eligible to execute in this profile. + * @stubbed + */ + tag: TestTag | undefined; + + /** + * If this method is present, a configuration gear will be present in the + * UI, and this method will be invoked when it's clicked. When called, + * you can take other editor actions, such as showing a quick pick or + * opening a configuration file. + * @stubbed + */ + configureHandler: (() => void) | undefined; + + /** + * Handler called to start a test run. When invoked, the function should call + * {@link TestController.createTestRun} at least once, and all test runs + * associated with the request should be created before the function returns + * or the returned promise is resolved. + * + * @param request Request information for the test run. + * @param cancellationToken Token that signals the used asked to abort the + * test run. If cancellation is requested on this token, all {@link TestRun} + * instances associated with the request will be + * automatically cancelled as well. + * @stubbed + */ + runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void; + + /** + * Deletes the run profile. + * @stubbed + */ + dispose(): void; +} + +/** + * Entry point to discover and execute tests. It contains {@link TestController.items} which + * are used to populate the editor UI, and is associated with + * {@link TestController.createRunProfile run profiles} to allow + * for tests to be executed. + */ +export interface TestController { + /** + * The id of the controller passed in {@link vscode.tests.createTestController}. + * This must be globally unique. + * @stubbed + */ + readonly id: string; + + /** + * Human-readable label for the test controller. + * @stubbed + */ + label: string; + + /** + * A collection of "top-level" {@link TestItem} instances, which can in + * turn have their own {@link TestItem.children children} to form the + * "test tree." + * + * The extension controls when to add tests. For example, extensions should + * add tests for a file when {@link vscode.workspace.onDidOpenTextDocument} + * fires in order for decorations for tests within a file to be visible. + * + * However, the editor may sometimes explicitly request children using the + * {@link resolveHandler} See the documentation on that method for more details. + * @stubbed + */ + readonly items: TestItemCollection; + + /** + * Creates a profile used for running tests. Extensions must create + * at least one profile in order for tests to be run. + * @param label A human-readable label for this profile. + * @param kind Configures what kind of execution this profile manages. + * @param runHandler Function called to start a test run. + * @param isDefault Whether this is the default action for its kind. + * @param tag Profile test tag. + * @returns An instance of a {@link TestRunProfile}, which is automatically + * associated with this controller. + * @stubbed + */ + createRunProfile(label: string, kind: TestRunProfileKind, runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void, isDefault?: boolean, tag?: TestTag): TestRunProfile; + + /** + * A function provided by the extension that the editor may call to request + * children of a test item, if the {@link TestItem.canResolveChildren} is + * `true`. When called, the item should discover children and call + * {@link vscode.tests.createTestItem} as children are discovered. + * + * Generally the extension manages the lifecycle of test items, but under + * certain conditions the editor may request the children of a specific + * item to be loaded. For example, if the user requests to re-run tests + * after reloading the editor, the editor may need to call this method + * to resolve the previously-run tests. + * + * The item in the explorer will automatically be marked as "busy" until + * the function returns or the returned thenable resolves. + * + * @param item An unresolved test item for which children are being + * requested, or `undefined` to resolve the controller's initial {@link TestController.items items}. + * @stubbed + */ + resolveHandler?: (item: TestItem | undefined) => Thenable | void; + + /** + * If this method is present, a refresh button will be present in the + * UI, and this method will be invoked when it's clicked. When called, + * the extension should scan the workspace for any new, changed, or + * removed tests. + * + * It's recommended that extensions try to update tests in realtime, using + * a {@link FileSystemWatcher} for example, and use this method as a fallback. + * + * @returns A thenable that resolves when tests have been refreshed. + * @stubbed + */ + refreshHandler: ((token: CancellationToken) => Thenable | void) | undefined; + + /** + * Creates a {@link TestRun}. This should be called by the + * {@link TestRunProfile} when a request is made to execute tests, and may + * also be called if a test run is detected externally. Once created, tests + * that are included in the request will be moved into the queued state. + * + * All runs created using the same `request` instance will be grouped + * together. This is useful if, for example, a single suite of tests is + * run on multiple platforms. + * + * @param request Test run request. Only tests inside the `include` may be + * modified, and tests in its `exclude` are ignored. + * @param name The human-readable name of the run. This can be used to + * disambiguate multiple sets of results in a test run. It is useful if + * tests are run across multiple platforms, for example. + * @param persist Whether the results created by the run should be + * persisted in the editor. This may be false if the results are coming from + * a file already saved externally, such as a coverage information file. + * @returns An instance of the {@link TestRun}. It will be considered "running" + * from the moment this method is invoked until {@link TestRun.end} is called. + * @stubbed + */ + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun; + + /** + * Creates a new managed {@link TestItem} instance. It can be added into + * the {@link TestItem.children} of an existing item, or into the + * {@link TestController.items}. + * + * @param id Identifier for the TestItem. The test item's ID must be unique + * in the {@link TestItemCollection} it's added to. + * @param label Human-readable label of the test item. + * @param uri URI this TestItem is associated with. May be a file or directory. + * @stubbed + */ + createTestItem(id: string, label: string, uri?: Uri): TestItem; + + /** + * Unregisters the test controller, disposing of its associated tests + * and unpersisted results. + * @stubbed + */ + dispose(): void; +} + +/** + * A TestRunRequest is a precursor to a {@link TestRun}, which in turn is + * created by passing a request to {@link tests.runTests}. The TestRunRequest + * contains information about which tests should be run, which should not be + * run, and how they are run (via the {@link TestRunRequest.profile profile}). + * + * In general, TestRunRequests are created by the editor and pass to + * {@link TestRunProfile.runHandler}, however you can also create test + * requests and runs outside of the `runHandler`. + */ +export class TestRunRequest { + /** + * A filter for specific tests to run. If given, the extension should run + * all of the included tests and all their children, excluding any tests + * that appear in {@link TestRunRequest.exclude}. If this property is + * undefined, then the extension should simply run all tests. + * + * The process of running tests should resolve the children of any test + * items who have not yet been resolved. + */ + readonly include: readonly TestItem[] | undefined; + + /** + * An array of tests the user has marked as excluded from the test included + * in this run; exclusions should apply after inclusions. + * + * May be omitted if no exclusions were requested. Test controllers should + * not run excluded tests or any children of excluded tests. + */ + readonly exclude: readonly TestItem[] | undefined; + + /** + * The profile used for this request. This will always be defined + * for requests issued from the editor UI, though extensions may + * programmatically create requests not associated with any profile. + */ + readonly profile: TestRunProfile | undefined; + + /** + * @param include Array of specific tests to run, or undefined to run all tests + * @param exclude An array of tests to exclude from the run. + * @param profile The run profile used for this request. + */ + constructor(include?: readonly TestItem[], exclude?: readonly TestItem[], profile?: TestRunProfile); +} + +/** + * Options given to {@link TestController.runTests} + */ +export interface TestRun { + /** + * The human-readable name of the run. This can be used to + * disambiguate multiple sets of results in a test run. It is useful if + * tests are run across multiple platforms, for example. + * @stubbed + */ + readonly name: string | undefined; + + /** + * A cancellation token which will be triggered when the test run is + * canceled from the UI. + * @stubbed + */ + readonly token: CancellationToken; + + /** + * Whether the test run will be persisted across reloads by the editor. + * @stubbed + */ + readonly isPersisted: boolean; + + /** + * Indicates a test is queued for later execution. + * @param test Test item to update. + * @stubbed + */ + enqueued(test: TestItem): void; + + /** + * Indicates a test has started running. + * @param test Test item to update. + * @stubbed + */ + started(test: TestItem): void; + + /** + * Indicates a test has been skipped. + * @param test Test item to update. + * @stubbed + */ + skipped(test: TestItem): void; + + /** + * Indicates a test has failed. You should pass one or more + * {@link TestMessage TestMessages} to describe the failure. + * @param test Test item to update. + * @param message Messages associated with the test failure. + * @param duration How long the test took to execute, in milliseconds. + * @stubbed + */ + failed(test: TestItem, message: TestMessage | readonly TestMessage[], duration?: number): void; + + /** + * Indicates a test has errored. You should pass one or more + * {@link TestMessage TestMessages} to describe the failure. This differs + * from the "failed" state in that it indicates a test that couldn't be + * executed at all, from a compilation error for example. + * @param test Test item to update. + * @param message Messages associated with the test failure. + * @param duration How long the test took to execute, in milliseconds. + * @stubbed + */ + errored(test: TestItem, message: TestMessage | readonly TestMessage[], duration?: number): void; + + /** + * Indicates a test has passed. + * @param test Test item to update. + * @param duration How long the test took to execute, in milliseconds. + * @stubbed + */ + passed(test: TestItem, duration?: number): void; + + /** + * Appends raw output from the test runner. On the user's request, the + * output will be displayed in a terminal. ANSI escape sequences, + * such as colors and text styles, are supported. + * + * @param output Output text to append. + * @param location Indicate that the output was logged at the given + * location. + * @param test Test item to associate the output with. + * @stubbed + */ + appendOutput(output: string, location?: Location, test?: TestItem): void; + + /** + * Signals that the end of the test run. Any tests included in the run whose + * states have not been updated will have their state reset. + * @stubbed + */ + end(): void; +} + +/** + * Collection of test items, found in {@link TestItem.children} and + * {@link TestController.items}. + */ +export interface TestItemCollection extends Iterable<[id: string, testItem: TestItem]> { + /** + * Gets the number of items in the collection. + * @stubbed + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + * @stubbed + */ + replace(items: readonly TestItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + * @stubbed + */ + forEach(callback: (item: TestItem, collection: TestItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the test item to the children. If an item with the same ID already + * exists, it'll be replaced. + * @param item Item to add. + * @stubbed + */ + add(item: TestItem): void; + + /** + * Removes a single test item from the collection. + * @param itemId Item ID to delete. + * @stubbed + */ + delete(itemId: string): void; + + /** + * Efficiently gets a test item by ID, if it exists, in the children. + * @param itemId Item ID to get. + * @returns The found item or undefined if it does not exist. + * @stubbed + */ + get(itemId: string): TestItem | undefined; +} + +/** + * An item shown in the "test explorer" view. + * + * A `TestItem` can represent either a test suite or a test itself, since + * they both have similar capabilities. + */ +export interface TestItem { + /** + * Identifier for the `TestItem`. This is used to correlate + * test results and tests in the document with those in the workspace + * (test explorer). This cannot change for the lifetime of the `TestItem`, + * and must be unique among its parent's direct children. + * @stubbed + */ + readonly id: string; + + /** + * URI this `TestItem` is associated with. May be a file or directory. + * @stubbed + */ + readonly uri: Uri | undefined; + + /** + * The children of this test item. For a test suite, this may contain the + * individual test cases or nested suites. + * @stubbed + */ + readonly children: TestItemCollection; + + /** + * The parent of this item. It's set automatically, and is undefined + * top-level items in the {@link TestController.items} and for items that + * aren't yet included in another item's {@link TestItem.children children}. + * @stubbed + */ + readonly parent: TestItem | undefined; + + /** + * Tags associated with this test item. May be used in combination with + * {@link TestRunProfile.tags}, or simply as an organizational feature. + * @stubbed + */ + tags: readonly TestTag[]; + + /** + * Indicates whether this test item may have children discovered by resolving. + * + * If true, this item is shown as expandable in the Test Explorer view and + * expanding the item will cause {@link TestController.resolveHandler} + * to be invoked with the item. + * + * Default to `false`. + * @stubbed + */ + canResolveChildren: boolean; + + /** + * Controls whether the item is shown as "busy" in the Test Explorer view. + * This is useful for showing status while discovering children. + * + * Defaults to `false`. + * @stubbed + */ + busy: boolean; + + /** + * Display name describing the test case. + * @stubbed + */ + label: string; + + /** + * Optional description that appears next to the label. + * @stubbed + */ + description?: string; + + /** + * A string that should be used when comparing this item + * with other items. When `falsy` the {@link TestItem.label label} + * is used. + * @stubbed + */ + sortText?: string | undefined; + + /** + * Location of the test item in its {@link TestItem.uri uri}. + * + * This is only meaningful if the `uri` points to a file. + * @stubbed + */ + range: Range | undefined; + + /** + * Optional error encountered while loading the test. + * + * Note that this is not a test result and should only be used to represent errors in + * test discovery, such as syntax errors. + * @stubbed + */ + error: string | MarkdownString | undefined; +} + +/** + * Message associated with the test state. Can be linked to a specific + * source range -- useful for assertion failures, for example. + */ +export class TestMessage { + /** + * Human-readable message text to display. + */ + message: string | MarkdownString; + + /** + * Expected test output. If given with {@link TestMessage.actualOutput actualOutput }, a diff view will be shown. + */ + expectedOutput?: string; + + /** + * Actual test output. If given with {@link TestMessage.expectedOutput expectedOutput }, a diff view will be shown. + */ + actualOutput?: string; + + /** + * Associated file location. + */ + location?: Location; + + /** + * Creates a new TestMessage that will present as a diff in the editor. + * @param message Message to display to the user. + * @param expected Expected output. + * @param actual Actual output. + */ + static diff(message: string | MarkdownString, expected: string, actual: string): TestMessage; + + /** + * Creates a new TestMessage instance. + * @param message The message to show to the user. + */ + constructor(message: string | MarkdownString); +} + /** * Thenable is a common denominator between ES6 promises, Q, jquery.Deferred, WinJS.Promise, * and others. This API makes no assumption about what promise library is being used which