blob: 94a74a94e1051809ef481416ecde1af1962fb6bc [file] [log] [blame]
/*---------------------------------------------------------
* 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();
}