src/goTest: split up test explorer
Change-Id: Id2da687d115d5551d70bc8235a6aab5f7ce69ecc
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/343790
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/src/goMain.ts b/src/goMain.ts
index 5f7d26d..8300c5b 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -114,7 +114,7 @@
import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey';
import { ExtensionAPI } from './export';
import extensionAPI from './extensionAPI';
-import { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer';
+import { GoTestExplorer, isVscodeTestingAPIAvailable } from './goTest/explore';
export let buildDiagnosticCollection: vscode.DiagnosticCollection;
export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -337,11 +337,11 @@
);
if (isVscodeTestingAPIAvailable) {
- const testExplorer = TestExplorer.setup(ctx);
+ const testExplorer = GoTestExplorer.setup(ctx);
ctx.subscriptions.push(
vscode.commands.registerCommand('go.test.refresh', (args) => {
- if (args) testExplorer.resolve(args);
+ if (args) testExplorer.resolver.resolve(args);
})
);
}
diff --git a/src/goTest/explore.ts b/src/goTest/explore.ts
new file mode 100644
index 0000000..139769f
--- /dev/null
+++ b/src/goTest/explore.ts
@@ -0,0 +1,247 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+ ConfigurationChangeEvent,
+ ExtensionContext,
+ Range,
+ TestController,
+ TestItem,
+ TestItemCollection,
+ TestRunProfileKind,
+ TextDocument,
+ TextDocumentChangeEvent,
+ Uri,
+ workspace,
+ WorkspaceFoldersChangeEvent
+} from 'vscode';
+import vscode = require('vscode');
+import { GoDocumentSymbolProvider } from '../goOutline';
+import { outputChannel } from '../goStatus';
+import { dispose, disposeIfEmpty, findItem, GoTest, Workspace } from './utils';
+import { GoTestResolver, ProvideSymbols } from './resolve';
+import { GoTestRunner } from './run';
+
+// 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;
+
+// Check whether the process is running as a test.
+function isInTest() {
+ return process.env.VSCODE_GO_IN_TEST === '1';
+}
+
+export class GoTestExplorer {
+ static setup(context: ExtensionContext): GoTestExplorer {
+ if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable');
+
+ const ctrl = vscode.tests.createTestController('go', 'Go');
+ const symProvider = new GoDocumentSymbolProvider(true);
+ const inst = new this(workspace, ctrl, (doc, token) => symProvider.provideDocumentSymbols(doc, token));
+
+ context.subscriptions.push(ctrl);
+
+ context.subscriptions.push(
+ workspace.onDidChangeConfiguration(async (x) => {
+ try {
+ await inst.didChangeConfiguration(x);
+ } catch (error) {
+ if (isInTest()) throw error;
+ else outputChannel.appendLine(`Failed while handling 'onDidChangeConfiguration': ${error}`);
+ }
+ })
+ );
+
+ context.subscriptions.push(
+ workspace.onDidOpenTextDocument(async (x) => {
+ try {
+ await inst.didOpenTextDocument(x);
+ } catch (error) {
+ if (isInTest()) throw error;
+ else outputChannel.appendLine(`Failed while handling 'onDidOpenTextDocument': ${error}`);
+ }
+ })
+ );
+
+ context.subscriptions.push(
+ workspace.onDidChangeTextDocument(async (x) => {
+ try {
+ await inst.didChangeTextDocument(x);
+ } catch (error) {
+ if (isInTest()) throw error;
+ else outputChannel.appendLine(`Failed while handling 'onDidChangeTextDocument': ${error}`);
+ }
+ })
+ );
+
+ context.subscriptions.push(
+ workspace.onDidChangeWorkspaceFolders(async (x) => {
+ try {
+ await inst.didChangeWorkspaceFolders(x);
+ } catch (error) {
+ if (isInTest()) throw error;
+ else outputChannel.appendLine(`Failed while handling 'onDidChangeWorkspaceFolders': ${error}`);
+ }
+ })
+ );
+
+ const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false);
+ context.subscriptions.push(watcher);
+ context.subscriptions.push(
+ watcher.onDidCreate(async (x) => {
+ try {
+ await inst.didCreateFile(x);
+ } catch (error) {
+ if (isInTest()) throw error;
+ else outputChannel.appendLine(`Failed while handling 'FileSystemWatcher.onDidCreate': ${error}`);
+ }
+ })
+ );
+ context.subscriptions.push(
+ watcher.onDidDelete(async (x) => {
+ try {
+ await inst.didDeleteFile(x);
+ } catch (error) {
+ if (isInTest()) throw error;
+ else outputChannel.appendLine(`Failed while handling 'FileSystemWatcher.onDidDelete': ${error}`);
+ }
+ })
+ );
+
+ return inst;
+ }
+
+ public readonly resolver: GoTestResolver;
+
+ constructor(
+ private readonly workspace: Workspace,
+ private readonly ctrl: TestController,
+ provideDocumentSymbols: ProvideSymbols
+ ) {
+ const resolver = new GoTestResolver(workspace, ctrl, provideDocumentSymbols);
+ const runner = new GoTestRunner(workspace, ctrl, resolver);
+
+ this.resolver = resolver;
+
+ ctrl.resolveHandler = async (item) => {
+ try {
+ await resolver.resolve(item);
+ } catch (error) {
+ if (isInTest()) throw error;
+
+ const m = 'Failed to resolve tests';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ }
+ };
+
+ ctrl.createRunProfile(
+ 'go test',
+ TestRunProfileKind.Run,
+ async (request, token) => {
+ try {
+ await runner.run(request, token);
+ } catch (error) {
+ const m = 'Failed to execute tests';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ }
+ },
+ true
+ );
+ }
+
+ /* ***** Listeners ***** */
+
+ protected async didOpenTextDocument(doc: TextDocument) {
+ await this.documentUpdate(doc);
+ }
+
+ protected async didChangeTextDocument(e: TextDocumentChangeEvent) {
+ await this.documentUpdate(
+ e.document,
+ e.contentChanges.map((x) => x.range)
+ );
+ }
+
+ protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) {
+ if (e.added.length > 0) {
+ await this.resolver.resolve();
+ }
+
+ if (e.removed.length === 0) {
+ return;
+ }
+
+ this.ctrl.items.forEach((item) => {
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'package') {
+ return;
+ }
+
+ const ws = this.workspace.getWorkspaceFolder(uri);
+ if (!ws) {
+ dispose(item);
+ }
+ });
+ }
+
+ protected async didCreateFile(file: Uri) {
+ await this.documentUpdate(await this.workspace.openTextDocument(file));
+ }
+
+ protected async didDeleteFile(file: Uri) {
+ const id = GoTest.id(file, 'file');
+ function find(children: TestItemCollection): TestItem {
+ return findItem(children, (item) => {
+ if (item.id === id) {
+ return item;
+ }
+
+ const uri = Uri.parse(item.id);
+ if (!file.path.startsWith(uri.path)) {
+ return;
+ }
+
+ return find(item.children);
+ });
+ }
+
+ const found = find(this.ctrl.items);
+ if (found) {
+ dispose(found);
+ disposeIfEmpty(found.parent);
+ }
+ }
+
+ protected async didChangeConfiguration(e: ConfigurationChangeEvent) {
+ let update = false;
+ this.ctrl.items.forEach((item) => {
+ if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
+ dispose(item);
+ update = true;
+ }
+ });
+
+ if (update) {
+ this.resolver.resolve();
+ }
+ }
+
+ // Handle opened documents, document changes, and file creation.
+ private async documentUpdate(doc: TextDocument, ranges?: Range[]) {
+ if (doc.uri.scheme === 'git') {
+ // TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
+ const { path } = JSON.parse(doc.uri.query);
+ doc = await vscode.workspace.openTextDocument(path);
+ }
+
+ if (!doc.uri.path.endsWith('_test.go')) {
+ return;
+ }
+
+ await this.resolver.processDocument(doc, ranges);
+ }
+}
diff --git a/src/goTest/resolve.ts b/src/goTest/resolve.ts
new file mode 100644
index 0000000..1cdf74d
--- /dev/null
+++ b/src/goTest/resolve.ts
@@ -0,0 +1,460 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+ CancellationToken,
+ DocumentSymbol,
+ FileType,
+ Range,
+ SymbolKind,
+ TestController,
+ TestItem,
+ TestItemCollection,
+ TextDocument,
+ Uri,
+ workspace,
+ WorkspaceFolder
+} from 'vscode';
+import path = require('path');
+import { getModFolderPath } from '../goModules';
+import { getCurrentGoPath } from '../util';
+import { getGoConfig } from '../config';
+import { dispose, disposeIfEmpty, FileSystem, GoTest, Workspace } from './utils';
+import { walk, WalkStop } from './walk';
+
+export type ProvideSymbols = (doc: TextDocument, token: CancellationToken) => Thenable<DocumentSymbol[]>;
+
+const testFuncRegex = /^(?<name>(?<kind>Test|Benchmark|Example)($|\P{Ll}.*))/u;
+const testMethodRegex = /^\(\*(?<type>[^)]+)\)\.(?<name>(?<kind>Test)($|\P{Ll}.*))$/u;
+const runTestSuiteRegex = /^\s*suite\.Run\(\w+,\s*(?:&?(?<type1>\w+)\{\}|new\((?<type2>\w+)\))\)/mu;
+
+interface TestSuite {
+ func?: TestItem;
+ methods: Set<TestItem>;
+}
+
+export class GoTestResolver {
+ public readonly isDynamicSubtest = new WeakSet<TestItem>();
+ public readonly isTestMethod = new WeakSet<TestItem>();
+ public readonly isTestSuiteFunc = new WeakSet<TestItem>();
+ private readonly testSuites = new Map<string, TestSuite>();
+
+ constructor(
+ private readonly workspace: Workspace,
+ private readonly ctrl: TestController,
+ private readonly provideDocumentSymbols: ProvideSymbols
+ ) {}
+
+ get items() {
+ return this.ctrl.items;
+ }
+
+ async resolve(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
+ this.ctrl.items.forEach((item) => {
+ const uri = Uri.parse(item.id);
+ if (uri.query !== 'package') {
+ return;
+ }
+
+ if (this.workspace.getWorkspaceFolder(uri)) {
+ dispose(item);
+ }
+ });
+
+ // Create entries for all modules and workspaces
+ for (const folder of this.workspace.workspaceFolders || []) {
+ const found = await walkWorkspaces(this.workspace.fs, folder.uri);
+ let needWorkspace = false;
+ for (const [uri, isMod] of found.entries()) {
+ if (!isMod) {
+ needWorkspace = true;
+ continue;
+ }
+
+ await this.getModule(Uri.parse(uri));
+ }
+
+ // If the workspace folder contains any Go files not in a module, create a workspace entry
+ if (needWorkspace) {
+ await this.getWorkspace(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(this.workspace.fs, uri, async (uri) => {
+ await this.getPackage(uri);
+ });
+ }
+
+ // The user expanded a module or package - find all files
+ if (uri.query === 'module' || uri.query === 'package') {
+ for (const [file, type] of await this.workspace.fs.readDirectory(uri)) {
+ if (type !== FileType.File || !file.endsWith('_test.go')) {
+ continue;
+ }
+
+ await this.getFile(Uri.joinPath(uri, file));
+ }
+ }
+
+ // The user expanded a file - find all functions
+ if (uri.query === 'file') {
+ const doc = await this.workspace.openTextDocument(uri.with({ query: '', fragment: '' }));
+ await this.processDocument(doc);
+ }
+
+ // TODO(firelizzard18): If uri.query is test or benchmark, this is where we
+ // would discover sub tests or benchmarks, if that is feasible.
+ }
+
+ // Find test items related to the given resource
+ find(resource: Uri): TestItem[] {
+ const findStr = resource.toString();
+ const found: TestItem[] = [];
+
+ function find(items: TestItemCollection) {
+ items.forEach((item) => {
+ const itemStr = item.uri.toString();
+ if (findStr === itemStr) {
+ found.push(item);
+ find(item.children);
+ } else if (findStr.startsWith(itemStr)) {
+ find(item.children);
+ }
+ });
+ }
+
+ find(this.ctrl.items);
+ return found;
+ }
+
+ // 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, dynamic?: boolean): TestItem {
+ const { fragment: parentName, query: kind } = Uri.parse(item.id);
+
+ let existing: TestItem | undefined;
+ item.children.forEach((child) => {
+ if (child.label === name) existing = child;
+ });
+ 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;
+ if (dynamic) this.isDynamicSubtest.add(item);
+ return sub;
+ }
+
+ // 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 processDocument(doc: TextDocument, ranges?: Range[]) {
+ const seen = new Set<string>();
+ const item = await this.getFile(doc.uri);
+ const symbols = await this.provideDocumentSymbols(doc, null);
+ const testify = symbols.some((s) =>
+ s.children.some(
+ (sym) => sym.kind === SymbolKind.Namespace && sym.name === '"github.com/stretchr/testify/suite"'
+ )
+ );
+ for (const symbol of symbols) {
+ await this.processSymbol(doc, item, seen, testify, symbol);
+ }
+
+ item.children.forEach((child) => {
+ const uri = Uri.parse(child.id);
+ if (!seen.has(uri.fragment)) {
+ dispose(child);
+ return;
+ }
+
+ if (ranges?.some((r) => !!child.range.intersection(r))) {
+ item.children.forEach(dispose);
+ }
+ });
+
+ disposeIfEmpty(item);
+ }
+
+ /* ***** Private ***** */
+
+ // Create an item.
+ private createItem(label: string, uri: Uri, kind: string, name?: string): TestItem {
+ return this.ctrl.createTestItem(GoTest.id(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+ }
+
+ // Retrieve an item.
+ private getItem(parent: TestItem | undefined, uri: Uri, kind: string, name?: string): TestItem {
+ return (parent?.children || this.ctrl.items).get(GoTest.id(uri, kind, name));
+ }
+
+ // Create or retrieve an item.
+ private 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);
+ (parent?.children || this.ctrl.items).add(item);
+ return item;
+ }
+
+ // If a test/benchmark with children is relocated, update the children's
+ // location.
+ private relocateChildren(item: TestItem) {
+ item.children.forEach((child) => {
+ child.range = item.range;
+ this.relocateChildren(child);
+ });
+ }
+
+ // Retrieve or create an item for a Go module.
+ private async getModule(uri: Uri): Promise<TestItem> {
+ const existing = this.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 this.workspace.fs.readFile(goMod);
+ const modLine = contents.toString().split('\n', 2)[0];
+ const match = modLine.match(/^module (?<name>.*?)(?:\s|\/\/|$)/);
+ const item = this.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.
+ private async getWorkspace(ws: WorkspaceFolder): Promise<TestItem> {
+ const existing = this.getItem(null, ws.uri, 'workspace');
+ if (existing) {
+ return existing;
+ }
+
+ // Use the workspace folder name as the label
+ const item = this.getOrCreateItem(null, ws.name, ws.uri, 'workspace');
+ item.canResolveChildren = true;
+ return item;
+ }
+
+ // Retrieve or create an item for a Go package.
+ private async getPackage(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 this.getModule(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 = this.getOrCreateItem(parent, dir, dirUri, 'package');
+ }
+ }
+
+ const label = uri.path.startsWith(parent.uri.path)
+ ? uri.path.substring(parent.uri.path.length + 1)
+ : uri.path;
+ item = this.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 this.getWorkspace(wsfolder);
+ const existing = this.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 = this.getOrCreateItem(workspace, label, uri, 'package');
+ } else {
+ // Otherwise, add it directly to the root
+ const existing = this.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 = this.getOrCreateItem(null, label, uri, 'package');
+ }
+
+ item.canResolveChildren = true;
+ return item;
+ }
+
+ // Retrieve or create an item for a Go file.
+ private async getFile(uri: Uri): Promise<TestItem> {
+ const dir = path.dirname(uri.path);
+ const pkg = await this.getPackage(uri.with({ path: dir, query: '', fragment: '' }));
+ const existing = this.getItem(pkg, uri, 'file');
+ if (existing) {
+ return existing;
+ }
+
+ const label = path.basename(uri.path);
+ const item = this.getOrCreateItem(pkg, label, uri, 'file');
+ item.canResolveChildren = true;
+ return item;
+ }
+
+ private getTestSuite(type: string): TestSuite {
+ if (this.testSuites.has(type)) {
+ return this.testSuites.get(type);
+ }
+
+ const methods = new Set<TestItem>();
+ const suite = { methods };
+ this.testSuites.set(type, suite);
+ return suite;
+ }
+
+ // 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.
+ private async processSymbol(
+ doc: TextDocument,
+ file: TestItem,
+ seen: Set<string>,
+ importsTestify: boolean,
+ 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 this.processSymbol(doc, file, seen, importsTestify, sym);
+ return;
+ }
+
+ const match = symbol.name.match(testFuncRegex) || (importsTestify && symbol.name.match(testMethodRegex));
+ if (!match) {
+ return;
+ }
+
+ seen.add(symbol.name);
+
+ const kind = match.groups.kind.toLowerCase();
+ const suite = match.groups.type ? this.getTestSuite(match.groups.type) : undefined;
+ const existing =
+ this.getItem(file, doc.uri, kind, symbol.name) ||
+ (suite?.func && this.getItem(suite?.func, doc.uri, kind, symbol.name));
+
+ if (existing) {
+ if (!existing.range.isEqual(symbol.range)) {
+ existing.range = symbol.range;
+ this.relocateChildren(existing);
+ }
+ return existing;
+ }
+
+ const item = this.getOrCreateItem(suite?.func || file, match.groups.name, doc.uri, kind, symbol.name);
+ item.range = symbol.range;
+
+ if (suite) {
+ this.isTestMethod.add(item);
+ if (!suite.func) suite.methods.add(item);
+ return;
+ }
+
+ if (!importsTestify) {
+ return;
+ }
+
+ // Runs any suite
+ const text = doc.getText(symbol.range);
+ if (text.includes('suite.Run(')) {
+ this.isTestSuiteFunc.add(item);
+ }
+
+ // Runs a specific suite
+ // - suite.Run(t, new(MySuite))
+ // - suite.Run(t, MySuite{})
+ // - suite.Run(t, &MySuite{})
+ const matchRunSuite = text.match(runTestSuiteRegex);
+ if (matchRunSuite) {
+ const g = matchRunSuite.groups;
+ const suite = this.getTestSuite(g.type1 || g.type2);
+ suite.func = item;
+
+ for (const method of suite.methods) {
+ if (Uri.parse(method.parent.id).query !== 'file') {
+ continue;
+ }
+
+ method.parent.children.delete(method.id);
+ item.children.add(method);
+ }
+ }
+ }
+}
+
+// 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: 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: 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;
+ }
+ });
+}
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
new file mode 100644
index 0000000..5bc9df9
--- /dev/null
+++ b/src/goTest/run.ts
@@ -0,0 +1,474 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+ CancellationToken,
+ Location,
+ OutputChannel,
+ Position,
+ TestController,
+ TestItem,
+ TestMessage,
+ TestRun,
+ TestRunRequest,
+ Uri
+} from 'vscode';
+import vscode = require('vscode');
+import path = require('path');
+import { isModSupported } from '../goModules';
+import { getGoConfig } from '../config';
+import { getTestFlags, goTest, GoTestOutput } from '../testUtils';
+import { GoTestResolver } from './resolve';
+import { dispose, forEachAsync, Workspace } from './utils';
+
+type CollectedTest = { item: TestItem; explicitlyIncluded?: boolean };
+
+// 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() {}
+}
+
+export class GoTestRunner {
+ constructor(
+ private readonly workspace: Workspace,
+ private readonly ctrl: TestController,
+ private readonly resolver: GoTestResolver
+ ) {}
+
+ // Execute tests - TestController.runTest callback
+ async run(request: TestRunRequest, token: CancellationToken) {
+ const collected = new Map<TestItem, CollectedTest[]>();
+ const files = new Set<TestItem>();
+ if (request.include) {
+ for (const item of request.include) {
+ await this.collectTests(item, true, request.exclude || [], collected, files);
+ }
+ } else {
+ const promises: Promise<unknown>[] = [];
+ this.ctrl.items.forEach((item) => {
+ const p = this.collectTests(item, true, request.exclude || [], collected, files);
+ 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
+ const fileUris = new Set(Array.from(files).map((x) => x.uri));
+ await Promise.all(this.workspace.textDocuments.filter((x) => fileUris.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;
+ }
+ }
+
+ function isInMod(item: TestItem): boolean {
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'module') return true;
+ if (!item.parent) return false;
+ return isInMod(item.parent);
+ }
+
+ const run = this.ctrl.createTestRun(request);
+ const outputChannel = new TestRunOutput(run);
+ for (const [pkg, items] of collected.entries()) {
+ const isMod = isInMod(pkg) || (await isModSupported(pkg.uri, true));
+ const goConfig = getGoConfig(pkg.uri);
+ const flags = getTestFlags(goConfig);
+ const includeBench = getGoConfig(pkg.uri).get('testExplorerRunBenchmarks');
+
+ // If any of the tests are test suite methods, add all test functions that call `suite.Run`
+ const hasTestMethod = items.some(({ item }) => this.resolver.isTestMethod.has(item));
+ if (hasTestMethod) {
+ const add: TestItem[] = [];
+ pkg.children.forEach((file) => {
+ file.children.forEach((test) => {
+ if (!this.resolver.isTestSuiteFunc.has(test)) return;
+ if (items.some(({ item }) => item === test)) return;
+ add.push(test);
+ });
+ });
+ items.push(...add.map((item) => ({ item })));
+ }
+
+ // 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);
+
+ // Remove subtests created dynamically from test output
+ item.children.forEach((child) => {
+ if (this.resolver.isDynamicSubtest.has(child)) {
+ dispose(child);
+ }
+ });
+
+ 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: pkg.uri.fsPath,
+ functions: testFns,
+ cancel: token,
+ goTestOutputConsumer: (e) => this.consumeGoTestEvent(run, tests, record, complete, concat, e)
+ });
+ if (!success) {
+ if (this.isBuildFailure(outputChannel.lines)) {
+ this.markComplete(tests, new Set(), (item) => {
+ run.errored(item, { message: 'Compilation failed' });
+ item.error = 'Compilation failed';
+ });
+ } else {
+ this.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: pkg.uri.fsPath,
+ functions: benchmarkFns,
+ isBenchmark: true,
+ cancel: token,
+ goTestOutputConsumer: (e) => this.consumeGoBenchmarkEvent(run, benchmarks, complete, e)
+ });
+
+ // Explicitly complete any incomplete benchmarks (see test_events.md)
+ if (success) {
+ this.markComplete(benchmarks, complete, (x) => run.passed(x));
+ } else if (this.isBuildFailure(outputChannel.lines)) {
+ this.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 {
+ this.markComplete(benchmarks, complete, (x) => run.skipped(x));
+ }
+ }
+ }
+
+ run.end();
+ }
+
+ // 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 collectTests(
+ item: TestItem,
+ explicitlyIncluded: boolean,
+ excluded: TestItem[],
+ functions: Map<TestItem, CollectedTest[]>,
+ files: Set<TestItem>
+ ) {
+ 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 this.resolver.resolve(item);
+ }
+
+ await forEachAsync(item.children, (child) => {
+ return this.collectTests(child, false, excluded, functions, files);
+ });
+ return;
+ }
+
+ function getFile(item: TestItem): TestItem {
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'file') return item;
+ return getFile(item.parent);
+ }
+
+ const file = getFile(item);
+ files.add(file);
+
+ const pkg = file.parent;
+ if (functions.has(pkg)) {
+ functions.get(pkg).push({ item, explicitlyIncluded });
+ } else {
+ functions.set(pkg, [{ item, explicitlyIncluded }]);
+ }
+ return;
+ }
+
+ // 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.
+ resolveTestName(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 = this.resolver.getOrCreateSubTest(test, part, true);
+ }
+ return test;
+ }
+
+ // Process benchmark events (see test_events.md)
+ consumeGoBenchmarkEvent(
+ run: TestRun,
+ benchmarks: Record<string, TestItem>,
+ complete: Set<TestItem>,
+ e: GoTestOutput
+ ) {
+ if (e.Test) {
+ // Find (or create) the (sub)benchmark
+ const test = this.resolveTestName(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 = this.resolveTestName(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)
+ markComplete(items: Record<string, TestItem>, complete: Set<TestItem>, fn: (item: TestItem) => void) {
+ function mark(item: TestItem) {
+ if (!complete.has(item)) {
+ fn(item);
+ }
+ item.children.forEach((child) => mark(child));
+ }
+
+ for (const name in items) {
+ mark(items[name]);
+ }
+ }
+
+ // Process test events (see test_events.md)
+ consumeGoTestEvent(
+ run: TestRun,
+ tests: Record<string, TestItem>,
+ record: Map<TestItem, string[]>,
+ complete: Set<TestItem>,
+ concat: boolean,
+ e: GoTestOutput
+ ) {
+ const test = this.resolveTestName(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 = this.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;
+ }
+ }
+
+ 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;
+ }
+
+ 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));
+ }
+}
diff --git a/src/goTest/utils.ts b/src/goTest/utils.ts
new file mode 100644
index 0000000..7da5cbe
--- /dev/null
+++ b/src/goTest/utils.ts
@@ -0,0 +1,75 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import * as vscode from 'vscode';
+
+export class GoTest {
+ // 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
+ static id(uri: vscode.Uri, kind: string, name?: string): string {
+ uri = uri.with({ query: kind });
+ if (name) uri = uri.with({ fragment: name });
+ return uri.toString();
+ }
+}
+
+// The subset of vscode.FileSystem that is used by the test explorer.
+export type FileSystem = Pick<vscode.FileSystem, 'readFile' | 'readDirectory'>;
+
+// The subset of vscode.workspace that is used by the test explorer.
+export interface Workspace
+ extends Pick<typeof vscode.workspace, 'workspaceFolders' | 'getWorkspaceFolder' | 'textDocuments'> {
+ // use custom FS type
+ readonly fs: FileSystem;
+
+ // only include one overload
+ openTextDocument(uri: vscode.Uri): Thenable<vscode.TextDocument>;
+}
+
+export function findItem(
+ items: vscode.TestItemCollection,
+ fn: (item: vscode.TestItem) => vscode.TestItem | undefined
+): vscode.TestItem | undefined {
+ let found: vscode.TestItem | undefined;
+ items.forEach((item) => {
+ if (found) return;
+ found = fn(item);
+ });
+ return found;
+}
+
+export function forEachAsync<T>(
+ items: vscode.TestItemCollection,
+ fn: (item: vscode.TestItem) => Promise<T>
+): Promise<T[]> {
+ const promises: Promise<T>[] = [];
+ items.forEach((item) => promises.push(fn(item)));
+ return Promise.all(promises);
+}
+
+export function dispose(item: vscode.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.
+export function disposeIfEmpty(item: vscode.TestItem) {
+ // Don't dispose of empty top-level items
+ const uri = vscode.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);
+}
diff --git a/src/goTest/walk.ts b/src/goTest/walk.ts
new file mode 100644
index 0000000..f748dcd
--- /dev/null
+++ b/src/goTest/walk.ts
@@ -0,0 +1,82 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import * as vscode from 'vscode';
+import { FileSystem } from './utils';
+
+// Reasons to stop walking, used by walk
+export 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.
+export async function walk(
+ fs: FileSystem,
+ uri: vscode.Uri,
+ cb: (dir: vscode.Uri, file: string, type: vscode.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 === vscode.FileType.File) || (skipDirs && type === vscode.FileType.Directory)) {
+ continue;
+ }
+
+ // Ignore all dotfiles
+ if (file.startsWith('.')) {
+ continue;
+ }
+
+ if (type === vscode.FileType.Directory) {
+ dirs2.push(vscode.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);
+ }
+ }
+}
diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts
deleted file mode 100644
index 85dd90c..0000000
--- a/src/goTestExplorer.ts
+++ /dev/null
@@ -1,1225 +0,0 @@
-/*---------------------------------------------------------
- * 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;
-
-const testFuncRegex = /^(?<name>(?<kind>Test|Benchmark|Example)\P{Ll}.*)/u;
-const testMethodRegex = /^\(\*(?<type>[^)]+)\)\.(?<name>(?<kind>Test)\P{Ll}.*)$/u;
-const runTestSuiteRegex = /^\s*suite\.Run\(\w+,\s*(?:&?(?<type1>\w+)\{\}|new\((?<type2>\w+)\))\)/mu;
-
-interface TestSuite {
- func?: TestItem;
- methods: Set<TestItem>;
-}
-
-// 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 symProvider = new GoDocumentSymbolProvider(true);
- const inst = new this(ctrl, workspace, (doc, token) => symProvider.provideDocumentSymbols(doc, token));
-
- context.subscriptions.push(ctrl);
-
- 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) ***** */
-
- readonly isDynamicSubtest = new WeakSet<TestItem>();
- readonly isTestMethod = new WeakSet<TestItem>();
- readonly isTestSuiteFunc = new WeakSet<TestItem>();
- readonly testSuites = new Map<string, TestSuite>();
-
- getTestSuite(type: string): TestSuite {
- if (this.testSuites.has(type)) {
- return this.testSuites.get(type);
- }
-
- const methods = new Set<TestItem>();
- const suite = { methods };
- this.testSuites.set(type, suite);
- return suite;
- }
-
- find(uri: vscode.Uri): TestItem[] {
- const findStr = uri.toString();
- const found: TestItem[] = [];
-
- function find(items: TestItemCollection) {
- items.forEach((item) => {
- const itemStr = item.uri.toString();
- if (findStr === itemStr) {
- found.push(item);
- find(item.children);
- } else if (findStr.startsWith(itemStr)) {
- find(item.children);
- }
- });
- }
-
- find(this.ctrl.items);
- return found;
- }
-
- // 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, dynamic?: boolean): TestItem {
- const { fragment: parentName, query: kind } = Uri.parse(item.id);
- const existing = collect(item.children).find((child) => child.label === 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;
- if (dynamic) this.isDynamicSubtest.add(item);
- 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);
-}
-
-// 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,
- doc: TextDocument,
- file: TestItem,
- seen: Set<string>,
- importsTestify: boolean,
- 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, doc, file, seen, importsTestify, sym);
- return;
- }
-
- const match = symbol.name.match(testFuncRegex) || (importsTestify && symbol.name.match(testMethodRegex));
- if (!match) {
- return;
- }
-
- seen.add(symbol.name);
-
- const kind = match.groups.kind.toLowerCase();
- const suite = match.groups.type ? expl.getTestSuite(match.groups.type) : undefined;
- const existing =
- expl.getItem(file, doc.uri, kind, symbol.name) ||
- (suite?.func && expl.getItem(suite?.func, doc.uri, kind, symbol.name));
-
- if (existing) {
- if (!existing.range.isEqual(symbol.range)) {
- existing.range = symbol.range;
- relocateChildren(existing);
- }
- return existing;
- }
-
- const item = expl.getOrCreateItem(suite?.func || file, match.groups.name, doc.uri, kind, symbol.name);
- item.range = symbol.range;
-
- if (suite) {
- expl.isTestMethod.add(item);
- if (!suite.func) suite.methods.add(item);
- return;
- }
-
- if (!importsTestify) {
- return;
- }
-
- // Runs any suite
- const text = doc.getText(symbol.range);
- if (text.includes('suite.Run(')) {
- expl.isTestSuiteFunc.add(item);
- }
-
- // Runs a specific suite
- // - suite.Run(t, new(MySuite))
- // - suite.Run(t, MySuite{})
- // - suite.Run(t, &MySuite{})
- const matchRunSuite = text.match(runTestSuiteRegex);
- if (matchRunSuite) {
- const g = matchRunSuite.groups;
- const suite = expl.getTestSuite(g.type1 || g.type2);
- suite.func = item;
-
- for (const method of suite.methods) {
- if (Uri.parse(method.parent.id).query !== 'file') {
- continue;
- }
-
- method.parent.children.delete(method.id);
- item.children.add(method);
- }
- }
-}
-
-// 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);
- const testify = symbols.some((s) =>
- s.children.some(
- (sym) => sym.kind === SymbolKind.Namespace && sym.name === '"github.com/stretchr/testify/suite"'
- )
- );
- for (const symbol of symbols) {
- await processSymbol(expl, doc, item, seen, testify, 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))) {
- item.children.forEach(dispose);
- }
- }
-
- 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.scheme === 'git') {
- // TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
- const { path } = JSON.parse(doc.uri.query);
- doc = await vscode.workspace.openTextDocument(path);
- }
-
- if (!doc.uri.path.endsWith('_test.go')) {
- 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<TestItem, CollectedTest[]>,
- files: Set<TestItem>
-) {
- 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, files);
- }
- return;
- }
-
- function getFile(item: TestItem): TestItem {
- const uri = Uri.parse(item.id);
- if (uri.query === 'file') return item;
- return getFile(item.parent);
- }
-
- const file = getFile(item);
- files.add(file);
-
- const pkg = file.parent;
- if (functions.has(pkg)) {
- functions.get(pkg).push({ item, explicitlyIncluded });
- } else {
- functions.set(pkg, [{ 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, true);
- }
- 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<TestItem, CollectedTest[]>();
- const files = new Set<TestItem>();
- if (request.include) {
- for (const item of request.include) {
- await collectTests(expl, item, true, request.exclude || [], collected, files);
- }
- } else {
- const promises: Promise<unknown>[] = [];
- expl.ctrl.items.forEach((item) => {
- const p = collectTests(expl, item, true, request.exclude || [], collected, files);
- 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
- const fileUris = new Set(Array.from(files).map((x) => x.uri));
- await Promise.all(expl.ws.textDocuments.filter((x) => fileUris.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;
- }
- }
-
- function isInMod(item: TestItem): boolean {
- const uri = Uri.parse(item.id);
- if (uri.query === 'module') return true;
- if (!item.parent) return false;
- return isInMod(item.parent);
- }
-
- const run = expl.ctrl.createTestRun(request);
- const outputChannel = new TestRunOutput(run);
- for (const [pkg, items] of collected.entries()) {
- const isMod = isInMod(pkg) || (await isModSupported(pkg.uri, true));
- const goConfig = getGoConfig(pkg.uri);
- const flags = getTestFlags(goConfig);
- const includeBench = getGoConfig(pkg.uri).get('testExplorerRunBenchmarks');
-
- // If any of the tests are test suite methods, add all test functions that call `suite.Run`
- const hasTestMethod = items.some(({ item }) => expl.isTestMethod.has(item));
- if (hasTestMethod) {
- const add: TestItem[] = [];
- pkg.children.forEach((file) => {
- file.children.forEach((test) => {
- if (!expl.isTestSuiteFunc.has(test)) return;
- if (items.some(({ item }) => item === test)) return;
- add.push(test);
- });
- });
- items.push(...add.map((item) => ({ item })));
- }
-
- // 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);
-
- // Remove subtests created dynamically from test output
- item.children.forEach((child) => {
- if (expl.isDynamicSubtest.has(child)) {
- dispose(child);
- }
- });
-
- 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: pkg.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: pkg.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/test/integration/goTest.explore.test.ts b/test/integration/goTest.explore.test.ts
new file mode 100644
index 0000000..2222678
--- /dev/null
+++ b/test/integration/goTest.explore.test.ts
@@ -0,0 +1,236 @@
+/*---------------------------------------------------------
+ * 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 fs = require('fs-extra');
+import { TextDocument, TestItemCollection, TextDocumentChangeEvent, ExtensionContext, workspace, Uri } from 'vscode';
+import { GoTestExplorer } from '../../src/goTest/explore';
+import { getCurrentGoPath } from '../../src/util';
+import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
+import { getSymbols_Regex, populateModulePathCache } from './goTest.utils';
+
+type Files = Record<string, string | { contents: string; language: string }>;
+
+interface TestCase {
+ workspace: string[];
+ files: Files;
+}
+
+function setupCtor<T extends GoTestExplorer>(
+ folders: string[],
+ files: Files,
+ ctor: new (...args: ConstructorParameters<typeof GoTestExplorer>) => T
+) {
+ const ws = MockTestWorkspace.from(folders, files);
+ const ctrl = new MockTestController();
+ const expl = new ctor(ws, ctrl, getSymbols_Regex);
+ populateModulePathCache(ws);
+ 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('Go Test Explorer', () => {
+ suite('Document opened', () => {
+ class DUT extends GoTestExplorer {
+ async _didOpen(doc: TextDocument) {
+ await this.didOpenTextDocument(doc);
+ }
+ }
+
+ interface TC extends TestCase {
+ open: string;
+ expect: string[];
+ }
+
+ const cases: Record<string, TC> = {
+ '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 GoTestExplorer {
+ 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<string, TC> = {
+ '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);
+ });
+ }
+ });
+
+ suite('stretchr', () => {
+ let gopath: string;
+ let repoPath: string;
+ let fixturePath: string;
+ let fixtureSourcePath: string;
+ let document: TextDocument;
+ let testExplorer: GoTestExplorer;
+
+ const ctx: Partial<ExtensionContext> = {
+ subscriptions: []
+ };
+
+ suiteSetup(async () => {
+ gopath = getCurrentGoPath();
+ if (!gopath) {
+ assert.fail('Cannot run tests without a configured GOPATH');
+ }
+ console.log(`Using GOPATH: ${gopath}`);
+
+ // Set up the test fixtures.
+ repoPath = path.join(gopath, 'src', 'test');
+ fixturePath = path.join(repoPath, 'testfixture');
+ fixtureSourcePath = path.join(__dirname, '..', '..', '..', 'test', 'testdata', 'stretchrTestSuite');
+
+ await fs.remove(repoPath);
+ await fs.copy(fixtureSourcePath, fixturePath, {
+ recursive: true
+ });
+
+ testExplorer = GoTestExplorer.setup(ctx as ExtensionContext);
+
+ const uri = Uri.file(path.join(fixturePath, 'suite_test.go'));
+ document = await workspace.openTextDocument(uri);
+
+ // Force didOpenTextDocument to fire. Without this, the test may run
+ // before the event is handled.
+ //
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (testExplorer as any).didOpenTextDocument(document);
+ });
+
+ suiteTeardown(() => {
+ fs.removeSync(repoPath);
+ ctx.subscriptions.forEach((x) => x.dispose());
+ });
+
+ test('discovery', () => {
+ const tests = testExplorer.resolver.find(document.uri).map((x) => x.id);
+ assert.deepStrictEqual(tests.sort(), [
+ document.uri.with({ query: 'file' }).toString(),
+ document.uri.with({ query: 'test', fragment: '(*ExampleTestSuite).TestExample' }).toString(),
+ document.uri.with({ query: 'test', fragment: 'TestExampleTestSuite' }).toString()
+ ]);
+ });
+ });
+});
diff --git a/test/integration/goTest.resolve.test.ts b/test/integration/goTest.resolve.test.ts
new file mode 100644
index 0000000..293ab24
--- /dev/null
+++ b/test/integration/goTest.resolve.test.ts
@@ -0,0 +1,213 @@
+/*---------------------------------------------------------
+ * 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 { TestItem, Uri } from 'vscode';
+import { GoTestResolver } from '../../src/goTest/resolve';
+import { GoTest } from '../../src/goTest/utils';
+import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
+import { getSymbols_Regex, populateModulePathCache } from './goTest.utils';
+
+type Files = Record<string, string | { contents: string; language: string }>;
+
+interface TestCase {
+ workspace: string[];
+ files: Files;
+}
+
+function setup(folders: string[], files: Files) {
+ const workspace = MockTestWorkspace.from(folders, files);
+ const ctrl = new MockTestController();
+ const resolver = new GoTestResolver(workspace, ctrl, getSymbols_Regex);
+ populateModulePathCache(workspace);
+ return { resolver, ctrl };
+}
+
+suite('Go Test Resolver', () => {
+ interface TC extends TestCase {
+ item?: ([string, string, string] | [string, string, string, string])[];
+ expect: string[];
+ }
+
+ const cases: Record<string, Record<string, TC>> = {
+ 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, resolver } = setup(workspace, files);
+
+ let item: TestItem | undefined;
+ for (const [label, uri, kind, name] of itemData) {
+ const u = Uri.parse(uri);
+ const child = ctrl.createTestItem(GoTest.id(u, kind, name), label, u);
+ (item?.children || resolver.items).add(child);
+ item = child;
+ }
+ await resolver.resolve(item);
+
+ const actual: string[] = [];
+ (item?.children || resolver.items).forEach((x) => actual.push(x.id));
+ assert.deepStrictEqual(actual, expect);
+ });
+ }
+ });
+ }
+});
diff --git a/test/integration/goTest.utils.ts b/test/integration/goTest.utils.ts
new file mode 100644
index 0000000..e95a3b2
--- /dev/null
+++ b/test/integration/goTest.utils.ts
@@ -0,0 +1,43 @@
+/*---------------------------------------------------------
+ * 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 { DocumentSymbol, FileType, Uri, TextDocument, SymbolKind, Range, Position } from 'vscode';
+import { packagePathToGoModPathMap } from '../../src/goModules';
+import { MockTestWorkspace } from '../mocks/MockTest';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function getSymbols_Regex(doc: TextDocument, token: unknown): Thenable<DocumentSymbol[]> {
+ 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);
+}
+
+export function populateModulePathCache(workspace: MockTestWorkspace) {
+ function walk(dir: Uri, modpath?: string) {
+ const dirs: Uri[] = [];
+ for (const [name, type] of workspace.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;
+ }
+ }
+ packagePathToGoModPathMap[dir.path] = modpath || '';
+ for (const dir of dirs) {
+ walk(dir, modpath);
+ }
+ }
+
+ // prevent getModFolderPath from actually doing anything;
+ for (const pkg in packagePathToGoModPathMap) {
+ delete packagePathToGoModPathMap[pkg];
+ }
+ walk(Uri.file('/'));
+}
diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts
deleted file mode 100644
index e04bb71..0000000
--- a/test/integration/goTestExplorer.test.ts
+++ /dev/null
@@ -1,463 +0,0 @@
-/*---------------------------------------------------------
- * 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 fs = require('fs-extra');
-import vscode = require('vscode');
-import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules';
-import { TestExplorer, testID } from '../../src/goTestExplorer';
-import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
-import { getCurrentGoPath } from '../../src/util';
-import { GoDocumentSymbolProvider } from '../../src/goOutline';
-import { getGoConfig } from '../../src/config';
-
-type Files = Record<string, string | { contents: string; language: string }>;
-
-interface TestCase {
- workspace: string[];
- files: Files;
-}
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function symbols(doc: vscode.TextDocument, token: unknown): Thenable<vscode.DocumentSymbol[]> {
- const syms: vscode.DocumentSymbol[] = [];
- const range = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0));
- doc.getText().replace(/^func (Test|Benchmark|Example)([A-Z]\w+)(\(.*\))/gm, (m, type, name, details) => {
- syms.push(new vscode.DocumentSymbol(type + name, details, vscode.SymbolKind.Function, range, range));
- return m;
- });
- return Promise.resolve(syms);
-}
-
-function setup(folders: string[], files: Files) {
- return setupCtor(folders, files, TestExplorer);
-}
-
-function setupCtor<T extends TestExplorer>(
- folders: string[],
- files: Files,
- ctor: new (...args: ConstructorParameters<typeof TestExplorer>) => T
-) {
- const ws = MockTestWorkspace.from(folders, files);
- const ctrl = new MockTestController();
- const expl = new ctor(ctrl, ws, symbols);
-
- function walk(dir: vscode.Uri, modpath?: string) {
- const dirs: vscode.Uri[] = [];
- for (const [name, type] of ws.fs.dirs.get(dir.toString())) {
- const uri = dir.with({ path: path.join(dir.path, name) });
- if (type === vscode.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(vscode.Uri.file('/'));
-
- return { ctrl, expl, ws };
-}
-
-function assertTestItems(items: vscode.TestItemCollection, expect: string[]) {
- const actual: string[] = [];
- function walk(items: vscode.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<string, Record<string, TC>> = {
- 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: vscode.TestItem | undefined;
- for (const [label, uri, kind, name] of itemData) {
- const u = vscode.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: vscode.TextDocument) {
- await this.didOpenTextDocument(doc);
- }
- }
-
- interface TC extends TestCase {
- open: string;
- expect: string[];
- }
-
- const cases: Record<string, TC> = {
- '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: vscode.TextDocument) {
- await this.didOpenTextDocument(doc);
- }
-
- async _didChange(e: vscode.TextDocumentChangeEvent) {
- await this.didChangeTextDocument(e);
- }
- }
-
- interface TC extends TestCase {
- open: string;
- changes: [string, string][];
- expect: {
- before: string[];
- after: string[];
- };
- }
-
- const cases: Record<string, TC> = {
- '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);
- });
- }
- });
- });
-
- suite('stretchr', () => {
- let gopath: string;
- let repoPath: string;
- let fixturePath: string;
- let fixtureSourcePath: string;
- let document: vscode.TextDocument;
- let testExplorer: TestExplorer;
-
- const ctx: Partial<vscode.ExtensionContext> = {
- subscriptions: []
- };
-
- suiteSetup(async () => {
- gopath = getCurrentGoPath();
- if (!gopath) {
- assert.fail('Cannot run tests without a configured GOPATH');
- }
- console.log(`Using GOPATH: ${gopath}`);
-
- // Set up the test fixtures.
- repoPath = path.join(gopath, 'src', 'test');
- fixturePath = path.join(repoPath, 'testfixture');
- fixtureSourcePath = path.join(__dirname, '..', '..', '..', 'test', 'testdata', 'stretchrTestSuite');
-
- fs.removeSync(repoPath);
- fs.copySync(fixtureSourcePath, fixturePath, {
- recursive: true
- });
-
- testExplorer = TestExplorer.setup(ctx as vscode.ExtensionContext);
-
- const uri = vscode.Uri.file(path.join(fixturePath, 'suite_test.go'));
- document = await vscode.workspace.openTextDocument(uri);
-
- // Force didOpenTextDocument to fire. Without this, the test may run
- // before the event is handled.
- //
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await (testExplorer as any).didOpenTextDocument(document);
- });
-
- suiteTeardown(() => {
- fs.removeSync(repoPath);
- ctx.subscriptions.forEach((x) => x.dispose());
- });
-
- test('discovery', () => {
- const tests = testExplorer.find(document.uri).map((x) => x.id);
- assert.deepStrictEqual(tests.sort(), [
- document.uri.with({ query: 'file' }).toString(),
- document.uri.with({ query: 'test', fragment: '(*ExampleTestSuite).TestExample' }).toString(),
- document.uri.with({ query: 'test', fragment: 'TestExampleTestSuite' }).toString()
- ]);
- });
- });
-});
diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts
index 1d6cee8..41bc6aa 100644
--- a/test/mocks/MockTest.ts
+++ b/test/mocks/MockTest.ts
@@ -24,7 +24,7 @@
Uri,
WorkspaceFolder
} from 'vscode';
-import { TestExplorer } from '../../src/goTestExplorer';
+import { FileSystem, Workspace } from '../../src/goTest/utils';
type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable<void> | void;
@@ -138,7 +138,7 @@
type DirEntry = [string, FileType];
-class MockTestFileSystem implements TestExplorer.FileSystem {
+class MockTestFileSystem implements FileSystem {
constructor(public dirs: Map<string, DirEntry[]>, public files: Map<string, MockTestDocument>) {}
readDirectory(uri: Uri): Thenable<[string, FileType][]> {
@@ -167,7 +167,7 @@
return lines.join('\n');
}
-export class MockTestWorkspace implements TestExplorer.Workspace {
+export class MockTestWorkspace implements Workspace {
static from(folders: string[], contents: Record<string, string | { contents: string; language: string }>) {
const wsdirs: WorkspaceFolder[] = [];
const dirs = new Map<string, DirEntry[]>();