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 2b8d0c04eb..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. diff --git a/package-lock.json b/package-lock.json index 28c3c3f984..cd32598e12 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.59.0", "adm-zip": "^0.4.14", "fs-extra": "^9.0.0", "get-port": "^5.1.1", @@ -49,7 +49,7 @@ "yarn": "^1.22.4" }, "engines": { - "vscode": "^1.59.0" + "vscode": "^1.58.0" } }, "node_modules/@babel/code-frame": { @@ -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.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.54.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz", - "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==", + "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 9439ec4d64..25a71b50b0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "Snippets", "Linters", "Debuggers", - "Formatters" + "Formatters", + "Testing" ], "galleryBanner": { "color": "#F2F2F2", @@ -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.59.0", "adm-zip": "^0.4.14", "fs-extra": "^9.0.0", "get-port": "^5.1.1", @@ -232,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. Only available as a context menu option in the test explorer.", + "category": "Test", + "icon": "$(refresh)" + }, { "command": "go.benchmark.package", "title": "Go: Benchmark Package", @@ -1282,6 +1290,28 @@ "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 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.", + "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": { @@ -2356,6 +2386,12 @@ } }, "menus": { + "commandPalette": [ + { + "command": "go.test.refresh", + "when": "false" + } + ], "editor/context": [ { "when": "editorTextFocus && config.go.editorContextMenuCommands.toggleTestFile && resourceLangId == go", @@ -2437,6 +2473,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/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/goMain.ts b/src/goMain.ts index 541ad64729..5f7d26d46f 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 { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer'; export let buildDiagnosticCollection: vscode.DiagnosticCollection; export let lintDiagnosticCollection: vscode.DiagnosticCollection; @@ -335,6 +336,16 @@ If you would like additional configuration for diagnostics from gopls, please se }) ); + 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.subtest.cursor', (args) => { const goConfig = getGoConfig(); diff --git a/src/goModules.ts b/src/goModules.ts index a0b6f51d16..ab0dd04fae 100644 --- a/src/goModules.ts +++ b/src/goModules.ts @@ -36,7 +36,8 @@ 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); }); }); } 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/goTestExplorer.ts b/src/goTestExplorer.ts new file mode 100644 index 0000000000..94a74a94e1 --- /dev/null +++ b/src/goTestExplorer.ts @@ -0,0 +1,1097 @@ +/*--------------------------------------------------------- + * Copyright 2021 The Go Authors. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------*/ +import { + CancellationToken, + ConfigurationChangeEvent, + DocumentSymbol, + ExtensionContext, + FileType, + Location, + OutputChannel, + Position, + Range, + SymbolKind, + TestController, + TestItem, + TestItemCollection, + TestMessage, + TestRun, + TestRunProfileKind, + TestRunRequest, + TextDocument, + TextDocumentChangeEvent, + Uri, + workspace, + WorkspaceFolder, + WorkspaceFoldersChangeEvent +} from 'vscode'; +import vscode = require('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, 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 + + export type FileSystem = Pick; + + export interface Workspace + extends Pick { + // use custom FS type + readonly fs: FileSystem; + + // only include one overload + openTextDocument(uri: Uri): Thenable; + } +} + +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 occurred'; + outputChannel.appendLine(`${m}: ${error}`); + await vscode.window.showErrorMessage(m); + } + return onError; + } +} + +export class TestExplorer { + static setup(context: ExtensionContext): TestExplorer { + if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable'); + + const ctrl = vscode.tests.createTestController('go', 'Go'); + 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))) + ); + + context.subscriptions.push( + workspace.onDidChangeTextDocument((x) => doSafe('onDidChangeTextDocument', inst.didChangeTextDocument(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) => doSafe('onDidCreate', inst.didCreateFile(x)))); + context.subscriptions.push(watcher.onDidDelete((x) => doSafe('onDidDelete', inst.didDeleteFile(x)))); + + return inst; + } + + constructor( + public ctrl: TestController, + public ws: TestExplorer.Workspace, + public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable + ) { + ctrl.resolveHandler = (item) => this.resolve(item); + ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq, tok) => this.run(rq, tok), true); + } + + /* ***** Interface (external) ***** */ + + resolve(item?: TestItem) { + return doSafe('resolveHandler', resolve(this, item)); + } + + run(request: TestRunRequest, token: CancellationToken) { + return doSafe('runHandler', runTests(this, request, token)); + } + + /* ***** 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: '' })); + } + + // 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; + } + + /* ***** Listeners ***** */ + + protected async didOpenTextDocument(doc: TextDocument) { + await documentUpdate(this, doc); + } + + protected async didChangeTextDocument(e: TextDocumentChangeEvent) { + await documentUpdate( + this, + e.document, + e.contentChanges.map((x) => x.range) + ); + } + + protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) { + if (e.removed.length > 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); + } + } + } + + if (e.added) { + await resolve(this); + } + } + + protected async didCreateFile(file: Uri) { + await documentUpdate(this, await this.ws.openTextDocument(file)); + } + + protected async didDeleteFile(file: Uri) { + const id = testID(file, 'file'); + function find(children: TestItemCollection): TestItem { + for (const item of collect(children)) { + if (item.id === id) { + return item; + } + + const uri = Uri.parse(item.id); + if (!file.path.startsWith(uri.path)) { + continue; + } + + const found = find(item.children); + if (found) { + return found; + } + } + } + + const found = find(this.ctrl.items); + if (found) { + dispose(found); + disposeIfEmpty(found.parent); + } + } + + protected async didChangeConfiguration(e: ConfigurationChangeEvent) { + let update = false; + for (const item of collect(this.ctrl.items)) { + if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) { + dispose(item); + update = true; + } + } + + if (update) { + resolve(this); + } + } +} + +// 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 +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(); +} + +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; + } + return parent; +} + +function dispose(item: TestItem) { + item.parent.children.delete(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 empty top-level items + const uri = Uri.parse(item.id); + if (uri.query === 'module' || uri.query === 'workspace' || (uri.query === 'package' && !item.parent)) { + return; + } + + if (item.children.size > 0) { + return; + } + + dispose(item); + 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; + 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 collect(item.children)) { + 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 = expl.getItem(null, uri, 'module'); + if (existing) { + return existing; + } + + // Use the module name as the label + const goMod = Uri.joinPath(uri, 'go.mod'); + const contents = await expl.ws.fs.readFile(goMod); + const modLine = contents.toString().split('\n', 2)[0]; + const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/); + const item = expl.getOrCreateItem(null, match.groups.name, uri, 'module'); + item.canResolveChildren = 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 = expl.getItem(null, ws.uri, 'workspace'); + if (existing) { + return existing; + } + + // Use the workspace folder name as the label + const item = expl.getOrCreateItem(null, ws.name, ws.uri, 'workspace'); + item.canResolveChildren = true; + return item; +} + +// Retrieve or create an item for a Go package. +async function getPackage(expl: TestExplorer, uri: Uri): 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 + let parent = await getModule(expl, uri.with({ path: modDir, query: '', fragment: '' })); + if (uri.path === parent.uri.path) { + return parent; + } + + 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 = 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 = 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 = expl.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 = expl.getOrCreateItem(workspace, label, uri, 'package'); + } else { + // Otherwise, add it directly to the root + 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 = expl.getOrCreateItem(null, label, uri, 'package'); + } + + item.canResolveChildren = true; + return item; +} + +// Retrieve or create an item for a Go file. +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 = expl.getItem(pkg, uri, 'file'); + if (existing) { + return existing; + } + + const label = path.basename(uri.path); + const item = expl.getOrCreateItem(pkg, label, uri, 'file'); + item.canResolveChildren = 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(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; + } + + // Recursively process symbols that are nested + if (symbol.kind !== SymbolKind.Function) { + for (const sym of symbol.children) await processSymbol(expl, 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 = expl.getItem(file, uri, kind, symbol.name); + if (existing) { + if (!existing.range.isEqual(symbol.range)) { + existing.range = symbol.range; + relocateChildren(existing); + } + return existing; + } + + const item = expl.getOrCreateItem(file, symbol.name, uri, kind, symbol.name); + item.range = symbol.range; +} + +// 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(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) { + const seen = new Set(); + 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 collect(item.children)) { + const uri = Uri.parse(child.id); + if (!seen.has(uri.fragment)) { + dispose(child); + continue; + } + + if (ranges?.some((r) => !!child.range.intersection(r))) { + discardChildren(child); + } + } + + disposeIfEmpty(item); +} + +// Reasons to stop walking +enum WalkStop { + 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. +async function walk( + fs: TestExplorer.FileSystem, + uri: Uri, + cb: (dir: Uri, file: string, type: FileType) => Promise +): Promise { + let dirs = [uri]; + + // While there are directories to be scanned + while (dirs.length > 0) { + 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 fs.readDirectory(uri)) { + if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) { + continue; + } + + // Ignore all dotfiles + if (file.startsWith('.')) { + continue; + } + + if (type === FileType.Directory) { + dirs2.push(Uri.joinPath(uri, file)); + } + + 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; + } + } + + // Add subdirectories to the recursion list + dirs.push(...dirs2); + } + } +} + +// 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(fs: TestExplorer.FileSystem, uri: Uri): Promise> { + const found = new Map(); + await walk(fs, uri, async (dir, file, type) => { + if (type !== FileType.File) { + return; + } + + if (file === 'go.mod') { + // BUG(firelizard18): This does not create a separate entry for + // modules within a module. Thus, tests in a module within another + // module will appear under the top-level module's tree. This may or + // may not be acceptable. + found.set(dir.toString(), true); + return WalkStop.Current; + } + + if (file.endsWith('.go')) { + found.set(dir.toString(), false); + } + }); + return found; +} + +// Walk the workspace, calling the callback for any directory that contains a Go +// test 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; + } + }); +} + +// Handle opened documents, document changes, and file creation. +async function documentUpdate(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) { + 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 processDocument(expl, doc, ranges); +} + +// TestController.resolveChildrenHandler callback +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 collect(expl.ctrl.items)) { + const uri = Uri.parse(item.id); + if (uri.query !== 'package') { + continue; + } + + if (expl.ws.getWorkspaceFolder(uri)) { + dispose(item); + } + } + + // Create entries for all modules and workspaces + 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) { + needWorkspace = true; + continue; + } + + 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(expl, folder); + } + } + return; + } + + const uri = Uri.parse(item.id); + + // The user expanded a module or workspace - find all packages + if (uri.query === 'module' || uri.query === 'workspace') { + 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 expl.ws.fs.readDirectory(uri)) { + if (type !== FileType.File || !file.endsWith('_test.go')) { + continue; + } + + await getFile(expl, Uri.joinPath(uri, file)); + } + } + + // The user expanded a file - find all functions + if (uri.query === 'file') { + 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 + // 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, + docs: Set +) { + for (let i = item; i.parent; i = i.parent) { + if (excluded.indexOf(i) >= 0) { + return; + } + } + + const uri = Uri.parse(item.id); + if (!uri.fragment) { + if (item.children.size === 0) { + await resolve(expl, item); + } + + for (const child of collect(item.children)) { + await collectTests(expl, child, false, excluded, functions, docs); + } + return; + } + + 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, explicitlyIncluded }); + } else { + functions.set(dir, [{ item, explicitlyIncluded }]); + } + return; +} + +// TestRunOutput is a fake OutputChannel that forwards all test output to the test API +// console. +class TestRunOutput implements OutputChannel { + readonly name: string; + readonly lines: string[] = []; + + constructor(private run: TestRun) { + this.name = `Test run at ${new Date()}`; + } + + append(value: string) { + this.run.appendOutput(value); + } + + appendLine(value: string) { + this.lines.push(value); + this.run.appendOutput(value + '\r\n'); + } + + clear() {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + show(...args: unknown[]) {} + hide() {} + 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(expl: TestExplorer, 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 = expl.getOrCreateSubTest(test, part); + } + return test; +} + +// Process benchmark events (see test_events.md) +function consumeGoBenchmarkEvent( + expl: TestExplorer, + run: TestRun, + benchmarks: Record, + complete: Set, + e: GoTestOutput +) { + if (e.Test) { + // Find (or create) the (sub)benchmark + const test = resolveTestName(expl, benchmarks, e.Test); + if (!test) { + return; + } + + switch (e.Action) { + case 'fail': // Failed + run.failed(test, { message: 'Failed' }); + complete.add(test); + break; + + case 'skip': // Skipped + run.skipped(test); + complete.add(test); + break; + } + + return; + } + + // Ignore anything that's not an output event + if (!e.Output) { + return; + } + + // 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(expl, 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.passed(test); + complete.add(test); + vscode.commands.executeCommand('testing.showMostRecentOutput'); + } else { + run.started(test); + } +} + +// Pass any incomplete benchmarks (see test_events.md) +function markComplete(items: Record, complete: Set, fn: (item: TestItem) => void) { + function mark(item: TestItem) { + if (!complete.has(item)) { + fn(item); + } + for (const child of collect(item.children)) { + mark(child); + } + } + + for (const name in items) { + mark(items[name]); + } +} + +// Process test events (see test_events.md) +function consumeGoTestEvent( + expl: TestExplorer, + run: TestRun, + tests: Record, + record: Map, + complete: Set, + concat: boolean, + e: GoTestOutput +) { + const test = resolveTestName(expl, tests, e.Test); + if (!test) { + return; + } + + switch (e.Action) { + case 'cont': + case 'pause': + // ignore + break; + + case 'run': + run.started(test); + 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(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': + complete.add(test); + run.skipped(test); + break; + + case 'output': + if (/^(=== RUN|\s*--- (FAIL|PASS): )/.test(e.Output)) { + break; + } + + if (record.has(test)) record.get(test).push(e.Output); + else record.set(test, [e.Output]); + break; + } +} + +function parseOutput(test: TestItem, output: string[]): TestMessage[] { + const messages: TestMessage[] = []; + + 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.location = new Location(test.uri, test.range.start); + messages.push(message); + output = output.slice(0, gotI); + } + + 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 }); + } + } + + return messages; +} + +function isBuildFailure(output: string[]): boolean { + const rePkg = /^# (?[\w/.-]+)(?: \[(?[\w/.-]+).test\])?/; + + // TODO(firelizzard18): Add more sophisticated check for build failures? + return output.some((x) => rePkg.test(x)); +} + +// Execute tests - TestController.runTest callback +async function runTests(expl: TestExplorer, request: TestRunRequest, token: CancellationToken) { + const collected = new Map(); + const docs = new Set(); + 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 + // test` has the latest changes + await Promise.all(expl.ws.textDocuments.filter((x) => docs.has(x.uri)).map((x) => x.save())); + + 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()) { + const uri = Uri.parse(dir); + 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, explicitlyIncluded } of items) { + const uri = Uri.parse(item.id); + if (/[/#]/.test(uri.fragment)) { + // running sub-tests is not currently supported + vscode.window.showErrorMessage(`Cannot run ${uri.fragment} - running sub-tests is not supported`); + continue; + } + + // When the user clicks the run button on a package, they expect all + // of the tests within that package to run - they probably don't + // want to run the benchmarks. So if a benchmark is not explicitly + // selected, don't run benchmarks. But the user may disagree, so + // behavior can be changed with `go.testExplorerRunBenchmarks`. + // However, if the user clicks the run button on a file or package + // that contains benchmarks and nothing else, they likely expect + // those benchmarks to run. + if (uri.query === 'benchmark' && !explicitlyIncluded && !includeBench && !(hasBench && !hasNonBench)) { + continue; + } + + item.error = null; + run.enqueued(item); + discardChildren(item); + + if (uri.query === 'benchmark') { + benchmarks[uri.fragment] = item; + } else { + tests[uri.fragment] = item; + } + } + + 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) { + const complete = new Set(); + const success = await goTest({ + goConfig, + flags, + isMod, + outputChannel, + dir: uri.fsPath, + functions: testFns, + cancel: token, + goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, complete, concat, e) + }); + if (!success) { + if (isBuildFailure(outputChannel.lines)) { + markComplete(tests, new Set(), (item) => { + run.errored(item, { message: 'Compilation failed' }); + item.error = 'Compilation failed'; + }); + } else { + markComplete(tests, complete, (x) => run.skipped(x)); + } + } + } + + // Run benchmarks + if (benchmarkFns.length > 0) { + const complete = new Set(); + const success = await goTest({ + goConfig, + flags, + isMod, + outputChannel, + dir: uri.fsPath, + functions: benchmarkFns, + isBenchmark: true, + cancel: token, + goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e) + }); + + // 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 { + markComplete(benchmarks, complete, (x) => run.skipped(x)); + } + } + } + + run.end(); +} diff --git a/src/testUtils.ts b/src/testUtils.ts index 77e812ded7..c33bad1e68 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -85,6 +85,14 @@ export interface TestConfig { * Output channel for test output. */ outputChannel?: vscode.OutputChannel; + /** + * Can be used to terminate the test process. + */ + cancel?: vscode.CancellationToken; + /** + * Output channel for JSON test output. + */ + goTestOutputConsumer?: (_: GoTestOutput) => void; } export function getTestEnvVars(config: vscode.WorkspaceConfiguration): any { @@ -236,10 +244,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; // seconds } /** @@ -294,9 +304,16 @@ export async function goTest(testconfig: TestConfig): Promise { const outBuf = new LineBuffer(); const errBuf = new LineBuffer(); + testconfig.cancel?.onCancellationRequested(() => killProcessTree(tp)); + 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 +460,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 +495,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; } 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 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 new file mode 100644 index 0000000000..645d73ef0c --- /dev/null +++ b/test/integration/goTestExplorer.test.ts @@ -0,0 +1,414 @@ +/*--------------------------------------------------------- + * 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'); +import path = require('path'); +import { + DocumentSymbol, + FileType, + TestItem, + Uri, + TextDocument, + SymbolKind, + Range, + Position, + TestItemCollection, + TextDocumentChangeEvent +} from 'vscode'; +import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules'; +import { TestExplorer, testID } from '../../src/goTestExplorer'; +import { MockTestController, MockTestWorkspace } from '../mocks/MockTest'; + +type Files = Record; + +interface TestCase { + workspace: string[]; + files: Files; +} + +// 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 setup(folders: string[], files: Files) { + return setupCtor(folders, files, TestExplorer); +} + +function setupCtor( + folders: string[], + files: Files, + ctor: new (...args: ConstructorParameters) => T +) { + const ws = MockTestWorkspace.from(folders, files); + const ctrl = new MockTestController(); + const expl = new ctor(ctrl, ws, 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]; + walk(Uri.file('/')); + + return { ctrl, expl, ws }; +} + +function assertTestItems(items: TestItemCollection, expect: string[]) { + const actual: string[] = []; + function walk(items: TestItemCollection) { + items.forEach((item) => { + actual.push(item.id); + walk(item.children); + }); + } + walk(items); + assert.deepStrictEqual(actual, expect); +} + +suite('Test Explorer', () => { + suite('Items', () => { + interface TC extends TestCase { + item?: ([string, string, string] | [string, string, string, string])[]; + expect: string[]; + } + + 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: [['test', '/src/proj', 'module']], + expect: [] + }, + 'Root package': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main' + }, + item: [['test', '/src/proj', 'module']], + 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: [['test', '/src/proj', 'module']], + 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: [['test', '/src/proj', 'module']], + 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: [ + ['test', '/src/proj', 'module'], + ['pkg', '/src/proj/pkg', 'package'] + ], + 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: [ + ['test', '/src/proj', 'module'], + ['pkg', '/src/proj/pkg', 'package'] + ], + 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: [ + ['test', '/src/proj', 'module'], + ['pkg', '/src/proj/pkg', 'package'] + ], + expect: [] + } + }, + File: { + 'Empty': { + workspace: ['/src/proj'], + files: { + '/src/proj/go.mod': 'module test', + '/src/proj/main_test.go': 'package main' + }, + item: [ + ['test', '/src/proj', 'module'], + ['main_test.go', '/src/proj/main_test.go', 'file'] + ], + expect: [] + }, + '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() {} + ` + }, + item: [ + ['test', '/src/proj', 'module'], + ['main_test.go', '/src/proj/main_test.go', 'file'] + ], + 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, async () => { + const { workspace, files, expect, item: itemData = [] } = cases[n][m]; + const { ctrl } = setup(workspace, files); + + let item: TestItem | undefined; + for (const [label, uri, kind, name] of itemData) { + const u = Uri.parse(uri); + const child = ctrl.createTestItem(testID(u, kind, name), label, u); + (item?.children || ctrl.items).add(child); + item = child; + } + await ctrl.resolveHandler(item); + + const actual: string[] = []; + (item?.children || ctrl.items).forEach((x) => actual.push(x.id)); + assert.deepStrictEqual(actual, expect); + }); + } + }); + } + }); + + 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[]; + } + + 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 } = setupCtor(workspace, files, DUT); + + await expl._didOpen(ws.fs.files.get(open)); + + assertTestItems(ctrl.items, expect); + }); + } + }); + + 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][]; + 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 } = setupCtor(workspace, files, DUT); + + 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._didChange({ + document: doc, + contentChanges: [] + }); + } + + assertTestItems(ctrl.items, expect.after); + }); + } + }); + }); +}); 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/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); + } } diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts new file mode 100644 index 0000000000..1d6cee8f81 --- /dev/null +++ b/test/mocks/MockTest.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/*--------------------------------------------------------- + * 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'); +import { + CancellationToken, + EndOfLine, + FileType, + MarkdownString, + Position, + Range, + TestController, + TestItem, + TestItemCollection, + TestRun, + TestRunProfile, + TestRunProfileKind, + TestRunRequest, + TextDocument, + TextLine, + Uri, + WorkspaceFolder +} 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 readonly m = new Map(); + + get size() { + return this.m.size; + } + + forEach(fn: (item: TestItem, coll: TestItemCollection) => unknown) { + for (const item of this.m.values()) fn(item, this); + } + + 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); + } + + 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'); + } +} + +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; + range?: Range; + error?: string | MarkdownString; + runnable: boolean; + debuggable: boolean; + + children: MockTestCollection = new MockTestCollection(this); + + invalidateResults(): void {} + + dispose(): void { + if (this.parent instanceof MockTestItem) { + this.parent.children.delete(this.id); + } + } +} + +class MockTestRunProfile implements TestRunProfile { + constructor( + public label: string, + public kind: TestRunProfileKind, + public runHandler: TestRunHandler, + public isDefault: boolean + ) {} + + configureHandler?: () => void; + dispose(): void {} +} + +export class MockTestController implements TestController { + id = 'go'; + label = 'Go'; + items = new MockTestCollection(this); + + resolveHandler?: (item: TestItem) => void | Thenable; + + createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun { + throw new Error('Method not implemented.'); + } + + createRunProfile( + label: string, + kind: TestRunProfileKind, + runHandler: TestRunHandler, + isDefault?: boolean + ): 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 {} +} + +type DirEntry = [string, FileType]; + +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 || '')); + } +} + +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(); + + 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: MockTestDocument; + if (typeof entry === 'object') { + doc = new MockTestDocument(uri, unindent(entry.contents), entry.language); + } else if (path.basename(uri.path) === 'go.mod') { + doc = new MockTestDocument(uri, unindent(entry), 'go.mod'); + } else { + doc = new MockTestDocument(uri, unindent(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]; + } + + textDocuments: TextDocument[] = []; +} + +class MockTestDocument implements TextDocument { + constructor( + public uri: Uri, + 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; + } + + 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`