| /*--------------------------------------------------------- |
| * 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<vscode.FileSystem, 'readFile' | 'readDirectory'>; |
| |
| export interface Workspace |
| extends Pick<typeof vscode.workspace, 'workspaceFolders' | 'getWorkspaceFolder' | 'textDocuments'> { |
| // use custom FS type |
| readonly fs: FileSystem; |
| |
| // only include one overload |
| openTextDocument(uri: Uri): Thenable<TextDocument>; |
| } |
| } |
| |
| async function doSafe<T>(context: string, p: Thenable<T> | (() => T | Thenable<T>), onError?: T): Promise<T> { |
| 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<DocumentSymbol[]> |
| ) { |
| 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<TestItem> { |
| 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 (?<name>.*?)(?:\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<TestItem> { |
| 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<TestItem> { |
| 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<TestItem> { |
| 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<string>, 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(/^(?<type>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<string>(); |
| 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<WalkStop | undefined> |
| ): Promise<void> { |
| 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<Map<string, boolean>> { |
| const found = new Map<string, boolean>(); |
| 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<unknown>) { |
| 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<string, CollectedTest[]>, |
| docs: Set<Uri> |
| ) { |
| 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<string, TestItem>, 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<string, TestItem>, |
| complete: Set<TestItem>, |
| 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(/^(?<name>Benchmark[/\w]+)(?:-(?<procs>\d+)\s+(?<result>.*))?(?:$|\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<string, TestItem>, complete: Set<TestItem>, 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<string, TestItem>, |
| record: Map<TestItem, string[]>, |
| complete: Set<TestItem>, |
| 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<string, TestMessage>(); |
| 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*(?<file>.*\.go):(?<line>\d+): ?(?<message>.*\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 = /^# (?<pkg>[\w/.-]+)(?: \[(?<test>[\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<string, CollectedTest[]>(); |
| const docs = new Set<Uri>(); |
| if (request.include) { |
| for (const item of request.include) { |
| await collectTests(expl, item, true, request.exclude || [], collected, docs); |
| } |
| } else { |
| const promises: Promise<unknown>[] = []; |
| 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<string, TestItem> = {}; |
| const benchmarks: Record<string, TestItem> = {}; |
| 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<TestItem, string[]>(); |
| const testFns = Object.keys(tests); |
| const benchmarkFns = Object.keys(benchmarks); |
| const concat = goConfig.get<boolean>('testExplorerConcatenateMessages'); |
| |
| // Run tests |
| if (testFns.length > 0) { |
| const complete = new Set<TestItem>(); |
| 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<TestItem>(); |
| 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(); |
| } |