From 29b89e0f5fd22fee74599881c5a7ec1a7f18b10a Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 24 Jun 2021 21:38:11 -0500 Subject: [PATCH 01/34] src/goModules: correctly handle gopath mode In certain circumstances, `go env GOPATH` can return 'NUL' or '/dev/null'. This updates goModules to handle that case. --- src/goModules.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/goModules.ts b/src/goModules.ts index a0b6f51d16..8eeedfa528 100644 --- a/src/goModules.ts +++ b/src/goModules.ts @@ -36,7 +36,10 @@ async function runGoModEnv(folderPath: string): Promise { return resolve(''); } const [goMod] = stdout.split('\n'); - resolve(goMod); + if (goMod == '/dev/null' || goMod == 'NUL') + resolve(''); + else + resolve(goMod); }); }); } From 1fa5e105c46b8380848fc533551dbfc9b9b39622 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 24 Jun 2021 21:39:50 -0500 Subject: [PATCH 02/34] package.json: prepare for test api This updates package.json and adds the relevant propsed API definitions to prepare for implementing the test API. This commit should be reverted once the test API is no longer experimental. --- package-lock.json | 14 +- package.json | 3 +- src/vscode.proposed.d.ts | 559 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 src/vscode.proposed.d.ts diff --git a/package-lock.json b/package-lock.json index 28c3c3f984..3ecf643071 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "@types/node": "^13.11.1", "@types/semver": "^7.1.0", "@types/sinon": "^9.0.0", - "@types/vscode": "^1.52.0", + "@types/vscode": "^1.57.0", "adm-zip": "^0.4.14", "fs-extra": "^9.0.0", "get-port": "^5.1.1", @@ -387,9 +387,9 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz", - "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.57.0.tgz", + "integrity": "sha512-FeznBFtIDCWRluojTsi9c3LLcCHOXP5etQfBK42+ixo1CoEAchkw39tuui9zomjZuKfUVL33KZUDIwHZ/xvOkQ==", "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -7276,9 +7276,9 @@ "dev": true }, "@types/vscode": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz", - "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.57.0.tgz", + "integrity": "sha512-FeznBFtIDCWRluojTsi9c3LLcCHOXP5etQfBK42+ixo1CoEAchkw39tuui9zomjZuKfUVL33KZUDIwHZ/xvOkQ==", "dev": true }, "@typescript-eslint/eslint-plugin": { diff --git a/package.json b/package.json index 644bac684d..17e199e1b6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "license": "MIT", "icon": "media/go-logo-blue.png", + "enableProposedApi": true, "categories": [ "Programming Languages", "Snippets", @@ -70,7 +71,7 @@ "@types/node": "^13.11.1", "@types/semver": "^7.1.0", "@types/sinon": "^9.0.0", - "@types/vscode": "^1.52.0", + "@types/vscode": "^1.57.0", "adm-zip": "^0.4.14", "fs-extra": "^9.0.0", "get-port": "^5.1.1", diff --git a/src/vscode.proposed.d.ts b/src/vscode.proposed.d.ts new file mode 100644 index 0000000000..c2bf9cebcc --- /dev/null +++ b/src/vscode.proposed.d.ts @@ -0,0 +1,559 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * This is the place for API experiments and proposals. + * These API are NOT stable and subject to change. They are only available in the Insiders + * distribution and CANNOT be used in published extensions. + * + * To test these API in local environment: + * - Use Insiders release of 'VS Code'. + * - Add `"enableProposedApi": true` to your package.json. + * - Copy this file to your project. + */ + + declare module 'vscode' { + //#region https://github.com/microsoft/vscode/issues/107467 + export namespace test { + /** + * Creates a new test controller. + * + * @param id Identifier for the controller, must be globally unique. + */ + export function createTestController(id: string): TestController; + + /** + * Requests that tests be run by their controller. + * @param run Run options to use + * @param token Cancellation token for the test run + */ + export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; + + /** + * Returns an observer that watches and can request tests. + * @stability experimental + */ + export function createTestObserver(): TestObserver; + + /** + * List of test results stored by the editor, sorted in descending + * order by their `completedAt` time. + * @stability experimental + */ + export const testResults: ReadonlyArray; + + /** + * Event that fires when the {@link testResults} array is updated. + * @stability experimental + */ + export const onDidChangeTestResults: Event; + } + + /** + * @stability experimental + */ + 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; + + /** + * Dispose of the observer, allowing the editor to eventually tell test + * providers that they no longer need to update tests. + */ + dispose(): void; + } + + /** + * @stability experimental + */ + export interface TestsChangeEvent { + /** + * List of all tests that are newly added. + */ + readonly added: ReadonlyArray>; + + /** + * List of existing tests that have updated. + */ + readonly updated: ReadonlyArray>; + + /** + * List of existing tests that have been removed. + */ + readonly removed: ReadonlyArray>; + } + + /** + * Interface to discover and execute tests. + */ + export interface TestController { + + //todo@API expose `readonly id: string` that createTestController asked me for + + /** + * Root test item. Tests in the workspace should be added as children of + * the root. The extension controls when to add these, although the + * editor may request children using the {@link resolveChildrenHandler}, + * and the extension should add tests for a file when + * {@link vscode.workspace.onDidOpenTextDocument} fires in order for + * decorations for tests within the file to be visible. + * + * Tests in this collection should be watched and updated by the extension + * as files change. See {@link resolveChildrenHandler} for details around + * for the lifecycle of watches. + */ + // todo@API a little weird? what is its label, id, busy state etc? Can I dispose this? + // todo@API allow createTestItem-calls without parent and simply treat them as root (similar to createSourceControlResourceGroup) + readonly root: TestItem; + + /** + * Creates a new managed {@link TestItem} instance as a child of this + * one. + * @param id Unique identifier for the TestItem. + * @param label Human-readable label of the test item. + * @param parent Parent of the item. This is required; top-level items + * should be created as children of the {@link root}. + * @param uri URI this TestItem is associated with. May be a file or directory. + * @param data Custom data to be stored in {@link TestItem.data} + */ + createTestItem( + id: string, + label: string, + parent: TestItem, + uri?: Uri, + data?: TChild, + ): TestItem; + + + /** + * A function provided by the extension that the editor may call to request + * children of a test item, if the {@link TestItem.canExpand} is `true`. + * When called, the item should discover children and call + * {@link TestController.createTestItem} as children are discovered. + * + * The item in the explorer will automatically be marked as "busy" until + * the function returns or the returned thenable resolves. + * + * The controller may wish to set up listeners or watchers to update the + * children as files and documents change. + * + * @param item An unresolved test item for which + * children are being requested + */ + resolveChildrenHandler?: (item: TestItem) => Thenable | void; + + /** + * Starts a test run. When called, the controller should call + * {@link TestController.createTestRun}. All tasks associated with the + * run should be created before the function returns or the reutrned + * 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. + */ + runHandler?: (request: TestRunRequest, token: CancellationToken) => Thenable | void; + /** + * Creates a {@link TestRun}. This should be called by the + * {@link TestRunner} 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 results will be moved into the + * {@link TestResultState.Pending} state. + * + * @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. + */ + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun; + + /** + * Unregisters the test controller, disposing of its associated tests + * and unpersisted results. + */ + dispose(): void; + } + + /** + * Options given to {@link test.runTests}. + */ + export class TestRunRequest { + /** + * Array of specific tests to run. The controllers should run all of the + * given tests and all children of the given tests, excluding any tests + * that appear in {@link TestRunRequest.exclude}. + */ + tests: TestItem[]; + + /** + * An array of tests the user has marked as excluded in the editor. May be + * omitted if no exclusions were requested. Test controllers should not run + * excluded tests or any children of excluded tests. + */ + exclude?: TestItem[]; + + /** + * Whether tests in this run should be debugged. + */ + debug: boolean; + + /** + * @param tests Array of specific tests to run. + * @param exclude Tests to exclude from the run + * @param debug Whether tests in this run should be debugged. + */ + constructor(tests: readonly TestItem[], exclude?: readonly TestItem[], debug?: boolean); + } + + /** + * 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. + */ + readonly name?: string; + + /** + * A cancellation token which will be triggered when the test run is + * canceled from the UI. + */ + readonly token: CancellationToken; + + /** + * Updates the state of the test in the run. Calling with method with nodes + * outside the {@link TestRunRequest.tests} or in the + * {@link TestRunRequest.exclude} array will no-op. + * + * @param test The test to update + * @param state The state to assign to the test + * @param duration Optionally sets how long the test took to run, in milliseconds + */ + //todo@API is this "update" state or set final state? should this be called setTestResult? + setState(test: TestItem, state: TestResultState, duration?: number): void; + + /** + * Appends a message, such as an assertion error, to the test item. + * + * Calling with method with nodes outside the {@link TestRunRequest.tests} + * or in the {@link TestRunRequest.exclude} array will no-op. + * + * @param test The test to update + * @param message The message to add + */ + appendMessage(test: TestItem, message: TestMessage): 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 associateTo Optionally, associate the given segment of output + */ + appendOutput(output: string): void; + + /** + * Signals that the end of the test run. Any tests whose states have not + * been updated will be moved into the {@link TestResultState.Unset} state. + */ + end(): void; + } + + /** + * A test item is an item shown in the "test explorer" view. It encompasses + * both a suite and a test, since they have almost or identical capabilities. + */ + export interface TestItem { + /** + * Unique identifier for the TestItem. This is used to correlate + * test results and tests in the document with those in the workspace + * (test explorer). This must not change for the lifetime of the TestItem. + */ + readonly id: string; + + /** + * URI this TestItem is associated with. May be a file or directory. + */ + readonly uri?: Uri; + + /** + * A mapping of children by ID to the associated TestItem instances. + */ + //todo@API use array over es6-map + readonly children: ReadonlyMap; + + /** + * The parent of this item, if any. Assigned automatically when calling + * {@link TestItem.addChild}. + */ + //todo@API jsdoc outdated (likely in many places) + //todo@API is this needed? with TestController#root this is never undefined but `root` is questionable (see above) + readonly parent?: TestItem; + + /** + * Indicates whether this test item may have children discovered by resolving. + * If so, it will be shown as expandable in the Test Explorer view, and + * expanding the item will cause {@link TestController.resolveChildrenHandler} + * to be invoked with the item. + * + * Default to false. + */ + 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. + */ + busy: boolean; + + /** + * Display name describing the test case. + */ + label: string; + + /** + * Optional description that appears next to the label. + */ + description?: string; + + /** + * Location of the test item in its `uri`. This is only meaningful if the + * `uri` points to a file. + */ + range?: Range; + + /** + * May be set to an error associated with loading the test. Note that this + * is not a test result and should only be used to represent errors in + * discovery, such as syntax errors. + */ + error?: string | MarkdownString; + + /** + * Whether this test item can be run by providing it in the + * {@link TestRunRequest.tests} array. Defaults to `true`. + */ + runnable: boolean; + + /** + * Whether this test item can be debugged by providing it in the + * {@link TestRunRequest.tests} array. Defaults to `false`. + */ + debuggable: boolean; + + /** + * Custom extension data on the item. This data will never be serialized + * or shared outside the extenion who created the item. + */ + // todo@API remove? this brings in a ton of generics, into every single type... extension own test items, they create and dispose them, + // and therefore can have a WeakMap or Map to the side. + data: T; + + /** + * Marks the test as outdated. This can happen as a result of file changes, + * for example. In "auto run" mode, tests that are outdated will be + * automatically rerun after a short delay. Invoking this on a + * test with children will mark the entire subtree as outdated. + * + * Extensions should generally not override this method. + */ + // todo@API boolean property instead? stale: boolean + invalidate(): void; + + /** + * Removes the test and its children from the tree. + */ + dispose(): void; + } + + /** + * Possible states of tests in a test run. + */ + export enum TestResultState { + // Initial state + Unset = 0, + // Test will be run, but is not currently running. + Queued = 1, + // Test is currently running + Running = 2, + // Test run has passed + Passed = 3, + // Test run has failed (on an assertion) + Failed = 4, + // Test run has been skipped + Skipped = 5, + // Test run failed for some other reason (compilation error, timeout, etc) + Errored = 6 + } + + /** + * 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 class TestMessage { + /** + * Human-readable message text to display. + */ + message: string | MarkdownString; + + /** + * Message severity. Defaults to "Error". + */ + severity: TestMessageSeverity; + + /** + * Expected test output. If given with `actualOutput`, a diff view will be shown. + */ + expectedOutput?: string; + + /** + * Actual test output. If given with `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); + } + + /** + * TestResults can be provided to the editor in {@link test.publishTestResult}, + * or read from it in {@link test.testResults}. + * + * The results contain a 'snapshot' of the tests at the point when the test + * run is complete. Therefore, information such as its {@link Range} may be + * out of date. If the test still exists in the workspace, consumers can use + * its `id` to correlate the result instance with the living test. + * + * @todo coverage and other info may eventually be provided here + */ + export interface TestRunResult { + /** + * Unix milliseconds timestamp at which the test run was completed. + */ + completedAt: number; + + /** + * Optional raw output from the test run. + */ + output?: string; + + /** + * List of test results. The items in this array are the items that + * were passed in the {@link test.runTests} method. + */ + results: ReadonlyArray>; + } + + /** + * A {@link TestItem}-like interface with an associated result, which appear + * or can be provided in {@link TestResult} interfaces. + */ + export interface TestResultSnapshot { + /** + * Unique identifier that matches that of the associated TestItem. + * This is used to correlate test results and tests in the document with + * those in the workspace (test explorer). + */ + readonly id: string; + + /** + * URI this TestItem is associated with. May be a file or file. + */ + readonly uri?: Uri; + + /** + * Display name describing the test case. + */ + readonly label: string; + + /** + * Optional description that appears next to the label. + */ + readonly description?: string; + + /** + * Location of the test item in its `uri`. This is only meaningful if the + * `uri` points to a file. + */ + readonly range?: Range; + + /** + * State of the test in each task. In the common case, a test will only + * be executed in a single task and the length of this array will be 1. + */ + readonly taskStates: ReadonlyArray; + + /** + * Optional list of nested tests for this item. + */ + readonly children: Readonly[]; + } + + export interface TestSnapshoptTaskState { + /** + * Current result of the test. + */ + readonly state: TestResultState; + + /** + * The number of milliseconds the test took to run. This is set once the + * `state` is `Passed`, `Failed`, or `Errored`. + */ + readonly duration?: number; + + /** + * Associated test run message. Can, for example, contain assertion + * failure information if the test fails. + */ + readonly messages: ReadonlyArray; + } + + //#endregion +} From 8b83fcc955d1b2cfbeb7eb8e70fd67aea5dc1846 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 24 Jun 2021 21:40:57 -0500 Subject: [PATCH 03/34] src/goTestExplorer: implement test api This takes a dynamic approach to test discovery. Tree nodes will be populated as they are expanded in the UI. Tests in open files will be added. Fixes #1579 --- src/goMain.ts | 4 + src/goTestExplorer.ts | 283 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/goTestExplorer.ts diff --git a/src/goMain.ts b/src/goMain.ts index 541ad64729..e96578d954 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -114,6 +114,7 @@ import { getFormatTool } from './goFormat'; import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey'; import { ExtensionAPI } from './export'; import extensionAPI from './extensionAPI'; +import { setupTestExplorer } from './goTestExplorer'; export let buildDiagnosticCollection: vscode.DiagnosticCollection; export let lintDiagnosticCollection: vscode.DiagnosticCollection; @@ -226,6 +227,9 @@ If you would like additional configuration for diagnostics from gopls, please se ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, testCodeLensProvider)); ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, referencesCodeLensProvider)); + // testing + setupTestExplorer(ctx); + // debug ctx.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('go', new GoDebugConfigurationProvider('go')) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts new file mode 100644 index 0000000000..13ce80180a --- /dev/null +++ b/src/goTestExplorer.ts @@ -0,0 +1,283 @@ +import { + test, + workspace, + ExtensionContext, + TestController, + TestItem, + TextDocument, + Uri, + DocumentSymbol, + SymbolKind, + FileType +} from 'vscode'; +import path = require('path'); +import { getModFolderPath } from './goModules'; +import { getCurrentGoPath } from './util'; +import { GoDocumentSymbolProvider } from './goOutline'; + +export function setupTestExplorer(context: ExtensionContext) { + const ctrl = test.createTestController('go'); + context.subscriptions.push(ctrl); + ctrl.root.label = 'Go'; + ctrl.root.canResolveChildren = true; + ctrl.resolveChildrenHandler = (item) => resolveChildren(ctrl, item); + + context.subscriptions.push( + workspace.onDidOpenTextDocument((e) => documentUpdate(ctrl, e).catch((err) => console.log(err))) + ); + + context.subscriptions.push( + workspace.onDidChangeTextDocument((e) => documentUpdate(ctrl, e.document).catch((err) => console.log(err))) + ); +} + +function testID(uri: Uri, kind: string, name?: string): string { + uri = uri.with({ query: kind }); + if (name) uri = uri.with({ fragment: name }); + return uri.toString(); +} + +function getItem(parent: TestItem, uri: Uri, kind: string, name?: string): TestItem | undefined { + return parent.children.get(testID(uri, kind, name)); +} + +function createItem( + ctrl: TestController, + parent: TestItem, + label: string, + uri: Uri, + kind: string, + name?: string +): TestItem { + const id = testID(uri, kind, name); + const existing = parent.children.get(id); + if (existing) { + return existing; + } + + console.log(`Creating ${id}`); + return ctrl.createTestItem(id, label, parent, uri); +} + +function removeIfEmpty(item: TestItem) { + // Don't dispose of the root + if (!item.parent) { + return; + } + + // Don't dispose of empty modules + const uri = Uri.parse(item.id); + if (uri.query == 'module') { + return; + } + + if (item.children.size) { + return; + } + + item.dispose(); + removeIfEmpty(item.parent); +} + +async function getModule(ctrl: TestController, uri: Uri): Promise { + const existing = getItem(ctrl.root, uri, 'module'); + if (existing) { + return existing; + } + + // Use the module name as the label + const goMod = Uri.joinPath(uri, 'go.mod'); + const contents = await workspace.fs.readFile(goMod); + const modLine = contents.toString().split('\n', 2)[0]; + const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); + const item = createItem(ctrl, ctrl.root, match.groups.name, uri, 'module'); + item.canResolveChildren = true; + return item; +} + +async function getPackage(ctrl: TestController, uri: Uri): Promise { + // If the package is not in a module, add it as a child of the root + const modDir = await getModFolderPath(uri, true); + if (!modDir) { + const existing = getItem(ctrl.root, uri, 'package'); + if (existing) { + return existing; + } + + const srcPath = path.join(getCurrentGoPath(uri), 'src'); + const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path; + const item = createItem(ctrl, ctrl.root, label, uri, 'package'); + item.canResolveChildren = true; + return item; + } + + // Otherwise, add it as a child of the module + const modUri = uri.with({ path: modDir }); + const module = await getModule(ctrl, modUri); + const existing = getItem(module, uri, 'package'); + if (existing) { + return existing; + } + + const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; + const item = createItem(ctrl, module, label, uri, 'package'); + item.canResolveChildren = true; + return item; +} + +async function getFile(ctrl: TestController, uri: Uri): Promise { + const dir = path.dirname(uri.path); + const pkg = await getPackage(ctrl, uri.with({ path: dir })); + const existing = getItem(pkg, uri, 'file'); + if (existing) { + return existing; + } + + const label = path.basename(uri.path); + const item = createItem(ctrl, pkg, label, uri, 'file'); + item.canResolveChildren = true; + return item; +} + +async function processSymbol( + ctrl: TestController, + uri: Uri, + file: TestItem, + seen: Set, + symbol: DocumentSymbol +) { + // Skip TestMain(*testing.M) + if (symbol.name === 'TestMain' || /\*testing.M\)/.test(symbol.detail)) { + return; + } + + // Recursively process symbols that are nested + if (symbol.kind !== SymbolKind.Function) { + for (const sym of symbol.children) await processSymbol(ctrl, uri, file, seen, sym); + return; + } + + const match = symbol.name.match(/^(?Test|Example|Benchmark)/); + if (!match) { + return; + } + + seen.add(symbol.name); + + const kind = match.groups.type.toLowerCase(); + const existing = getItem(file, uri, kind, symbol.name); + if (existing) { + return existing; + } + + const item = createItem(ctrl, file, symbol.name, uri, kind, symbol.name); + item.range = symbol.range; + item.runnable = true; + item.debuggable = true; +} + +async function loadFileTests(ctrl: TestController, doc: TextDocument) { + const seen = new Set(); + const item = await getFile(ctrl, doc.uri); + const symbols = await new GoDocumentSymbolProvider().provideDocumentSymbols(doc, null); + for (const symbol of symbols) await processSymbol(ctrl, doc.uri, item, seen, symbol); + + for (const child of item.children.values()) { + const uri = Uri.parse(child.id); + if (!seen.has(uri.fragment)) { + child.dispose(); + } + } + + removeIfEmpty(item); +} + +async function containsGoFiles(uri: Uri): Promise { + for (const [file, type] of await workspace.fs.readDirectory(uri)) { + if (file.startsWith('.')) { + continue; + } + + switch (type) { + case FileType.File: + if (file.endsWith('.go')) { + return true; + } + break; + + case FileType.Directory: + if (await containsGoFiles(Uri.joinPath(uri, file))) { + return true; + } + break; + } + } +} + +async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { + let called = false; + for (const [file, type] of await workspace.fs.readDirectory(uri)) { + if (file.startsWith('.')) { + continue; + } + + switch (type) { + case FileType.File: + if (!called && file.endsWith('_test.go')) { + called = true; + await cb(uri); + } + break; + + case FileType.Directory: + await walkPackages(Uri.joinPath(uri, file), cb); + break; + } + } +} + +async function resolveChildren(ctrl: TestController, item: TestItem) { + if (!item.parent) { + for (const folder of workspace.workspaceFolders || []) { + if (await containsGoFiles(folder.uri)) { + await getModule(ctrl, folder.uri); + } + } + return; + } + + const uri = Uri.parse(item.id); + switch (uri.query) { + case 'module': + await walkPackages(uri, (uri) => getPackage(ctrl, uri)); + break; + + case 'package': + for (const [file, type] of await workspace.fs.readDirectory(uri)) { + if (type !== FileType.File || !file.endsWith('_test.go')) { + continue; + } + + await getFile(ctrl, Uri.joinPath(uri, file)); + } + break; + + case 'file': + const doc = await workspace.openTextDocument(uri); + await loadFileTests(ctrl, doc); + break; + } +} + +async function documentUpdate(ctrl: TestController, doc: TextDocument) { + if (!doc.uri.path.endsWith('_test.go')) { + return; + } + + if (doc.uri.scheme === 'git') { + // TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why? + return; + } + + await loadFileTests(ctrl, doc); +} From 4dbce415e6cf95dc3548afc1a0c70c6c38cedc24 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 24 Jun 2021 21:32:18 -0500 Subject: [PATCH 04/34] src/goTestExplorer: handle workspaces correctly This fixes discovery of workspace tests when loading the workspace, when adding and removing workspace folders, and when adding and removing files. --- src/goTestExplorer.ts | 297 +++++++++++++++++++++++++++++++++--------- 1 file changed, 233 insertions(+), 64 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 13ce80180a..00c46bd1e0 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -8,7 +8,8 @@ import { Uri, DocumentSymbol, SymbolKind, - FileType + FileType, + WorkspaceFolder } from 'vscode'; import path = require('path'); import { getModFolderPath } from './goModules'; @@ -29,6 +30,57 @@ export function setupTestExplorer(context: ExtensionContext) { context.subscriptions.push( workspace.onDidChangeTextDocument((e) => documentUpdate(ctrl, e.document).catch((err) => console.log(err))) ); + + const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false); + context.subscriptions.push(watcher); + watcher.onDidCreate(async (e) => await documentUpdate(ctrl, await workspace.openTextDocument(e))); + watcher.onDidDelete(async (e) => { + const id = testID(e, 'file'); + function find(parent: TestItem): TestItem { + for (const item of parent.children.values()) { + if (item.id == id) { + return item; + } + + const uri = Uri.parse(item.id); + if (!e.path.startsWith(uri.path)) { + continue; + } + + const found = find(item); + if (found) { + return found; + } + } + } + + const found = find(ctrl.root); + if (found) { + found.dispose(); + removeIfEmpty(found.parent); + } + }); + + context.subscriptions.push( + workspace.onDidChangeWorkspaceFolders(async (e) => { + const items = Array.from(ctrl.root.children.values()); + for (const item of items) { + const uri = Uri.parse(item.id); + if (uri.query == 'package') { + continue; + } + + const ws = workspace.getWorkspaceFolder(uri); + if (!ws) { + item.dispose(); + } + } + + if (e.added) { + await resolveChildren(ctrl, ctrl.root); + } + }) + ); } function testID(uri: Uri, kind: string, name?: string): string { @@ -95,10 +147,52 @@ async function getModule(ctrl: TestController, uri: Uri): Promise { return item; } +async function getWorkspace(ctrl: TestController, ws: WorkspaceFolder): Promise { + const existing = getItem(ctrl.root, ws.uri, 'workspace'); + if (existing) { + return existing; + } + + // Use the workspace folder name as the label + const item = createItem(ctrl, ctrl.root, ws.name, ws.uri, 'workspace'); + item.canResolveChildren = true; + return item; +} + async function getPackage(ctrl: TestController, uri: Uri): Promise { - // If the package is not in a module, add it as a child of the root + let item: TestItem; + const modDir = await getModFolderPath(uri, true); - if (!modDir) { + const wsfolder = workspace.getWorkspaceFolder(uri); + if (modDir) { + // If the package is in a module, add it as a child of the module + const modUri = uri.with({ path: modDir }); + const module = await getModule(ctrl, modUri); + const existing = getItem(module, uri, 'package'); + if (existing) { + return existing; + } + + if (uri.path == modUri.path) { + return module; + } + + const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; + item = createItem(ctrl, module, label, uri, 'package'); + } else if (wsfolder) { + // If the package is in a workspace folder, add it as a child of the workspace + const workspace = await getWorkspace(ctrl, wsfolder); + const existing = getItem(workspace, uri, 'package'); + if (existing) { + return existing; + } + + const label = uri.path.startsWith(wsfolder.uri.path) + ? uri.path.substring(wsfolder.uri.path.length + 1) + : uri.path; + item = createItem(ctrl, workspace, label, uri, 'package'); + } else { + // Otherwise, add it directly to the root const existing = getItem(ctrl.root, uri, 'package'); if (existing) { return existing; @@ -106,21 +200,9 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { const srcPath = path.join(getCurrentGoPath(uri), 'src'); const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path; - const item = createItem(ctrl, ctrl.root, label, uri, 'package'); - item.canResolveChildren = true; - return item; + item = createItem(ctrl, ctrl.root, label, uri, 'package'); } - // Otherwise, add it as a child of the module - const modUri = uri.with({ path: modDir }); - const module = await getModule(ctrl, modUri); - const existing = getItem(module, uri, 'package'); - if (existing) { - return existing; - } - - const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; - const item = createItem(ctrl, module, label, uri, 'package'); item.canResolveChildren = true; return item; } @@ -192,80 +274,167 @@ async function loadFileTests(ctrl: TestController, doc: TextDocument) { removeIfEmpty(item); } -async function containsGoFiles(uri: Uri): Promise { - for (const [file, type] of await workspace.fs.readDirectory(uri)) { - if (file.startsWith('.')) { - continue; - } +enum WalkStop { + None = 0, + Abort, + Current, + Files, + Directories +} + +// Recursively walk a directory, breadth first +async function walk( + uri: Uri, + cb: (dir: Uri, file: string, type: FileType) => Promise +): Promise { + let dirs = [uri]; + + // While there are directories to be scanned + while (dirs.length) { + const d = dirs; + dirs = []; + + outer: for (const uri of d) { + const dirs2 = []; + let skipFiles = false, + skipDirs = false; + + // Scan the directory + inner: for (const [file, type] of await workspace.fs.readDirectory(uri)) { + if ((skipFiles && type == FileType.File) || (skipDirs && type == FileType.Directory)) { + continue; + } - switch (type) { - case FileType.File: - if (file.endsWith('.go')) { - return true; + // Ignore all dotfiles + if (file.startsWith('.')) { + continue; + } + + if (type == FileType.Directory) { + dirs2.push(Uri.joinPath(uri, file)); } - break; - case FileType.Directory: - if (await containsGoFiles(Uri.joinPath(uri, file))) { - return true; + const s = await cb(uri, file, type); + switch (s) { + case WalkStop.Abort: + // Immediately abort the entire walk + return; + + case WalkStop.Current: + // Immediately abort the current directory + continue outer; + + case WalkStop.Files: + // Skip all subsequent files in the current directory + skipFiles = true; + if (skipFiles && skipDirs) { + break inner; + } + break; + + case WalkStop.Directories: + // Skip all subsequent directories in the current directory + skipDirs = true; + if (skipFiles && skipDirs) { + break inner; + } + break; } - break; + } + + // Add subdirectories to the recursion list + dirs.push(...dirs2); } } } -async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { - let called = false; - for (const [file, type] of await workspace.fs.readDirectory(uri)) { - if (file.startsWith('.')) { - continue; +async function walkWorkspaces(uri: Uri) { + const found = new Map(); + await walk(uri, async (dir, file, type) => { + if (type != FileType.File) { + return; } - switch (type) { - case FileType.File: - if (!called && file.endsWith('_test.go')) { - called = true; - await cb(uri); - } - break; + if (file == 'go.mod') { + found.set(dir.toString(), true); + return WalkStop.Current; + } - case FileType.Directory: - await walkPackages(Uri.joinPath(uri, file), cb); - break; + if (file.endsWith('.go')) { + found.set(dir.toString(), false); } - } + }); + return found; +} + +async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { + await walk(uri, async (dir, file, type) => { + if (file.endsWith('_test.go')) { + await cb(dir); + return WalkStop.Files; + } + }); } async function resolveChildren(ctrl: TestController, item: TestItem) { if (!item.parent) { + // Dispose of package entries at the root if they are now part of a workspace folder + const items = Array.from(ctrl.root.children.values()); + for (const item of items) { + const uri = Uri.parse(item.id); + if (uri.query !== 'package') { + continue; + } + + if (workspace.getWorkspaceFolder(uri)) { + item.dispose(); + } + } + + // Create entries for all modules and workspaces for (const folder of workspace.workspaceFolders || []) { - if (await containsGoFiles(folder.uri)) { - await getModule(ctrl, folder.uri); + const found = await walkWorkspaces(folder.uri); + let needWorkspace = false; + for (const [uri, isMod] of found.entries()) { + if (!isMod) { + needWorkspace = true; + continue; + } + + await getModule(ctrl, Uri.parse(uri)); + } + + // If the workspace folder contains any Go files not in a module, create a workspace entry + if (needWorkspace) { + await getWorkspace(ctrl, folder); } } return; } const uri = Uri.parse(item.id); - switch (uri.query) { - case 'module': - await walkPackages(uri, (uri) => getPackage(ctrl, uri)); - break; - - case 'package': - for (const [file, type] of await workspace.fs.readDirectory(uri)) { - if (type !== FileType.File || !file.endsWith('_test.go')) { - continue; - } + if (uri.query == 'module' || uri.query == 'workspace') { + // Create entries for all packages in the module or workspace + await walkPackages(uri, async (uri) => { + await getPackage(ctrl, uri); + }); + } - await getFile(ctrl, Uri.joinPath(uri, file)); + if (uri.query == 'module' || uri.query == 'package') { + // Create entries for all test files in the package + for (const [file, type] of await workspace.fs.readDirectory(uri)) { + if (type !== FileType.File || !file.endsWith('_test.go')) { + continue; } - break; - case 'file': - const doc = await workspace.openTextDocument(uri); - await loadFileTests(ctrl, doc); - break; + await getFile(ctrl, Uri.joinPath(uri, file)); + } + } + + if (uri.query == 'file') { + // Create entries for all test functions in a file + const doc = await workspace.openTextDocument(uri); + await loadFileTests(ctrl, doc); } } From 80bf7b3ad62eb3813ae74eddac7bbec0f7dab443 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 24 Jun 2021 23:04:52 -0500 Subject: [PATCH 05/34] src/goTestExplorer: run tests This adds the capability to the test provider to actually run tests. Debugging is still not supported. --- src/goTestExplorer.ts | 141 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 11 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 00c46bd1e0..3c8393a4c8 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -9,19 +9,37 @@ import { DocumentSymbol, SymbolKind, FileType, - WorkspaceFolder + WorkspaceFolder, + TestRunRequest, + CancellationToken, + window, + OutputChannel, + TestResultState, + TestRun } from 'vscode'; import path = require('path'); -import { getModFolderPath } from './goModules'; +import { getModFolderPath, isModSupported } from './goModules'; import { getCurrentGoPath } from './util'; import { GoDocumentSymbolProvider } from './goOutline'; +import { testAtCursor } from './goTest'; +import { getGoConfig } from './config'; +import { gocodeClose } from './goTools'; +import { getTestFlags, getTestFunctionDebugArgs, getTestTags, goTest, TestConfig } from './testUtils'; +import { resolve } from 'path'; + +// We could use TestItem.data, but that may be removed +const symbols = new WeakMap(); export function setupTestExplorer(context: ExtensionContext) { const ctrl = test.createTestController('go'); context.subscriptions.push(ctrl); ctrl.root.label = 'Go'; ctrl.root.canResolveChildren = true; - ctrl.resolveChildrenHandler = (item) => resolveChildren(ctrl, item); + ctrl.resolveChildrenHandler = (...args) => resolveChildren(ctrl, ...args); + ctrl.runHandler = (request, token) => { + // TODO handle cancelation + runTest(ctrl, request); + }; context.subscriptions.push( workspace.onDidOpenTextDocument((e) => documentUpdate(ctrl, e).catch((err) => console.log(err))) @@ -107,7 +125,6 @@ function createItem( return existing; } - console.log(`Creating ${id}`); return ctrl.createTestItem(id, label, parent, uri); } @@ -144,6 +161,7 @@ async function getModule(ctrl: TestController, uri: Uri): Promise { const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); const item = createItem(ctrl, ctrl.root, match.groups.name, uri, 'module'); item.canResolveChildren = true; + item.runnable = true; return item; } @@ -156,6 +174,7 @@ async function getWorkspace(ctrl: TestController, ws: WorkspaceFolder): Promise< // Use the workspace folder name as the label const item = createItem(ctrl, ctrl.root, ws.name, ws.uri, 'workspace'); item.canResolveChildren = true; + item.runnable = true; return item; } @@ -204,6 +223,7 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { } item.canResolveChildren = true; + item.runnable = true; return item; } @@ -218,6 +238,7 @@ async function getFile(ctrl: TestController, uri: Uri): Promise { const label = path.basename(uri.path); const item = createItem(ctrl, pkg, label, uri, 'file'); item.canResolveChildren = true; + item.runnable = true; return item; } @@ -255,7 +276,8 @@ async function processSymbol( const item = createItem(ctrl, file, symbol.name, uri, kind, symbol.name); item.range = symbol.range; item.runnable = true; - item.debuggable = true; + // item.debuggable = true; + symbols.set(item, symbol); } async function loadFileTests(ctrl: TestController, doc: TextDocument) { @@ -376,6 +398,19 @@ async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { }); } +async function documentUpdate(ctrl: TestController, doc: TextDocument) { + if (!doc.uri.path.endsWith('_test.go')) { + return; + } + + if (doc.uri.scheme === 'git') { + // TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why? + return; + } + + await loadFileTests(ctrl, doc); +} + async function resolveChildren(ctrl: TestController, item: TestItem) { if (!item.parent) { // Dispose of package entries at the root if they are now part of a workspace folder @@ -438,15 +473,99 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { } } -async function documentUpdate(ctrl: TestController, doc: TextDocument) { - if (!doc.uri.path.endsWith('_test.go')) { - return; +async function collectTests( + ctrl: TestController, + item: TestItem, + excluded: TestItem[], + functions: Map, + docs: Set +) { + for (let i = item; i.parent; i = i.parent) { + if (excluded.indexOf(i) >= 0) { + return; + } } - if (doc.uri.scheme === 'git') { - // TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why? + const uri = Uri.parse(item.id); + if (!uri.fragment) { + if (!item.children.size) { + await resolveChildren(ctrl, item); + } + + for (const child of item.children.values()) { + await collectTests(ctrl, child, excluded, functions, docs); + } return; } - await loadFileTests(ctrl, doc); + const file = uri.with({ query: '', fragment: '' }); + docs.add(file); + + const dir = file.with({ path: path.dirname(uri.path) }).toString(); + if (functions.has(dir)) { + functions.get(dir).push(item); + } else { + functions.set(dir, [item]); + } + return; +} + +class TestRunOutput implements OutputChannel { + constructor(private run: TestRun, private tests: TestItem[]) {} + + get name() { + return 'Go Test API'; + } + + append(value: string) { + this.run.appendOutput(value); + } + + appendLine(value: string) { + this.run.appendOutput(value + '\n'); + } + + clear() {} + show(...args: any[]) {} + hide() {} + dispose() {} +} + +async function runTest(ctrl: TestController, request: TestRunRequest) { + const functions = new Map(); + const docs = new Set(); + for (const item of request.tests) { + await collectTests(ctrl, item, request.exclude, functions, docs); + } + + // Ensure `go test` has the latest changes + await Promise.all( + Array.from(docs).map((uri) => { + workspace.openTextDocument(uri).then((doc) => doc.save()); + }) + ); + + const run = ctrl.createTestRun(request); + const goConfig = getGoConfig(); + for (const [dir, tests] of functions.entries()) { + const functions = tests.map((test) => Uri.parse(test.id).fragment); + + // TODO this should be more granular + tests.forEach((test) => run.setState(test, TestResultState.Running)); + + const uri = Uri.parse(dir); + const result = await goTest({ + goConfig, + dir: uri.fsPath, + functions, + flags: getTestFlags(goConfig), + isMod: await isModSupported(uri, true), + outputChannel: new TestRunOutput(run, tests), + applyCodeCoverage: goConfig.get('coverOnSingleTest') + }); + + tests.forEach((test) => run.setState(test, result ? TestResultState.Passed : TestResultState.Failed)); + } + + run.end(); } From 7b637aa0d58d6df9e23817cb39a112ffce9b487a Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 24 Jun 2021 23:17:46 -0500 Subject: [PATCH 06/34] src/goTestExplorer: cleanup imports --- src/goTestExplorer.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 3c8393a4c8..dcad570f56 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -11,8 +11,6 @@ import { FileType, WorkspaceFolder, TestRunRequest, - CancellationToken, - window, OutputChannel, TestResultState, TestRun @@ -21,11 +19,8 @@ import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; import { getCurrentGoPath } from './util'; import { GoDocumentSymbolProvider } from './goOutline'; -import { testAtCursor } from './goTest'; import { getGoConfig } from './config'; -import { gocodeClose } from './goTools'; -import { getTestFlags, getTestFunctionDebugArgs, getTestTags, goTest, TestConfig } from './testUtils'; -import { resolve } from 'path'; +import { getTestFlags, goTest } from './testUtils'; // We could use TestItem.data, but that may be removed const symbols = new WeakMap(); From 300e5e6680db4c4061fd2bcce229822f06b5987f Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 25 Jun 2021 03:41:05 -0500 Subject: [PATCH 07/34] src/goTestExplorer: improve test status granularity --- src/goTestExplorer.ts | 227 +++++++++++++++++++++++++++++++++++------- src/testUtils.ts | 23 ++++- 2 files changed, 208 insertions(+), 42 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index dcad570f56..5c6c6dced9 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -13,14 +13,16 @@ import { TestRunRequest, OutputChannel, TestResultState, - TestRun + TestRun, + TestMessageSeverity, + Location } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; import { getCurrentGoPath } from './util'; import { GoDocumentSymbolProvider } from './goOutline'; import { getGoConfig } from './config'; -import { getTestFlags, goTest } from './testUtils'; +import { getTestFlags, goTest, GoTestOutput } from './testUtils'; // We could use TestItem.data, but that may be removed const symbols = new WeakMap(); @@ -51,7 +53,7 @@ export function setupTestExplorer(context: ExtensionContext) { const id = testID(e, 'file'); function find(parent: TestItem): TestItem { for (const item of parent.children.values()) { - if (item.id == id) { + if (item.id === id) { return item; } @@ -79,7 +81,7 @@ export function setupTestExplorer(context: ExtensionContext) { const items = Array.from(ctrl.root.children.values()); for (const item of items) { const uri = Uri.parse(item.id); - if (uri.query == 'package') { + if (uri.query === 'package') { continue; } @@ -120,7 +122,21 @@ function createItem( return existing; } - return ctrl.createTestItem(id, label, parent, uri); + return ctrl.createTestItem(id, label, parent, uri.with({ query: '', fragment: '' })); +} + +function createSubItem(ctrl: TestController, item: TestItem, name: string): TestItem { + let uri = Uri.parse(item.id); + uri = uri.with({ fragment: `${uri.fragment}/${name}` }); + const existing = item.children.get(uri.toString()); + if (existing) { + return existing; + } + + const sub = ctrl.createTestItem(uri.toString(), name, item, item.uri); + sub.runnable = false; + sub.range = item.range; + return sub; } function removeIfEmpty(item: TestItem) { @@ -131,7 +147,7 @@ function removeIfEmpty(item: TestItem) { // Don't dispose of empty modules const uri = Uri.parse(item.id); - if (uri.query == 'module') { + if (uri.query === 'module') { return; } @@ -187,7 +203,7 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { return existing; } - if (uri.path == modUri.path) { + if (uri.path === modUri.path) { return module; } @@ -318,7 +334,7 @@ async function walk( // Scan the directory inner: for (const [file, type] of await workspace.fs.readDirectory(uri)) { - if ((skipFiles && type == FileType.File) || (skipDirs && type == FileType.Directory)) { + if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) { continue; } @@ -327,7 +343,7 @@ async function walk( continue; } - if (type == FileType.Directory) { + if (type === FileType.Directory) { dirs2.push(Uri.joinPath(uri, file)); } @@ -368,11 +384,11 @@ async function walk( async function walkWorkspaces(uri: Uri) { const found = new Map(); await walk(uri, async (dir, file, type) => { - if (type != FileType.File) { + if (type !== FileType.File) { return; } - if (file == 'go.mod') { + if (file === 'go.mod') { found.set(dir.toString(), true); return WalkStop.Current; } @@ -443,14 +459,14 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { } const uri = Uri.parse(item.id); - if (uri.query == 'module' || uri.query == 'workspace') { + if (uri.query === 'module' || uri.query === 'workspace') { // Create entries for all packages in the module or workspace await walkPackages(uri, async (uri) => { await getPackage(ctrl, uri); }); } - if (uri.query == 'module' || uri.query == 'package') { + if (uri.query === 'module' || uri.query === 'package') { // Create entries for all test files in the package for (const [file, type] of await workspace.fs.readDirectory(uri)) { if (type !== FileType.File || !file.endsWith('_test.go')) { @@ -461,9 +477,9 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { } } - if (uri.query == 'file') { + if (uri.query === 'file') { // Create entries for all test functions in a file - const doc = await workspace.openTextDocument(uri); + const doc = await workspace.openTextDocument(uri.with({ query: '', fragment: '' })); await loadFileTests(ctrl, doc); } } @@ -505,11 +521,10 @@ async function collectTests( return; } -class TestRunOutput implements OutputChannel { - constructor(private run: TestRun, private tests: TestItem[]) {} - - get name() { - return 'Go Test API'; +class TestRunOutput implements OutputChannel { + readonly name: string; + constructor(private run: TestRun) { + this.name = `Test run at ${new Date()}`; } append(value: string) { @@ -517,7 +532,7 @@ class TestRunOutput implements OutputChannel { } appendLine(value: string) { - this.run.appendOutput(value + '\n'); + this.run.appendOutput(value + '\r\n'); } clear() {} @@ -526,11 +541,109 @@ class TestRunOutput implements OutputChannel { dispose() {} } +function resolveTestName(ctrl: TestController, tests: Record, name: string): TestItem | undefined { + if (!name) { + return; + } + + const parts = name.split(/\/|#/); + let test = tests[parts[0]]; + if (!test) { + return; + } + + for (const part of parts.slice(1)) { + test = createSubItem(ctrl, test, part); + } + return test; +} + +function consumeGoBenchmarkEvent( + ctrl: TestController, + run: TestRun, + benchmarks: Record, + failed: Set, + e: GoTestOutput +) { + if (e.Test) { + const test = benchmarks[e.Test]; + if (!test) { + return; + } + + if (e.Action === 'fail') { + run.setState(test, TestResultState.Failed); + failed.add(e.Test); + } + + return; + } + + if (!e.Output) { + // console.log(e); + return; + } + + const [name, rest] = e.Output.trim().split(/-|\s/); + const test = resolveTestName(ctrl, benchmarks, name); + if (!test) { + return; + } + + if (!rest) { + run.setState(test, TestResultState.Running); + return; + } + + run.appendMessage(test, { + message: e.Output, + severity: TestMessageSeverity.Information, + location: new Location(test.uri, test.range) + }); +} + +function consumeGoTestEvent( + ctrl: TestController, + run: TestRun, + tests: Record, + e: GoTestOutput +) { + const test = resolveTestName(ctrl, tests, e.Test); + if (!test) { + return; + } + + switch (e.Action) { + case 'run': + run.setState(test, TestResultState.Running); + break; + case 'pass': + run.setState(test, TestResultState.Passed, e.Elapsed * 1000); + break; + case 'fail': + run.setState(test, TestResultState.Failed, e.Elapsed * 1000); + break; + case 'skip': + run.setState(test, TestResultState.Skipped); + break; + case 'output': + run.appendMessage(test, { + message: e.Output, + severity: TestMessageSeverity.Information, + location: new Location(test.uri, test.range) + }); + break; + default: + console.log(e); + break; + } +} + async function runTest(ctrl: TestController, request: TestRunRequest) { - const functions = new Map(); + const collected = new Map(); const docs = new Set(); for (const item of request.tests) { - await collectTests(ctrl, item, request.exclude, functions, docs); + await collectTests(ctrl, item, request.exclude, collected, docs); } // Ensure `go test` has the latest changes @@ -541,25 +654,63 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { ); const run = ctrl.createTestRun(request); + const outputChannel = new TestRunOutput(run); const goConfig = getGoConfig(); - for (const [dir, tests] of functions.entries()) { - const functions = tests.map((test) => Uri.parse(test.id).fragment); + for (const [dir, items] of collected.entries()) { + const uri = Uri.parse(dir); + const isMod = await isModSupported(uri, true); + const flags = getTestFlags(goConfig); - // TODO this should be more granular - tests.forEach((test) => run.setState(test, TestResultState.Running)); + const tests: Record = {}; + const benchmarks: Record = {}; + for (const item of items) { + run.setState(item, TestResultState.Queued); - const uri = Uri.parse(dir); - const result = await goTest({ - goConfig, - dir: uri.fsPath, - functions, - flags: getTestFlags(goConfig), - isMod: await isModSupported(uri, true), - outputChannel: new TestRunOutput(run, tests), - applyCodeCoverage: goConfig.get('coverOnSingleTest') - }); + // Remove any subtests + Array.from(item.children.values()).forEach((x) => x.dispose()); + + const uri = Uri.parse(item.id); + if (uri.query === 'benchmark') { + benchmarks[uri.fragment] = item; + } else { + tests[uri.fragment] = item; + } + } + + const testFns = Object.keys(tests); + const benchmarkFns = Object.keys(benchmarks); + + if (testFns.length) { + await goTest({ + goConfig, + flags, + isMod, + outputChannel, + dir: uri.fsPath, + functions: testFns, + goTestOutputConsumer: (e) => consumeGoTestEvent(ctrl, run, tests, e) + }); + } - tests.forEach((test) => run.setState(test, result ? TestResultState.Passed : TestResultState.Failed)); + if (benchmarkFns.length) { + const failed = new Set(); + await goTest({ + goConfig, + flags, + isMod, + outputChannel, + dir: uri.fsPath, + functions: benchmarkFns, + isBenchmark: true, + goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(ctrl, run, benchmarks, failed, e) + }); + + for (const name in benchmarks) { + if (!failed.has(name)) { + run.setState(benchmarks[name], TestResultState.Passed); + } + } + } } run.end(); diff --git a/src/testUtils.ts b/src/testUtils.ts index 77e812ded7..270056ef28 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -85,6 +85,10 @@ export interface TestConfig { * Output channel for test output. */ outputChannel?: vscode.OutputChannel; + /** + * Output channel for JSON test output. + */ + goTestOutputConsumer?: (_: GoTestOutput) => void; } export function getTestEnvVars(config: vscode.WorkspaceConfiguration): any { @@ -236,10 +240,12 @@ export async function getBenchmarkFunctions( * which is a subset of https://golang.org/cmd/test2json/#hdr-Output_Format * and includes only the fields that we are using. */ -interface GoTestOutput { +export interface GoTestOutput { Action: string; Output?: string; Package?: string; + Test?: string; + Elapsed?: number; } /** @@ -296,7 +302,12 @@ export async function goTest(testconfig: TestConfig): Promise { const testResultLines: string[] = []; const processTestResultLine = addJSONFlag - ? processTestResultLineInJSONMode(pkgMap, currentGoWorkspace, outputChannel) + ? processTestResultLineInJSONMode( + pkgMap, + currentGoWorkspace, + outputChannel, + testconfig.goTestOutputConsumer + ) : processTestResultLineInStandardMode(pkgMap, currentGoWorkspace, testResultLines, outputChannel); outBuf.onLine((line) => processTestResultLine(line)); @@ -443,7 +454,7 @@ export function computeTestCommand( const outArgs = args.slice(0); // command to show // if user set -v, set -json to emulate streaming test output - const addJSONFlag = userFlags.includes('-v') && !userFlags.includes('-json'); + const addJSONFlag = (userFlags.includes('-v') || testconfig.goTestOutputConsumer) && !userFlags.includes('-json'); if (addJSONFlag) { args.push('-json'); // this is not shown to the user. } @@ -478,11 +489,15 @@ export function computeTestCommand( function processTestResultLineInJSONMode( pkgMap: Map, currentGoWorkspace: string, - outputChannel: vscode.OutputChannel + outputChannel: vscode.OutputChannel, + goTestOutputConsumer?: (_: GoTestOutput) => void ) { return (line: string) => { try { const m = JSON.parse(line); + if (goTestOutputConsumer) { + goTestOutputConsumer(m); + } if (m.Action !== 'output' || !m.Output) { return; } From c3b37e5c63e3cf6fec59ac1ae6ac4c57b1c6b3a0 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 25 Jun 2021 18:55:54 -0500 Subject: [PATCH 08/34] src/goTestExplorer: improve reporting - Log messages with 'file.go:line:' are attached to the correct location - Output without file information is only reported via the test console - Benchmark lifecycle is handled better --- src/goTestExplorer.ts | 153 ++++++++++++++++++++++++++++++++---------- 1 file changed, 117 insertions(+), 36 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 5c6c6dced9..8164412835 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -15,7 +15,8 @@ import { TestResultState, TestRun, TestMessageSeverity, - Location + Location, + Position } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -133,6 +134,7 @@ function createSubItem(ctrl: TestController, item: TestItem, name: string): Test return existing; } + item.canResolveChildren = true; const sub = ctrl.createTestItem(uri.toString(), name, item, item.uri); sub.runnable = false; sub.range = item.range; @@ -546,7 +548,7 @@ function resolveTestName(ctrl: TestController, tests: Record, return; } - const parts = name.split(/\/|#/); + const parts = name.split(/[#\/]+/); let test = tests[parts[0]]; if (!test) { return; @@ -562,50 +564,79 @@ function consumeGoBenchmarkEvent( ctrl: TestController, run: TestRun, benchmarks: Record, - failed: Set, + complete: Set, e: GoTestOutput ) { if (e.Test) { - const test = benchmarks[e.Test]; + const test = resolveTestName(ctrl, benchmarks, e.Test); if (!test) { return; } - if (e.Action === 'fail') { - run.setState(test, TestResultState.Failed); - failed.add(e.Test); + switch (e.Action) { + case 'fail': + run.setState(test, TestResultState.Failed); + complete.add(test); + break; + + case 'skip': + run.setState(test, TestResultState.Skipped); + complete.add(test); + break; } return; } if (!e.Output) { - // console.log(e); return; } - const [name, rest] = e.Output.trim().split(/-|\s/); - const test = resolveTestName(ctrl, benchmarks, name); + // Started: "BenchmarkFooBar" + // Completed: "BenchmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op" + const m = e.Output.match(/^(?Benchmark[\/\w]+)(?:-(?\d+)\s+(?.*))?(?:$|\n)/); + if (!m) { + return; + } + + const test = resolveTestName(ctrl, benchmarks, m.groups.name); if (!test) { return; } - if (!rest) { + if (m.groups.result) { + run.appendMessage(test, { + message: m.groups.result, + severity: TestMessageSeverity.Information, + location: new Location(test.uri, test.range.start) + }); + run.setState(test, TestResultState.Passed); + complete.add(test); + } else { run.setState(test, TestResultState.Running); - return; } +} - run.appendMessage(test, { - message: e.Output, - severity: TestMessageSeverity.Information, - location: new Location(test.uri, test.range) - }); +function passBenchmarks(run: TestRun, items: Record, complete: Set) { + function pass(item: TestItem) { + if (!complete.has(item)) { + run.setState(item, TestResultState.Passed); + } + for (const child of item.children.values()) { + pass(child); + } + } + + for (const name in items) { + pass(items[name]); + } } function consumeGoTestEvent( ctrl: TestController, run: TestRun, tests: Record, + record: Map, e: GoTestOutput ) { const test = resolveTestName(ctrl, tests, e.Test); @@ -616,26 +647,74 @@ function consumeGoTestEvent( switch (e.Action) { case 'run': run.setState(test, TestResultState.Running); - break; + return; + case 'pass': run.setState(test, TestResultState.Passed, e.Elapsed * 1000); - break; + return; + case 'fail': run.setState(test, TestResultState.Failed, e.Elapsed * 1000); - break; + return; + case 'skip': run.setState(test, TestResultState.Skipped); - break; + return; + case 'output': - run.appendMessage(test, { - message: e.Output, - severity: TestMessageSeverity.Information, - location: new Location(test.uri, test.range) - }); - break; + if (/^(=== RUN|\s*--- (FAIL|PASS): )/.test(e.Output)) { + return; + } + + if (record.has(test)) record.get(test).push(e.Output); + else record.set(test, [e.Output]); + return; + default: console.log(e); - break; + return; + } +} + +function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { + // mostly copy and pasted from https://gitlab.com/firelizzard/vscode-go-test-adapter/-/blob/733443d229df68c90145a5ae7ed78ca64dec6f43/src/tests.ts + type message = { all: string; error?: string }; + const parsed = new Map(); + let current: message | undefined; + + for (const item of output) { + const fileAndLine = item.match(/^\s*(?.*\.go):(?\d+): ?(?.*\n)$/); + if (fileAndLine) { + current = { all: fileAndLine.groups.message }; + parsed.set(`${fileAndLine.groups.file}:${fileAndLine.groups.line}`, current); + continue; + } + + if (!current) continue; + + const entry = item.match(/^\s*(?:(?[^:]+): *| +)\t(?.*\n)$/); + if (!entry) continue; + + current.all += entry.groups.message; + if (entry.groups.name == 'Error') { + current.error = entry.groups.message; + } else if (!entry.groups.name && current.error) current.error += entry.groups.message; + } + + const dir = Uri.joinPath(test.uri, '..'); + for (const [location, { all, error }] of parsed.entries()) { + const hover = (error || all).trim(); + const message = hover.split('\n')[0].replace(/:\s+$/, ''); + + const i = location.lastIndexOf(':'); + const file = location.substring(0, i); + const line = Number(location.substring(i + 1)) - 1; + + run.appendMessage(test, { + message, + severity: error ? TestMessageSeverity.Error : TestMessageSeverity.Information, + location: new Location(Uri.joinPath(dir, file), new Position(line, 0)) + }); } } @@ -667,6 +746,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { run.setState(item, TestResultState.Queued); // Remove any subtests + item.canResolveChildren = false; Array.from(item.children.values()).forEach((x) => x.dispose()); const uri = Uri.parse(item.id); @@ -677,6 +757,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { } } + const record = new Map(); const testFns = Object.keys(tests); const benchmarkFns = Object.keys(benchmarks); @@ -688,12 +769,12 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { outputChannel, dir: uri.fsPath, functions: testFns, - goTestOutputConsumer: (e) => consumeGoTestEvent(ctrl, run, tests, e) + goTestOutputConsumer: (e) => consumeGoTestEvent(ctrl, run, tests, record, e) }); } if (benchmarkFns.length) { - const failed = new Set(); + const complete = new Set(); await goTest({ goConfig, flags, @@ -702,14 +783,14 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { dir: uri.fsPath, functions: benchmarkFns, isBenchmark: true, - goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(ctrl, run, benchmarks, failed, e) + goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(ctrl, run, benchmarks, complete, e) }); - for (const name in benchmarks) { - if (!failed.has(name)) { - run.setState(benchmarks[name], TestResultState.Passed); - } - } + passBenchmarks(run, benchmarks, complete); + } + + for (const [test, output] of record.entries()) { + processRecordedOutput(run, test, output); } } From 0c35f087466bf012e775755625ba5d658416e4f7 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 25 Jun 2021 20:57:23 -0500 Subject: [PATCH 09/34] src/goModules,src/goTestExplorer: lint --- src/goModules.ts | 6 ++---- src/goTestExplorer.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/goModules.ts b/src/goModules.ts index 8eeedfa528..ab0dd04fae 100644 --- a/src/goModules.ts +++ b/src/goModules.ts @@ -36,10 +36,8 @@ async function runGoModEnv(folderPath: string): Promise { return resolve(''); } const [goMod] = stdout.split('\n'); - if (goMod == '/dev/null' || goMod == 'NUL') - resolve(''); - else - resolve(goMod); + if (goMod === '/dev/null' || goMod === 'NUL') resolve(''); + else resolve(goMod); }); }); } diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 8164412835..bce5fa656c 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -34,7 +34,7 @@ export function setupTestExplorer(context: ExtensionContext) { ctrl.root.label = 'Go'; ctrl.root.canResolveChildren = true; ctrl.resolveChildrenHandler = (...args) => resolveChildren(ctrl, ...args); - ctrl.runHandler = (request, token) => { + ctrl.runHandler = (request) => { // TODO handle cancelation runTest(ctrl, request); }; @@ -402,8 +402,8 @@ async function walkWorkspaces(uri: Uri) { return found; } -async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { - await walk(uri, async (dir, file, type) => { +async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { + await walk(uri, async (dir, file) => { if (file.endsWith('_test.go')) { await cb(dir); return WalkStop.Files; @@ -538,7 +538,8 @@ class TestRunOutput implements OutputChannel { } clear() {} - show(...args: any[]) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + show(...args: unknown[]) {} hide() {} dispose() {} } @@ -548,7 +549,7 @@ function resolveTestName(ctrl: TestController, tests: Record, return; } - const parts = name.split(/[#\/]+/); + const parts = name.split(/[#/]+/); let test = tests[parts[0]]; if (!test) { return; @@ -594,7 +595,7 @@ function consumeGoBenchmarkEvent( // Started: "BenchmarkFooBar" // Completed: "BenchmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op" - const m = e.Output.match(/^(?Benchmark[\/\w]+)(?:-(?\d+)\s+(?.*))?(?:$|\n)/); + const m = e.Output.match(/^(?Benchmark[/\w]+)(?:-(?\d+)\s+(?.*))?(?:$|\n)/); if (!m) { return; } @@ -696,7 +697,7 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin if (!entry) continue; current.all += entry.groups.message; - if (entry.groups.name == 'Error') { + if (entry.groups.name === 'Error') { current.error = entry.groups.message; } else if (!entry.groups.name && current.error) current.error += entry.groups.message; } From 0c79b0ae2e65020e7729419c3ac2885815590b93 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 8 Jul 2021 17:10:05 -0500 Subject: [PATCH 10/34] src/goTestExplorer: explain benchmark output - Add test_events.md to detail what go test -json looks like - Add comments - Also, address gerrit comments --- src/goTestExplorer.ts | 33 +++++++++++++++++++++++---------- src/test_events.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 src/test_events.md diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index bce5fa656c..b9a2c3d735 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -262,8 +262,8 @@ async function processSymbol( seen: Set, symbol: DocumentSymbol ) { - // Skip TestMain(*testing.M) - if (symbol.name === 'TestMain' || /\*testing.M\)/.test(symbol.detail)) { + // Skip TestMain(*testing.M) - allow TestMain(*testing.T) + if (symbol.name === 'TestMain' && /\*testing.M\)/.test(symbol.detail)) { return; } @@ -325,7 +325,7 @@ async function walk( let dirs = [uri]; // While there are directories to be scanned - while (dirs.length) { + while (dirs.length > 0) { const d = dirs; dirs = []; @@ -561,6 +561,7 @@ function resolveTestName(ctrl: TestController, tests: Record, return test; } +// Process benchmark test events (see test_events.md) function consumeGoBenchmarkEvent( ctrl: TestController, run: TestRun, @@ -569,18 +570,19 @@ function consumeGoBenchmarkEvent( e: GoTestOutput ) { if (e.Test) { + // Find (or create) the (sub)benchmark const test = resolveTestName(ctrl, benchmarks, e.Test); if (!test) { return; } switch (e.Action) { - case 'fail': + case 'fail': // Failed run.setState(test, TestResultState.Failed); complete.add(test); break; - case 'skip': + case 'skip': // Skipped run.setState(test, TestResultState.Skipped); complete.add(test); break; @@ -589,22 +591,29 @@ function consumeGoBenchmarkEvent( return; } + // Ignore anything that's not an output event if (!e.Output) { return; } - // Started: "BenchmarkFooBar" - // Completed: "BenchmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op" + // On start: "BenchmarkFooBar" + // On complete: "BenchmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op" + + // Extract the benchmark name and status const m = e.Output.match(/^(?Benchmark[/\w]+)(?:-(?\d+)\s+(?.*))?(?:$|\n)/); if (!m) { + // If the output doesn't start with `BenchmarkFooBar`, ignore it return; } + // Find (or create) the (sub)benchmark const test = resolveTestName(ctrl, benchmarks, m.groups.name); if (!test) { return; } + // If output includes benchmark results, the benchmark passed. If output + // only includes the benchmark name, the benchmark is running. if (m.groups.result) { run.appendMessage(test, { message: m.groups.result, @@ -618,6 +627,7 @@ function consumeGoBenchmarkEvent( } } +// Pass any incomplete benchmarks (see test_events.md) function passBenchmarks(run: TestRun, items: Record, complete: Set) { function pass(item: TestItem) { if (!complete.has(item)) { @@ -746,7 +756,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { for (const item of items) { run.setState(item, TestResultState.Queued); - // Remove any subtests + // Clear any dynamic subtests generated by a previous run item.canResolveChildren = false; Array.from(item.children.values()).forEach((x) => x.dispose()); @@ -762,7 +772,8 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { const testFns = Object.keys(tests); const benchmarkFns = Object.keys(benchmarks); - if (testFns.length) { + if (testFns.length > 0) { + // Run tests await goTest({ goConfig, flags, @@ -774,7 +785,8 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { }); } - if (benchmarkFns.length) { + if (benchmarkFns.length > 0) { + // Run benchmarks const complete = new Set(); await goTest({ goConfig, @@ -787,6 +799,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(ctrl, run, benchmarks, complete, e) }); + // Explicitly pass any incomplete benchmarks (see test_events.md) passBenchmarks(run, benchmarks, complete); } diff --git a/src/test_events.md b/src/test_events.md new file mode 100644 index 0000000000..b3abe9373f --- /dev/null +++ b/src/test_events.md @@ -0,0 +1,38 @@ +# Go test events + +Running tests with the `-json` flag or passing test output through `go tool +test2json1` will produce a stream of JSON events. Each event specifies an +action, such as `run`, `pass`, `output`, etc. An event *may* specify what test +it belongs to. The VSCode Go test controller must capture these events in order +to notify VSCode of test output and lifecycle events. + +## Tests + +Processing test events generated by `TestXxx(*testing.T)` functions is easy. +Events with an empty `Test` field can be ignored, and all other events have a +meaningful `Action` field. Output is recorded, and run/pass/fail/skip events are +converted to VSCode test API events. + +[go#37555](https://github.com/golang/go/issues/37555) did require special +handling, but that only appeared in Go 1.14 and was backported to 1.14.1. + +## Benchmarks + +Test events generated by `BenchmarkXxx(*testing.B)` functions require +significantly more processing. If a benchmark fails or is skipped, the `Test` +and `Action` fields are populated appropriately. Otherwise, `Test` is empty and +`Action` is always `output`. Thus, nominal lifecycle events (run/pass) must be +deduced purely from test output. When a benchmark begins, an output such as +`BenchmarkFooBar\n` is produced. When a benchmark completes, an output such as +`BencmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op` is produced. No +explicit `run` or `pass` events are generated. Thus: + +- When `BenchmarkFooBar\n` is seen, the benchmark will be marked as running +- When an explicit fail/skip is seen, the benchmark will be marked as failed/skipped +- When benchmark results are seen, the benchmark will be marked as passed + +Thus, a benchmark that does not produce results (and does not fail or skip) will +never produce an event indicating that it has completed. Benchmarks that call +`(*testing.B).Run` will not produce results. In practice, this means that any +incomplete benchmarks must be explicitly marked as passed once `go test` +returns. \ No newline at end of file From 075fea034c2ac43c2bb8542e840843987abc5e04 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 8 Jul 2021 20:15:59 -0500 Subject: [PATCH 11/34] src/goTestExplorer: improve readability Change function names and add comments. --- src/goTestExplorer.ts | 119 ++++++++++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 32 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index b9a2c3d735..9c68d8b965 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -73,7 +73,7 @@ export function setupTestExplorer(context: ExtensionContext) { const found = find(ctrl.root); if (found) { found.dispose(); - removeIfEmpty(found.parent); + disposeIfEmpty(found.parent); } }); @@ -99,17 +99,26 @@ export function setupTestExplorer(context: ExtensionContext) { ); } +// Construct an ID for an item. +// - Module: file:///path/to/mod?module +// - Package: file:///path/to/mod/pkg?package +// - File: file:///path/to/mod/file.go?file +// - Test: file:///path/to/mod/file.go?test#TestXxx +// - Benchmark: file:///path/to/mod/file.go?benchmark#BenchmarkXxx +// - Example: file:///path/to/mod/file.go?example#ExampleXxx function testID(uri: Uri, kind: string, name?: string): string { uri = uri.with({ query: kind }); if (name) uri = uri.with({ fragment: name }); return uri.toString(); } +// Retrieve a child item. function getItem(parent: TestItem, uri: Uri, kind: string, name?: string): TestItem | undefined { return parent.children.get(testID(uri, kind, name)); } -function createItem( +// Create or Retrieve a child item. +function getOrCreateItem( ctrl: TestController, parent: TestItem, label: string, @@ -126,7 +135,9 @@ function createItem( return ctrl.createTestItem(id, label, parent, uri.with({ query: '', fragment: '' })); } -function createSubItem(ctrl: TestController, item: TestItem, name: string): TestItem { +// Create or Retrieve a sub test or benchmark. The ID will be of the form: +// file:///path/to/mod/file.go?test#TestXxx/A/B/C +function getOrCreateSubTest(ctrl: TestController, item: TestItem, name: string): TestItem { let uri = Uri.parse(item.id); uri = uri.with({ fragment: `${uri.fragment}/${name}` }); const existing = item.children.get(uri.toString()); @@ -141,7 +152,9 @@ function createSubItem(ctrl: TestController, item: TestItem, name: string): Test return sub; } -function removeIfEmpty(item: TestItem) { +// Dispose of the item if it has no children, recursively. This facilitates +// cleaning up package/file trees that contain no tests. +function disposeIfEmpty(item: TestItem) { // Don't dispose of the root if (!item.parent) { return; @@ -158,9 +171,10 @@ function removeIfEmpty(item: TestItem) { } item.dispose(); - removeIfEmpty(item.parent); + disposeIfEmpty(item.parent); } +// Retrieve or create an item for a Go module. async function getModule(ctrl: TestController, uri: Uri): Promise { const existing = getItem(ctrl.root, uri, 'module'); if (existing) { @@ -172,12 +186,13 @@ async function getModule(ctrl: TestController, uri: Uri): Promise { const contents = await workspace.fs.readFile(goMod); const modLine = contents.toString().split('\n', 2)[0]; const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); - const item = createItem(ctrl, ctrl.root, match.groups.name, uri, 'module'); + const item = getOrCreateItem(ctrl, ctrl.root, match.groups.name, uri, 'module'); item.canResolveChildren = true; item.runnable = true; return item; } +// Retrieve or create an item for a workspace folder that is not a module. async function getWorkspace(ctrl: TestController, ws: WorkspaceFolder): Promise { const existing = getItem(ctrl.root, ws.uri, 'workspace'); if (existing) { @@ -185,12 +200,13 @@ async function getWorkspace(ctrl: TestController, ws: WorkspaceFolder): Promise< } // Use the workspace folder name as the label - const item = createItem(ctrl, ctrl.root, ws.name, ws.uri, 'workspace'); + const item = getOrCreateItem(ctrl, ctrl.root, ws.name, ws.uri, 'workspace'); item.canResolveChildren = true; item.runnable = true; return item; } +// Retrieve or create an item for a Go package. async function getPackage(ctrl: TestController, uri: Uri): Promise { let item: TestItem; @@ -210,7 +226,7 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { } const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; - item = createItem(ctrl, module, label, uri, 'package'); + item = getOrCreateItem(ctrl, module, label, uri, 'package'); } else if (wsfolder) { // If the package is in a workspace folder, add it as a child of the workspace const workspace = await getWorkspace(ctrl, wsfolder); @@ -222,7 +238,7 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { const label = uri.path.startsWith(wsfolder.uri.path) ? uri.path.substring(wsfolder.uri.path.length + 1) : uri.path; - item = createItem(ctrl, workspace, label, uri, 'package'); + item = getOrCreateItem(ctrl, workspace, label, uri, 'package'); } else { // Otherwise, add it directly to the root const existing = getItem(ctrl.root, uri, 'package'); @@ -232,7 +248,7 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { const srcPath = path.join(getCurrentGoPath(uri), 'src'); const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path; - item = createItem(ctrl, ctrl.root, label, uri, 'package'); + item = getOrCreateItem(ctrl, ctrl.root, label, uri, 'package'); } item.canResolveChildren = true; @@ -240,6 +256,7 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { return item; } +// Retrieve or create an item for a Go file. async function getFile(ctrl: TestController, uri: Uri): Promise { const dir = path.dirname(uri.path); const pkg = await getPackage(ctrl, uri.with({ path: dir })); @@ -249,12 +266,16 @@ async function getFile(ctrl: TestController, uri: Uri): Promise { } const label = path.basename(uri.path); - const item = createItem(ctrl, pkg, label, uri, 'file'); + const item = getOrCreateItem(ctrl, pkg, label, uri, 'file'); item.canResolveChildren = true; item.runnable = true; return item; } +// Recursively process a Go AST symbol. If the symbol represents a test, +// benchmark, or example function, a test item will be created for it, if one +// does not already exist. If the symbol is not a function and contains +// children, those children will be processed recursively. async function processSymbol( ctrl: TestController, uri: Uri, @@ -286,14 +307,20 @@ async function processSymbol( return existing; } - const item = createItem(ctrl, file, symbol.name, uri, kind, symbol.name); + const item = getOrCreateItem(ctrl, file, symbol.name, uri, kind, symbol.name); item.range = symbol.range; item.runnable = true; // item.debuggable = true; symbols.set(item, symbol); } -async function loadFileTests(ctrl: TestController, doc: TextDocument) { +// Processes a Go document, calling processSymbol for each symbol in the +// document. +// +// Any previously existing tests that no longer have a corresponding symbol in +// the file will be disposed. If the document contains no tests, it will be +// disposed. +async function processDocument(ctrl: TestController, doc: TextDocument) { const seen = new Set(); const item = await getFile(ctrl, doc.uri); const symbols = await new GoDocumentSymbolProvider().provideDocumentSymbols(doc, null); @@ -306,18 +333,19 @@ async function loadFileTests(ctrl: TestController, doc: TextDocument) { } } - removeIfEmpty(item); + disposeIfEmpty(item); } +// Reasons to stop walking enum WalkStop { - None = 0, - Abort, - Current, - Files, - Directories + None = 0, // Don't stop + Abort, // Abort the walk + Current, // Stop walking the current directory + Files, // Skip remaining files + Directories // Skip remaining directories } -// Recursively walk a directory, breadth first +// Recursively walk a directory, breadth first. async function walk( uri: Uri, cb: (dir: Uri, file: string, type: FileType) => Promise @@ -383,7 +411,10 @@ async function walk( } } -async function walkWorkspaces(uri: Uri) { +// Walk the workspace, looking for Go modules. Returns a map indicating paths +// that are modules (value == true) and paths that are not modules but contain +// Go files (value == false). +async function walkWorkspaces(uri: Uri): Promise> { const found = new Map(); await walk(uri, async (dir, file, type) => { if (type !== FileType.File) { @@ -402,6 +433,8 @@ async function walkWorkspaces(uri: Uri) { return found; } +// Walk the workspace, calling the callback for any directory that contains a Go +// test file. async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { await walk(uri, async (dir, file) => { if (file.endsWith('_test.go')) { @@ -411,6 +444,7 @@ async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { }); } +// Handle opened documents, document changes, and file creation. async function documentUpdate(ctrl: TestController, doc: TextDocument) { if (!doc.uri.path.endsWith('_test.go')) { return; @@ -421,10 +455,12 @@ async function documentUpdate(ctrl: TestController, doc: TextDocument) { return; } - await loadFileTests(ctrl, doc); + await processDocument(ctrl, doc); } +// TestController.resolveChildrenHandler callback async function resolveChildren(ctrl: TestController, item: TestItem) { + // The user expanded the root item - find all modules and workspaces if (!item.parent) { // Dispose of package entries at the root if they are now part of a workspace folder const items = Array.from(ctrl.root.children.values()); @@ -461,15 +497,16 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { } const uri = Uri.parse(item.id); + + // The user expanded a module or workspace - find all packages if (uri.query === 'module' || uri.query === 'workspace') { - // Create entries for all packages in the module or workspace await walkPackages(uri, async (uri) => { await getPackage(ctrl, uri); }); } + // The user expanded a module or package - find all files if (uri.query === 'module' || uri.query === 'package') { - // Create entries for all test files in the package for (const [file, type] of await workspace.fs.readDirectory(uri)) { if (type !== FileType.File || !file.endsWith('_test.go')) { continue; @@ -479,13 +516,19 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { } } + // The user expanded a file - find all functions if (uri.query === 'file') { - // Create entries for all test functions in a file const doc = await workspace.openTextDocument(uri.with({ query: '', fragment: '' })); - await loadFileTests(ctrl, doc); + await processDocument(ctrl, doc); } + + // TODO(firelizzard18): If uri.query is test or benchmark, this is where we + // would discover sub tests or benchmarks, if that is feasible. } +// Recursively find all tests, benchmarks, and examples within a +// module/package/etc, minus exclusions. Map tests to the package they are +// defined in, and track files. async function collectTests( ctrl: TestController, item: TestItem, @@ -523,6 +566,8 @@ async function collectTests( return; } +// TestRunOutput is a fake OutputChannel that forwards all test output to the test API +// console. class TestRunOutput implements OutputChannel { readonly name: string; constructor(private run: TestRun) { @@ -544,6 +589,9 @@ class TestRunOutput implements OutputChannel { dispose() {} } +// Resolve a test name to a test item. If the test name is TestXxx/Foo, Foo is +// created as a child of TestXxx. The same is true for TestXxx#Foo and +// TestXxx/#Foo. function resolveTestName(ctrl: TestController, tests: Record, name: string): TestItem | undefined { if (!name) { return; @@ -556,12 +604,12 @@ function resolveTestName(ctrl: TestController, tests: Record, } for (const part of parts.slice(1)) { - test = createSubItem(ctrl, test, part); + test = getOrCreateSubTest(ctrl, test, part); } return test; } -// Process benchmark test events (see test_events.md) +// Process benchmark events (see test_events.md) function consumeGoBenchmarkEvent( ctrl: TestController, run: TestRun, @@ -643,6 +691,7 @@ function passBenchmarks(run: TestRun, items: Record, com } } +// Process test events (see test_events.md) function consumeGoTestEvent( ctrl: TestController, run: TestRun, @@ -687,6 +736,8 @@ function consumeGoTestEvent( } } +// Search recorded test output for `file.go:123: Foo bar` and attach a message +// to the corresponding location. function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { // mostly copy and pasted from https://gitlab.com/firelizzard/vscode-go-test-adapter/-/blob/733443d229df68c90145a5ae7ed78ca64dec6f43/src/tests.ts type message = { all: string; error?: string }; @@ -729,6 +780,7 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin } } +// Execute tests - TestController.runTest callback async function runTest(ctrl: TestController, request: TestRunRequest) { const collected = new Map(); const docs = new Set(); @@ -736,7 +788,8 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { await collectTests(ctrl, item, request.exclude, collected, docs); } - // Ensure `go test` has the latest changes + // Save all documents that contain a test we're about to run, to ensure `go + // test` has the latest changes await Promise.all( Array.from(docs).map((uri) => { workspace.openTextDocument(uri).then((doc) => doc.save()); @@ -751,12 +804,13 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { const isMod = await isModSupported(uri, true); const flags = getTestFlags(goConfig); + // Separate tests and benchmarks and mark them as queued for execution. + // Clear any sub tests/benchmarks generated by a previous run. const tests: Record = {}; const benchmarks: Record = {}; for (const item of items) { run.setState(item, TestResultState.Queued); - // Clear any dynamic subtests generated by a previous run item.canResolveChildren = false; Array.from(item.children.values()).forEach((x) => x.dispose()); @@ -772,8 +826,8 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { const testFns = Object.keys(tests); const benchmarkFns = Object.keys(benchmarks); + // Run tests if (testFns.length > 0) { - // Run tests await goTest({ goConfig, flags, @@ -785,8 +839,8 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { }); } + // Run benchmarks if (benchmarkFns.length > 0) { - // Run benchmarks const complete = new Set(); await goTest({ goConfig, @@ -803,6 +857,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { passBenchmarks(run, benchmarks, complete); } + // Create test messages for (const [test, output] of record.entries()) { processRecordedOutput(run, test, output); } From a846bc959a5fcab3dfc4d32bd842c8dbf9d92d31 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 9 Jul 2021 02:32:39 -0500 Subject: [PATCH 12/34] src/goTestExplorer: add tests --- src/goMain.ts | 4 +- src/goTestExplorer.ts | 273 ++++++++++++++---------- test/integration/goTestExplorer.test.ts | 230 ++++++++++++++++++++ test/integration/index.ts | 1 + test/mocks/MockTest.ts | 217 +++++++++++++++++++ test/runTest.ts | 3 + 6 files changed, 614 insertions(+), 114 deletions(-) create mode 100644 test/integration/goTestExplorer.test.ts create mode 100644 test/mocks/MockTest.ts diff --git a/src/goMain.ts b/src/goMain.ts index e96578d954..55b1e7233a 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -114,7 +114,7 @@ import { getFormatTool } from './goFormat'; import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey'; import { ExtensionAPI } from './export'; import extensionAPI from './extensionAPI'; -import { setupTestExplorer } from './goTestExplorer'; +import { TestExplorer } from './goTestExplorer'; export let buildDiagnosticCollection: vscode.DiagnosticCollection; export let lintDiagnosticCollection: vscode.DiagnosticCollection; @@ -228,7 +228,7 @@ If you would like additional configuration for diagnostics from gopls, please se ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, referencesCodeLensProvider)); // testing - setupTestExplorer(ctx); + TestExplorer.setup(ctx); // debug ctx.subscriptions.push( diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 9c68d8b965..fcc39bc144 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -16,7 +16,10 @@ import { TestRun, TestMessageSeverity, Location, - Position + Position, + TextDocumentChangeEvent, + WorkspaceFoldersChangeEvent, + CancellationToken } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -28,30 +31,83 @@ import { getTestFlags, goTest, GoTestOutput } from './testUtils'; // We could use TestItem.data, but that may be removed const symbols = new WeakMap(); -export function setupTestExplorer(context: ExtensionContext) { - const ctrl = test.createTestController('go'); - context.subscriptions.push(ctrl); - ctrl.root.label = 'Go'; - ctrl.root.canResolveChildren = true; - ctrl.resolveChildrenHandler = (...args) => resolveChildren(ctrl, ...args); - ctrl.runHandler = (request) => { - // TODO handle cancelation - runTest(ctrl, request); - }; - - context.subscriptions.push( - workspace.onDidOpenTextDocument((e) => documentUpdate(ctrl, e).catch((err) => console.log(err))) - ); +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TestExplorer { + // exported for tests - context.subscriptions.push( - workspace.onDidChangeTextDocument((e) => documentUpdate(ctrl, e.document).catch((err) => console.log(err))) - ); + export interface FileSystem { + readFile(uri: Uri): Thenable; + readDirectory(uri: Uri): Thenable<[string, FileType][]>; + } + + export interface Workspace { + readonly fs: FileSystem; + readonly workspaceFolders: readonly WorkspaceFolder[] | undefined; + + openTextDocument(uri: Uri): Thenable; + getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined; + } +} + +export class TestExplorer { + static setup(context: ExtensionContext) { + const ctrl = test.createTestController('go'); + const inst = new this( + ctrl, + workspace, + (e) => console.log(e), + new GoDocumentSymbolProvider().provideDocumentSymbols + ); + + context.subscriptions.push(workspace.onDidOpenTextDocument((x) => inst.didOpenTextDocument(x))); + context.subscriptions.push(workspace.onDidChangeTextDocument((x) => inst.didChangeTextDocument(x))); + context.subscriptions.push(workspace.onDidChangeWorkspaceFolders((x) => inst.didChangeWorkspaceFolders(x))); + + const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false); + context.subscriptions.push(watcher); + context.subscriptions.push(watcher.onDidCreate((x) => inst.didCreateFile(x))); + context.subscriptions.push(watcher.onDidDelete((x) => inst.didDeleteFile(x))); + } + + constructor( + public ctrl: TestController, + public ws: TestExplorer.Workspace, + public errored: (e: unknown) => void, + public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable + ) { + // TODO handle cancelation of test runs + ctrl.root.label = 'Go'; + ctrl.root.canResolveChildren = true; + ctrl.resolveChildrenHandler = (...args) => resolveChildren(this, ...args); + ctrl.runHandler = (request) => runTest(this, request); + } + + async didOpenTextDocument(doc: TextDocument) { + try { + await documentUpdate(this, doc); + } catch (e) { + this.errored(e); + } + } - const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false); - context.subscriptions.push(watcher); - watcher.onDidCreate(async (e) => await documentUpdate(ctrl, await workspace.openTextDocument(e))); - watcher.onDidDelete(async (e) => { - const id = testID(e, 'file'); + async didChangeTextDocument(e: TextDocumentChangeEvent) { + try { + await documentUpdate(this, e.document); + } catch (e) { + this.errored(e); + } + } + + async didCreateFile(file: Uri) { + try { + await documentUpdate(this, await this.ws.openTextDocument(file)); + } catch (e) { + this.errored(e); + } + } + + async didDeleteFile(file: Uri) { + const id = testID(file, 'file'); function find(parent: TestItem): TestItem { for (const item of parent.children.values()) { if (item.id === id) { @@ -59,7 +115,7 @@ export function setupTestExplorer(context: ExtensionContext) { } const uri = Uri.parse(item.id); - if (!e.path.startsWith(uri.path)) { + if (!file.path.startsWith(uri.path)) { continue; } @@ -70,33 +126,31 @@ export function setupTestExplorer(context: ExtensionContext) { } } - const found = find(ctrl.root); + const found = find(this.ctrl.root); if (found) { found.dispose(); disposeIfEmpty(found.parent); } - }); - - context.subscriptions.push( - workspace.onDidChangeWorkspaceFolders(async (e) => { - const items = Array.from(ctrl.root.children.values()); - for (const item of items) { - const uri = Uri.parse(item.id); - if (uri.query === 'package') { - continue; - } + } - const ws = workspace.getWorkspaceFolder(uri); - if (!ws) { - item.dispose(); - } + async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { + const items = Array.from(this.ctrl.root.children.values()); + for (const item of items) { + const uri = Uri.parse(item.id); + if (uri.query === 'package') { + continue; } - if (e.added) { - await resolveChildren(ctrl, ctrl.root); + const ws = this.ws.getWorkspaceFolder(uri); + if (!ws) { + item.dispose(); } - }) - ); + } + + if (e.added) { + await resolveChildren(this, this.ctrl.root); + } + } } // Construct an ID for an item. @@ -119,7 +173,7 @@ function getItem(parent: TestItem, uri: Uri, kind: string, name?: string): TestI // Create or Retrieve a child item. function getOrCreateItem( - ctrl: TestController, + { ctrl }: TestExplorer, parent: TestItem, label: string, uri: Uri, @@ -137,7 +191,7 @@ function getOrCreateItem( // Create or Retrieve a sub test or benchmark. The ID will be of the form: // file:///path/to/mod/file.go?test#TestXxx/A/B/C -function getOrCreateSubTest(ctrl: TestController, item: TestItem, name: string): TestItem { +function getOrCreateSubTest({ ctrl }: TestExplorer, item: TestItem, name: string): TestItem { let uri = Uri.parse(item.id); uri = uri.with({ fragment: `${uri.fragment}/${name}` }); const existing = item.children.get(uri.toString()); @@ -175,47 +229,47 @@ function disposeIfEmpty(item: TestItem) { } // Retrieve or create an item for a Go module. -async function getModule(ctrl: TestController, uri: Uri): Promise { - const existing = getItem(ctrl.root, uri, 'module'); +async function getModule(expl: TestExplorer, uri: Uri): Promise { + const existing = getItem(expl.ctrl.root, uri, 'module'); if (existing) { return existing; } // Use the module name as the label const goMod = Uri.joinPath(uri, 'go.mod'); - const contents = await workspace.fs.readFile(goMod); + const contents = await expl.ws.fs.readFile(goMod); const modLine = contents.toString().split('\n', 2)[0]; const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); - const item = getOrCreateItem(ctrl, ctrl.root, match.groups.name, uri, 'module'); + const item = getOrCreateItem(expl, expl.ctrl.root, match.groups.name, uri, 'module'); item.canResolveChildren = true; item.runnable = true; return item; } // Retrieve or create an item for a workspace folder that is not a module. -async function getWorkspace(ctrl: TestController, ws: WorkspaceFolder): Promise { - const existing = getItem(ctrl.root, ws.uri, 'workspace'); +async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise { + const existing = getItem(expl.ctrl.root, ws.uri, 'workspace'); if (existing) { return existing; } // Use the workspace folder name as the label - const item = getOrCreateItem(ctrl, ctrl.root, ws.name, ws.uri, 'workspace'); + const item = getOrCreateItem(expl, expl.ctrl.root, ws.name, ws.uri, 'workspace'); item.canResolveChildren = true; item.runnable = true; return item; } // Retrieve or create an item for a Go package. -async function getPackage(ctrl: TestController, uri: Uri): Promise { +async function getPackage(expl: TestExplorer, uri: Uri): Promise { let item: TestItem; const modDir = await getModFolderPath(uri, true); const wsfolder = workspace.getWorkspaceFolder(uri); if (modDir) { // If the package is in a module, add it as a child of the module - const modUri = uri.with({ path: modDir }); - const module = await getModule(ctrl, modUri); + const modUri = uri.with({ path: modDir, query: '', fragment: '' }); + const module = await getModule(expl, modUri); const existing = getItem(module, uri, 'package'); if (existing) { return existing; @@ -226,10 +280,10 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { } const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; - item = getOrCreateItem(ctrl, module, label, uri, 'package'); + item = getOrCreateItem(expl, module, label, uri, 'package'); } else if (wsfolder) { // If the package is in a workspace folder, add it as a child of the workspace - const workspace = await getWorkspace(ctrl, wsfolder); + const workspace = await getWorkspace(expl, wsfolder); const existing = getItem(workspace, uri, 'package'); if (existing) { return existing; @@ -238,17 +292,17 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { const label = uri.path.startsWith(wsfolder.uri.path) ? uri.path.substring(wsfolder.uri.path.length + 1) : uri.path; - item = getOrCreateItem(ctrl, workspace, label, uri, 'package'); + item = getOrCreateItem(expl, workspace, label, uri, 'package'); } else { // Otherwise, add it directly to the root - const existing = getItem(ctrl.root, uri, 'package'); + const existing = getItem(expl.ctrl.root, uri, 'package'); if (existing) { return existing; } const srcPath = path.join(getCurrentGoPath(uri), 'src'); const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path; - item = getOrCreateItem(ctrl, ctrl.root, label, uri, 'package'); + item = getOrCreateItem(expl, expl.ctrl.root, label, uri, 'package'); } item.canResolveChildren = true; @@ -257,16 +311,16 @@ async function getPackage(ctrl: TestController, uri: Uri): Promise { } // Retrieve or create an item for a Go file. -async function getFile(ctrl: TestController, uri: Uri): Promise { +async function getFile(expl: TestExplorer, uri: Uri): Promise { const dir = path.dirname(uri.path); - const pkg = await getPackage(ctrl, uri.with({ path: dir })); + const pkg = await getPackage(expl, uri.with({ path: dir, query: '', fragment: '' })); const existing = getItem(pkg, uri, 'file'); if (existing) { return existing; } const label = path.basename(uri.path); - const item = getOrCreateItem(ctrl, pkg, label, uri, 'file'); + const item = getOrCreateItem(expl, pkg, label, uri, 'file'); item.canResolveChildren = true; item.runnable = true; return item; @@ -276,13 +330,7 @@ async function getFile(ctrl: TestController, uri: Uri): Promise { // benchmark, or example function, a test item will be created for it, if one // does not already exist. If the symbol is not a function and contains // children, those children will be processed recursively. -async function processSymbol( - ctrl: TestController, - uri: Uri, - file: TestItem, - seen: Set, - symbol: DocumentSymbol -) { +async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: Set, symbol: DocumentSymbol) { // Skip TestMain(*testing.M) - allow TestMain(*testing.T) if (symbol.name === 'TestMain' && /\*testing.M\)/.test(symbol.detail)) { return; @@ -290,7 +338,7 @@ async function processSymbol( // Recursively process symbols that are nested if (symbol.kind !== SymbolKind.Function) { - for (const sym of symbol.children) await processSymbol(ctrl, uri, file, seen, sym); + for (const sym of symbol.children) await processSymbol(expl, uri, file, seen, sym); return; } @@ -307,7 +355,7 @@ async function processSymbol( return existing; } - const item = getOrCreateItem(ctrl, file, symbol.name, uri, kind, symbol.name); + const item = getOrCreateItem(expl, file, symbol.name, uri, kind, symbol.name); item.range = symbol.range; item.runnable = true; // item.debuggable = true; @@ -320,11 +368,11 @@ async function processSymbol( // Any previously existing tests that no longer have a corresponding symbol in // the file will be disposed. If the document contains no tests, it will be // disposed. -async function processDocument(ctrl: TestController, doc: TextDocument) { +async function processDocument(expl: TestExplorer, doc: TextDocument) { const seen = new Set(); - const item = await getFile(ctrl, doc.uri); - const symbols = await new GoDocumentSymbolProvider().provideDocumentSymbols(doc, null); - for (const symbol of symbols) await processSymbol(ctrl, doc.uri, item, seen, symbol); + const item = await getFile(expl, doc.uri); + const symbols = await expl.provideDocumentSymbols(doc, null); + for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol); for (const child of item.children.values()) { const uri = Uri.parse(child.id); @@ -347,6 +395,7 @@ enum WalkStop { // Recursively walk a directory, breadth first. async function walk( + fs: TestExplorer.FileSystem, uri: Uri, cb: (dir: Uri, file: string, type: FileType) => Promise ): Promise { @@ -363,7 +412,7 @@ async function walk( skipDirs = false; // Scan the directory - inner: for (const [file, type] of await workspace.fs.readDirectory(uri)) { + inner: for (const [file, type] of await fs.readDirectory(uri)) { if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) { continue; } @@ -414,9 +463,9 @@ async function walk( // Walk the workspace, looking for Go modules. Returns a map indicating paths // that are modules (value == true) and paths that are not modules but contain // Go files (value == false). -async function walkWorkspaces(uri: Uri): Promise> { +async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise> { const found = new Map(); - await walk(uri, async (dir, file, type) => { + await walk(fs, uri, async (dir, file, type) => { if (type !== FileType.File) { return; } @@ -435,8 +484,8 @@ async function walkWorkspaces(uri: Uri): Promise> { // Walk the workspace, calling the callback for any directory that contains a Go // test file. -async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { - await walk(uri, async (dir, file) => { +async function walkPackages(fs: TestExplorer.FileSystem, uri: Uri, cb: (uri: Uri) => Promise) { + await walk(fs, uri, async (dir, file) => { if (file.endsWith('_test.go')) { await cb(dir); return WalkStop.Files; @@ -445,7 +494,7 @@ async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise) { } // Handle opened documents, document changes, and file creation. -async function documentUpdate(ctrl: TestController, doc: TextDocument) { +async function documentUpdate(expl: TestExplorer, doc: TextDocument) { if (!doc.uri.path.endsWith('_test.go')) { return; } @@ -455,29 +504,29 @@ async function documentUpdate(ctrl: TestController, doc: TextDocument) { return; } - await processDocument(ctrl, doc); + await processDocument(expl, doc); } // TestController.resolveChildrenHandler callback -async function resolveChildren(ctrl: TestController, item: TestItem) { +async function resolveChildren(expl: TestExplorer, item: TestItem) { // The user expanded the root item - find all modules and workspaces if (!item.parent) { // Dispose of package entries at the root if they are now part of a workspace folder - const items = Array.from(ctrl.root.children.values()); + const items = Array.from(expl.ctrl.root.children.values()); for (const item of items) { const uri = Uri.parse(item.id); if (uri.query !== 'package') { continue; } - if (workspace.getWorkspaceFolder(uri)) { + if (expl.ws.getWorkspaceFolder(uri)) { item.dispose(); } } // Create entries for all modules and workspaces - for (const folder of workspace.workspaceFolders || []) { - const found = await walkWorkspaces(folder.uri); + for (const folder of expl.ws.workspaceFolders || []) { + const found = await walkWorkspaces(expl.ws.fs, folder.uri); let needWorkspace = false; for (const [uri, isMod] of found.entries()) { if (!isMod) { @@ -485,12 +534,12 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { continue; } - await getModule(ctrl, Uri.parse(uri)); + await getModule(expl, Uri.parse(uri)); } // If the workspace folder contains any Go files not in a module, create a workspace entry if (needWorkspace) { - await getWorkspace(ctrl, folder); + await getWorkspace(expl, folder); } } return; @@ -500,26 +549,26 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { // The user expanded a module or workspace - find all packages if (uri.query === 'module' || uri.query === 'workspace') { - await walkPackages(uri, async (uri) => { - await getPackage(ctrl, uri); + await walkPackages(expl.ws.fs, uri, async (uri) => { + await getPackage(expl, uri); }); } // The user expanded a module or package - find all files if (uri.query === 'module' || uri.query === 'package') { - for (const [file, type] of await workspace.fs.readDirectory(uri)) { + for (const [file, type] of await expl.ws.fs.readDirectory(uri)) { if (type !== FileType.File || !file.endsWith('_test.go')) { continue; } - await getFile(ctrl, Uri.joinPath(uri, file)); + await getFile(expl, Uri.joinPath(uri, file)); } } // The user expanded a file - find all functions if (uri.query === 'file') { - const doc = await workspace.openTextDocument(uri.with({ query: '', fragment: '' })); - await processDocument(ctrl, doc); + const doc = await expl.ws.openTextDocument(uri.with({ query: '', fragment: '' })); + await processDocument(expl, doc); } // TODO(firelizzard18): If uri.query is test or benchmark, this is where we @@ -530,7 +579,7 @@ async function resolveChildren(ctrl: TestController, item: TestItem) { // module/package/etc, minus exclusions. Map tests to the package they are // defined in, and track files. async function collectTests( - ctrl: TestController, + expl: TestExplorer, item: TestItem, excluded: TestItem[], functions: Map, @@ -545,11 +594,11 @@ async function collectTests( const uri = Uri.parse(item.id); if (!uri.fragment) { if (!item.children.size) { - await resolveChildren(ctrl, item); + await resolveChildren(expl, item); } for (const child of item.children.values()) { - await collectTests(ctrl, child, excluded, functions, docs); + await collectTests(expl, child, excluded, functions, docs); } return; } @@ -592,7 +641,7 @@ class TestRunOutput implements OutputChannel { // Resolve a test name to a test item. If the test name is TestXxx/Foo, Foo is // created as a child of TestXxx. The same is true for TestXxx#Foo and // TestXxx/#Foo. -function resolveTestName(ctrl: TestController, tests: Record, name: string): TestItem | undefined { +function resolveTestName(expl: TestExplorer, tests: Record, name: string): TestItem | undefined { if (!name) { return; } @@ -604,14 +653,14 @@ function resolveTestName(ctrl: TestController, tests: Record, } for (const part of parts.slice(1)) { - test = getOrCreateSubTest(ctrl, test, part); + test = getOrCreateSubTest(expl, test, part); } return test; } // Process benchmark events (see test_events.md) function consumeGoBenchmarkEvent( - ctrl: TestController, + expl: TestExplorer, run: TestRun, benchmarks: Record, complete: Set, @@ -619,7 +668,7 @@ function consumeGoBenchmarkEvent( ) { if (e.Test) { // Find (or create) the (sub)benchmark - const test = resolveTestName(ctrl, benchmarks, e.Test); + const test = resolveTestName(expl, benchmarks, e.Test); if (!test) { return; } @@ -655,7 +704,7 @@ function consumeGoBenchmarkEvent( } // Find (or create) the (sub)benchmark - const test = resolveTestName(ctrl, benchmarks, m.groups.name); + const test = resolveTestName(expl, benchmarks, m.groups.name); if (!test) { return; } @@ -693,13 +742,13 @@ function passBenchmarks(run: TestRun, items: Record, com // Process test events (see test_events.md) function consumeGoTestEvent( - ctrl: TestController, + expl: TestExplorer, run: TestRun, tests: Record, record: Map, e: GoTestOutput ) { - const test = resolveTestName(ctrl, tests, e.Test); + const test = resolveTestName(expl, tests, e.Test); if (!test) { return; } @@ -781,22 +830,22 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin } // Execute tests - TestController.runTest callback -async function runTest(ctrl: TestController, request: TestRunRequest) { +async function runTest(expl: TestExplorer, request: TestRunRequest) { const collected = new Map(); const docs = new Set(); for (const item of request.tests) { - await collectTests(ctrl, item, request.exclude, collected, docs); + await collectTests(expl, item, request.exclude, collected, docs); } // Save all documents that contain a test we're about to run, to ensure `go // test` has the latest changes await Promise.all( Array.from(docs).map((uri) => { - workspace.openTextDocument(uri).then((doc) => doc.save()); + expl.ws.openTextDocument(uri).then((doc) => doc.save()); }) ); - const run = ctrl.createTestRun(request); + const run = expl.ctrl.createTestRun(request); const outputChannel = new TestRunOutput(run); const goConfig = getGoConfig(); for (const [dir, items] of collected.entries()) { @@ -835,7 +884,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { outputChannel, dir: uri.fsPath, functions: testFns, - goTestOutputConsumer: (e) => consumeGoTestEvent(ctrl, run, tests, record, e) + goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, e) }); } @@ -850,7 +899,7 @@ async function runTest(ctrl: TestController, request: TestRunRequest) { dir: uri.fsPath, functions: benchmarkFns, isBenchmark: true, - goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(ctrl, run, benchmarks, complete, e) + goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e) }); // Explicitly pass any incomplete benchmarks (see test_events.md) diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts new file mode 100644 index 0000000000..2df7e7d578 --- /dev/null +++ b/test/integration/goTestExplorer.test.ts @@ -0,0 +1,230 @@ +import assert = require('assert'); +import path = require('path'); +import { DocumentSymbol, FileType, TestItem, Uri, TextDocument, SymbolKind, Range, Position } from 'vscode'; +import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules'; +import { TestExplorer } from '../../src/goTestExplorer'; +import { MockTestController, MockTestWorkspace } from '../mocks/MockTest'; + +type Files = Record; + +interface ResolveChildrenTestCase { + workspace: string[]; + files: Files; + item?: [string, string][]; + expect: string[]; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function symbols(doc: TextDocument, token: unknown): Thenable { + const syms: DocumentSymbol[] = []; + const range = new Range(new Position(0, 0), new Position(0, 0)); + doc.getText().replace(/^func (Test|Benchmark|Example)([A-Z]\w+)(\(.*\))/gm, (m, type, name, details) => { + syms.push(new DocumentSymbol(type + name, details, SymbolKind.Function, range, range)); + return m; + }); + return Promise.resolve(syms); +} + +function rethrow(e: unknown) { + throw e; +} + +function setup(folders: string[], files: Files) { + const ws = MockTestWorkspace.from(folders, files); + const ctrl = new MockTestController(); + const expl = new TestExplorer(ctrl, ws, rethrow, symbols); + + function walk(dir: Uri, modpath?: string) { + const dirs: Uri[] = []; + for (const [name, type] of ws.fs.dirs.get(dir.toString())) { + const uri = dir.with({ path: path.join(dir.path, name) }); + if (type === FileType.Directory) { + dirs.push(uri); + } else if (name === 'go.mod') { + modpath = dir.path; + } + } + pkg2mod[dir.path] = modpath; + for (const dir of dirs) { + walk(dir, modpath); + } + } + + // prevent getModFolderPath from actually doing anything; + for (const pkg in pkg2mod) delete pkg2mod[pkg]; + for (const dir of folders) walk(Uri.file(dir)); + + return { ctrl, expl }; +} + +async function testResolveChildren(tc: ResolveChildrenTestCase) { + const { workspace, files, expect } = tc; + const { ctrl } = setup(workspace, files); + + let item: TestItem = ctrl.root; + for (const [id, label] of tc.item || []) { + const uri = Uri.parse(id).with({ query: '' }); + item = ctrl.createTestItem(id, label, item, uri); + } + await ctrl.resolveChildrenHandler(item); + + const actual = Array.from(item.children.values()).map((x) => x.id); + assert.deepStrictEqual(actual, expect); +} + +suite('Test Explorer', () => { + suite('Items', () => { + const cases: Record> = { + Root: { + 'Basic module': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main.go': 'package main' + }, + expect: ['file:///src/proj?module'] + }, + 'Basic workspace': { + workspace: ['/src/proj'], + files: { + '/src/proj/main.go': 'package main' + }, + expect: ['file:///src/proj?workspace'] + }, + 'Module and workspace': { + workspace: ['/src/proj1', '/src/proj2'], + files: { + '/src/proj1/go.mod': 'module test', + '/src/proj2/main.go': 'package main' + }, + expect: ['file:///src/proj1?module', 'file:///src/proj2?workspace'] + }, + 'Module in workspace': { + workspace: ['/src/proj'], + files: { + '/src/proj/mod/go.mod': 'module test', + '/src/proj/main.go': 'package main' + }, + expect: ['file:///src/proj/mod?module', 'file:///src/proj?workspace'] + } + }, + Module: { + 'Empty': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: [] + }, + 'Root package': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: ['file:///src/proj/main_test.go?file'] + }, + 'Sub packages': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/foo/main_test.go': 'package main', + '/src/proj/bar/main_test.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: ['file:///src/proj/foo?package', 'file:///src/proj/bar?package'] + }, + 'Nested packages': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main', + '/src/proj/foo/main_test.go': 'package main', + '/src/proj/foo/bar/main_test.go': 'package main' + }, + item: [['file:///src/proj?module', 'test']], + expect: [ + 'file:///src/proj/foo?package', + 'file:///src/proj/foo/bar?package', + 'file:///src/proj/main_test.go?file' + ] + } + }, + Package: { + 'Empty': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/pkg/main.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/pkg?package', 'pkg'] + ], + expect: [] + }, + 'Flat': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/pkg/main_test.go': 'package main', + '/src/proj/pkg/sub/main_test.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/pkg?package', 'pkg'] + ], + expect: ['file:///src/proj/pkg/main_test.go?file'] + }, + 'Sub package': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/pkg/sub/main_test.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/pkg?package', 'pkg'] + ], + expect: [] + } + }, + File: { + 'One of each': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': ` + package main + + func TestMain(*testing.M) {} + func TestFoo(*testing.T) {} + func BenchmarkBar(*testing.B) {} + func ExampleBaz() {} + `.replace(/^\s+/gm, '') + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/main_test.go?file', 'main_test.go'] + ], + expect: [ + 'file:///src/proj/main_test.go?test#TestFoo', + 'file:///src/proj/main_test.go?benchmark#BenchmarkBar', + 'file:///src/proj/main_test.go?example#ExampleBaz' + ] + } + } + }; + + for (const n in cases) { + suite(n, () => { + for (const m in cases[n]) { + test(m, () => testResolveChildren(cases[n][m])); + } + }); + } + }); +}); diff --git a/test/integration/index.ts b/test/integration/index.ts index d6549f0144..efc7404d58 100644 --- a/test/integration/index.ts +++ b/test/integration/index.ts @@ -9,6 +9,7 @@ import * as Mocha from 'mocha'; import * as path from 'path'; export function run(): Promise { const mocha = new Mocha({ + grep: process.env.MOCHA_GREP, ui: 'tdd' }); diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts new file mode 100644 index 0000000000..8b6df4f421 --- /dev/null +++ b/test/mocks/MockTest.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import path = require('path'); +import { + CancellationToken, + EndOfLine, + FileSystem, + FileType, + MarkdownString, + Position, + Range, + TestController, + TestItem, + TestRun, + TestRunRequest, + TextDocument, + TextLine, + Uri, + WorkspaceFolder +} from 'vscode'; +import { TestExplorer } from '../../src/goTestExplorer'; + +export class MockTestItem implements TestItem { + constructor( + public id: string, + public label: string, + public parent: TestItem | undefined, + public uri: Uri | undefined, + public data: T + ) {} + + canResolveChildren: boolean; + busy: boolean; + description?: string; + range?: Range; + error?: string | MarkdownString; + runnable: boolean; + debuggable: boolean; + + children = new Map(); + + invalidate(): void {} + + dispose(): void { + if (this.parent instanceof MockTestItem) { + this.parent.children.delete(this.id); + } + } +} + +export class MockTestController implements TestController { + root = new MockTestItem('Go', 'Go', void 0, void 0, void 0); + + resolveChildrenHandler?: (item: TestItem) => void | Thenable; + runHandler?: (request: TestRunRequest, token: CancellationToken) => void | Thenable; + + createTestItem( + id: string, + label: string, + parent: TestItem, + uri?: Uri, + data?: TChild + ): TestItem { + if (parent.children.has(id)) { + throw new Error(`Test item ${id} already exists`); + } + const item = new MockTestItem(id, label, parent, uri, data); + (parent).children.set(id, item); + return item; + } + + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun { + throw new Error('Method not implemented.'); + } + + dispose(): void {} +} + +type DirEntry = [string, FileType]; + +export class MockTestFileSystem implements TestExplorer.FileSystem { + constructor(public dirs: Map, public files: Map) {} + + readDirectory(uri: Uri): Thenable<[string, FileType][]> { + const k = uri.with({ query: '', fragment: '' }).toString(); + return Promise.resolve(this.dirs.get(k) || []); + } + + readFile(uri: Uri): Thenable { + const k = uri.with({ query: '', fragment: '' }).toString(); + const s = this.files.get(k)?.getText(); + return Promise.resolve(Buffer.from(s || '')); + } +} + +export class MockTestWorkspace implements TestExplorer.Workspace { + static from(folders: string[], contents: Record) { + const wsdirs: WorkspaceFolder[] = []; + const dirs = new Map(); + const files = new Map(); + + for (const i in folders) { + const uri = Uri.parse(folders[i]); + wsdirs.push({ uri, index: Number(i), name: path.basename(uri.path) }); + } + + function push(uri: Uri, child: FileType) { + const entry: DirEntry = [path.basename(uri.path), child]; + const dir = uri.with({ path: path.dirname(uri.path) }); + if (dirs.has(dir.toString())) { + dirs.get(dir.toString()).push(entry); + return; + } + + if (path.dirname(dir.path) !== dir.path) { + push(dir, FileType.Directory); + } + dirs.set(dir.toString(), [entry]); + } + + for (const k in contents) { + const uri = Uri.parse(k); + const entry = contents[k]; + + let doc: TextDocument; + if (typeof entry === 'object') { + doc = new MockTestDocument(uri, entry.contents, entry.language); + } else if (path.basename(uri.path) === 'go.mod') { + doc = new MockTestDocument(uri, entry, 'go.mod'); + } else { + doc = new MockTestDocument(uri, entry); + } + + files.set(uri.toString(), doc); + push(uri, FileType.File); + } + + return new this(wsdirs, new MockTestFileSystem(dirs, files)); + } + + constructor(public workspaceFolders: WorkspaceFolder[], public fs: MockTestFileSystem) {} + + openTextDocument(uri: Uri): Thenable { + return Promise.resolve(this.fs.files.get(uri.toString())); + } + + getWorkspaceFolder(uri: Uri): WorkspaceFolder { + return this.workspaceFolders.filter((x) => x.uri === uri)[0]; + } +} + +export class MockTestDocument implements TextDocument { + constructor( + public uri: Uri, + private contents: string, + public languageId: string = 'go', + public isUntitled: boolean = false, + public isDirty: boolean = false + ) {} + + readonly version: number = 1; + readonly eol: EndOfLine = EndOfLine.LF; + + get lineCount() { + return this.contents.split('\n').length; + } + + get fileName() { + return path.basename(this.uri.path); + } + + save(): Thenable { + if (!this.isDirty) { + return Promise.resolve(false); + } + + this.isDirty = false; + return Promise.resolve(true); + } + + get isClosed(): boolean { + throw new Error('Method not implemented.'); + } + + lineAt(line: number): TextLine; + lineAt(position: Position): TextLine; + lineAt(position: any): TextLine { + throw new Error('Method not implemented.'); + } + + offsetAt(position: Position): number { + throw new Error('Method not implemented.'); + } + + positionAt(offset: number): Position { + throw new Error('Method not implemented.'); + } + + getText(range?: Range): string { + if (range) { + throw new Error('Method not implemented.'); + } + return this.contents; + } + + getWordRangeAtPosition(position: Position, regex?: RegExp): Range { + throw new Error('Method not implemented.'); + } + + validateRange(range: Range): Range { + throw new Error('Method not implemented.'); + } + + validatePosition(position: Position): Position { + throw new Error('Method not implemented.'); + } +} diff --git a/test/runTest.ts b/test/runTest.ts index de9eaa8479..27a23f4322 100644 --- a/test/runTest.ts +++ b/test/runTest.ts @@ -6,6 +6,9 @@ import { runTests } from 'vscode-test'; async function main() { // We are in test mode. process.env['VSCODE_GO_IN_TEST'] = '1'; + if (process.argv.length > 2) { + process.env['MOCHA_GREP'] = process.argv[2]; + } // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath` From 04a1899f28bce64ea54b7271b8c9307583d58948 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 9 Jul 2021 13:43:14 -0500 Subject: [PATCH 13/34] src/goTestExplorer: add tests for workspace events --- src/goTestExplorer.ts | 55 +++---- test/gopls/index.ts | 1 + test/integration/goTestExplorer.test.ts | 200 +++++++++++++++++++++--- test/mocks/MockTest.ts | 36 +++-- 4 files changed, 233 insertions(+), 59 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index fcc39bc144..dcf23ea598 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -19,7 +19,9 @@ import { Position, TextDocumentChangeEvent, WorkspaceFoldersChangeEvent, - CancellationToken + CancellationToken, + FileSystem as vsFileSystem, + workspace as vsWorkspace } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -35,17 +37,12 @@ const symbols = new WeakMap(); export namespace TestExplorer { // exported for tests - export interface FileSystem { - readFile(uri: Uri): Thenable; - readDirectory(uri: Uri): Thenable<[string, FileType][]>; - } + export type FileSystem = Pick; - export interface Workspace { - readonly fs: FileSystem; - readonly workspaceFolders: readonly WorkspaceFolder[] | undefined; + export interface Workspace extends Pick { + readonly fs: FileSystem; // custom FS type - openTextDocument(uri: Uri): Thenable; - getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined; + openTextDocument(uri: Uri): Thenable; // only one overload } } @@ -98,6 +95,25 @@ export class TestExplorer { } } + async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { + const items = Array.from(this.ctrl.root.children.values()); + for (const item of items) { + const uri = Uri.parse(item.id); + if (uri.query === 'package') { + continue; + } + + const ws = this.ws.getWorkspaceFolder(uri); + if (!ws) { + item.dispose(); + } + } + + if (e.added) { + await resolveChildren(this, this.ctrl.root); + } + } + async didCreateFile(file: Uri) { try { await documentUpdate(this, await this.ws.openTextDocument(file)); @@ -132,25 +148,6 @@ export class TestExplorer { disposeIfEmpty(found.parent); } } - - async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { - const items = Array.from(this.ctrl.root.children.values()); - for (const item of items) { - const uri = Uri.parse(item.id); - if (uri.query === 'package') { - continue; - } - - const ws = this.ws.getWorkspaceFolder(uri); - if (!ws) { - item.dispose(); - } - } - - if (e.added) { - await resolveChildren(this, this.ctrl.root); - } - } } // Construct an ID for an item. diff --git a/test/gopls/index.ts b/test/gopls/index.ts index f332137df6..7d7be4a64f 100644 --- a/test/gopls/index.ts +++ b/test/gopls/index.ts @@ -10,6 +10,7 @@ import * as path from 'path'; export function run(): Promise { // Create the mocha test const mocha = new Mocha({ + grep: process.env.MOCHA_GREP, ui: 'tdd' }); diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts index 2df7e7d578..cd5cd442d0 100644 --- a/test/integration/goTestExplorer.test.ts +++ b/test/integration/goTestExplorer.test.ts @@ -1,17 +1,25 @@ import assert = require('assert'); import path = require('path'); -import { DocumentSymbol, FileType, TestItem, Uri, TextDocument, SymbolKind, Range, Position } from 'vscode'; +import { + DocumentSymbol, + FileType, + TestItem, + Uri, + TextDocument, + SymbolKind, + Range, + Position, + TextDocumentContentChangeEvent +} from 'vscode'; import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules'; import { TestExplorer } from '../../src/goTestExplorer'; import { MockTestController, MockTestWorkspace } from '../mocks/MockTest'; type Files = Record; -interface ResolveChildrenTestCase { +interface TestCase { workspace: string[]; files: Files; - item?: [string, string][]; - expect: string[]; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -44,7 +52,7 @@ function setup(folders: string[], files: Files) { modpath = dir.path; } } - pkg2mod[dir.path] = modpath; + pkg2mod[dir.path] = modpath || ''; for (const dir of dirs) { walk(dir, modpath); } @@ -52,29 +60,31 @@ function setup(folders: string[], files: Files) { // prevent getModFolderPath from actually doing anything; for (const pkg in pkg2mod) delete pkg2mod[pkg]; - for (const dir of folders) walk(Uri.file(dir)); + walk(Uri.file('/')); - return { ctrl, expl }; + return { ctrl, expl, ws }; } -async function testResolveChildren(tc: ResolveChildrenTestCase) { - const { workspace, files, expect } = tc; - const { ctrl } = setup(workspace, files); - - let item: TestItem = ctrl.root; - for (const [id, label] of tc.item || []) { - const uri = Uri.parse(id).with({ query: '' }); - item = ctrl.createTestItem(id, label, item, uri); +function assertTestItems(root: TestItem, expect: string[]) { + const actual: string[] = []; + function walk(item: TestItem) { + for (const child of item.children.values()) { + actual.push(child.id); + walk(child); + } } - await ctrl.resolveChildrenHandler(item); - - const actual = Array.from(item.children.values()).map((x) => x.id); + walk(root); assert.deepStrictEqual(actual, expect); } suite('Test Explorer', () => { suite('Items', () => { - const cases: Record> = { + interface TC extends TestCase { + item?: [string, string][]; + expect: string[]; + } + + const cases: Record> = { Root: { 'Basic module': { workspace: ['/src/proj'], @@ -193,6 +203,18 @@ suite('Test Explorer', () => { } }, File: { + 'Empty': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main' + }, + item: [ + ['file:///src/proj?module', 'test'], + ['file:///src/proj/main_test.go?file', 'main_test.go'] + ], + expect: [] + }, 'One of each': { workspace: ['/src/proj'], files: { @@ -204,7 +226,7 @@ suite('Test Explorer', () => { func TestFoo(*testing.T) {} func BenchmarkBar(*testing.B) {} func ExampleBaz() {} - `.replace(/^\s+/gm, '') + ` }, item: [ ['file:///src/proj?module', 'test'], @@ -222,9 +244,145 @@ suite('Test Explorer', () => { for (const n in cases) { suite(n, () => { for (const m in cases[n]) { - test(m, () => testResolveChildren(cases[n][m])); + test(m, async () => { + const { workspace, files, expect, item: itemData = [] } = cases[n][m]; + const { ctrl } = setup(workspace, files); + + let item: TestItem = ctrl.root; + for (const [id, label] of itemData) { + const uri = Uri.parse(id).with({ query: '' }); + item = ctrl.createTestItem(id, label, item, uri); + } + await ctrl.resolveChildrenHandler(item); + + const actual = Array.from(item.children.values()).map((x) => x.id); + assert.deepStrictEqual(actual, expect); + }); } }); } }); + + suite('Events', () => { + suite('Document opened', () => { + interface TC extends TestCase { + open: string; + expect: string[]; + } + + const cases: Record = { + 'In workspace': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/foo_test.go': 'package main\nfunc TestFoo(*testing.T) {}', + '/src/proj/bar_test.go': 'package main\nfunc TestBar(*testing.T) {}', + '/src/proj/baz/main_test.go': 'package main\nfunc TestBaz(*testing.T) {}' + }, + open: 'file:///src/proj/foo_test.go', + expect: [ + 'file:///src/proj?module', + 'file:///src/proj/foo_test.go?file', + 'file:///src/proj/foo_test.go?test#TestFoo' + ] + }, + 'Outside workspace': { + workspace: [], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/foo_test.go': 'package main\nfunc TestFoo(*testing.T) {}' + }, + open: 'file:///src/proj/foo_test.go', + expect: [ + 'file:///src/proj?module', + 'file:///src/proj/foo_test.go?file', + 'file:///src/proj/foo_test.go?test#TestFoo' + ] + } + }; + + for (const name in cases) { + test(name, async () => { + const { workspace, files, open, expect } = cases[name]; + const { ctrl, expl, ws } = setup(workspace, files); + + await expl.didOpenTextDocument(ws.fs.files.get(open)); + + assertTestItems(ctrl.root, expect); + }); + } + }); + + suite('Document edited', async () => { + interface TC extends TestCase { + open: string; + changes: [string, string][]; + expect: { + before: string[]; + after: string[]; + }; + } + + const cases: Record = { + 'Add test': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/foo_test.go': 'package main' + }, + open: 'file:///src/proj/foo_test.go', + changes: [['file:///src/proj/foo_test.go', 'package main\nfunc TestFoo(*testing.T) {}']], + expect: { + before: ['file:///src/proj?module'], + after: [ + 'file:///src/proj?module', + 'file:///src/proj/foo_test.go?file', + 'file:///src/proj/foo_test.go?test#TestFoo' + ] + } + }, + 'Remove test': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/foo_test.go': 'package main\nfunc TestFoo(*testing.T) {}' + }, + open: 'file:///src/proj/foo_test.go', + changes: [['file:///src/proj/foo_test.go', 'package main']], + expect: { + before: [ + 'file:///src/proj?module', + 'file:///src/proj/foo_test.go?file', + 'file:///src/proj/foo_test.go?test#TestFoo' + ], + after: ['file:///src/proj?module'] + } + } + }; + + for (const name in cases) { + test(name, async () => { + const { workspace, files, open, changes, expect } = cases[name]; + const { ctrl, expl, ws } = setup(workspace, files); + + await expl.didOpenTextDocument(ws.fs.files.get(open)); + + assertTestItems(ctrl.root, expect.before); + + for (const [file, contents] of changes) { + const doc = ws.fs.files.get(file); + doc.contents = contents; + await expl.didChangeTextDocument({ + document: doc, + get contentChanges(): TextDocumentContentChangeEvent[] { + throw new Error('not implemented'); + } + }); + } + + assertTestItems(ctrl.root, expect.after); + }); + } + }); + }); }); diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts index 8b6df4f421..74a4b4b5c6 100644 --- a/test/mocks/MockTest.ts +++ b/test/mocks/MockTest.ts @@ -79,7 +79,7 @@ export class MockTestController implements TestController { type DirEntry = [string, FileType]; export class MockTestFileSystem implements TestExplorer.FileSystem { - constructor(public dirs: Map, public files: Map) {} + constructor(public dirs: Map, public files: Map) {} readDirectory(uri: Uri): Thenable<[string, FileType][]> { const k = uri.with({ query: '', fragment: '' }).toString(); @@ -93,11 +93,25 @@ export class MockTestFileSystem implements TestExplorer.FileSystem { } } +function unindent(s: string): string { + let lines = s.split('\n'); + if (/^\s*$/.test(lines[0])) lines = lines.slice(1); + + const m = lines[0].match(/^\s+/); + if (!m) return s; + if (!lines.every((l) => /^\s*$/.test(l) || l.startsWith(m[0]))) return s; + + for (const i in lines) { + lines[i] = lines[i].substring(m[0].length); + } + return lines.join('\n'); +} + export class MockTestWorkspace implements TestExplorer.Workspace { static from(folders: string[], contents: Record) { const wsdirs: WorkspaceFolder[] = []; const dirs = new Map(); - const files = new Map(); + const files = new Map(); for (const i in folders) { const uri = Uri.parse(folders[i]); @@ -122,13 +136,13 @@ export class MockTestWorkspace implements TestExplorer.Workspace { const uri = Uri.parse(k); const entry = contents[k]; - let doc: TextDocument; + let doc: MockTestDocument; if (typeof entry === 'object') { - doc = new MockTestDocument(uri, entry.contents, entry.language); + doc = new MockTestDocument(uri, unindent(entry.contents), entry.language); } else if (path.basename(uri.path) === 'go.mod') { - doc = new MockTestDocument(uri, entry, 'go.mod'); + doc = new MockTestDocument(uri, unindent(entry), 'go.mod'); } else { - doc = new MockTestDocument(uri, entry); + doc = new MockTestDocument(uri, unindent(entry)); } files.set(uri.toString(), doc); @@ -152,17 +166,21 @@ export class MockTestWorkspace implements TestExplorer.Workspace { export class MockTestDocument implements TextDocument { constructor( public uri: Uri, - private contents: string, + private _contents: string, public languageId: string = 'go', public isUntitled: boolean = false, public isDirty: boolean = false ) {} + set contents(s: string) { + this._contents = s; + } + readonly version: number = 1; readonly eol: EndOfLine = EndOfLine.LF; get lineCount() { - return this.contents.split('\n').length; + return this._contents.split('\n').length; } get fileName() { @@ -200,7 +218,7 @@ export class MockTestDocument implements TextDocument { if (range) { throw new Error('Method not implemented.'); } - return this.contents; + return this._contents; } getWordRangeAtPosition(position: Position, regex?: RegExp): Range { From 27bd88ffc6f413f10ca074d25a94e9618e002574 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 15 Jul 2021 22:17:32 -0500 Subject: [PATCH 14/34] src/goTestExplorer: handle document updates --- src/goTestExplorer.ts | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index dcf23ea598..4bd3c3907e 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -89,7 +89,11 @@ export class TestExplorer { async didChangeTextDocument(e: TextDocumentChangeEvent) { try { - await documentUpdate(this, e.document); + await documentUpdate( + this, + e.document, + e.contentChanges.map((x) => x.range) + ); } catch (e) { this.errored(e); } @@ -225,6 +229,23 @@ function disposeIfEmpty(item: TestItem) { disposeIfEmpty(item.parent); } +// Dispose of the children of a test. Sub-tests and sub-benchmarks are +// discovered emperically (from test output) not semantically (from code), so +// there are situations where they must be discarded. +function discardChildren(item: TestItem) { + item.canResolveChildren = false; + Array.from(item.children.values()).forEach((x) => x.dispose()); +} + +// If a test/benchmark with children is relocated, update the children's +// location. +function relocateChildren(item: TestItem) { + for (const child of item.children.values()) { + child.range = item.range; + relocateChildren(child); + } +} + // Retrieve or create an item for a Go module. async function getModule(expl: TestExplorer, uri: Uri): Promise { const existing = getItem(expl.ctrl.root, uri, 'module'); @@ -349,6 +370,10 @@ async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: const kind = match.groups.type.toLowerCase(); const existing = getItem(file, uri, kind, symbol.name); if (existing) { + if (!existing.range.isEqual(symbol.range)) { + existing.range = symbol.range; + relocateChildren(existing); + } return existing; } @@ -365,7 +390,7 @@ async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: // Any previously existing tests that no longer have a corresponding symbol in // the file will be disposed. If the document contains no tests, it will be // disposed. -async function processDocument(expl: TestExplorer, doc: TextDocument) { +async function processDocument(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) { const seen = new Set(); const item = await getFile(expl, doc.uri); const symbols = await expl.provideDocumentSymbols(doc, null); @@ -375,6 +400,11 @@ async function processDocument(expl: TestExplorer, doc: TextDocument) { const uri = Uri.parse(child.id); if (!seen.has(uri.fragment)) { child.dispose(); + continue; + } + + if (ranges?.some((r) => !!child.range.intersection(r))) { + discardChildren(child); } } @@ -491,7 +521,7 @@ async function walkPackages(fs: TestExplorer.FileSystem, uri: Uri, cb: (uri: Uri } // Handle opened documents, document changes, and file creation. -async function documentUpdate(expl: TestExplorer, doc: TextDocument) { +async function documentUpdate(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) { if (!doc.uri.path.endsWith('_test.go')) { return; } @@ -501,7 +531,7 @@ async function documentUpdate(expl: TestExplorer, doc: TextDocument) { return; } - await processDocument(expl, doc); + await processDocument(expl, doc, ranges); } // TestController.resolveChildrenHandler callback @@ -857,8 +887,7 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { for (const item of items) { run.setState(item, TestResultState.Queued); - item.canResolveChildren = false; - Array.from(item.children.values()).forEach((x) => x.dispose()); + discardChildren(item); const uri = Uri.parse(item.id); if (uri.query === 'benchmark') { From 29f9eb8722403d98f3ffc8483262b3f4d02e499a Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 15 Jul 2021 22:18:36 -0500 Subject: [PATCH 15/34] src/goTestExplorer: default to not run benchmarks --- package.json | 6 ++++++ src/goTestExplorer.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/package.json b/package.json index 17e199e1b6..b786ce967a 100644 --- a/package.json +++ b/package.json @@ -1283,6 +1283,12 @@ "description": "Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.", "scope": "resource" }, + "go.testExplorerRunBenchmarks": { + "type": "boolean", + "default": false, + "description": "Include benchmarks when running all tests in a group", + "scope": "resource" + }, "go.generateTestsFlags": { "type": "array", "items": { diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 4bd3c3907e..19b9fa3071 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -624,7 +624,10 @@ async function collectTests( await resolveChildren(expl, item); } + const runBench = getGoConfig(item.uri).get('testExplorerRunBenchmarks'); for (const child of item.children.values()) { + const uri = Uri.parse(child.id); + if (uri.query === 'benchmark' && !runBench) continue; await collectTests(expl, child, excluded, functions, docs); } return; From 9bb5fcc8d9165b7cce1287ced926866c0084698b Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 15 Jul 2021 22:20:32 -0500 Subject: [PATCH 16/34] src/goTestExplorer: configure flat/nested packages --- package.json | 7 +++++++ src/goTestExplorer.ts | 38 +++++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index b786ce967a..337e984b7b 100644 --- a/package.json +++ b/package.json @@ -1283,6 +1283,13 @@ "description": "Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.", "scope": "resource" }, + "go.testExplorerPackages": { + "type": "string", + "enum": ["flat", "nested"], + "default": "flat", + "description": "Control whether packages are presented flat or nested", + "scope": "resource" + }, "go.testExplorerRunBenchmarks": { "type": "boolean", "default": false, diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 19b9fa3071..ba61a965cf 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -56,6 +56,8 @@ export class TestExplorer { new GoDocumentSymbolProvider().provideDocumentSymbols ); + context.subscriptions.push(workspace.onDidChangeConfiguration((x) => inst.didChangeConfiguration(x))); + context.subscriptions.push(workspace.onDidOpenTextDocument((x) => inst.didOpenTextDocument(x))); context.subscriptions.push(workspace.onDidChangeTextDocument((x) => inst.didChangeTextDocument(x))); context.subscriptions.push(workspace.onDidChangeWorkspaceFolders((x) => inst.didChangeWorkspaceFolders(x))); @@ -152,6 +154,20 @@ export class TestExplorer { disposeIfEmpty(found.parent); } } + + async didChangeConfiguration(e: ConfigurationChangeEvent) { + let update = false; + for (const item of this.ctrl.root.children.values()) { + if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) { + item.dispose(); + update = true; + } + } + + if (update) { + resolveChildren(this, this.ctrl.root); + } + } } // Construct an ID for an item. @@ -282,23 +298,27 @@ async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise { let item: TestItem; + const nested = getGoConfig(uri).get('testExplorerPackages') === 'nested'; const modDir = await getModFolderPath(uri, true); const wsfolder = workspace.getWorkspaceFolder(uri); if (modDir) { // If the package is in a module, add it as a child of the module - const modUri = uri.with({ path: modDir, query: '', fragment: '' }); - const module = await getModule(expl, modUri); - const existing = getItem(module, uri, 'package'); - if (existing) { - return existing; + let parent = await getModule(expl, uri.with({ path: modDir, query: '', fragment: '' })); + if (uri.path === parent.uri.path) { + return parent; } - if (uri.path === modUri.path) { - return module; + if (nested) { + const bits = path.relative(parent.uri.path, uri.path).split(path.sep); + while (bits.length > 1) { + const dir = bits.shift(); + const dirUri = uri.with({ path: path.join(parent.uri.path, dir), query: '', fragment: '' }); + parent = getOrCreateItem(expl, parent, dir, dirUri, 'package'); + } } - const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path; - item = getOrCreateItem(expl, module, label, uri, 'package'); + const label = uri.path.startsWith(parent.uri.path) ? uri.path.substring(parent.uri.path.length + 1) : uri.path; + item = getOrCreateItem(expl, parent, label, uri, 'package'); } else if (wsfolder) { // If the package is in a workspace folder, add it as a child of the workspace const workspace = await getWorkspace(expl, wsfolder); From b34fe493ba7a8c1d7acf036280d477db7271569f Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 15 Jul 2021 22:20:54 -0500 Subject: [PATCH 17/34] src/goTestExplorer: diff for examples --- src/goTestExplorer.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index ba61a965cf..21a01822c6 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -843,6 +843,19 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin const parsed = new Map(); let current: message | undefined; + const uri = Uri.parse(test.id); + const gotI = output.indexOf('got:\n'); + const wantI = output.indexOf('want:\n'); + if (uri.query === 'example' && gotI >= 0 && wantI >= 0) { + const got = output.slice(gotI + 1, wantI).join(''); + const want = output.slice(wantI + 1).join(''); + const message = TestMessage.diff('Output does not match', want, got); + message.severity = TestMessageSeverity.Error; + message.location = new Location(test.uri, test.range.start); + run.appendMessage(test, message); + output = output.slice(0, gotI); + } + for (const item of output) { const fileAndLine = item.match(/^\s*(?.*\.go):(?\d+): ?(?.*\n)$/); if (fileAndLine) { From f59453dc412e93bc28c473d2c11455d286e5c906 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 15 Jul 2021 22:21:06 -0500 Subject: [PATCH 18/34] src/goTestExplorer: handle build failures --- src/goTestExplorer.ts | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 21a01822c6..dc83b2c193 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -21,7 +21,10 @@ import { WorkspaceFoldersChangeEvent, CancellationToken, FileSystem as vsFileSystem, - workspace as vsWorkspace + workspace as vsWorkspace, + ConfigurationChangeEvent, + TestMessage, + Range } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -669,6 +672,8 @@ async function collectTests( // console. class TestRunOutput implements OutputChannel { readonly name: string; + readonly lines: string[] = []; + constructor(private run: TestRun) { this.name = `Test run at ${new Date()}`; } @@ -678,6 +683,7 @@ class TestRunOutput implements OutputChannel { } appendLine(value: string) { + this.lines.push(value); this.run.appendOutput(value + '\r\n'); } @@ -892,6 +898,17 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin } } +function checkForBuildFailure(run: TestRun, tests: Record, output: string[]) { + const rePkg = /^# (?[\w/.-]+)(?: \[(?[\w/.-]+).test\])?/; + + // TODO(firelizzard18): Add more sophisticated check for build failures? + if (!output.some((x) => rePkg.test(x))) return; + + for (const name in tests) { + run.setState(tests[name], TestResultState.Errored); + } +} + // Execute tests - TestController.runTest callback async function runTest(expl: TestExplorer, request: TestRunRequest) { const collected = new Map(); @@ -939,7 +956,7 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { // Run tests if (testFns.length > 0) { - await goTest({ + const success = await goTest({ goConfig, flags, isMod, @@ -948,12 +965,15 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { functions: testFns, goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, e) }); + if (!success) { + checkForBuildFailure(run, tests, outputChannel.lines); + } } // Run benchmarks if (benchmarkFns.length > 0) { const complete = new Set(); - await goTest({ + const success = await goTest({ goConfig, flags, isMod, @@ -964,8 +984,12 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e) }); - // Explicitly pass any incomplete benchmarks (see test_events.md) - passBenchmarks(run, benchmarks, complete); + if (success || complete.size > 0) { + // Explicitly pass any incomplete benchmarks (see test_events.md) + passBenchmarks(run, benchmarks, complete); + } else { + checkForBuildFailure(run, benchmarks, outputChannel.lines); + } } // Create test messages From 1a7266ce12de715db8d02a8d82c84503dd1480c2 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 16 Jul 2021 01:16:52 -0500 Subject: [PATCH 19/34] src/goTestExplorer: update VSCode api --- package.json | 20 ++ src/goMain.ts | 8 +- src/goTestExplorer.ts | 228 ++++++++++--------- src/vscode.proposed.d.ts | 287 ++++++++++++++++-------- test/integration/goTestExplorer.test.ts | 75 ++++--- test/mocks/MockTest.ts | 110 ++++++--- 6 files changed, 451 insertions(+), 277 deletions(-) diff --git a/package.json b/package.json index 337e984b7b..44e34f6898 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,13 @@ "title": "Go: Test Package", "description": "Runs all unit tests in the package of the current file." }, + { + "command": "go.test.refresh", + "title": "Go Test: Refresh", + "description": "Refresh a test in the test explorer", + "category": "Test", + "icon": "$(refresh)" + }, { "command": "go.benchmark.package", "title": "Go: Benchmark Package", @@ -2370,6 +2377,12 @@ } }, "menus": { + "commandPalette": [ + { + "command": "go.test.refresh", + "when": "false" + } + ], "editor/context": [ { "when": "editorTextFocus && config.go.editorContextMenuCommands.toggleTestFile && resourceLangId == go", @@ -2451,6 +2464,13 @@ "command": "go.show.commands", "group": "Go group 2" } + ], + "testing/item/context": [ + { + "command": "go.test.refresh", + "when": "testId =~ /_test\\.go/", + "group": "inline" + } ] } } diff --git a/src/goMain.ts b/src/goMain.ts index 55b1e7233a..692ad1ae0b 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -228,7 +228,7 @@ If you would like additional configuration for diagnostics from gopls, please se ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, referencesCodeLensProvider)); // testing - TestExplorer.setup(ctx); + const testExplorer = TestExplorer.setup(ctx); // debug ctx.subscriptions.push( @@ -339,6 +339,12 @@ If you would like additional configuration for diagnostics from gopls, please se }) ); + ctx.subscriptions.push( + vscode.commands.registerCommand('go.test.refresh', (args) => { + if (args) testExplorer.resolve(args); + }) + ); + ctx.subscriptions.push( vscode.commands.registerCommand('go.subtest.cursor', (args) => { const goConfig = getGoConfig(); diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index dc83b2c193..a596160ab0 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -24,7 +24,10 @@ import { workspace as vsWorkspace, ConfigurationChangeEvent, TestMessage, - Range + Range, + TestItemCollection, + TestRunConfigurationGroup, + window } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -50,14 +53,15 @@ export namespace TestExplorer { } export class TestExplorer { - static setup(context: ExtensionContext) { - const ctrl = test.createTestController('go'); + static setup(context: ExtensionContext): TestExplorer { + const ctrl = test.createTestController('go', 'Go'); const inst = new this( ctrl, workspace, (e) => console.log(e), new GoDocumentSymbolProvider().provideDocumentSymbols ); + resolveChildren(inst); context.subscriptions.push(workspace.onDidChangeConfiguration((x) => inst.didChangeConfiguration(x))); @@ -69,6 +73,8 @@ export class TestExplorer { context.subscriptions.push(watcher); context.subscriptions.push(watcher.onDidCreate((x) => inst.didCreateFile(x))); context.subscriptions.push(watcher.onDidDelete((x) => inst.didDeleteFile(x))); + + return inst; } constructor( @@ -78,10 +84,47 @@ export class TestExplorer { public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable ) { // TODO handle cancelation of test runs - ctrl.root.label = 'Go'; - ctrl.root.canResolveChildren = true; ctrl.resolveChildrenHandler = (...args) => resolveChildren(this, ...args); - ctrl.runHandler = (request) => runTest(this, request); + ctrl.createRunConfiguration('Go [Run]', TestRunConfigurationGroup.Run, (rq) => runTest(this, rq), true); + } + + // Create an item. + createItem(label: string, uri: Uri, kind: string, name?: string): TestItem { + return test.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' })); + } + + // Retrieve an item. + getItem(parent: TestItem | undefined, uri: Uri, kind: string, name?: string): TestItem { + const items = getChildren(parent || this.ctrl.items); + return items.get(testID(uri, kind, name)); + } + + // Create or retrieve an item. + getOrCreateItem(parent: TestItem | undefined, label: string, uri: Uri, kind: string, name?: string): TestItem { + const existing = this.getItem(parent, uri, kind, name); + if (existing) return existing; + + const item = this.createItem(label, uri, kind, name); + getChildren(parent || this.ctrl.items).add(item); + return item; + } + + // Create or Retrieve a sub test or benchmark. The ID will be of the form: + // file:///path/to/mod/file.go?test#TestXxx/A/B/C + getOrCreateSubTest(item: TestItem, name: string): TestItem { + const { fragment: parentName, query: kind } = Uri.parse(item.id); + const existing = this.getItem(item, item.uri, kind, `${parentName}/${name}`); + if (existing) return existing; + + item.canResolveChildren = true; + const sub = this.createItem(name, item.uri, kind, `${parentName}/${name}`); + item.children.add(sub); + sub.range = item.range; + return sub; + } + + resolve(item?: TestItem) { + resolveChildren(this, item); } async didOpenTextDocument(doc: TextDocument) { @@ -105,8 +148,7 @@ export class TestExplorer { } async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { - const items = Array.from(this.ctrl.root.children.values()); - for (const item of items) { + for (const item of this.ctrl.items.all) { const uri = Uri.parse(item.id); if (uri.query === 'package') { continue; @@ -114,12 +156,12 @@ export class TestExplorer { const ws = this.ws.getWorkspaceFolder(uri); if (!ws) { - item.dispose(); + dispose(item); } } if (e.added) { - await resolveChildren(this, this.ctrl.root); + await resolveChildren(this); } } @@ -133,8 +175,8 @@ export class TestExplorer { async didDeleteFile(file: Uri) { const id = testID(file, 'file'); - function find(parent: TestItem): TestItem { - for (const item of parent.children.values()) { + function find(children: TestItemCollection): TestItem { + for (const item of children.all) { if (item.id === id) { return item; } @@ -144,107 +186,73 @@ export class TestExplorer { continue; } - const found = find(item); + const found = find(item.children); if (found) { return found; } } } - const found = find(this.ctrl.root); + const found = find(this.ctrl.items); if (found) { - found.dispose(); + dispose(found); disposeIfEmpty(found.parent); } } async didChangeConfiguration(e: ConfigurationChangeEvent) { let update = false; - for (const item of this.ctrl.root.children.values()) { + for (const item of this.ctrl.items.all) { if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) { - item.dispose(); + dispose(item); update = true; } } if (update) { - resolveChildren(this, this.ctrl.root); + resolveChildren(this); } } } -// Construct an ID for an item. +// Construct an ID for an item. Exported for tests. // - Module: file:///path/to/mod?module // - Package: file:///path/to/mod/pkg?package // - File: file:///path/to/mod/file.go?file // - Test: file:///path/to/mod/file.go?test#TestXxx // - Benchmark: file:///path/to/mod/file.go?benchmark#BenchmarkXxx // - Example: file:///path/to/mod/file.go?example#ExampleXxx -function testID(uri: Uri, kind: string, name?: string): string { +export function testID(uri: Uri, kind: string, name?: string): string { uri = uri.with({ query: kind }); if (name) uri = uri.with({ fragment: name }); return uri.toString(); } -// Retrieve a child item. -function getItem(parent: TestItem, uri: Uri, kind: string, name?: string): TestItem | undefined { - return parent.children.get(testID(uri, kind, name)); -} - -// Create or Retrieve a child item. -function getOrCreateItem( - { ctrl }: TestExplorer, - parent: TestItem, - label: string, - uri: Uri, - kind: string, - name?: string -): TestItem { - const id = testID(uri, kind, name); - const existing = parent.children.get(id); - if (existing) { - return existing; +function getChildren(parent: TestItem | TestItemCollection): TestItemCollection { + if ('children' in parent) { + return parent.children; } - - return ctrl.createTestItem(id, label, parent, uri.with({ query: '', fragment: '' })); + return parent; } -// Create or Retrieve a sub test or benchmark. The ID will be of the form: -// file:///path/to/mod/file.go?test#TestXxx/A/B/C -function getOrCreateSubTest({ ctrl }: TestExplorer, item: TestItem, name: string): TestItem { - let uri = Uri.parse(item.id); - uri = uri.with({ fragment: `${uri.fragment}/${name}` }); - const existing = item.children.get(uri.toString()); - if (existing) { - return existing; - } - - item.canResolveChildren = true; - const sub = ctrl.createTestItem(uri.toString(), name, item, item.uri); - sub.runnable = false; - sub.range = item.range; - return sub; +function dispose(item: TestItem) { + item.parent.children.remove(item.id); } // Dispose of the item if it has no children, recursively. This facilitates // cleaning up package/file trees that contain no tests. function disposeIfEmpty(item: TestItem) { - // Don't dispose of the root - if (!item.parent) { - return; - } - - // Don't dispose of empty modules + // Don't dispose of empty top-level items const uri = Uri.parse(item.id); - if (uri.query === 'module') { + if (uri.query === 'module' || uri.query === 'workspace' || (uri.query === 'package' && !item.parent)) { return; } - if (item.children.size) { + if (item.children.all.length > 0) { return; } - item.dispose(); + dispose(item); disposeIfEmpty(item.parent); } @@ -253,13 +261,13 @@ function disposeIfEmpty(item: TestItem) { // there are situations where they must be discarded. function discardChildren(item: TestItem) { item.canResolveChildren = false; - Array.from(item.children.values()).forEach((x) => x.dispose()); + item.children.all.forEach(dispose); } // If a test/benchmark with children is relocated, update the children's // location. function relocateChildren(item: TestItem) { - for (const child of item.children.values()) { + for (const child of item.children.all) { child.range = item.range; relocateChildren(child); } @@ -267,7 +275,7 @@ function relocateChildren(item: TestItem) { // Retrieve or create an item for a Go module. async function getModule(expl: TestExplorer, uri: Uri): Promise { - const existing = getItem(expl.ctrl.root, uri, 'module'); + const existing = expl.getItem(null, uri, 'module'); if (existing) { return existing; } @@ -277,23 +285,21 @@ async function getModule(expl: TestExplorer, uri: Uri): Promise { const contents = await expl.ws.fs.readFile(goMod); const modLine = contents.toString().split('\n', 2)[0]; const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); - const item = getOrCreateItem(expl, expl.ctrl.root, match.groups.name, uri, 'module'); + const item = expl.getOrCreateItem(null, match.groups.name, uri, 'module'); item.canResolveChildren = true; - item.runnable = true; return item; } // Retrieve or create an item for a workspace folder that is not a module. async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise { - const existing = getItem(expl.ctrl.root, ws.uri, 'workspace'); + const existing = expl.getItem(null, ws.uri, 'workspace'); if (existing) { return existing; } // Use the workspace folder name as the label - const item = getOrCreateItem(expl, expl.ctrl.root, ws.name, ws.uri, 'workspace'); + const item = expl.getOrCreateItem(null, ws.name, ws.uri, 'workspace'); item.canResolveChildren = true; - item.runnable = true; return item; } @@ -316,16 +322,16 @@ async function getPackage(expl: TestExplorer, uri: Uri): Promise { while (bits.length > 1) { const dir = bits.shift(); const dirUri = uri.with({ path: path.join(parent.uri.path, dir), query: '', fragment: '' }); - parent = getOrCreateItem(expl, parent, dir, dirUri, 'package'); + parent = expl.getOrCreateItem(parent, dir, dirUri, 'package'); } } const label = uri.path.startsWith(parent.uri.path) ? uri.path.substring(parent.uri.path.length + 1) : uri.path; - item = getOrCreateItem(expl, parent, label, uri, 'package'); + item = expl.getOrCreateItem(parent, label, uri, 'package'); } else if (wsfolder) { // If the package is in a workspace folder, add it as a child of the workspace const workspace = await getWorkspace(expl, wsfolder); - const existing = getItem(workspace, uri, 'package'); + const existing = expl.getItem(workspace, uri, 'package'); if (existing) { return existing; } @@ -333,21 +339,20 @@ async function getPackage(expl: TestExplorer, uri: Uri): Promise { const label = uri.path.startsWith(wsfolder.uri.path) ? uri.path.substring(wsfolder.uri.path.length + 1) : uri.path; - item = getOrCreateItem(expl, workspace, label, uri, 'package'); + item = expl.getOrCreateItem(workspace, label, uri, 'package'); } else { // Otherwise, add it directly to the root - const existing = getItem(expl.ctrl.root, uri, 'package'); + const existing = expl.getItem(null, uri, 'package'); if (existing) { return existing; } const srcPath = path.join(getCurrentGoPath(uri), 'src'); const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path; - item = getOrCreateItem(expl, expl.ctrl.root, label, uri, 'package'); + item = expl.getOrCreateItem(null, label, uri, 'package'); } item.canResolveChildren = true; - item.runnable = true; return item; } @@ -355,15 +360,14 @@ async function getPackage(expl: TestExplorer, uri: Uri): Promise { async function getFile(expl: TestExplorer, uri: Uri): Promise { const dir = path.dirname(uri.path); const pkg = await getPackage(expl, uri.with({ path: dir, query: '', fragment: '' })); - const existing = getItem(pkg, uri, 'file'); + const existing = expl.getItem(pkg, uri, 'file'); if (existing) { return existing; } const label = path.basename(uri.path); - const item = getOrCreateItem(expl, pkg, label, uri, 'file'); + const item = expl.getOrCreateItem(pkg, label, uri, 'file'); item.canResolveChildren = true; - item.runnable = true; return item; } @@ -391,7 +395,7 @@ async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: seen.add(symbol.name); const kind = match.groups.type.toLowerCase(); - const existing = getItem(file, uri, kind, symbol.name); + const existing = expl.getItem(file, uri, kind, symbol.name); if (existing) { if (!existing.range.isEqual(symbol.range)) { existing.range = symbol.range; @@ -400,10 +404,8 @@ async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: return existing; } - const item = getOrCreateItem(expl, file, symbol.name, uri, kind, symbol.name); + const item = expl.getOrCreateItem(file, symbol.name, uri, kind, symbol.name); item.range = symbol.range; - item.runnable = true; - // item.debuggable = true; symbols.set(item, symbol); } @@ -419,10 +421,10 @@ async function processDocument(expl: TestExplorer, doc: TextDocument, ranges?: R const symbols = await expl.provideDocumentSymbols(doc, null); for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol); - for (const child of item.children.values()) { + for (const child of item.children.all) { const uri = Uri.parse(child.id); if (!seen.has(uri.fragment)) { - child.dispose(); + dispose(child); continue; } @@ -558,19 +560,18 @@ async function documentUpdate(expl: TestExplorer, doc: TextDocument, ranges?: Ra } // TestController.resolveChildrenHandler callback -async function resolveChildren(expl: TestExplorer, item: TestItem) { - // The user expanded the root item - find all modules and workspaces - if (!item.parent) { +async function resolveChildren(expl: TestExplorer, item?: TestItem) { + // Expand the root item - find all modules and workspaces + if (!item) { // Dispose of package entries at the root if they are now part of a workspace folder - const items = Array.from(expl.ctrl.root.children.values()); - for (const item of items) { + for (const item of expl.ctrl.items.all) { const uri = Uri.parse(item.id); if (uri.query !== 'package') { continue; } if (expl.ws.getWorkspaceFolder(uri)) { - item.dispose(); + dispose(item); } } @@ -643,12 +644,12 @@ async function collectTests( const uri = Uri.parse(item.id); if (!uri.fragment) { - if (!item.children.size) { + if (item.children.all.length === 0) { await resolveChildren(expl, item); } const runBench = getGoConfig(item.uri).get('testExplorerRunBenchmarks'); - for (const child of item.children.values()) { + for (const child of item.children.all) { const uri = Uri.parse(child.id); if (uri.query === 'benchmark' && !runBench) continue; await collectTests(expl, child, excluded, functions, docs); @@ -670,11 +671,11 @@ async function collectTests( // TestRunOutput is a fake OutputChannel that forwards all test output to the test API // console. -class TestRunOutput implements OutputChannel { +class TestRunOutput implements OutputChannel { readonly name: string; readonly lines: string[] = []; - constructor(private run: TestRun) { + constructor(private run: TestRun) { this.name = `Test run at ${new Date()}`; } @@ -709,15 +710,15 @@ function resolveTestName(expl: TestExplorer, tests: Record, na } for (const part of parts.slice(1)) { - test = getOrCreateSubTest(expl, test, part); + test = expl.getOrCreateSubTest(test, part); } return test; } // Process benchmark events (see test_events.md) -function consumeGoBenchmarkEvent( +function consumeGoBenchmarkEvent( expl: TestExplorer, - run: TestRun, + run: TestRun, benchmarks: Record, complete: Set, e: GoTestOutput @@ -781,12 +782,12 @@ function consumeGoBenchmarkEvent( } // Pass any incomplete benchmarks (see test_events.md) -function passBenchmarks(run: TestRun, items: Record, complete: Set) { +function passBenchmarks(run: TestRun, items: Record, complete: Set) { function pass(item: TestItem) { if (!complete.has(item)) { run.setState(item, TestResultState.Passed); } - for (const child of item.children.values()) { + for (const child of item.children.all) { pass(child); } } @@ -797,9 +798,9 @@ function passBenchmarks(run: TestRun, items: Record, com } // Process test events (see test_events.md) -function consumeGoTestEvent( +function consumeGoTestEvent( expl: TestExplorer, - run: TestRun, + run: TestRun, tests: Record, record: Map, e: GoTestOutput @@ -843,7 +844,7 @@ function consumeGoTestEvent( // Search recorded test output for `file.go:123: Foo bar` and attach a message // to the corresponding location. -function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { +function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { // mostly copy and pasted from https://gitlab.com/firelizzard/vscode-go-test-adapter/-/blob/733443d229df68c90145a5ae7ed78ca64dec6f43/src/tests.ts type message = { all: string; error?: string }; const parsed = new Map(); @@ -898,7 +899,7 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: strin } } -function checkForBuildFailure(run: TestRun, tests: Record, output: string[]) { +function checkForBuildFailure(run: TestRun, tests: Record, output: string[]) { const rePkg = /^# (?[\w/.-]+)(?: \[(?[\w/.-]+).test\])?/; // TODO(firelizzard18): Add more sophisticated check for build failures? @@ -910,10 +911,10 @@ function checkForBuildFailure(run: TestRun, tests: Record(expl: TestExplorer, request: TestRunRequest) { +async function runTest(expl: TestExplorer, request: TestRunRequest) { const collected = new Map(); const docs = new Set(); - for (const item of request.tests) { + for (const item of request.include) { await collectTests(expl, item, request.exclude, collected, docs); } @@ -938,11 +939,16 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { const tests: Record = {}; const benchmarks: Record = {}; for (const item of items) { - run.setState(item, TestResultState.Queued); + const uri = Uri.parse(item.id); + if (/[/#]/.test(uri.fragment)) { + // running sub-tests is not currently supported + window.showErrorMessage(`Cannot run ${uri.fragment} - running sub-tests is not supported`); + continue; + } + run.setState(item, TestResultState.Queued); discardChildren(item); - const uri = Uri.parse(item.id); if (uri.query === 'benchmark') { benchmarks[uri.fragment] = item; } else { diff --git a/src/vscode.proposed.d.ts b/src/vscode.proposed.d.ts index c2bf9cebcc..8da37b39f5 100644 --- a/src/vscode.proposed.d.ts +++ b/src/vscode.proposed.d.ts @@ -14,7 +14,7 @@ * - Copy this file to your project. */ - declare module 'vscode' { +declare module 'vscode' { //#region https://github.com/microsoft/vscode/issues/107467 export namespace test { /** @@ -22,14 +22,15 @@ * * @param id Identifier for the controller, must be globally unique. */ - export function createTestController(id: string): TestController; + export function createTestController(id: string, label: string): TestController; /** * Requests that tests be run by their controller. - * @param run Run options to use + * @param run Run options to use. * @param token Cancellation token for the test run + * @stability experimental */ - export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; + export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; /** * Returns an observer that watches and can request tests. @@ -37,6 +38,16 @@ */ export function createTestObserver(): TestObserver; + /** + * 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 Unique identifier for the TestItem. + * @param label Human-readable label of the test item. + * @param uri URI this TestItem is associated with. May be a file or directory. + */ + export function createTestItem(id: string, label: string, uri?: Uri): TestItem; + /** * List of test results stored by the editor, sorted in descending * order by their `completedAt` time. @@ -58,7 +69,7 @@ /** * List of tests returned by test provider for files in the workspace. */ - readonly tests: ReadonlyArray>; + readonly tests: ReadonlyArray; /** * An event that fires when an existing test in the collection changes, or @@ -81,66 +92,148 @@ /** * List of all tests that are newly added. */ - readonly added: ReadonlyArray>; + readonly added: ReadonlyArray; /** * List of existing tests that have updated. */ - readonly updated: ReadonlyArray>; + readonly updated: ReadonlyArray; /** * List of existing tests that have been removed. */ - readonly removed: ReadonlyArray>; + readonly removed: ReadonlyArray; + } + + // Todo@api: this is basically the same as the TaskGroup, which is a class that + // allows custom groups to be created. However I don't anticipate having any + // UI for that, so enum for now? + export enum TestRunConfigurationGroup { + Run = 1, + Debug = 2, + Coverage = 3 + } + + /** + * Handler called to start a test run. When invoked, the function should + * {@link TestController.createTestRun} at least once, and all tasks + * associated with the run should be created before the function returns + * or the reutrned 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. + */ + // todo@api We have been there with NotebookCtrl#executeHandler and I believe the recommendation is still not to inline. + // At least with that we can still do it later + export type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable | void; + + export interface TestRunConfiguration { + /** + * 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 in the `Debug` + * group. If there is no such configuration, the default will be used. + */ + label: string; + + /** + * Configures where this configuration is grouped in the UI. If there + * are no configurations for a group, it will not be available in the UI. + */ + readonly group: TestRunConfigurationGroup; + + /** + * Controls whether this configuration is the default action that will + * be taken when its group is actions. For example, if the user clicks + * the generic "run all" button, then the default configuration for + * {@link TestRunConfigurationGroup.Run} will be executed. + */ + isDefault: boolean; + + /** + * 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. + */ + configureHandler?: () => void; + + /** + * Starts a test run. When called, the controller should call + * {@link TestController.createTestRun}. All tasks associated with the + * run should be created before the function returns or the reutrned + * 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. + */ + runHandler: TestRunHandler; + + /** + * Deletes the run configuration. + */ + dispose(): void; } /** * Interface to discover and execute tests. */ - export interface TestController { + // todo@api maybe some words on this being the "entry point" + export interface TestController { + /** + * The ID of the controller, passed in {@link vscode.test.createTestController} + */ + // todo@api maybe explain what the id is used for and iff it must be globally unique or only unique within the extension + readonly id: string; - //todo@API expose `readonly id: string` that createTestController asked me for + /** + * Human-readable label for the test controller. + */ + label: string; /** - * Root test item. Tests in the workspace should be added as children of - * the root. The extension controls when to add these, although the + * Available test items. Tests in the workspace should be added in this + * collection. The extension controls when to add these, although the * editor may request children using the {@link resolveChildrenHandler}, * and the extension should add tests for a file when * {@link vscode.workspace.onDidOpenTextDocument} fires in order for * decorations for tests within the file to be visible. * * Tests in this collection should be watched and updated by the extension - * as files change. See {@link resolveChildrenHandler} for details around + * as files change. See {@link resolveChildrenHandler} for details around * for the lifecycle of watches. */ - // todo@API a little weird? what is its label, id, busy state etc? Can I dispose this? - // todo@API allow createTestItem-calls without parent and simply treat them as root (similar to createSourceControlResourceGroup) - readonly root: TestItem; + readonly items: TestItemCollection; /** - * Creates a new managed {@link TestItem} instance as a child of this - * one. - * @param id Unique identifier for the TestItem. - * @param label Human-readable label of the test item. - * @param parent Parent of the item. This is required; top-level items - * should be created as children of the {@link root}. - * @param uri URI this TestItem is associated with. May be a file or directory. - * @param data Custom data to be stored in {@link TestItem.data} + * Creates a configuration used for running tests. Extensions must create + * at least one configuration in order for tests to be run. + * @param label Human-readable label for this configuration + * @param group Configures where this configuration is grouped in the UI. + * @param runHandler Function called to start a test run + * @param isDefault Whether this is the default action for the group */ - createTestItem( - id: string, + createRunConfiguration( label: string, - parent: TestItem, - uri?: Uri, - data?: TChild, - ): TestItem; - + group: TestRunConfigurationGroup, + runHandler: TestRunHandler, + isDefault?: boolean + ): TestRunConfiguration; /** * A function provided by the extension that the editor may call to request * children of a test item, if the {@link TestItem.canExpand} is `true`. * When called, the item should discover children and call - * {@link TestController.createTestItem} as children are discovered. + * {@link vscode.test.createTestItem} as children are discovered. * * The item in the explorer will automatically be marked as "busy" until * the function returns or the returned thenable resolves. @@ -151,21 +244,9 @@ * @param item An unresolved test item for which * children are being requested */ - resolveChildrenHandler?: (item: TestItem) => Thenable | void; + // todo@API maybe just `resolveHandler` so that we could extends its usage in the future? + resolveChildrenHandler?: (item: TestItem) => Thenable | void; - /** - * Starts a test run. When called, the controller should call - * {@link TestController.createTestRun}. All tasks associated with the - * run should be created before the function returns or the reutrned - * 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. - */ - runHandler?: (request: TestRunRequest, token: CancellationToken) => Thenable | void; /** * Creates a {@link TestRun}. This should be called by the * {@link TestRunner} when a request is made to execute tests, and may also @@ -173,6 +254,10 @@ * that are included in the results will be moved into the * {@link TestResultState.Pending} 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 @@ -182,7 +267,7 @@ * 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. */ - createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun; + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun; /** * Unregisters the test controller, disposing of its associated tests @@ -194,38 +279,41 @@ /** * Options given to {@link test.runTests}. */ - export class TestRunRequest { + export class TestRunRequest { /** - * Array of specific tests to run. The controllers should run all of the - * given tests and all children of the given tests, excluding any tests - * that appear in {@link TestRunRequest.exclude}. + * Filter for specific tests to run. If given, the extension should run all + * of the given tests and all children of the given tests, excluding + * any tests that appear in {@link TestRunRequest.exclude}. If this is + * not given, then the extension should simply run all tests. */ - tests: TestItem[]; + include?: TestItem[]; /** * An array of tests the user has marked as excluded in the editor. May be * omitted if no exclusions were requested. Test controllers should not run * excluded tests or any children of excluded tests. */ - exclude?: TestItem[]; + exclude?: TestItem[]; /** - * Whether tests in this run should be debugged. + * The configuration 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 configuration. */ - debug: boolean; + configuration?: TestRunConfiguration; /** - * @param tests Array of specific tests to run. + * @param tests Array of specific tests to run, or undefined to run all tests * @param exclude Tests to exclude from the run - * @param debug Whether tests in this run should be debugged. + * @param configuration The run configuration used for this request. */ - constructor(tests: readonly TestItem[], exclude?: readonly TestItem[], debug?: boolean); + constructor(include?: readonly TestItem[], exclude?: readonly TestItem[], configuration?: TestRunConfiguration); } /** * Options given to {@link TestController.runTests} */ - export interface TestRun { + 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 @@ -249,7 +337,7 @@ * @param duration Optionally sets how long the test took to run, in milliseconds */ //todo@API is this "update" state or set final state? should this be called setTestResult? - setState(test: TestItem, state: TestResultState, duration?: number): void; + setState(test: TestItem, state: TestResultState, duration?: number): void; /** * Appends a message, such as an assertion error, to the test item. @@ -260,7 +348,7 @@ * @param test The test to update * @param message The message to add */ - appendMessage(test: TestItem, message: TestMessage): void; + appendMessage(test: TestItem, message: TestMessage): void; /** * Appends raw output from the test runner. On the user's request, the @@ -276,19 +364,51 @@ * Signals that the end of the test run. Any tests whose states have not * been updated will be moved into the {@link TestResultState.Unset} state. */ + // todo@api is the Unset logic smart and only considering those tests that are included? end(): void; } + /** + * Collection of test items, found in {@link TestItem.children} and + * {@link TestController.items}. + */ + export interface TestItemCollection { + /** + * A read-only array of all the test items children. Can be retrieved, or + * set in order to replace children in the collection. + */ + // todo@API unsure if this should readonly and have a separate replaceAll-like function + all: readonly TestItem[]; + + /** + * Adds the test item to the children. If an item with the same ID already + * exists, it'll be replaced. + */ + add(item: TestItem): void; + + /** + * Removes the a single test item from the collection. + */ + //todo@API `delete` as Map, EnvironmentVariableCollection, DiagnosticCollection + remove(itemId: string): void; + + /** + * Efficiently gets a test item by ID, if it exists, in the children. + */ + get(itemId: string): TestItem | undefined; + } + /** * 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 { + export interface TestItem { /** * Unique identifier for the TestItem. This is used to correlate * test results and tests in the document with those in the workspace * (test explorer). This must not change for the lifetime of the TestItem. */ + // todo@API globally vs extension vs controller unique. I would strongly recommend non-global readonly id: string; /** @@ -299,16 +419,15 @@ /** * A mapping of children by ID to the associated TestItem instances. */ - //todo@API use array over es6-map - readonly children: ReadonlyMap; + readonly children: TestItemCollection; /** - * The parent of this item, if any. Assigned automatically when calling - * {@link TestItem.addChild}. + * The parent of this item, given in {@link vscode.test.createTestItem}. + * This is undefined top-level items in the `TestController`, and for + * items that aren't yet assigned to a parent. */ - //todo@API jsdoc outdated (likely in many places) - //todo@API is this needed? with TestController#root this is never undefined but `root` is questionable (see above) - readonly parent?: TestItem; + // todo@api obsolete? doc is outdated at least + readonly parent?: TestItem; /** * Indicates whether this test item may have children discovered by resolving. @@ -350,26 +469,6 @@ */ error?: string | MarkdownString; - /** - * Whether this test item can be run by providing it in the - * {@link TestRunRequest.tests} array. Defaults to `true`. - */ - runnable: boolean; - - /** - * Whether this test item can be debugged by providing it in the - * {@link TestRunRequest.tests} array. Defaults to `false`. - */ - debuggable: boolean; - - /** - * Custom extension data on the item. This data will never be serialized - * or shared outside the extenion who created the item. - */ - // todo@API remove? this brings in a ton of generics, into every single type... extension own test items, they create and dispose them, - // and therefore can have a WeakMap or Map to the side. - data: T; - /** * Marks the test as outdated. This can happen as a result of file changes, * for example. In "auto run" mode, tests that are outdated will be @@ -378,13 +477,8 @@ * * Extensions should generally not override this method. */ - // todo@API boolean property instead? stale: boolean - invalidate(): void; - - /** - * Removes the test and its children from the tree. - */ - dispose(): void; + // todo@api still unsure about this + invalidateResults(): void; } /** @@ -404,6 +498,7 @@ // Test run has been skipped Skipped = 5, // Test run failed for some other reason (compilation error, timeout, etc) + // todo@api could I just use `Skipped` and TestItem#error? Errored = 6 } diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts index cd5cd442d0..9fcd5f5b8f 100644 --- a/test/integration/goTestExplorer.test.ts +++ b/test/integration/goTestExplorer.test.ts @@ -9,11 +9,11 @@ import { SymbolKind, Range, Position, - TextDocumentContentChangeEvent + TestItemCollection } from 'vscode'; import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules'; -import { TestExplorer } from '../../src/goTestExplorer'; -import { MockTestController, MockTestWorkspace } from '../mocks/MockTest'; +import { TestExplorer, testID } from '../../src/goTestExplorer'; +import { MockTestController, MockTestItem, MockTestWorkspace } from '../mocks/MockTest'; type Files = Record; @@ -42,6 +42,12 @@ function setup(folders: string[], files: Files) { const ctrl = new MockTestController(); const expl = new TestExplorer(ctrl, ws, rethrow, symbols); + // Override TestExplorer.createItem so we can control the TestItem implementation + expl.createItem = (label: string, uri: Uri, kind: string, name?: string) => { + const id = testID(uri, kind, name); + return new MockTestItem(id, label, uri, ctrl); + }; + function walk(dir: Uri, modpath?: string) { const dirs: Uri[] = []; for (const [name, type] of ws.fs.dirs.get(dir.toString())) { @@ -65,22 +71,22 @@ function setup(folders: string[], files: Files) { return { ctrl, expl, ws }; } -function assertTestItems(root: TestItem, expect: string[]) { +function assertTestItems(items: TestItemCollection, expect: string[]) { const actual: string[] = []; - function walk(item: TestItem) { - for (const child of item.children.values()) { + function walk(items: TestItemCollection) { + for (const child of items.all) { actual.push(child.id); - walk(child); + walk(child.children); } } - walk(root); + walk(items); assert.deepStrictEqual(actual, expect); } suite('Test Explorer', () => { suite('Items', () => { interface TC extends TestCase { - item?: [string, string][]; + item?: ([string, string, string] | [string, string, string, string])[]; expect: string[]; } @@ -125,7 +131,7 @@ suite('Test Explorer', () => { '/src/proj/go.mod': 'module test', '/src/proj/main.go': 'package main' }, - item: [['file:///src/proj?module', 'test']], + item: [['test', '/src/proj', 'module']], expect: [] }, 'Root package': { @@ -134,7 +140,7 @@ suite('Test Explorer', () => { '/src/proj/go.mod': 'module test', '/src/proj/main_test.go': 'package main' }, - item: [['file:///src/proj?module', 'test']], + item: [['test', '/src/proj', 'module']], expect: ['file:///src/proj/main_test.go?file'] }, 'Sub packages': { @@ -144,7 +150,7 @@ suite('Test Explorer', () => { '/src/proj/foo/main_test.go': 'package main', '/src/proj/bar/main_test.go': 'package main' }, - item: [['file:///src/proj?module', 'test']], + item: [['test', '/src/proj', 'module']], expect: ['file:///src/proj/foo?package', 'file:///src/proj/bar?package'] }, 'Nested packages': { @@ -155,7 +161,7 @@ suite('Test Explorer', () => { '/src/proj/foo/main_test.go': 'package main', '/src/proj/foo/bar/main_test.go': 'package main' }, - item: [['file:///src/proj?module', 'test']], + item: [['test', '/src/proj', 'module']], expect: [ 'file:///src/proj/foo?package', 'file:///src/proj/foo/bar?package', @@ -171,8 +177,8 @@ suite('Test Explorer', () => { '/src/proj/pkg/main.go': 'package main' }, item: [ - ['file:///src/proj?module', 'test'], - ['file:///src/proj/pkg?package', 'pkg'] + ['test', '/src/proj', 'module'], + ['pkg', '/src/proj/pkg', 'package'] ], expect: [] }, @@ -184,8 +190,8 @@ suite('Test Explorer', () => { '/src/proj/pkg/sub/main_test.go': 'package main' }, item: [ - ['file:///src/proj?module', 'test'], - ['file:///src/proj/pkg?package', 'pkg'] + ['test', '/src/proj', 'module'], + ['pkg', '/src/proj/pkg', 'package'] ], expect: ['file:///src/proj/pkg/main_test.go?file'] }, @@ -196,8 +202,8 @@ suite('Test Explorer', () => { '/src/proj/pkg/sub/main_test.go': 'package main' }, item: [ - ['file:///src/proj?module', 'test'], - ['file:///src/proj/pkg?package', 'pkg'] + ['test', '/src/proj', 'module'], + ['pkg', '/src/proj/pkg', 'package'] ], expect: [] } @@ -210,8 +216,8 @@ suite('Test Explorer', () => { '/src/proj/main_test.go': 'package main' }, item: [ - ['file:///src/proj?module', 'test'], - ['file:///src/proj/main_test.go?file', 'main_test.go'] + ['test', '/src/proj', 'module'], + ['main_test.go', '/src/proj/main_test.go', 'file'] ], expect: [] }, @@ -229,8 +235,8 @@ suite('Test Explorer', () => { ` }, item: [ - ['file:///src/proj?module', 'test'], - ['file:///src/proj/main_test.go?file', 'main_test.go'] + ['test', '/src/proj', 'module'], + ['main_test.go', '/src/proj/main_test.go', 'file'] ], expect: [ 'file:///src/proj/main_test.go?test#TestFoo', @@ -246,16 +252,17 @@ suite('Test Explorer', () => { for (const m in cases[n]) { test(m, async () => { const { workspace, files, expect, item: itemData = [] } = cases[n][m]; - const { ctrl } = setup(workspace, files); + const { expl, ctrl } = setup(workspace, files); - let item: TestItem = ctrl.root; - for (const [id, label] of itemData) { - const uri = Uri.parse(id).with({ query: '' }); - item = ctrl.createTestItem(id, label, item, uri); + let item: TestItem | undefined; + for (const [label, uri, kind, name] of itemData) { + const child = expl.createItem(label, Uri.parse(uri), kind, name); + (item?.children || ctrl.items).add(child); + item = child; } await ctrl.resolveChildrenHandler(item); - const actual = Array.from(item.children.values()).map((x) => x.id); + const actual = (item?.children || ctrl.items).all.map((x) => x.id); assert.deepStrictEqual(actual, expect); }); } @@ -308,7 +315,7 @@ suite('Test Explorer', () => { await expl.didOpenTextDocument(ws.fs.files.get(open)); - assertTestItems(ctrl.root, expect); + assertTestItems(ctrl.items, expect); }); } }); @@ -367,20 +374,18 @@ suite('Test Explorer', () => { await expl.didOpenTextDocument(ws.fs.files.get(open)); - assertTestItems(ctrl.root, expect.before); + assertTestItems(ctrl.items, expect.before); for (const [file, contents] of changes) { const doc = ws.fs.files.get(file); doc.contents = contents; await expl.didChangeTextDocument({ document: doc, - get contentChanges(): TextDocumentContentChangeEvent[] { - throw new Error('not implemented'); - } + contentChanges: [] }); } - assertTestItems(ctrl.root, expect.after); + assertTestItems(ctrl.items, expect.after); }); } }); diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts index 74a4b4b5c6..374ba981f7 100644 --- a/test/mocks/MockTest.ts +++ b/test/mocks/MockTest.ts @@ -2,16 +2,18 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import path = require('path'); import { - CancellationToken, EndOfLine, - FileSystem, FileType, MarkdownString, Position, Range, TestController, TestItem, + TestItemCollection, TestRun, + TestRunConfiguration, + TestRunConfigurationGroup, + TestRunHandler, TestRunRequest, TextDocument, TextLine, @@ -20,15 +22,48 @@ import { } from 'vscode'; import { TestExplorer } from '../../src/goTestExplorer'; -export class MockTestItem implements TestItem { - constructor( - public id: string, - public label: string, - public parent: TestItem | undefined, - public uri: Uri | undefined, - public data: T - ) {} +class MockTestCollection implements TestItemCollection { + constructor(private item: MockTestItem | MockTestController) {} + + private m = new Map(); + + get all(): TestItem[] { + return Array.from(this.m.values()); + } + + add(item: TestItem): void { + if (this.m.has(item.id)) { + throw new Error(`Test item ${item.id} already exists`); + } + + if (!(item instanceof MockTestItem)) { + throw new Error('not a mock'); + } else if (this.item instanceof MockTestItem) { + item.parent = this.item; + } + + this.m.set(item.id, item); + } + + remove(id: string): void { + this.m.delete(id); + } + + get(id: string): TestItem { + return this.m.get(id); + } +} + +export class MockTestItem implements TestItem { + private static idNum = 0; + private idNum: number; + + constructor(public id: string, public label: string, public uri: Uri | undefined, public ctrl: MockTestController) { + this.idNum = MockTestItem.idNum; + MockTestItem.idNum++; + } + parent: TestItem | undefined; canResolveChildren: boolean; busy: boolean; description?: string; @@ -37,40 +72,47 @@ export class MockTestItem implements TestItem { runnable: boolean; debuggable: boolean; - children = new Map(); + children: MockTestCollection = new MockTestCollection(this); - invalidate(): void {} + invalidateResults(): void {} dispose(): void { if (this.parent instanceof MockTestItem) { - this.parent.children.delete(this.id); + this.parent.children.remove(this.id); } } } -export class MockTestController implements TestController { - root = new MockTestItem('Go', 'Go', void 0, void 0, void 0); +class MockTestRunConfiguration implements TestRunConfiguration { + constructor( + public label: string, + public group: TestRunConfigurationGroup, + public runHandler: TestRunHandler, + public isDefault: boolean + ) {} + + configureHandler?: () => void; + dispose(): void {} +} + +export class MockTestController implements TestController { + id = 'go'; + label = 'Go'; + items = new MockTestCollection(this); - resolveChildrenHandler?: (item: TestItem) => void | Thenable; - runHandler?: (request: TestRunRequest, token: CancellationToken) => void | Thenable; + resolveChildrenHandler?: (item: TestItem) => void | Thenable; - createTestItem( - id: string, - label: string, - parent: TestItem, - uri?: Uri, - data?: TChild - ): TestItem { - if (parent.children.has(id)) { - throw new Error(`Test item ${id} already exists`); - } - const item = new MockTestItem(id, label, parent, uri, data); - (parent).children.set(id, item); - return item; + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun { + throw new Error('Method not implemented.'); } - createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun { - throw new Error('Method not implemented.'); + createRunConfiguration( + label: string, + group: TestRunConfigurationGroup, + runHandler: TestRunHandler, + isDefault?: boolean + ): TestRunConfiguration { + return new MockTestRunConfiguration(label, group, runHandler, isDefault); } dispose(): void {} @@ -78,7 +120,7 @@ export class MockTestController implements TestController { type DirEntry = [string, FileType]; -export class MockTestFileSystem implements TestExplorer.FileSystem { +class MockTestFileSystem implements TestExplorer.FileSystem { constructor(public dirs: Map, public files: Map) {} readDirectory(uri: Uri): Thenable<[string, FileType][]> { @@ -163,7 +205,7 @@ export class MockTestWorkspace implements TestExplorer.Workspace { } } -export class MockTestDocument implements TextDocument { +class MockTestDocument implements TextDocument { constructor( public uri: Uri, private _contents: string, From aab850541eafad2ea8ce96917f359142cd5f0e97 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Wed, 21 Jul 2021 16:58:35 -0500 Subject: [PATCH 20/34] src/goTestExplorer: improve test output handling --- package.json | 6 +++++ src/goTestExplorer.ts | 52 ++++++++++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 44e34f6898..b0d737bbde 100644 --- a/package.json +++ b/package.json @@ -1303,6 +1303,12 @@ "description": "Include benchmarks when running all tests in a group", "scope": "resource" }, + "go.testExplorerConcatenateMessages": { + "type": "boolean", + "default": true, + "description": "If true, test log messages associated with a given location will be shown as a single message", + "scope": "resource" + }, "go.generateTestsFlags": { "type": "array", "items": { diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index a596160ab0..9e702f32bf 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -844,10 +844,10 @@ function consumeGoTestEvent( // Search recorded test output for `file.go:123: Foo bar` and attach a message // to the corresponding location. -function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { +function processRecordedOutput(run: TestRun, test: TestItem, output: string[], concat: boolean) { // mostly copy and pasted from https://gitlab.com/firelizzard/vscode-go-test-adapter/-/blob/733443d229df68c90145a5ae7ed78ca64dec6f43/src/tests.ts type message = { all: string; error?: string }; - const parsed = new Map(); + const parsed = new Map(); let current: message | undefined; const uri = Uri.parse(test.id); @@ -867,7 +867,9 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { const fileAndLine = item.match(/^\s*(?.*\.go):(?\d+): ?(?.*\n)$/); if (fileAndLine) { current = { all: fileAndLine.groups.message }; - parsed.set(`${fileAndLine.groups.file}:${fileAndLine.groups.line}`, current); + const key = `${fileAndLine.groups.file}:${fileAndLine.groups.line}`; + if (parsed.has(key)) parsed.get(key).push(current); + else parsed.set(key, [current]); continue; } @@ -882,20 +884,43 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: string[]) { } else if (!entry.groups.name && current.error) current.error += entry.groups.message; } - const dir = Uri.joinPath(test.uri, '..'); - for (const [location, { all, error }] of parsed.entries()) { - const hover = (error || all).trim(); - const message = hover.split('\n')[0].replace(/:\s+$/, ''); + let append: (dir: Uri, file: string, line: number, severity: TestMessageSeverity, messages: string[]) => void; + if (concat) { + append = (dir, file, line, severity, messages) => { + const location = new Location(Uri.joinPath(dir, file), new Position(line, 0)); + const message = messages.join('\n'); + run.appendMessage(test, { severity, message, location }); + }; + } else { + append = (dir, file, line, severity, messages) => { + const location = new Location(Uri.joinPath(dir, file), new Position(line, 0)); + if (messages.length > 100) { + window.showWarningMessage( + `Only the first 100 messages generated by ${test.label} at ${file}:${line} are shown, for performance reasons` + ); + messages.splice(100); + } + for (const message of messages) { + run.appendMessage(test, { severity, message, location }); + } + }; + } + + const dir = Uri.joinPath(test.uri, '..'); + for (const [location, entries] of parsed.entries()) { const i = location.lastIndexOf(':'); const file = location.substring(0, i); const line = Number(location.substring(i + 1)) - 1; - run.appendMessage(test, { - message, - severity: error ? TestMessageSeverity.Error : TestMessageSeverity.Information, - location: new Location(Uri.joinPath(dir, file), new Position(line, 0)) + let severity = TestMessageSeverity.Information; + const messages = entries.map(({ all, error }) => { + if (error) severity = TestMessageSeverity.Error; + const hover = (error || all).trim(); + return hover.split('\n')[0].replace(/:\s+$/, ''); }); + + append(dir, file, line, severity, messages); } } @@ -928,10 +953,10 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { const run = expl.ctrl.createTestRun(request); const outputChannel = new TestRunOutput(run); - const goConfig = getGoConfig(); for (const [dir, items] of collected.entries()) { const uri = Uri.parse(dir); const isMod = await isModSupported(uri, true); + const goConfig = getGoConfig(uri); const flags = getTestFlags(goConfig); // Separate tests and benchmarks and mark them as queued for execution. @@ -999,8 +1024,9 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { } // Create test messages + const concat = goConfig.get('testExplorerConcatenateMessages'); for (const [test, output] of record.entries()) { - processRecordedOutput(run, test, output); + processRecordedOutput(run, test, output, concat); } } From 15d1d2e438eed580b2ebac424f8e9172b035bf13 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 22 Jul 2021 18:44:16 -0500 Subject: [PATCH 21/34] src/goTestExplorer: update to final API --- src/goTestExplorer.ts | 250 ++++----- src/vscode.d.ts | 450 ++++++++++++++++ src/vscode.proposed.d.ts | 654 ------------------------ test/integration/goTestExplorer.test.ts | 13 +- test/mocks/MockTest.ts | 44 +- 5 files changed, 595 insertions(+), 816 deletions(-) create mode 100644 src/vscode.d.ts delete mode 100644 src/vscode.proposed.d.ts diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 9e702f32bf..0f7f48a98d 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -1,33 +1,31 @@ import { - test, - workspace, - ExtensionContext, - TestController, - TestItem, - TextDocument, - Uri, + tests, + window, + workspace as vsWorkspace, + CancellationToken, + ConfigurationChangeEvent, DocumentSymbol, - SymbolKind, + ExtensionContext, + FileSystem as vsFileSystem, FileType, - WorkspaceFolder, - TestRunRequest, - OutputChannel, - TestResultState, - TestRun, - TestMessageSeverity, Location, + OutputChannel, Position, - TextDocumentChangeEvent, - WorkspaceFoldersChangeEvent, - CancellationToken, - FileSystem as vsFileSystem, - workspace as vsWorkspace, - ConfigurationChangeEvent, - TestMessage, Range, + SymbolKind, + TestController, + TestItem, TestItemCollection, - TestRunConfigurationGroup, - window + TestMessage, + TestRun, + TestRunProfileKind, + TestRunRequest, + TextDocument, + TextDocumentChangeEvent, + Uri, + workspace, + WorkspaceFolder, + WorkspaceFoldersChangeEvent } from 'vscode'; import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; @@ -54,14 +52,14 @@ export namespace TestExplorer { export class TestExplorer { static setup(context: ExtensionContext): TestExplorer { - const ctrl = test.createTestController('go', 'Go'); + const ctrl = tests.createTestController('go', 'Go'); const inst = new this( ctrl, workspace, (e) => console.log(e), new GoDocumentSymbolProvider().provideDocumentSymbols ); - resolveChildren(inst); + resolve(inst); context.subscriptions.push(workspace.onDidChangeConfiguration((x) => inst.didChangeConfiguration(x))); @@ -84,13 +82,13 @@ export class TestExplorer { public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable ) { // TODO handle cancelation of test runs - ctrl.resolveChildrenHandler = (...args) => resolveChildren(this, ...args); - ctrl.createRunConfiguration('Go [Run]', TestRunConfigurationGroup.Run, (rq) => runTest(this, rq), true); + ctrl.resolveHandler = (...args) => resolve(this, ...args); + ctrl.createRunProfile('Go [Run]', TestRunProfileKind.Run, (rq) => runTest(this, rq), true); } // Create an item. createItem(label: string, uri: Uri, kind: string, name?: string): TestItem { - return test.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' })); + return this.ctrl.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' })); } // Retrieve an item. @@ -124,7 +122,7 @@ export class TestExplorer { } resolve(item?: TestItem) { - resolveChildren(this, item); + resolve(this, item); } async didOpenTextDocument(doc: TextDocument) { @@ -148,7 +146,7 @@ export class TestExplorer { } async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { - for (const item of this.ctrl.items.all) { + for (const item of collect(this.ctrl.items)) { const uri = Uri.parse(item.id); if (uri.query === 'package') { continue; @@ -161,7 +159,7 @@ export class TestExplorer { } if (e.added) { - await resolveChildren(this); + await resolve(this); } } @@ -176,7 +174,7 @@ export class TestExplorer { async didDeleteFile(file: Uri) { const id = testID(file, 'file'); function find(children: TestItemCollection): TestItem { - for (const item of children.all) { + for (const item of collect(children)) { if (item.id === id) { return item; } @@ -202,7 +200,7 @@ export class TestExplorer { async didChangeConfiguration(e: ConfigurationChangeEvent) { let update = false; - for (const item of this.ctrl.items.all) { + for (const item of collect(this.ctrl.items)) { if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) { dispose(item); update = true; @@ -210,7 +208,7 @@ export class TestExplorer { } if (update) { - resolveChildren(this); + resolve(this); } } } @@ -228,6 +226,12 @@ export function testID(uri: Uri, kind: string, name?: string): string { return uri.toString(); } +function collect(items: TestItemCollection): TestItem[] { + const r: TestItem[] = []; + items.forEach((i) => r.push(i)); + return r; +} + function getChildren(parent: TestItem | TestItemCollection): TestItemCollection { if ('children' in parent) { return parent.children; @@ -236,7 +240,7 @@ function getChildren(parent: TestItem | TestItemCollection): TestItemCollection } function dispose(item: TestItem) { - item.parent.children.remove(item.id); + item.parent.children.delete(item.id); } // Dispose of the item if it has no children, recursively. This facilitates @@ -248,7 +252,7 @@ function disposeIfEmpty(item: TestItem) { return; } - if (item.children.all.length > 0) { + if (item.children.size > 0) { return; } @@ -261,13 +265,13 @@ function disposeIfEmpty(item: TestItem) { // there are situations where they must be discarded. function discardChildren(item: TestItem) { item.canResolveChildren = false; - item.children.all.forEach(dispose); + item.children.forEach(dispose); } // If a test/benchmark with children is relocated, update the children's // location. function relocateChildren(item: TestItem) { - for (const child of item.children.all) { + for (const child of collect(item.children)) { child.range = item.range; relocateChildren(child); } @@ -421,7 +425,7 @@ async function processDocument(expl: TestExplorer, doc: TextDocument, ranges?: R const symbols = await expl.provideDocumentSymbols(doc, null); for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol); - for (const child of item.children.all) { + for (const child of collect(item.children)) { const uri = Uri.parse(child.id); if (!seen.has(uri.fragment)) { dispose(child); @@ -560,11 +564,11 @@ async function documentUpdate(expl: TestExplorer, doc: TextDocument, ranges?: Ra } // TestController.resolveChildrenHandler callback -async function resolveChildren(expl: TestExplorer, item?: TestItem) { +async function resolve(expl: TestExplorer, item?: TestItem) { // Expand the root item - find all modules and workspaces if (!item) { // Dispose of package entries at the root if they are now part of a workspace folder - for (const item of expl.ctrl.items.all) { + for (const item of collect(expl.ctrl.items)) { const uri = Uri.parse(item.id); if (uri.query !== 'package') { continue; @@ -644,12 +648,12 @@ async function collectTests( const uri = Uri.parse(item.id); if (!uri.fragment) { - if (item.children.all.length === 0) { - await resolveChildren(expl, item); + if (item.children.size === 0) { + await resolve(expl, item); } const runBench = getGoConfig(item.uri).get('testExplorerRunBenchmarks'); - for (const child of item.children.all) { + for (const child of collect(item.children)) { const uri = Uri.parse(child.id); if (uri.query === 'benchmark' && !runBench) continue; await collectTests(expl, child, excluded, functions, docs); @@ -732,12 +736,12 @@ function consumeGoBenchmarkEvent( switch (e.Action) { case 'fail': // Failed - run.setState(test, TestResultState.Failed); + run.failed(test, { message: 'Failed' }); complete.add(test); break; case 'skip': // Skipped - run.setState(test, TestResultState.Skipped); + run.skipped(test); complete.add(test); break; } @@ -769,15 +773,10 @@ function consumeGoBenchmarkEvent( // If output includes benchmark results, the benchmark passed. If output // only includes the benchmark name, the benchmark is running. if (m.groups.result) { - run.appendMessage(test, { - message: m.groups.result, - severity: TestMessageSeverity.Information, - location: new Location(test.uri, test.range.start) - }); - run.setState(test, TestResultState.Passed); + run.passed(test); complete.add(test); } else { - run.setState(test, TestResultState.Running); + run.started(test); } } @@ -785,9 +784,9 @@ function consumeGoBenchmarkEvent( function passBenchmarks(run: TestRun, items: Record, complete: Set) { function pass(item: TestItem) { if (!complete.has(item)) { - run.setState(item, TestResultState.Passed); + run.passed(item); } - for (const child of item.children.all) { + for (const child of collect(item.children)) { pass(child); } } @@ -803,6 +802,7 @@ function consumeGoTestEvent( run: TestRun, tests: Record, record: Map, + concat: boolean, e: GoTestOutput ) { const test = resolveTestName(expl, tests, e.Test); @@ -811,44 +811,61 @@ function consumeGoTestEvent( } switch (e.Action) { + case 'cont': + // ignore + break; + case 'run': - run.setState(test, TestResultState.Running); - return; + run.started(test); + break; case 'pass': - run.setState(test, TestResultState.Passed, e.Elapsed * 1000); - return; + run.passed(test, e.Elapsed * 1000); + break; - case 'fail': - run.setState(test, TestResultState.Failed, e.Elapsed * 1000); - return; + case 'fail': { + const messages = parseOutput(run, test, record.get(test) || []); + + if (!concat) { + run.failed(test, messages, e.Elapsed * 1000); + break; + } + + const merged = new Map(); + for (const { message, location } of messages) { + const loc = `${location.uri}:${location.range.start.line}`; + if (merged.has(loc)) { + merged.get(loc).message += '\n' + message; + } else { + merged.set(loc, { message, location }); + } + } + + run.failed(test, Array.from(merged.values()), e.Elapsed * 1000); + break; + } case 'skip': - run.setState(test, TestResultState.Skipped); - return; + run.skipped(test); + break; case 'output': if (/^(=== RUN|\s*--- (FAIL|PASS): )/.test(e.Output)) { - return; + break; } if (record.has(test)) record.get(test).push(e.Output); else record.set(test, [e.Output]); - return; + break; default: console.log(e); - return; + break; } } -// Search recorded test output for `file.go:123: Foo bar` and attach a message -// to the corresponding location. -function processRecordedOutput(run: TestRun, test: TestItem, output: string[], concat: boolean) { - // mostly copy and pasted from https://gitlab.com/firelizzard/vscode-go-test-adapter/-/blob/733443d229df68c90145a5ae7ed78ca64dec6f43/src/tests.ts - type message = { all: string; error?: string }; - const parsed = new Map(); - let current: message | undefined; +function parseOutput(run: TestRun, test: TestItem, output: string[]): TestMessage[] { + const messages: TestMessage[] = []; const uri = Uri.parse(test.id); const gotI = output.indexOf('got:\n'); @@ -857,71 +874,26 @@ function processRecordedOutput(run: TestRun, test: TestItem, output: string[], c const got = output.slice(gotI + 1, wantI).join(''); const want = output.slice(wantI + 1).join(''); const message = TestMessage.diff('Output does not match', want, got); - message.severity = TestMessageSeverity.Error; message.location = new Location(test.uri, test.range.start); - run.appendMessage(test, message); + messages.push(message); output = output.slice(0, gotI); } - for (const item of output) { - const fileAndLine = item.match(/^\s*(?.*\.go):(?\d+): ?(?.*\n)$/); - if (fileAndLine) { - current = { all: fileAndLine.groups.message }; - const key = `${fileAndLine.groups.file}:${fileAndLine.groups.line}`; - if (parsed.has(key)) parsed.get(key).push(current); - else parsed.set(key, [current]); - continue; + let current: Location; + const dir = Uri.joinPath(test.uri, '..'); + for (const line of output) { + const m = line.match(/^\s*(?.*\.go):(?\d+): ?(?.*\n)$/); + if (m) { + const file = Uri.joinPath(dir, m.groups.file); + const ln = Number(m.groups.line) - 1; // VSCode uses 0-based line numbering (internally) + current = new Location(file, new Position(ln, 0)); + messages.push({ message: m.groups.message, location: current }); + } else if (current) { + messages.push({ message: line, location: current }); } - - if (!current) continue; - - const entry = item.match(/^\s*(?:(?[^:]+): *| +)\t(?.*\n)$/); - if (!entry) continue; - - current.all += entry.groups.message; - if (entry.groups.name === 'Error') { - current.error = entry.groups.message; - } else if (!entry.groups.name && current.error) current.error += entry.groups.message; - } - - let append: (dir: Uri, file: string, line: number, severity: TestMessageSeverity, messages: string[]) => void; - if (concat) { - append = (dir, file, line, severity, messages) => { - const location = new Location(Uri.joinPath(dir, file), new Position(line, 0)); - const message = messages.join('\n'); - run.appendMessage(test, { severity, message, location }); - }; - } else { - append = (dir, file, line, severity, messages) => { - const location = new Location(Uri.joinPath(dir, file), new Position(line, 0)); - if (messages.length > 100) { - window.showWarningMessage( - `Only the first 100 messages generated by ${test.label} at ${file}:${line} are shown, for performance reasons` - ); - messages.splice(100); - } - - for (const message of messages) { - run.appendMessage(test, { severity, message, location }); - } - }; } - const dir = Uri.joinPath(test.uri, '..'); - for (const [location, entries] of parsed.entries()) { - const i = location.lastIndexOf(':'); - const file = location.substring(0, i); - const line = Number(location.substring(i + 1)) - 1; - - let severity = TestMessageSeverity.Information; - const messages = entries.map(({ all, error }) => { - if (error) severity = TestMessageSeverity.Error; - const hover = (error || all).trim(); - return hover.split('\n')[0].replace(/:\s+$/, ''); - }); - - append(dir, file, line, severity, messages); - } + return messages; } function checkForBuildFailure(run: TestRun, tests: Record, output: string[]) { @@ -931,7 +903,8 @@ function checkForBuildFailure(run: TestRun, tests: Record, out if (!output.some((x) => rePkg.test(x))) return; for (const name in tests) { - run.setState(tests[name], TestResultState.Errored); + // TODO(firelizzard18): Previously, there was an Errored state that differed from Failed. + run.failed(tests[name], { message: 'Compilation failed' }); } } @@ -971,7 +944,7 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { continue; } - run.setState(item, TestResultState.Queued); + run.enqueued(item); discardChildren(item); if (uri.query === 'benchmark') { @@ -984,6 +957,7 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { const record = new Map(); const testFns = Object.keys(tests); const benchmarkFns = Object.keys(benchmarks); + const concat = goConfig.get('testExplorerConcatenateMessages'); // Run tests if (testFns.length > 0) { @@ -994,7 +968,7 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { outputChannel, dir: uri.fsPath, functions: testFns, - goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, e) + goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, concat, e) }); if (!success) { checkForBuildFailure(run, tests, outputChannel.lines); @@ -1022,12 +996,6 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { checkForBuildFailure(run, benchmarks, outputChannel.lines); } } - - // Create test messages - const concat = goConfig.get('testExplorerConcatenateMessages'); - for (const [test, output] of record.entries()) { - processRecordedOutput(run, test, output, concat); - } } run.end(); diff --git a/src/vscode.d.ts b/src/vscode.d.ts new file mode 100644 index 0000000000..a7dc594a42 --- /dev/null +++ b/src/vscode.d.ts @@ -0,0 +1,450 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export namespace tests { + /** + * Creates a new test controller. + * + * @param id Identifier for the controller, must be globally unique. + */ + 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 + } + + /** + * 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 in the `Debug` + * group. If there is no such configuration, the default will be used. + */ + 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. + */ + readonly kind: TestRunProfileKind; + + /** + * Controls whether this profile is the default action that will + * be taken when its group is actions. For example, if the user clicks + * the generic "run all" button, then the default profile for + * {@link TestRunProfileKind.Run} will be executed. + */ + isDefault: boolean; + + /** + * 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. + */ + configureHandler?: () => void; + + /** + * Handler called to start a test run. When invoked, the function should + * {@link TestController.createTestRun} at least once, and all tasks + * associated with the run should be created before the function returns + * or the reutrned 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. + */ + runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void; + + /** + * Deletes the run profile. + */ + dispose(): void; + } + + /** + * Entry point to discover and execute tests. It contains {@link items} which + * are used to populate the editor UI, and is associated with + * {@link 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, + */ + readonly id: string; + + /** + * Human-readable label for the test controller. + */ + label: string; + + /** + * Available test items. Tests in the workspace should be added in this + * collection. The extension controls when to add these, although the + * editor may request children using the {@link resolveHandler}, + * and the extension should add tests for a file when + * {@link vscode.workspace.onDidOpenTextDocument} fires in order for + * decorations for tests within the file to be visible. + * + * Tests in this collection should be watched and updated by the extension + * as files change. See {@link resolveHandler} for details around + * for the lifecycle of watches. + */ + 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 Human-readable label for this profile + * @param group Configures where this profile is grouped in the UI. + * @param runHandler Function called to start a test run + * @param isDefault Whether this is the default action for the group + */ + createRunProfile( + label: string, + group: TestRunProfileKind, + runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void, + isDefault?: boolean + ): 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. + * + * The item in the explorer will automatically be marked as "busy" until + * the function returns or the returned thenable resolves. + * + * The handler will be called `undefined` to resolve the controller's + * initial children. + * + * @param item An unresolved test item for which + * children are being requested + */ + resolveHandler?: (item: TestItem | undefined) => Thenable | void; + + /** + * Creates a {@link TestRun}. This should be called by the + * {@link TestRunner} 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 results 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. + */ + 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. + */ + createTestItem(id: string, label: string, uri?: Uri): TestItem; + + /** + * Unregisters the test controller, disposing of its associated tests + * and unpersisted results. + */ + dispose(): void; + } + + /** + * Options given to {@link tests.runTests}. + */ + export class TestRunRequest { + /** + * Filter for specific tests to run. If given, the extension should run all + * of the given tests and all children of the given tests, excluding + * any tests that appear in {@link TestRunRequest.exclude}. If this is + * not given, 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. + */ + include?: TestItem[]; + + /** + * 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. + */ + exclude?: TestItem[]; + + /** + * 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. + */ + profile?: TestRunProfile; + + /** + * @param tests Array of specific tests to run, or undefined to run all tests + * @param exclude 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. + */ + readonly name?: string; + + /** + * A cancellation token which will be triggered when the test run is + * canceled from the UI. + */ + readonly token: CancellationToken; + + /** + * Whether the test run will be persisteded across reloads by the editor UI. + */ + readonly isPersisted: boolean; + + /** + * Indicates a test in the run is queued for later execution. + * @param test Test item to update + */ + enqueued(test: TestItem): void; + + /** + * Indicates a test in the run has started running. + * @param test Test item to update + */ + started(test: TestItem): void; + + /** + * Indicates a test in the run has been skipped. + * @param test Test item to update + */ + skipped(test: TestItem): void; + + /** + * Indicates a test in the run has failed. You should pass one or more + * {@link TestMessage | TestMessages} to describe the failure. + * @param test Test item to update + * @param messages Messages associated with the test failure + * @param duration How long the test took to execute, in milliseconds + */ + failed(test: TestItem, message: TestMessage | readonly TestMessage[], duration?: number): void; + + /** + * Indicates a test in the run has passed. + * @param test Test item to update + * @param duration How long the test took to execute, in milliseconds + */ + 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 + */ + appendOutput(output: string): 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. + */ + end(): void; + } + + /** + * Collection of test items, found in {@link TestItem.children} and + * {@link TestController.items}. + */ + export interface TestItemCollection { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store, can be an array or other iterable. + */ + 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. + */ + forEach(callback: (item: TestItem, collection: TestItemCollection) => unknown, thisArg?: unknown): void; + + /** + * Adds the test item to the children. If an item with the same ID already + * exists, it'll be replaced. + * @param items Item to add. + */ + add(item: TestItem): void; + + /** + * Removes the a single test item from the collection. + * @param itemId Item ID to delete. + */ + delete(itemId: string): void; + + /** + * Efficiently gets a test item by ID, if it exists, in the children. + * @param itemId Item ID to get. + */ + get(itemId: string): TestItem | undefined; + } + + /** + * 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 { + /** + * 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. + */ + readonly id: string; + + /** + * URI this TestItem is associated with. May be a file or directory. + */ + readonly uri?: Uri; + + /** + * A mapping of children by ID to the associated TestItem instances. + */ + readonly children: TestItemCollection; + + /** + * The parent of this item. It's is undefined top-level items in the + * {@link TestController.items} and for items that aren't yet included in + * another item's {@link children}. + */ + readonly parent?: TestItem; + + /** + * Indicates whether this test item may have children discovered by resolving. + * If so, it will be 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. + */ + 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. + */ + busy: boolean; + + /** + * Display name describing the test case. + */ + label: string; + + /** + * Optional description that appears next to the label. + */ + description?: string; + + /** + * Location of the test item in its `uri`. This is only meaningful if the + * `uri` points to a file. + */ + range?: Range; + + /** + * May be set to an error associated with loading the test. Note that this + * is not a test result and should only be used to represent errors in + * discovery, such as syntax errors. + */ + error?: string | MarkdownString; + } + + /** + * 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 `actualOutput`, a diff view will be shown. + */ + expectedOutput?: string; + + /** + * Actual test output. If given with `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); + } +} diff --git a/src/vscode.proposed.d.ts b/src/vscode.proposed.d.ts deleted file mode 100644 index 8da37b39f5..0000000000 --- a/src/vscode.proposed.d.ts +++ /dev/null @@ -1,654 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * This is the place for API experiments and proposals. - * These API are NOT stable and subject to change. They are only available in the Insiders - * distribution and CANNOT be used in published extensions. - * - * To test these API in local environment: - * - Use Insiders release of 'VS Code'. - * - Add `"enableProposedApi": true` to your package.json. - * - Copy this file to your project. - */ - -declare module 'vscode' { - //#region https://github.com/microsoft/vscode/issues/107467 - export namespace test { - /** - * Creates a new test controller. - * - * @param id Identifier for the controller, must be globally unique. - */ - export function createTestController(id: string, label: string): TestController; - - /** - * Requests that tests be run by their controller. - * @param run Run options to use. - * @param token Cancellation token for the test run - * @stability experimental - */ - export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; - - /** - * Returns an observer that watches and can request tests. - * @stability experimental - */ - export function createTestObserver(): TestObserver; - - /** - * 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 Unique identifier for the TestItem. - * @param label Human-readable label of the test item. - * @param uri URI this TestItem is associated with. May be a file or directory. - */ - export function createTestItem(id: string, label: string, uri?: Uri): TestItem; - - /** - * List of test results stored by the editor, sorted in descending - * order by their `completedAt` time. - * @stability experimental - */ - export const testResults: ReadonlyArray; - - /** - * Event that fires when the {@link testResults} array is updated. - * @stability experimental - */ - export const onDidChangeTestResults: Event; - } - - /** - * @stability experimental - */ - 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; - - /** - * Dispose of the observer, allowing the editor to eventually tell test - * providers that they no longer need to update tests. - */ - dispose(): void; - } - - /** - * @stability experimental - */ - export interface TestsChangeEvent { - /** - * List of all tests that are newly added. - */ - readonly added: ReadonlyArray; - - /** - * List of existing tests that have updated. - */ - readonly updated: ReadonlyArray; - - /** - * List of existing tests that have been removed. - */ - readonly removed: ReadonlyArray; - } - - // Todo@api: this is basically the same as the TaskGroup, which is a class that - // allows custom groups to be created. However I don't anticipate having any - // UI for that, so enum for now? - export enum TestRunConfigurationGroup { - Run = 1, - Debug = 2, - Coverage = 3 - } - - /** - * Handler called to start a test run. When invoked, the function should - * {@link TestController.createTestRun} at least once, and all tasks - * associated with the run should be created before the function returns - * or the reutrned 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. - */ - // todo@api We have been there with NotebookCtrl#executeHandler and I believe the recommendation is still not to inline. - // At least with that we can still do it later - export type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable | void; - - export interface TestRunConfiguration { - /** - * 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 in the `Debug` - * group. If there is no such configuration, the default will be used. - */ - label: string; - - /** - * Configures where this configuration is grouped in the UI. If there - * are no configurations for a group, it will not be available in the UI. - */ - readonly group: TestRunConfigurationGroup; - - /** - * Controls whether this configuration is the default action that will - * be taken when its group is actions. For example, if the user clicks - * the generic "run all" button, then the default configuration for - * {@link TestRunConfigurationGroup.Run} will be executed. - */ - isDefault: boolean; - - /** - * 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. - */ - configureHandler?: () => void; - - /** - * Starts a test run. When called, the controller should call - * {@link TestController.createTestRun}. All tasks associated with the - * run should be created before the function returns or the reutrned - * 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. - */ - runHandler: TestRunHandler; - - /** - * Deletes the run configuration. - */ - dispose(): void; - } - - /** - * Interface to discover and execute tests. - */ - // todo@api maybe some words on this being the "entry point" - export interface TestController { - /** - * The ID of the controller, passed in {@link vscode.test.createTestController} - */ - // todo@api maybe explain what the id is used for and iff it must be globally unique or only unique within the extension - readonly id: string; - - /** - * Human-readable label for the test controller. - */ - label: string; - - /** - * Available test items. Tests in the workspace should be added in this - * collection. The extension controls when to add these, although the - * editor may request children using the {@link resolveChildrenHandler}, - * and the extension should add tests for a file when - * {@link vscode.workspace.onDidOpenTextDocument} fires in order for - * decorations for tests within the file to be visible. - * - * Tests in this collection should be watched and updated by the extension - * as files change. See {@link resolveChildrenHandler} for details around - * for the lifecycle of watches. - */ - readonly items: TestItemCollection; - - /** - * Creates a configuration used for running tests. Extensions must create - * at least one configuration in order for tests to be run. - * @param label Human-readable label for this configuration - * @param group Configures where this configuration is grouped in the UI. - * @param runHandler Function called to start a test run - * @param isDefault Whether this is the default action for the group - */ - createRunConfiguration( - label: string, - group: TestRunConfigurationGroup, - runHandler: TestRunHandler, - isDefault?: boolean - ): TestRunConfiguration; - - /** - * A function provided by the extension that the editor may call to request - * children of a test item, if the {@link TestItem.canExpand} is `true`. - * When called, the item should discover children and call - * {@link vscode.test.createTestItem} as children are discovered. - * - * The item in the explorer will automatically be marked as "busy" until - * the function returns or the returned thenable resolves. - * - * The controller may wish to set up listeners or watchers to update the - * children as files and documents change. - * - * @param item An unresolved test item for which - * children are being requested - */ - // todo@API maybe just `resolveHandler` so that we could extends its usage in the future? - resolveChildrenHandler?: (item: TestItem) => Thenable | void; - - /** - * Creates a {@link TestRun}. This should be called by the - * {@link TestRunner} 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 results will be moved into the - * {@link TestResultState.Pending} 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. - */ - createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun; - - /** - * Unregisters the test controller, disposing of its associated tests - * and unpersisted results. - */ - dispose(): void; - } - - /** - * Options given to {@link test.runTests}. - */ - export class TestRunRequest { - /** - * Filter for specific tests to run. If given, the extension should run all - * of the given tests and all children of the given tests, excluding - * any tests that appear in {@link TestRunRequest.exclude}. If this is - * not given, then the extension should simply run all tests. - */ - include?: TestItem[]; - - /** - * An array of tests the user has marked as excluded in the editor. May be - * omitted if no exclusions were requested. Test controllers should not run - * excluded tests or any children of excluded tests. - */ - exclude?: TestItem[]; - - /** - * The configuration 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 configuration. - */ - configuration?: TestRunConfiguration; - - /** - * @param tests Array of specific tests to run, or undefined to run all tests - * @param exclude Tests to exclude from the run - * @param configuration The run configuration used for this request. - */ - constructor(include?: readonly TestItem[], exclude?: readonly TestItem[], configuration?: TestRunConfiguration); - } - - /** - * 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. - */ - readonly name?: string; - - /** - * A cancellation token which will be triggered when the test run is - * canceled from the UI. - */ - readonly token: CancellationToken; - - /** - * Updates the state of the test in the run. Calling with method with nodes - * outside the {@link TestRunRequest.tests} or in the - * {@link TestRunRequest.exclude} array will no-op. - * - * @param test The test to update - * @param state The state to assign to the test - * @param duration Optionally sets how long the test took to run, in milliseconds - */ - //todo@API is this "update" state or set final state? should this be called setTestResult? - setState(test: TestItem, state: TestResultState, duration?: number): void; - - /** - * Appends a message, such as an assertion error, to the test item. - * - * Calling with method with nodes outside the {@link TestRunRequest.tests} - * or in the {@link TestRunRequest.exclude} array will no-op. - * - * @param test The test to update - * @param message The message to add - */ - appendMessage(test: TestItem, message: TestMessage): 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 associateTo Optionally, associate the given segment of output - */ - appendOutput(output: string): void; - - /** - * Signals that the end of the test run. Any tests whose states have not - * been updated will be moved into the {@link TestResultState.Unset} state. - */ - // todo@api is the Unset logic smart and only considering those tests that are included? - end(): void; - } - - /** - * Collection of test items, found in {@link TestItem.children} and - * {@link TestController.items}. - */ - export interface TestItemCollection { - /** - * A read-only array of all the test items children. Can be retrieved, or - * set in order to replace children in the collection. - */ - // todo@API unsure if this should readonly and have a separate replaceAll-like function - all: readonly TestItem[]; - - /** - * Adds the test item to the children. If an item with the same ID already - * exists, it'll be replaced. - */ - add(item: TestItem): void; - - /** - * Removes the a single test item from the collection. - */ - //todo@API `delete` as Map, EnvironmentVariableCollection, DiagnosticCollection - remove(itemId: string): void; - - /** - * Efficiently gets a test item by ID, if it exists, in the children. - */ - get(itemId: string): TestItem | undefined; - } - - /** - * 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 { - /** - * Unique identifier for the TestItem. This is used to correlate - * test results and tests in the document with those in the workspace - * (test explorer). This must not change for the lifetime of the TestItem. - */ - // todo@API globally vs extension vs controller unique. I would strongly recommend non-global - readonly id: string; - - /** - * URI this TestItem is associated with. May be a file or directory. - */ - readonly uri?: Uri; - - /** - * A mapping of children by ID to the associated TestItem instances. - */ - readonly children: TestItemCollection; - - /** - * The parent of this item, given in {@link vscode.test.createTestItem}. - * This is undefined top-level items in the `TestController`, and for - * items that aren't yet assigned to a parent. - */ - // todo@api obsolete? doc is outdated at least - readonly parent?: TestItem; - - /** - * Indicates whether this test item may have children discovered by resolving. - * If so, it will be shown as expandable in the Test Explorer view, and - * expanding the item will cause {@link TestController.resolveChildrenHandler} - * to be invoked with the item. - * - * Default to false. - */ - 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. - */ - busy: boolean; - - /** - * Display name describing the test case. - */ - label: string; - - /** - * Optional description that appears next to the label. - */ - description?: string; - - /** - * Location of the test item in its `uri`. This is only meaningful if the - * `uri` points to a file. - */ - range?: Range; - - /** - * May be set to an error associated with loading the test. Note that this - * is not a test result and should only be used to represent errors in - * discovery, such as syntax errors. - */ - error?: string | MarkdownString; - - /** - * Marks the test as outdated. This can happen as a result of file changes, - * for example. In "auto run" mode, tests that are outdated will be - * automatically rerun after a short delay. Invoking this on a - * test with children will mark the entire subtree as outdated. - * - * Extensions should generally not override this method. - */ - // todo@api still unsure about this - invalidateResults(): void; - } - - /** - * Possible states of tests in a test run. - */ - export enum TestResultState { - // Initial state - Unset = 0, - // Test will be run, but is not currently running. - Queued = 1, - // Test is currently running - Running = 2, - // Test run has passed - Passed = 3, - // Test run has failed (on an assertion) - Failed = 4, - // Test run has been skipped - Skipped = 5, - // Test run failed for some other reason (compilation error, timeout, etc) - // todo@api could I just use `Skipped` and TestItem#error? - Errored = 6 - } - - /** - * 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 class TestMessage { - /** - * Human-readable message text to display. - */ - message: string | MarkdownString; - - /** - * Message severity. Defaults to "Error". - */ - severity: TestMessageSeverity; - - /** - * Expected test output. If given with `actualOutput`, a diff view will be shown. - */ - expectedOutput?: string; - - /** - * Actual test output. If given with `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); - } - - /** - * TestResults can be provided to the editor in {@link test.publishTestResult}, - * or read from it in {@link test.testResults}. - * - * The results contain a 'snapshot' of the tests at the point when the test - * run is complete. Therefore, information such as its {@link Range} may be - * out of date. If the test still exists in the workspace, consumers can use - * its `id` to correlate the result instance with the living test. - * - * @todo coverage and other info may eventually be provided here - */ - export interface TestRunResult { - /** - * Unix milliseconds timestamp at which the test run was completed. - */ - completedAt: number; - - /** - * Optional raw output from the test run. - */ - output?: string; - - /** - * List of test results. The items in this array are the items that - * were passed in the {@link test.runTests} method. - */ - results: ReadonlyArray>; - } - - /** - * A {@link TestItem}-like interface with an associated result, which appear - * or can be provided in {@link TestResult} interfaces. - */ - export interface TestResultSnapshot { - /** - * Unique identifier that matches that of the associated TestItem. - * This is used to correlate test results and tests in the document with - * those in the workspace (test explorer). - */ - readonly id: string; - - /** - * URI this TestItem is associated with. May be a file or file. - */ - readonly uri?: Uri; - - /** - * Display name describing the test case. - */ - readonly label: string; - - /** - * Optional description that appears next to the label. - */ - readonly description?: string; - - /** - * Location of the test item in its `uri`. This is only meaningful if the - * `uri` points to a file. - */ - readonly range?: Range; - - /** - * State of the test in each task. In the common case, a test will only - * be executed in a single task and the length of this array will be 1. - */ - readonly taskStates: ReadonlyArray; - - /** - * Optional list of nested tests for this item. - */ - readonly children: Readonly[]; - } - - export interface TestSnapshoptTaskState { - /** - * Current result of the test. - */ - readonly state: TestResultState; - - /** - * The number of milliseconds the test took to run. This is set once the - * `state` is `Passed`, `Failed`, or `Errored`. - */ - readonly duration?: number; - - /** - * Associated test run message. Can, for example, contain assertion - * failure information if the test fails. - */ - readonly messages: ReadonlyArray; - } - - //#endregion -} diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts index 9fcd5f5b8f..5766bc6825 100644 --- a/test/integration/goTestExplorer.test.ts +++ b/test/integration/goTestExplorer.test.ts @@ -74,10 +74,10 @@ function setup(folders: string[], files: Files) { function assertTestItems(items: TestItemCollection, expect: string[]) { const actual: string[] = []; function walk(items: TestItemCollection) { - for (const child of items.all) { - actual.push(child.id); - walk(child.children); - } + items.forEach((item) => { + actual.push(item.id); + walk(item.children); + }); } walk(items); assert.deepStrictEqual(actual, expect); @@ -260,9 +260,10 @@ suite('Test Explorer', () => { (item?.children || ctrl.items).add(child); item = child; } - await ctrl.resolveChildrenHandler(item); + await ctrl.resolveHandler(item); - const actual = (item?.children || ctrl.items).all.map((x) => x.id); + const actual: string[] = []; + (item?.children || ctrl.items).forEach((x) => actual.push(x.id)); assert.deepStrictEqual(actual, expect); }); } diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts index 374ba981f7..1522bad52a 100644 --- a/test/mocks/MockTest.ts +++ b/test/mocks/MockTest.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import path = require('path'); import { + CancellationToken, EndOfLine, FileType, MarkdownString, @@ -11,9 +12,8 @@ import { TestItem, TestItemCollection, TestRun, - TestRunConfiguration, - TestRunConfigurationGroup, - TestRunHandler, + TestRunProfile, + TestRunProfileKind, TestRunRequest, TextDocument, TextLine, @@ -22,13 +22,19 @@ import { } from 'vscode'; import { TestExplorer } from '../../src/goTestExplorer'; +type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable | void; + class MockTestCollection implements TestItemCollection { constructor(private item: MockTestItem | MockTestController) {} - private m = new Map(); + private readonly m = new Map(); + + get size() { + return this.m.size; + } - get all(): TestItem[] { - return Array.from(this.m.values()); + forEach(fn: (item: TestItem, coll: TestItemCollection) => unknown) { + for (const item of this.m.values()) fn(item, this); } add(item: TestItem): void { @@ -45,13 +51,17 @@ class MockTestCollection implements TestItemCollection { this.m.set(item.id, item); } - remove(id: string): void { + delete(id: string): void { this.m.delete(id); } get(id: string): TestItem { return this.m.get(id); } + + replace(items: readonly TestItem[]): void { + throw new Error('not impelemented'); + } } export class MockTestItem implements TestItem { @@ -78,15 +88,15 @@ export class MockTestItem implements TestItem { dispose(): void { if (this.parent instanceof MockTestItem) { - this.parent.children.remove(this.id); + this.parent.children.delete(this.id); } } } -class MockTestRunConfiguration implements TestRunConfiguration { +class MockTestRunProfile implements TestRunProfile { constructor( public label: string, - public group: TestRunConfigurationGroup, + public kind: TestRunProfileKind, public runHandler: TestRunHandler, public isDefault: boolean ) {} @@ -100,19 +110,23 @@ export class MockTestController implements TestController { label = 'Go'; items = new MockTestCollection(this); - resolveChildrenHandler?: (item: TestItem) => void | Thenable; + resolveHandler?: (item: TestItem) => void | Thenable; createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun { throw new Error('Method not implemented.'); } - createRunConfiguration( + createRunProfile( label: string, - group: TestRunConfigurationGroup, + kind: TestRunProfileKind, runHandler: TestRunHandler, isDefault?: boolean - ): TestRunConfiguration { - return new MockTestRunConfiguration(label, group, runHandler, isDefault); + ): TestRunProfile { + return new MockTestRunProfile(label, kind, runHandler, isDefault); + } + + createTestItem(id: string, label: string, uri?: Uri): TestItem { + return new MockTestItem(id, label, uri, this); } dispose(): void {} From 43f273c684c13c47a80aa762b1acf5be7df8b4d4 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 22 Jul 2021 19:55:48 -0500 Subject: [PATCH 22/34] src/goTestExplorer: deal with benchmarks --- src/goTestExplorer.ts | 49 +++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 0f7f48a98d..e7e97f9805 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -1,12 +1,8 @@ import { - tests, - window, - workspace as vsWorkspace, CancellationToken, ConfigurationChangeEvent, DocumentSymbol, ExtensionContext, - FileSystem as vsFileSystem, FileType, Location, OutputChannel, @@ -27,6 +23,7 @@ import { WorkspaceFolder, WorkspaceFoldersChangeEvent } from 'vscode'; +import vscode = require('vscode'); import path = require('path'); import { getModFolderPath, isModSupported } from './goModules'; import { getCurrentGoPath } from './util'; @@ -41,9 +38,9 @@ const symbols = new WeakMap(); export namespace TestExplorer { // exported for tests - export type FileSystem = Pick; + export type FileSystem = Pick; - export interface Workspace extends Pick { + export interface Workspace extends Pick { readonly fs: FileSystem; // custom FS type openTextDocument(uri: Uri): Thenable; // only one overload @@ -52,7 +49,7 @@ export namespace TestExplorer { export class TestExplorer { static setup(context: ExtensionContext): TestExplorer { - const ctrl = tests.createTestController('go', 'Go'); + const ctrl = vscode.tests.createTestController('go', 'Go'); const inst = new this( ctrl, workspace, @@ -630,14 +627,17 @@ async function resolve(expl: TestExplorer, item?: TestItem) { // would discover sub tests or benchmarks, if that is feasible. } +type CollectedTest = { item: TestItem; explicitlyIncluded: boolean }; + // Recursively find all tests, benchmarks, and examples within a // module/package/etc, minus exclusions. Map tests to the package they are // defined in, and track files. async function collectTests( expl: TestExplorer, item: TestItem, + explicitlyIncluded: boolean, excluded: TestItem[], - functions: Map, + functions: Map, docs: Set ) { for (let i = item; i.parent; i = i.parent) { @@ -652,11 +652,8 @@ async function collectTests( await resolve(expl, item); } - const runBench = getGoConfig(item.uri).get('testExplorerRunBenchmarks'); for (const child of collect(item.children)) { - const uri = Uri.parse(child.id); - if (uri.query === 'benchmark' && !runBench) continue; - await collectTests(expl, child, excluded, functions, docs); + await collectTests(expl, child, false, excluded, functions, docs); } return; } @@ -666,9 +663,9 @@ async function collectTests( const dir = file.with({ path: path.dirname(uri.path) }).toString(); if (functions.has(dir)) { - functions.get(dir).push(item); + functions.get(dir).push({ item, explicitlyIncluded }); } else { - functions.set(dir, [item]); + functions.set(dir, [{ item, explicitlyIncluded }]); } return; } @@ -775,6 +772,7 @@ function consumeGoBenchmarkEvent( if (m.groups.result) { run.passed(test); complete.add(test); + vscode.commands.executeCommand('testing.showMostRecentOutput'); } else { run.started(test); } @@ -910,10 +908,10 @@ function checkForBuildFailure(run: TestRun, tests: Record, out // Execute tests - TestController.runTest callback async function runTest(expl: TestExplorer, request: TestRunRequest) { - const collected = new Map(); + const collected = new Map(); const docs = new Set(); for (const item of request.include) { - await collectTests(expl, item, request.exclude, collected, docs); + await collectTests(expl, item, true, request.exclude, collected, docs); } // Save all documents that contain a test we're about to run, to ensure `go @@ -924,6 +922,16 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { }) ); + let hasBench = false, + hasNonBench = false; + for (const items of collected.values()) { + for (const { item } of items) { + const uri = Uri.parse(item.id); + if (uri.query === 'benchmark') hasBench = true; + else hasNonBench = true; + } + } + const run = expl.ctrl.createTestRun(request); const outputChannel = new TestRunOutput(run); for (const [dir, items] of collected.entries()) { @@ -931,16 +939,21 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { const isMod = await isModSupported(uri, true); const goConfig = getGoConfig(uri); const flags = getTestFlags(goConfig); + const includeBench = getGoConfig(uri).get('testExplorerRunBenchmarks'); // Separate tests and benchmarks and mark them as queued for execution. // Clear any sub tests/benchmarks generated by a previous run. const tests: Record = {}; const benchmarks: Record = {}; - for (const item of items) { + for (const { item, explicitlyIncluded } of items) { const uri = Uri.parse(item.id); if (/[/#]/.test(uri.fragment)) { // running sub-tests is not currently supported - window.showErrorMessage(`Cannot run ${uri.fragment} - running sub-tests is not supported`); + vscode.window.showErrorMessage(`Cannot run ${uri.fragment} - running sub-tests is not supported`); + continue; + } + + if (uri.query === 'benchmark' && !explicitlyIncluded && !includeBench && !(hasBench && !hasNonBench)) { continue; } From c8258d269dd00fbba2957c5780ddf091d73d2633 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 23 Jul 2021 18:47:53 -0500 Subject: [PATCH 23/34] src/goTestExplorer: polish --- docs/settings.md | 12 +++ src/goTestExplorer.ts | 138 +++++++++++++++--------- test/integration/goTestExplorer.test.ts | 54 ++++++---- test/mocks/MockTest.ts | 2 +- 4 files changed, 136 insertions(+), 70 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index 2b8d0c04eb..6ea205ba28 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -409,6 +409,18 @@ The Go build tags to use for when running tests. If null, then buildTags will be Specifies the timeout for go test in ParseDuration format. Default: `"30s"` +### `go.testExplorerPackages` +Present packages in the test explorer as a flat list or as nested trees. + +Default: `flat` +### `go.testExplorerRunBenchmarks` +Include benchmarks when running a group of tests. + +Default: `false` +### `go.testExplorerConcatenateMessages` +Concatenate test log messages for a given location instead of presenting them individually. + +Default: `true` ### `go.toolsEnvVars` Environment variables that will be passed to the tools that run the Go tools (e.g. CGO_CFLAGS) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index e7e97f9805..706b97d86e 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -30,9 +30,7 @@ import { getCurrentGoPath } from './util'; import { GoDocumentSymbolProvider } from './goOutline'; import { getGoConfig } from './config'; import { getTestFlags, goTest, GoTestOutput } from './testUtils'; - -// We could use TestItem.data, but that may be removed -const symbols = new WeakMap(); +import { outputChannel } from './goStatus'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace TestExplorer { @@ -47,27 +45,68 @@ export namespace TestExplorer { } } +async function doSafe(context: string, p: Thenable | (() => T | Thenable), onError?: T): Promise { + try { + if (typeof p === 'function') { + return await p(); + } else { + return await p; + } + } catch (error) { + if (process.env.VSCODE_GO_IN_TEST === '1') { + throw error; + } + + // TODO internationalization? + if (context === 'resolveHandler') { + const m = 'Failed to resolve tests'; + outputChannel.appendLine(`${m}: ${error}`); + await vscode.window.showErrorMessage(m); + } else if (context === 'runHandler') { + const m = 'Failed to execute tests'; + outputChannel.appendLine(`${m}: ${error}`); + await vscode.window.showErrorMessage(m); + } else if (/^did/.test(context)) { + outputChannel.appendLine(`Failed while handling '${context}': ${error}`); + } else { + const m = 'An unknown error occured'; + outputChannel.appendLine(`${m}: ${error}`); + await vscode.window.showErrorMessage(m); + } + return onError; + } +} + export class TestExplorer { static setup(context: ExtensionContext): TestExplorer { const ctrl = vscode.tests.createTestController('go', 'Go'); - const inst = new this( - ctrl, - workspace, - (e) => console.log(e), - new GoDocumentSymbolProvider().provideDocumentSymbols + const getSym = new GoDocumentSymbolProvider().provideDocumentSymbols; + const inst = new this(ctrl, workspace, getSym); + + context.subscriptions.push( + workspace.onDidChangeConfiguration((x) => + doSafe('onDidChangeConfiguration', inst.didChangeConfiguration(x)) + ) + ); + + context.subscriptions.push( + workspace.onDidOpenTextDocument((x) => doSafe('onDidOpenTextDocument', inst.didOpenTextDocument(x))) ); - resolve(inst); - context.subscriptions.push(workspace.onDidChangeConfiguration((x) => inst.didChangeConfiguration(x))); + context.subscriptions.push( + workspace.onDidChangeTextDocument((x) => doSafe('onDidChangeTextDocument', inst.didChangeTextDocument(x))) + ); - context.subscriptions.push(workspace.onDidOpenTextDocument((x) => inst.didOpenTextDocument(x))); - context.subscriptions.push(workspace.onDidChangeTextDocument((x) => inst.didChangeTextDocument(x))); - context.subscriptions.push(workspace.onDidChangeWorkspaceFolders((x) => inst.didChangeWorkspaceFolders(x))); + context.subscriptions.push( + workspace.onDidChangeWorkspaceFolders((x) => + doSafe('onDidChangeWorkspaceFolders', inst.didChangeWorkspaceFolders(x)) + ) + ); const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false); context.subscriptions.push(watcher); - context.subscriptions.push(watcher.onDidCreate((x) => inst.didCreateFile(x))); - context.subscriptions.push(watcher.onDidDelete((x) => inst.didDeleteFile(x))); + context.subscriptions.push(watcher.onDidCreate((x) => doSafe('onDidCreate', inst.didCreateFile(x)))); + context.subscriptions.push(watcher.onDidDelete((x) => doSafe('onDidDelete', inst.didDeleteFile(x)))); return inst; } @@ -75,14 +114,25 @@ export class TestExplorer { constructor( public ctrl: TestController, public ws: TestExplorer.Workspace, - public errored: (e: unknown) => void, public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable ) { // TODO handle cancelation of test runs - ctrl.resolveHandler = (...args) => resolve(this, ...args); - ctrl.createRunProfile('Go [Run]', TestRunProfileKind.Run, (rq) => runTest(this, rq), true); + ctrl.resolveHandler = (item) => this.resolve(item); + ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq) => this.run(rq), true); } + /* ***** Interface (external) ***** */ + + resolve(item?: TestItem) { + return doSafe('resolveHandler', resolve(this, item)); + } + + run(request: TestRunRequest) { + return doSafe('runHandler', runTests(this, request)); + } + + /* ***** Interface (internal) ***** */ + // Create an item. createItem(label: string, uri: Uri, kind: string, name?: string): TestItem { return this.ctrl.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' })); @@ -118,31 +168,21 @@ export class TestExplorer { return sub; } - resolve(item?: TestItem) { - resolve(this, item); - } + /* ***** Listeners ***** */ - async didOpenTextDocument(doc: TextDocument) { - try { - await documentUpdate(this, doc); - } catch (e) { - this.errored(e); - } + protected async didOpenTextDocument(doc: TextDocument) { + await documentUpdate(this, doc); } - async didChangeTextDocument(e: TextDocumentChangeEvent) { - try { - await documentUpdate( - this, - e.document, - e.contentChanges.map((x) => x.range) - ); - } catch (e) { - this.errored(e); - } + protected async didChangeTextDocument(e: TextDocumentChangeEvent) { + await documentUpdate( + this, + e.document, + e.contentChanges.map((x) => x.range) + ); } - async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { + protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { for (const item of collect(this.ctrl.items)) { const uri = Uri.parse(item.id); if (uri.query === 'package') { @@ -160,15 +200,11 @@ export class TestExplorer { } } - async didCreateFile(file: Uri) { - try { - await documentUpdate(this, await this.ws.openTextDocument(file)); - } catch (e) { - this.errored(e); - } + protected async didCreateFile(file: Uri) { + await documentUpdate(this, await this.ws.openTextDocument(file)); } - async didDeleteFile(file: Uri) { + protected async didDeleteFile(file: Uri) { const id = testID(file, 'file'); function find(children: TestItemCollection): TestItem { for (const item of collect(children)) { @@ -195,7 +231,7 @@ export class TestExplorer { } } - async didChangeConfiguration(e: ConfigurationChangeEvent) { + protected async didChangeConfiguration(e: ConfigurationChangeEvent) { let update = false; for (const item of collect(this.ctrl.items)) { if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) { @@ -407,7 +443,6 @@ async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: const item = expl.getOrCreateItem(file, symbol.name, uri, kind, symbol.name); item.range = symbol.range; - symbols.set(item, symbol); } // Processes a Go document, calling processSymbol for each symbol in the @@ -524,6 +559,7 @@ async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise, out if (!output.some((x) => rePkg.test(x))) return; for (const name in tests) { - // TODO(firelizzard18): Previously, there was an Errored state that differed from Failed. + // TODO(firelizzard18): Use `run.errored` when that is added back + tests[name].error = 'Compilation failed'; run.failed(tests[name], { message: 'Compilation failed' }); } } // Execute tests - TestController.runTest callback -async function runTest(expl: TestExplorer, request: TestRunRequest) { +async function runTests(expl: TestExplorer, request: TestRunRequest) { const collected = new Map(); const docs = new Set(); for (const item of request.include) { - await collectTests(expl, item, true, request.exclude, collected, docs); + await collectTests(expl, item, true, request.exclude || [], collected, docs); } // Save all documents that contain a test we're about to run, to ensure `go @@ -957,6 +994,7 @@ async function runTest(expl: TestExplorer, request: TestRunRequest) { continue; } + item.error = null; run.enqueued(item); discardChildren(item); diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts index 5766bc6825..8b4eecde62 100644 --- a/test/integration/goTestExplorer.test.ts +++ b/test/integration/goTestExplorer.test.ts @@ -9,11 +9,12 @@ import { SymbolKind, Range, Position, - TestItemCollection + TestItemCollection, + TextDocumentChangeEvent } from 'vscode'; import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules'; import { TestExplorer, testID } from '../../src/goTestExplorer'; -import { MockTestController, MockTestItem, MockTestWorkspace } from '../mocks/MockTest'; +import { MockTestController, MockTestWorkspace } from '../mocks/MockTest'; type Files = Record; @@ -33,20 +34,18 @@ function symbols(doc: TextDocument, token: unknown): Thenable return Promise.resolve(syms); } -function rethrow(e: unknown) { - throw e; +function setup(folders: string[], files: Files) { + return setupCtor(folders, files, TestExplorer); } -function setup(folders: string[], files: Files) { +function setupCtor( + folders: string[], + files: Files, + ctor: new (...args: ConstructorParameters) => T +) { const ws = MockTestWorkspace.from(folders, files); const ctrl = new MockTestController(); - const expl = new TestExplorer(ctrl, ws, rethrow, symbols); - - // Override TestExplorer.createItem so we can control the TestItem implementation - expl.createItem = (label: string, uri: Uri, kind: string, name?: string) => { - const id = testID(uri, kind, name); - return new MockTestItem(id, label, uri, ctrl); - }; + const expl = new ctor(ctrl, ws, symbols); function walk(dir: Uri, modpath?: string) { const dirs: Uri[] = []; @@ -252,11 +251,12 @@ suite('Test Explorer', () => { for (const m in cases[n]) { test(m, async () => { const { workspace, files, expect, item: itemData = [] } = cases[n][m]; - const { expl, ctrl } = setup(workspace, files); + const { ctrl } = setup(workspace, files); let item: TestItem | undefined; for (const [label, uri, kind, name] of itemData) { - const child = expl.createItem(label, Uri.parse(uri), kind, name); + const u = Uri.parse(uri); + const child = ctrl.createTestItem(testID(u, kind, name), label, u); (item?.children || ctrl.items).add(child); item = child; } @@ -273,6 +273,12 @@ suite('Test Explorer', () => { suite('Events', () => { suite('Document opened', () => { + class DUT extends TestExplorer { + async _didOpen(doc: TextDocument) { + await this.didOpenTextDocument(doc); + } + } + interface TC extends TestCase { open: string; expect: string[]; @@ -312,9 +318,9 @@ suite('Test Explorer', () => { for (const name in cases) { test(name, async () => { const { workspace, files, open, expect } = cases[name]; - const { ctrl, expl, ws } = setup(workspace, files); + const { ctrl, expl, ws } = setupCtor(workspace, files, DUT); - await expl.didOpenTextDocument(ws.fs.files.get(open)); + await expl._didOpen(ws.fs.files.get(open)); assertTestItems(ctrl.items, expect); }); @@ -322,6 +328,16 @@ suite('Test Explorer', () => { }); suite('Document edited', async () => { + class DUT extends TestExplorer { + async _didOpen(doc: TextDocument) { + await this.didOpenTextDocument(doc); + } + + async _didChange(e: TextDocumentChangeEvent) { + await this.didChangeTextDocument(e); + } + } + interface TC extends TestCase { open: string; changes: [string, string][]; @@ -371,16 +387,16 @@ suite('Test Explorer', () => { for (const name in cases) { test(name, async () => { const { workspace, files, open, changes, expect } = cases[name]; - const { ctrl, expl, ws } = setup(workspace, files); + const { ctrl, expl, ws } = setupCtor(workspace, files, DUT); - await expl.didOpenTextDocument(ws.fs.files.get(open)); + await expl._didOpen(ws.fs.files.get(open)); assertTestItems(ctrl.items, expect.before); for (const [file, contents] of changes) { const doc = ws.fs.files.get(file); doc.contents = contents; - await expl.didChangeTextDocument({ + await expl._didChange({ document: doc, contentChanges: [] }); diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts index 1522bad52a..b1243219cd 100644 --- a/test/mocks/MockTest.ts +++ b/test/mocks/MockTest.ts @@ -64,7 +64,7 @@ class MockTestCollection implements TestItemCollection { } } -export class MockTestItem implements TestItem { +class MockTestItem implements TestItem { private static idNum = 0; private idNum: number; From 42b6214d75103aba30b79964803495d4b036e891 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 23 Jul 2021 19:03:12 -0500 Subject: [PATCH 24/34] src/goTestExplorer: cancel tests --- src/goTestExplorer.ts | 62 ++++++++++++++++++++++++++----------------- src/testUtils.ts | 7 +++++ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 706b97d86e..a0e277924e 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -116,9 +116,8 @@ export class TestExplorer { public ws: TestExplorer.Workspace, public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable ) { - // TODO handle cancelation of test runs ctrl.resolveHandler = (item) => this.resolve(item); - ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq) => this.run(rq), true); + ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq, tok) => this.run(rq, tok), true); } /* ***** Interface (external) ***** */ @@ -127,8 +126,8 @@ export class TestExplorer { return doSafe('resolveHandler', resolve(this, item)); } - run(request: TestRunRequest) { - return doSafe('runHandler', runTests(this, request)); + run(request: TestRunRequest, token: CancellationToken) { + return doSafe('runHandler', runTests(this, request, token)); } /* ***** Interface (internal) ***** */ @@ -815,18 +814,18 @@ function consumeGoBenchmarkEvent( } // Pass any incomplete benchmarks (see test_events.md) -function passBenchmarks(run: TestRun, items: Record, complete: Set) { - function pass(item: TestItem) { +function markComplete(items: Record, complete: Set, fn: (item: TestItem) => void) { + function mark(item: TestItem) { if (!complete.has(item)) { - run.passed(item); + fn(item); } for (const child of collect(item.children)) { - pass(child); + mark(child); } } for (const name in items) { - pass(items[name]); + mark(items[name]); } } @@ -836,6 +835,7 @@ function consumeGoTestEvent( run: TestRun, tests: Record, record: Map, + complete: Set, concat: boolean, e: GoTestOutput ) { @@ -854,10 +854,12 @@ function consumeGoTestEvent( break; case 'pass': + complete.add(test); run.passed(test, e.Elapsed * 1000); break; case 'fail': { + complete.add(test); const messages = parseOutput(run, test, record.get(test) || []); if (!concat) { @@ -880,6 +882,7 @@ function consumeGoTestEvent( } case 'skip': + complete.add(test); run.skipped(test); break; @@ -930,21 +933,15 @@ function parseOutput(run: TestRun, test: TestItem, output: string[]): TestMessag return messages; } -function checkForBuildFailure(run: TestRun, tests: Record, output: string[]) { +function isBuildFailure(output: string[]): boolean { const rePkg = /^# (?[\w/.-]+)(?: \[(?[\w/.-]+).test\])?/; // TODO(firelizzard18): Add more sophisticated check for build failures? - if (!output.some((x) => rePkg.test(x))) return; - - for (const name in tests) { - // TODO(firelizzard18): Use `run.errored` when that is added back - tests[name].error = 'Compilation failed'; - run.failed(tests[name], { message: 'Compilation failed' }); - } + return output.some((x) => rePkg.test(x)); } // Execute tests - TestController.runTest callback -async function runTests(expl: TestExplorer, request: TestRunRequest) { +async function runTests(expl: TestExplorer, request: TestRunRequest, token: CancellationToken) { const collected = new Map(); const docs = new Set(); for (const item of request.include) { @@ -1012,6 +1009,7 @@ async function runTests(expl: TestExplorer, request: TestRunRequest) { // Run tests if (testFns.length > 0) { + const complete = new Set(); const success = await goTest({ goConfig, flags, @@ -1019,10 +1017,19 @@ async function runTests(expl: TestExplorer, request: TestRunRequest) { outputChannel, dir: uri.fsPath, functions: testFns, - goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, concat, e) + cancel: token, + goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, complete, concat, e) }); if (!success) { - checkForBuildFailure(run, tests, outputChannel.lines); + if (isBuildFailure(outputChannel.lines)) { + markComplete(benchmarks, new Set(), (item) => { + // TODO change to errored when that is added back + run.failed(item, { message: 'Compilation failed' }); + item.error = 'Compilation failed'; + }); + } else { + markComplete(benchmarks, complete, (x) => run.skipped(x)); + } } } @@ -1037,14 +1044,21 @@ async function runTests(expl: TestExplorer, request: TestRunRequest) { dir: uri.fsPath, functions: benchmarkFns, isBenchmark: true, + cancel: token, goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e) }); - if (success || complete.size > 0) { - // Explicitly pass any incomplete benchmarks (see test_events.md) - passBenchmarks(run, benchmarks, complete); + // Explicitly complete any incomplete benchmarks (see test_events.md) + if (success) { + markComplete(benchmarks, complete, (x) => run.passed(x)); + } else if (isBuildFailure(outputChannel.lines)) { + markComplete(benchmarks, new Set(), (item) => { + // TODO change to errored when that is added back + run.failed(item, { message: 'Compilation failed' }); + item.error = 'Compilation failed'; + }); } else { - checkForBuildFailure(run, benchmarks, outputChannel.lines); + markComplete(benchmarks, complete, (x) => run.skipped(x)); } } } diff --git a/src/testUtils.ts b/src/testUtils.ts index 270056ef28..face7e311d 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -11,6 +11,7 @@ import cp = require('child_process'); import path = require('path'); import util = require('util'); import vscode = require('vscode'); +import { CancellationToken } from 'vscode-languageserver-protocol'; import { applyCodeCoverageToAllEditors } from './goCover'; import { toolExecutionEnvironment } from './goEnv'; @@ -85,6 +86,10 @@ export interface TestConfig { * Output channel for test output. */ outputChannel?: vscode.OutputChannel; + /** + * Can be used to terminate the test process. + */ + cancel?: CancellationToken; /** * Output channel for JSON test output. */ @@ -300,6 +305,8 @@ export async function goTest(testconfig: TestConfig): Promise { const outBuf = new LineBuffer(); const errBuf = new LineBuffer(); + testconfig.cancel?.onCancellationRequested(() => tp.kill()); + const testResultLines: string[] = []; const processTestResultLine = addJSONFlag ? processTestResultLineInJSONMode( From e225b49b2cb87542a5d4c405c84cd0102fce6a04 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Sun, 8 Aug 2021 18:20:00 -0500 Subject: [PATCH 25/34] Update to VSCode 1.59 --- package-lock.json | 14 +- package.json | 2 +- src/goLanguageServer.ts | 4 +- src/goSuggest.ts | 9 +- src/vscode.d.ts | 450 -------------------------------------- test/mocks/MockMemento.ts | 4 + 6 files changed, 22 insertions(+), 461 deletions(-) delete mode 100644 src/vscode.d.ts diff --git a/package-lock.json b/package-lock.json index 3ecf643071..ec07edc1b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "@types/node": "^13.11.1", "@types/semver": "^7.1.0", "@types/sinon": "^9.0.0", - "@types/vscode": "^1.57.0", + "@types/vscode": "^1.59.0", "adm-zip": "^0.4.14", "fs-extra": "^9.0.0", "get-port": "^5.1.1", @@ -387,9 +387,9 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.57.0.tgz", - "integrity": "sha512-FeznBFtIDCWRluojTsi9c3LLcCHOXP5etQfBK42+ixo1CoEAchkw39tuui9zomjZuKfUVL33KZUDIwHZ/xvOkQ==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz", + "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==", "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -7276,9 +7276,9 @@ "dev": true }, "@types/vscode": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.57.0.tgz", - "integrity": "sha512-FeznBFtIDCWRluojTsi9c3LLcCHOXP5etQfBK42+ixo1CoEAchkw39tuui9zomjZuKfUVL33KZUDIwHZ/xvOkQ==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz", + "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==", "dev": true }, "@typescript-eslint/eslint-plugin": { diff --git a/package.json b/package.json index b0d737bbde..69f66dca78 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@types/node": "^13.11.1", "@types/semver": "^7.1.0", "@types/sinon": "^9.0.0", - "@types/vscode": "^1.57.0", + "@types/vscode": "^1.59.0", "adm-zip": "^0.4.14", "fs-extra": "^9.0.0", "get-port": "^5.1.1", diff --git a/src/goLanguageServer.ts b/src/goLanguageServer.ts index 317687d717..2698dc13d9 100644 --- a/src/goLanguageServer.ts +++ b/src/goLanguageServer.ts @@ -631,7 +631,9 @@ export async function buildLanguageClient(cfg: BuildLanguageClientOption): Promi // cause to reorder candiates, which is not ideal. // Force to use non-empty `label`. // https://github.com/golang/vscode-go/issues/441 - hardcodedFilterText = items[0].label; + let { label } = items[0]; + if (typeof label !== 'string') label = label.label; + hardcodedFilterText = label; } for (const item of items) { item.filterText = hardcodedFilterText; diff --git a/src/goSuggest.ts b/src/goSuggest.ts index 22abcc08e0..1f3361a106 100644 --- a/src/goSuggest.ts +++ b/src/goSuggest.ts @@ -122,11 +122,14 @@ export class GoCompletionItemProvider implements vscode.CompletionItemProvider, return; } + let { label } = item; + if (typeof label !== 'string') label = label.label; + return runGodoc( path.dirname(item.fileName), item.package || path.dirname(item.fileName), item.receiver, - item.label, + label, token ) .then((doc) => { @@ -358,7 +361,9 @@ export class GoCompletionItemProvider implements vscode.CompletionItemProvider, areCompletionsForPackageSymbols = true; } if (suggest.class === 'package') { - const possiblePackageImportPaths = this.getPackageImportPath(item.label); + let { label } = item; + if (typeof label !== 'string') label = label.label; + const possiblePackageImportPaths = this.getPackageImportPath(label); if (possiblePackageImportPaths.length === 1) { item.detail = possiblePackageImportPaths[0]; } diff --git a/src/vscode.d.ts b/src/vscode.d.ts deleted file mode 100644 index a7dc594a42..0000000000 --- a/src/vscode.d.ts +++ /dev/null @@ -1,450 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - export namespace tests { - /** - * Creates a new test controller. - * - * @param id Identifier for the controller, must be globally unique. - */ - 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 - } - - /** - * 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 in the `Debug` - * group. If there is no such configuration, the default will be used. - */ - 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. - */ - readonly kind: TestRunProfileKind; - - /** - * Controls whether this profile is the default action that will - * be taken when its group is actions. For example, if the user clicks - * the generic "run all" button, then the default profile for - * {@link TestRunProfileKind.Run} will be executed. - */ - isDefault: boolean; - - /** - * 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. - */ - configureHandler?: () => void; - - /** - * Handler called to start a test run. When invoked, the function should - * {@link TestController.createTestRun} at least once, and all tasks - * associated with the run should be created before the function returns - * or the reutrned 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. - */ - runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void; - - /** - * Deletes the run profile. - */ - dispose(): void; - } - - /** - * Entry point to discover and execute tests. It contains {@link items} which - * are used to populate the editor UI, and is associated with - * {@link 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, - */ - readonly id: string; - - /** - * Human-readable label for the test controller. - */ - label: string; - - /** - * Available test items. Tests in the workspace should be added in this - * collection. The extension controls when to add these, although the - * editor may request children using the {@link resolveHandler}, - * and the extension should add tests for a file when - * {@link vscode.workspace.onDidOpenTextDocument} fires in order for - * decorations for tests within the file to be visible. - * - * Tests in this collection should be watched and updated by the extension - * as files change. See {@link resolveHandler} for details around - * for the lifecycle of watches. - */ - 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 Human-readable label for this profile - * @param group Configures where this profile is grouped in the UI. - * @param runHandler Function called to start a test run - * @param isDefault Whether this is the default action for the group - */ - createRunProfile( - label: string, - group: TestRunProfileKind, - runHandler: (request: TestRunRequest, token: CancellationToken) => Thenable | void, - isDefault?: boolean - ): 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. - * - * The item in the explorer will automatically be marked as "busy" until - * the function returns or the returned thenable resolves. - * - * The handler will be called `undefined` to resolve the controller's - * initial children. - * - * @param item An unresolved test item for which - * children are being requested - */ - resolveHandler?: (item: TestItem | undefined) => Thenable | void; - - /** - * Creates a {@link TestRun}. This should be called by the - * {@link TestRunner} 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 results 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. - */ - 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. - */ - createTestItem(id: string, label: string, uri?: Uri): TestItem; - - /** - * Unregisters the test controller, disposing of its associated tests - * and unpersisted results. - */ - dispose(): void; - } - - /** - * Options given to {@link tests.runTests}. - */ - export class TestRunRequest { - /** - * Filter for specific tests to run. If given, the extension should run all - * of the given tests and all children of the given tests, excluding - * any tests that appear in {@link TestRunRequest.exclude}. If this is - * not given, 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. - */ - include?: TestItem[]; - - /** - * 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. - */ - exclude?: TestItem[]; - - /** - * 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. - */ - profile?: TestRunProfile; - - /** - * @param tests Array of specific tests to run, or undefined to run all tests - * @param exclude 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. - */ - readonly name?: string; - - /** - * A cancellation token which will be triggered when the test run is - * canceled from the UI. - */ - readonly token: CancellationToken; - - /** - * Whether the test run will be persisteded across reloads by the editor UI. - */ - readonly isPersisted: boolean; - - /** - * Indicates a test in the run is queued for later execution. - * @param test Test item to update - */ - enqueued(test: TestItem): void; - - /** - * Indicates a test in the run has started running. - * @param test Test item to update - */ - started(test: TestItem): void; - - /** - * Indicates a test in the run has been skipped. - * @param test Test item to update - */ - skipped(test: TestItem): void; - - /** - * Indicates a test in the run has failed. You should pass one or more - * {@link TestMessage | TestMessages} to describe the failure. - * @param test Test item to update - * @param messages Messages associated with the test failure - * @param duration How long the test took to execute, in milliseconds - */ - failed(test: TestItem, message: TestMessage | readonly TestMessage[], duration?: number): void; - - /** - * Indicates a test in the run has passed. - * @param test Test item to update - * @param duration How long the test took to execute, in milliseconds - */ - 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 - */ - appendOutput(output: string): 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. - */ - end(): void; - } - - /** - * Collection of test items, found in {@link TestItem.children} and - * {@link TestController.items}. - */ - export interface TestItemCollection { - /** - * Gets the number of items in the collection. - */ - readonly size: number; - - /** - * Replaces the items stored by the collection. - * @param items Items to store, can be an array or other iterable. - */ - 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. - */ - forEach(callback: (item: TestItem, collection: TestItemCollection) => unknown, thisArg?: unknown): void; - - /** - * Adds the test item to the children. If an item with the same ID already - * exists, it'll be replaced. - * @param items Item to add. - */ - add(item: TestItem): void; - - /** - * Removes the a single test item from the collection. - * @param itemId Item ID to delete. - */ - delete(itemId: string): void; - - /** - * Efficiently gets a test item by ID, if it exists, in the children. - * @param itemId Item ID to get. - */ - get(itemId: string): TestItem | undefined; - } - - /** - * 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 { - /** - * 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. - */ - readonly id: string; - - /** - * URI this TestItem is associated with. May be a file or directory. - */ - readonly uri?: Uri; - - /** - * A mapping of children by ID to the associated TestItem instances. - */ - readonly children: TestItemCollection; - - /** - * The parent of this item. It's is undefined top-level items in the - * {@link TestController.items} and for items that aren't yet included in - * another item's {@link children}. - */ - readonly parent?: TestItem; - - /** - * Indicates whether this test item may have children discovered by resolving. - * If so, it will be 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. - */ - 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. - */ - busy: boolean; - - /** - * Display name describing the test case. - */ - label: string; - - /** - * Optional description that appears next to the label. - */ - description?: string; - - /** - * Location of the test item in its `uri`. This is only meaningful if the - * `uri` points to a file. - */ - range?: Range; - - /** - * May be set to an error associated with loading the test. Note that this - * is not a test result and should only be used to represent errors in - * discovery, such as syntax errors. - */ - error?: string | MarkdownString; - } - - /** - * 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 `actualOutput`, a diff view will be shown. - */ - expectedOutput?: string; - - /** - * Actual test output. If given with `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); - } -} diff --git a/test/mocks/MockMemento.ts b/test/mocks/MockMemento.ts index 5157fbe6c9..bac98d019e 100644 --- a/test/mocks/MockMemento.ts +++ b/test/mocks/MockMemento.ts @@ -25,4 +25,8 @@ export class MockMemento implements Memento { public clear() { this._value = {}; } + + keys(): readonly string[] { + return Object.keys(this._value); + } } From b4f688508e5dc362efe89e10cbd577f17255da27 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Sun, 8 Aug 2021 18:32:11 -0500 Subject: [PATCH 26/34] src/goTestExplorer: fix run all tests --- src/goTestExplorer.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index a0e277924e..3c6440b248 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -944,8 +944,17 @@ function isBuildFailure(output: string[]): boolean { async function runTests(expl: TestExplorer, request: TestRunRequest, token: CancellationToken) { const collected = new Map(); const docs = new Set(); - for (const item of request.include) { - await collectTests(expl, item, true, request.exclude || [], collected, docs); + if (request.include) { + for (const item of request.include) { + await collectTests(expl, item, true, request.exclude || [], collected, docs); + } + } else { + const promises: Promise[] = []; + expl.ctrl.items.forEach((item) => { + const p = collectTests(expl, item, true, request.exclude || [], collected, docs); + promises.push(p); + }); + await Promise.all(promises); } // Save all documents that contain a test we're about to run, to ensure `go From f6c1077fef1b6c03eaf009022b75670f0a378013 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Sun, 8 Aug 2021 18:37:39 -0500 Subject: [PATCH 27/34] src/goTestExplorer: cleanup --- src/goTestExplorer.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 3c6440b248..033035a1c7 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -846,6 +846,7 @@ function consumeGoTestEvent( switch (e.Action) { case 'cont': + case 'pause': // ignore break; @@ -854,13 +855,15 @@ function consumeGoTestEvent( break; case 'pass': + // TODO(firelizzard18): add messages on pass, once that capability + // is added. complete.add(test); run.passed(test, e.Elapsed * 1000); break; case 'fail': { complete.add(test); - const messages = parseOutput(run, test, record.get(test) || []); + const messages = parseOutput(test, record.get(test) || []); if (!concat) { run.failed(test, messages, e.Elapsed * 1000); @@ -894,14 +897,10 @@ function consumeGoTestEvent( if (record.has(test)) record.get(test).push(e.Output); else record.set(test, [e.Output]); break; - - default: - console.log(e); - break; } } -function parseOutput(run: TestRun, test: TestItem, output: string[]): TestMessage[] { +function parseOutput(test: TestItem, output: string[]): TestMessage[] { const messages: TestMessage[] = []; const uri = Uri.parse(test.id); From 1633f6773287ce79c79ce854a6f133702273de4b Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Sun, 8 Aug 2021 22:27:34 -0500 Subject: [PATCH 28/34] src/goTestExplorer: fixes Fix a dumb mistake and remove a TODO. --- src/goTestExplorer.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index 033035a1c7..c9b71e17d3 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -1030,13 +1030,12 @@ async function runTests(expl: TestExplorer, request: TestRunRequest, token: Canc }); if (!success) { if (isBuildFailure(outputChannel.lines)) { - markComplete(benchmarks, new Set(), (item) => { - // TODO change to errored when that is added back - run.failed(item, { message: 'Compilation failed' }); + markComplete(tests, new Set(), (item) => { + run.errored(item, { message: 'Compilation failed' }); item.error = 'Compilation failed'; }); } else { - markComplete(benchmarks, complete, (x) => run.skipped(x)); + markComplete(tests, complete, (x) => run.skipped(x)); } } } From 5b058a2e2789300374ecef2ed7a91b2807123df5 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Tue, 10 Aug 2021 22:12:39 -0500 Subject: [PATCH 29/34] src/goTestExplorer: address review comments --- package.json | 1 - src/goMain.ts | 5 ++-- src/goTestExplorer.ts | 37 ++++++++++++++++++------- src/testUtils.ts | 4 +-- test/integration/goTestExplorer.test.ts | 4 +++ test/mocks/MockTest.ts | 4 +++ 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 69f66dca78..2567bcda51 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ }, "license": "MIT", "icon": "media/go-logo-blue.png", - "enableProposedApi": true, "categories": [ "Programming Languages", "Snippets", diff --git a/src/goMain.ts b/src/goMain.ts index 692ad1ae0b..351d625cf0 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -227,9 +227,6 @@ If you would like additional configuration for diagnostics from gopls, please se ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, testCodeLensProvider)); ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, referencesCodeLensProvider)); - // testing - const testExplorer = TestExplorer.setup(ctx); - // debug ctx.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('go', new GoDebugConfigurationProvider('go')) @@ -339,6 +336,8 @@ If you would like additional configuration for diagnostics from gopls, please se }) ); + const testExplorer = TestExplorer.setup(ctx); + ctx.subscriptions.push( vscode.commands.registerCommand('go.test.refresh', (args) => { if (args) testExplorer.resolve(args); diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index c9b71e17d3..e401669a6c 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -1,3 +1,7 @@ +/*--------------------------------------------------------- + * Copyright 2020 The Go Authors. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------*/ import { CancellationToken, ConfigurationChangeEvent, @@ -69,7 +73,7 @@ async function doSafe(context: string, p: Thenable | (() => T | Thenable 0) { + for (const item of collect(this.ctrl.items)) { + const uri = Uri.parse(item.id); + if (uri.query === 'package') { + continue; + } - const ws = this.ws.getWorkspaceFolder(uri); - if (!ws) { - dispose(item); + const ws = this.ws.getWorkspaceFolder(uri); + if (!ws) { + dispose(item); + } } } @@ -558,7 +564,10 @@ async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise { const outBuf = new LineBuffer(); const errBuf = new LineBuffer(); - testconfig.cancel?.onCancellationRequested(() => tp.kill()); + testconfig.cancel?.onCancellationRequested(() => killProcessTree(tp)); const testResultLines: string[] = []; const processTestResultLine = addJSONFlag diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts index 8b4eecde62..6f0968adca 100644 --- a/test/integration/goTestExplorer.test.ts +++ b/test/integration/goTestExplorer.test.ts @@ -1,3 +1,7 @@ +/*--------------------------------------------------------- + * Copyright 2020 The Go Authors. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------*/ import assert = require('assert'); import path = require('path'); import { diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts index b1243219cd..57fee7cd92 100644 --- a/test/mocks/MockTest.ts +++ b/test/mocks/MockTest.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ +/*--------------------------------------------------------- + * Copyright 2020 The Go Authors. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------*/ import path = require('path'); import { CancellationToken, From 9ad01bf44b51fff3d3a83ab83fa87dac92c41ec4 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Thu, 12 Aug 2021 10:07:29 -0500 Subject: [PATCH 30/34] src/goTestExplorer: address comments --- src/goTestExplorer.ts | 17 ++++++++--------- src/testUtils.ts | 3 +-- test/integration/goTestExplorer.test.ts | 2 +- test/mocks/MockTest.ts | 4 +++- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index e401669a6c..b18fc7d205 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------- - * Copyright 2020 The Go Authors. All rights reserved. + * Copyright 2021 The Go Authors. All rights reserved. * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------*/ import { @@ -42,10 +42,13 @@ export namespace TestExplorer { export type FileSystem = Pick; - export interface Workspace extends Pick { - readonly fs: FileSystem; // custom FS type + export interface Workspace + extends Pick { + // use custom FS type + readonly fs: FileSystem; - openTextDocument(uri: Uri): Thenable; // only one overload + // only include one overload + openTextDocument(uri: Uri): Thenable; } } @@ -967,11 +970,7 @@ async function runTests(expl: TestExplorer, request: TestRunRequest, token: Canc // Save all documents that contain a test we're about to run, to ensure `go // test` has the latest changes - await Promise.all( - Array.from(docs).map((uri) => { - expl.ws.openTextDocument(uri).then((doc) => doc.save()); - }) - ); + await Promise.all(expl.ws.textDocuments.filter((x) => docs.has(x.uri)).map((x) => x.save())); let hasBench = false, hasNonBench = false; diff --git a/src/testUtils.ts b/src/testUtils.ts index dfae21acf0..c33bad1e68 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -11,7 +11,6 @@ import cp = require('child_process'); import path = require('path'); import util = require('util'); import vscode = require('vscode'); -import { CancellationToken } from 'vscode-languageserver-protocol'; import { applyCodeCoverageToAllEditors } from './goCover'; import { toolExecutionEnvironment } from './goEnv'; @@ -89,7 +88,7 @@ export interface TestConfig { /** * Can be used to terminate the test process. */ - cancel?: CancellationToken; + cancel?: vscode.CancellationToken; /** * Output channel for JSON test output. */ diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts index 6f0968adca..645d73ef0c 100644 --- a/test/integration/goTestExplorer.test.ts +++ b/test/integration/goTestExplorer.test.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------- - * Copyright 2020 The Go Authors. All rights reserved. + * Copyright 2021 The Go Authors. All rights reserved. * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------*/ import assert = require('assert'); diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts index 57fee7cd92..1d6cee8f81 100644 --- a/test/mocks/MockTest.ts +++ b/test/mocks/MockTest.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ /*--------------------------------------------------------- - * Copyright 2020 The Go Authors. All rights reserved. + * Copyright 2021 The Go Authors. All rights reserved. * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------*/ import path = require('path'); @@ -221,6 +221,8 @@ export class MockTestWorkspace implements TestExplorer.Workspace { getWorkspaceFolder(uri: Uri): WorkspaceFolder { return this.workspaceFolders.filter((x) => x.uri === uri)[0]; } + + textDocuments: TextDocument[] = []; } class MockTestDocument implements TextDocument { From 5e41e6509306f1ba9357781d42a00c4458533303 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 13 Aug 2021 11:06:25 -0600 Subject: [PATCH 31/34] src/goTestExplorer: support VSCode 1.58 --- package-lock.json | 2 +- package.json | 7 +++++-- src/goMain.ts | 16 +++++++++------- src/goTestExplorer.ts | 7 +++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec07edc1b5..cd32598e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "yarn": "^1.22.4" }, "engines": { - "vscode": "^1.59.0" + "vscode": "^1.58.0" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 2567bcda51..0adcb9aa2c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "yarn": "^1.22.4" }, "engines": { - "vscode": "^1.59.0" + "vscode": "^1.58.0" }, "activationEvents": [ "workspaceContains:**/*.go", @@ -1291,7 +1291,10 @@ }, "go.testExplorerPackages": { "type": "string", - "enum": ["flat", "nested"], + "enum": [ + "flat", + "nested" + ], "default": "flat", "description": "Control whether packages are presented flat or nested", "scope": "resource" diff --git a/src/goMain.ts b/src/goMain.ts index 351d625cf0..5f7d26d46f 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -114,7 +114,7 @@ import { getFormatTool } from './goFormat'; import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey'; import { ExtensionAPI } from './export'; import extensionAPI from './extensionAPI'; -import { TestExplorer } from './goTestExplorer'; +import { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer'; export let buildDiagnosticCollection: vscode.DiagnosticCollection; export let lintDiagnosticCollection: vscode.DiagnosticCollection; @@ -336,13 +336,15 @@ If you would like additional configuration for diagnostics from gopls, please se }) ); - const testExplorer = TestExplorer.setup(ctx); + if (isVscodeTestingAPIAvailable) { + const testExplorer = TestExplorer.setup(ctx); - ctx.subscriptions.push( - vscode.commands.registerCommand('go.test.refresh', (args) => { - if (args) testExplorer.resolve(args); - }) - ); + ctx.subscriptions.push( + vscode.commands.registerCommand('go.test.refresh', (args) => { + if (args) testExplorer.resolve(args); + }) + ); + } ctx.subscriptions.push( vscode.commands.registerCommand('go.subtest.cursor', (args) => { diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts index b18fc7d205..94a74a94e1 100644 --- a/src/goTestExplorer.ts +++ b/src/goTestExplorer.ts @@ -36,6 +36,11 @@ import { getGoConfig } from './config'; import { getTestFlags, goTest, GoTestOutput } from './testUtils'; import { outputChannel } from './goStatus'; +// Set true only if the Testing API is available (VSCode version >= 1.59). +export const isVscodeTestingAPIAvailable = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'object' === typeof (vscode as any).tests && 'function' === typeof (vscode as any).tests.createTestController; + // eslint-disable-next-line @typescript-eslint/no-namespace export namespace TestExplorer { // exported for tests @@ -86,6 +91,8 @@ async function doSafe(context: string, p: Thenable | (() => T | Thenable Date: Fri, 13 Aug 2021 11:34:00 -0600 Subject: [PATCH 32/34] package.json: add Testing category --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0adcb9aa2c..49de5c4997 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "Snippets", "Linters", "Debuggers", - "Formatters" + "Formatters", + "Testing" ], "galleryBanner": { "color": "#F2F2F2", From 4da995c51c890d8d650829164e973766dbe9c36c Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Fri, 13 Aug 2021 17:54:50 -0500 Subject: [PATCH 33/34] docs: update commands and settings --- docs/commands.md | 4 ++++ docs/settings.md | 28 ++++++++++++++++------------ package.json | 8 ++++---- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 23e0821ffd..9c0a92576e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,6 +59,10 @@ Runs all unit tests in the current file. Runs all unit tests in the package of the current file. +### `Go Test: Refresh` + +Refresh a test in the test explorer. Only available as a context menu option in the test explorer. + ### `Go: Benchmark Package` Runs all benchmarks in the package of the current file. diff --git a/docs/settings.md b/docs/settings.md index 6ea205ba28..bd599c8e05 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -393,6 +393,22 @@ Absolute path to a file containing environment variables definitions. File conte ### `go.testEnvVars` Environment variables that will be passed to the process that runs the Go tests +### `go.testExplorerConcatenateMessages` + +If true, test log messages associated with a given location will be shown as a single message. + +Default: `true` +### `go.testExplorerPackages` + +Control whether packages in the test explorer are presented flat or nested.
+Allowed Options: `flat`, `nested` + +Default: `"flat"` +### `go.testExplorerRunBenchmarks` + +Include benchmarks when running all tests in a group. + +Default: `false` ### `go.testFlags` Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server. @@ -409,18 +425,6 @@ The Go build tags to use for when running tests. If null, then buildTags will be Specifies the timeout for go test in ParseDuration format. Default: `"30s"` -### `go.testExplorerPackages` -Present packages in the test explorer as a flat list or as nested trees. - -Default: `flat` -### `go.testExplorerRunBenchmarks` -Include benchmarks when running a group of tests. - -Default: `false` -### `go.testExplorerConcatenateMessages` -Concatenate test log messages for a given location instead of presenting them individually. - -Default: `true` ### `go.toolsEnvVars` Environment variables that will be passed to the tools that run the Go tools (e.g. CGO_CFLAGS) diff --git a/package.json b/package.json index 49de5c4997..df6fd8d2f7 100644 --- a/package.json +++ b/package.json @@ -236,7 +236,7 @@ { "command": "go.test.refresh", "title": "Go Test: Refresh", - "description": "Refresh a test in the test explorer", + "description": "Refresh a test in the test explorer. Only available as a context menu option in the test explorer.", "category": "Test", "icon": "$(refresh)" }, @@ -1297,19 +1297,19 @@ "nested" ], "default": "flat", - "description": "Control whether packages are presented flat or nested", + "description": "Control whether packages in the test explorer are presented flat or nested.", "scope": "resource" }, "go.testExplorerRunBenchmarks": { "type": "boolean", "default": false, - "description": "Include benchmarks when running all tests in a group", + "description": "Include benchmarks when running all tests in a group.", "scope": "resource" }, "go.testExplorerConcatenateMessages": { "type": "boolean", "default": true, - "description": "If true, test log messages associated with a given location will be shown as a single message", + "description": "If true, test log messages associated with a given location will be shown as a single message.", "scope": "resource" }, "go.generateTestsFlags": { From 59af29b6a2f585135a58b91ed6cc842796219512 Mon Sep 17 00:00:00 2001 From: Ethan Reesor Date: Mon, 16 Aug 2021 20:16:06 -0600 Subject: [PATCH 34/34] src/goTestExplorer: back to 1.59 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df6fd8d2f7..0451e5afe3 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "yarn": "^1.22.4" }, "engines": { - "vscode": "^1.58.0" + "vscode": "^1.59.0" }, "activationEvents": [ "workspaceContains:**/*.go",