src/goTestExplorer: implement a test provider for the new test api
What this does:
- Implements a test provider using the new API
- Populates the test explorer with a list of modules and/or workspaces
- Populates each module/workspace/package/file as it is expanded
- Creates test entries for the current file, and package/module parents
- Runs tests!
What this does not:
- Debug tests
- Handle stretchr suites
Issues:
- Handling of benchmarks isn't great. But I'm not sure it can get much
better without changes to `go test`.
- If a test has subtests, I add those subtests. This can get annoying if
you have a bunch of subtests. Should this be configurable? Disabling
`testing.followRunningTest` can improve this UX.
Fixes golang/vscode-go#1579
Change-Id: I027c7c3b615eda4c528da9739520e6bfd1aa6911
GitHub-Last-Rev: 59af29b6a2f585135a58b91ed6cc842796219512
GitHub-Pull-Request: golang/vscode-go#1590
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/330809
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Suzy Mueller <suzmue@golang.org>
diff --git a/docs/commands.md b/docs/commands.md
index 23e0821..9c0a925 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -59,6 +59,10 @@
Runs all unit tests in the package of the current file.
+### `Go Test: Refresh`
+
+Refresh a test in the test explorer. Only available as a context menu option in the test explorer.
+
### `Go: Benchmark Package`
Runs all benchmarks in the package of the current file.
diff --git a/docs/settings.md b/docs/settings.md
index 2b8d0c0..bd599c8 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -393,6 +393,22 @@
### `go.testEnvVars`
Environment variables that will be passed to the process that runs the Go tests
+### `go.testExplorerConcatenateMessages`
+
+If true, test log messages associated with a given location will be shown as a single message.
+
+Default: `true`
+### `go.testExplorerPackages`
+
+Control whether packages in the test explorer are presented flat or nested.<br/>
+Allowed Options: `flat`, `nested`
+
+Default: `"flat"`
+### `go.testExplorerRunBenchmarks`
+
+Include benchmarks when running all tests in a group.
+
+Default: `false`
### `go.testFlags`
Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.
diff --git a/package-lock.json b/package-lock.json
index 28c3c3f..cd32598 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,7 @@
"@types/node": "^13.11.1",
"@types/semver": "^7.1.0",
"@types/sinon": "^9.0.0",
- "@types/vscode": "^1.52.0",
+ "@types/vscode": "^1.59.0",
"adm-zip": "^0.4.14",
"fs-extra": "^9.0.0",
"get-port": "^5.1.1",
@@ -49,7 +49,7 @@
"yarn": "^1.22.4"
},
"engines": {
- "vscode": "^1.59.0"
+ "vscode": "^1.58.0"
}
},
"node_modules/@babel/code-frame": {
@@ -387,9 +387,9 @@
"dev": true
},
"node_modules/@types/vscode": {
- "version": "1.54.0",
- "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz",
- "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==",
+ "version": "1.59.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz",
+ "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -7276,9 +7276,9 @@
"dev": true
},
"@types/vscode": {
- "version": "1.54.0",
- "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz",
- "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==",
+ "version": "1.59.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz",
+ "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==",
"dev": true
},
"@typescript-eslint/eslint-plugin": {
diff --git a/package.json b/package.json
index 9439ec4..25a71b5 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,8 @@
"Snippets",
"Linters",
"Debuggers",
- "Formatters"
+ "Formatters",
+ "Testing"
],
"galleryBanner": {
"color": "#F2F2F2",
@@ -70,7 +71,7 @@
"@types/node": "^13.11.1",
"@types/semver": "^7.1.0",
"@types/sinon": "^9.0.0",
- "@types/vscode": "^1.52.0",
+ "@types/vscode": "^1.59.0",
"adm-zip": "^0.4.14",
"fs-extra": "^9.0.0",
"get-port": "^5.1.1",
@@ -233,6 +234,13 @@
"description": "Runs all unit tests in the package of the current file."
},
{
+ "command": "go.test.refresh",
+ "title": "Go Test: Refresh",
+ "description": "Refresh a test in the test explorer. Only available as a context menu option in the test explorer.",
+ "category": "Test",
+ "icon": "$(refresh)"
+ },
+ {
"command": "go.benchmark.package",
"title": "Go: Benchmark Package",
"description": "Runs all benchmarks in the package of the current file."
@@ -1282,6 +1290,28 @@
"description": "Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.",
"scope": "resource"
},
+ "go.testExplorerPackages": {
+ "type": "string",
+ "enum": [
+ "flat",
+ "nested"
+ ],
+ "default": "flat",
+ "description": "Control whether packages in the test explorer are presented flat or nested.",
+ "scope": "resource"
+ },
+ "go.testExplorerRunBenchmarks": {
+ "type": "boolean",
+ "default": false,
+ "description": "Include benchmarks when running all tests in a group.",
+ "scope": "resource"
+ },
+ "go.testExplorerConcatenateMessages": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true, test log messages associated with a given location will be shown as a single message.",
+ "scope": "resource"
+ },
"go.generateTestsFlags": {
"type": "array",
"items": {
@@ -2356,6 +2386,12 @@
}
},
"menus": {
+ "commandPalette": [
+ {
+ "command": "go.test.refresh",
+ "when": "false"
+ }
+ ],
"editor/context": [
{
"when": "editorTextFocus && config.go.editorContextMenuCommands.toggleTestFile && resourceLangId == go",
@@ -2437,6 +2473,13 @@
"command": "go.show.commands",
"group": "Go group 2"
}
+ ],
+ "testing/item/context": [
+ {
+ "command": "go.test.refresh",
+ "when": "testId =~ /_test\\.go/",
+ "group": "inline"
+ }
]
}
}
diff --git a/src/goLanguageServer.ts b/src/goLanguageServer.ts
index 317687d..2698dc1 100644
--- a/src/goLanguageServer.ts
+++ b/src/goLanguageServer.ts
@@ -631,7 +631,9 @@
// cause to reorder candiates, which is not ideal.
// Force to use non-empty `label`.
// https://github.com/golang/vscode-go/issues/441
- hardcodedFilterText = items[0].label;
+ let { label } = items[0];
+ if (typeof label !== 'string') label = label.label;
+ hardcodedFilterText = label;
}
for (const item of items) {
item.filterText = hardcodedFilterText;
diff --git a/src/goMain.ts b/src/goMain.ts
index 541ad64..5f7d26d 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -114,6 +114,7 @@
import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey';
import { ExtensionAPI } from './export';
import extensionAPI from './extensionAPI';
+import { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer';
export let buildDiagnosticCollection: vscode.DiagnosticCollection;
export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -335,6 +336,16 @@
})
);
+ if (isVscodeTestingAPIAvailable) {
+ const testExplorer = TestExplorer.setup(ctx);
+
+ ctx.subscriptions.push(
+ vscode.commands.registerCommand('go.test.refresh', (args) => {
+ if (args) testExplorer.resolve(args);
+ })
+ );
+ }
+
ctx.subscriptions.push(
vscode.commands.registerCommand('go.subtest.cursor', (args) => {
const goConfig = getGoConfig();
diff --git a/src/goModules.ts b/src/goModules.ts
index a0b6f51..ab0dd04 100644
--- a/src/goModules.ts
+++ b/src/goModules.ts
@@ -36,7 +36,8 @@
return resolve('');
}
const [goMod] = stdout.split('\n');
- resolve(goMod);
+ if (goMod === '/dev/null' || goMod === 'NUL') resolve('');
+ else resolve(goMod);
});
});
}
diff --git a/src/goSuggest.ts b/src/goSuggest.ts
index 22abcc0..1f3361a 100644
--- a/src/goSuggest.ts
+++ b/src/goSuggest.ts
@@ -122,11 +122,14 @@
return;
}
+ let { label } = item;
+ if (typeof label !== 'string') label = label.label;
+
return runGodoc(
path.dirname(item.fileName),
item.package || path.dirname(item.fileName),
item.receiver,
- item.label,
+ label,
token
)
.then((doc) => {
@@ -358,7 +361,9 @@
areCompletionsForPackageSymbols = true;
}
if (suggest.class === 'package') {
- const possiblePackageImportPaths = this.getPackageImportPath(item.label);
+ let { label } = item;
+ if (typeof label !== 'string') label = label.label;
+ const possiblePackageImportPaths = this.getPackageImportPath(label);
if (possiblePackageImportPaths.length === 1) {
item.detail = possiblePackageImportPaths[0];
}
diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts
new file mode 100644
index 0000000..94a74a9
--- /dev/null
+++ b/src/goTestExplorer.ts
@@ -0,0 +1,1097 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+ CancellationToken,
+ ConfigurationChangeEvent,
+ DocumentSymbol,
+ ExtensionContext,
+ FileType,
+ Location,
+ OutputChannel,
+ Position,
+ Range,
+ SymbolKind,
+ TestController,
+ TestItem,
+ TestItemCollection,
+ TestMessage,
+ TestRun,
+ TestRunProfileKind,
+ TestRunRequest,
+ TextDocument,
+ TextDocumentChangeEvent,
+ Uri,
+ workspace,
+ WorkspaceFolder,
+ WorkspaceFoldersChangeEvent
+} from 'vscode';
+import vscode = require('vscode');
+import path = require('path');
+import { getModFolderPath, isModSupported } from './goModules';
+import { getCurrentGoPath } from './util';
+import { GoDocumentSymbolProvider } from './goOutline';
+import { getGoConfig } from './config';
+import { getTestFlags, goTest, GoTestOutput } from './testUtils';
+import { outputChannel } from './goStatus';
+
+// Set true only if the Testing API is available (VSCode version >= 1.59).
+export const isVscodeTestingAPIAvailable =
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 'object' === typeof (vscode as any).tests && 'function' === typeof (vscode as any).tests.createTestController;
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace TestExplorer {
+ // exported for tests
+
+ export type FileSystem = Pick<vscode.FileSystem, 'readFile' | 'readDirectory'>;
+
+ export interface Workspace
+ extends Pick<typeof vscode.workspace, 'workspaceFolders' | 'getWorkspaceFolder' | 'textDocuments'> {
+ // use custom FS type
+ readonly fs: FileSystem;
+
+ // only include one overload
+ openTextDocument(uri: Uri): Thenable<TextDocument>;
+ }
+}
+
+async function doSafe<T>(context: string, p: Thenable<T> | (() => T | Thenable<T>), onError?: T): Promise<T> {
+ try {
+ if (typeof p === 'function') {
+ return await p();
+ } else {
+ return await p;
+ }
+ } catch (error) {
+ if (process.env.VSCODE_GO_IN_TEST === '1') {
+ throw error;
+ }
+
+ // TODO internationalization?
+ if (context === 'resolveHandler') {
+ const m = 'Failed to resolve tests';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ } else if (context === 'runHandler') {
+ const m = 'Failed to execute tests';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ } else if (/^did/.test(context)) {
+ outputChannel.appendLine(`Failed while handling '${context}': ${error}`);
+ } else {
+ const m = 'An unknown error occurred';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ }
+ return onError;
+ }
+}
+
+export class TestExplorer {
+ static setup(context: ExtensionContext): TestExplorer {
+ if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable');
+
+ const ctrl = vscode.tests.createTestController('go', 'Go');
+ const getSym = new GoDocumentSymbolProvider().provideDocumentSymbols;
+ const inst = new this(ctrl, workspace, getSym);
+
+ context.subscriptions.push(
+ workspace.onDidChangeConfiguration((x) =>
+ doSafe('onDidChangeConfiguration', inst.didChangeConfiguration(x))
+ )
+ );
+
+ context.subscriptions.push(
+ workspace.onDidOpenTextDocument((x) => doSafe('onDidOpenTextDocument', inst.didOpenTextDocument(x)))
+ );
+
+ context.subscriptions.push(
+ workspace.onDidChangeTextDocument((x) => doSafe('onDidChangeTextDocument', inst.didChangeTextDocument(x)))
+ );
+
+ context.subscriptions.push(
+ workspace.onDidChangeWorkspaceFolders((x) =>
+ doSafe('onDidChangeWorkspaceFolders', inst.didChangeWorkspaceFolders(x))
+ )
+ );
+
+ const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false);
+ context.subscriptions.push(watcher);
+ context.subscriptions.push(watcher.onDidCreate((x) => doSafe('onDidCreate', inst.didCreateFile(x))));
+ context.subscriptions.push(watcher.onDidDelete((x) => doSafe('onDidDelete', inst.didDeleteFile(x))));
+
+ return inst;
+ }
+
+ constructor(
+ public ctrl: TestController,
+ public ws: TestExplorer.Workspace,
+ public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable<DocumentSymbol[]>
+ ) {
+ ctrl.resolveHandler = (item) => this.resolve(item);
+ ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq, tok) => this.run(rq, tok), true);
+ }
+
+ /* ***** Interface (external) ***** */
+
+ resolve(item?: TestItem) {
+ return doSafe('resolveHandler', resolve(this, item));
+ }
+
+ run(request: TestRunRequest, token: CancellationToken) {
+ return doSafe('runHandler', runTests(this, request, token));
+ }
+
+ /* ***** Interface (internal) ***** */
+
+ // Create an item.
+ createItem(label: string, uri: Uri, kind: string, name?: string): TestItem {
+ return this.ctrl.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+ }
+
+ // Retrieve an item.
+ getItem(parent: TestItem | undefined, uri: Uri, kind: string, name?: string): TestItem {
+ const items = getChildren(parent || this.ctrl.items);
+ return items.get(testID(uri, kind, name));
+ }
+
+ // Create or retrieve an item.
+ getOrCreateItem(parent: TestItem | undefined, label: string, uri: Uri, kind: string, name?: string): TestItem {
+ const existing = this.getItem(parent, uri, kind, name);
+ if (existing) return existing;
+
+ const item = this.createItem(label, uri, kind, name);
+ getChildren(parent || this.ctrl.items).add(item);
+ return item;
+ }
+
+ // Create or Retrieve a sub test or benchmark. The ID will be of the form:
+ // file:///path/to/mod/file.go?test#TestXxx/A/B/C
+ getOrCreateSubTest(item: TestItem, name: string): TestItem {
+ const { fragment: parentName, query: kind } = Uri.parse(item.id);
+ const existing = this.getItem(item, item.uri, kind, `${parentName}/${name}`);
+ if (existing) return existing;
+
+ item.canResolveChildren = true;
+ const sub = this.createItem(name, item.uri, kind, `${parentName}/${name}`);
+ item.children.add(sub);
+ sub.range = item.range;
+ return sub;
+ }
+
+ /* ***** Listeners ***** */
+
+ protected async didOpenTextDocument(doc: TextDocument) {
+ await documentUpdate(this, doc);
+ }
+
+ protected async didChangeTextDocument(e: TextDocumentChangeEvent) {
+ await documentUpdate(
+ this,
+ e.document,
+ e.contentChanges.map((x) => x.range)
+ );
+ }
+
+ protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) {
+ if (e.removed.length > 0) {
+ for (const item of collect(this.ctrl.items)) {
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'package') {
+ continue;
+ }
+
+ const ws = this.ws.getWorkspaceFolder(uri);
+ if (!ws) {
+ dispose(item);
+ }
+ }
+ }
+
+ if (e.added) {
+ await resolve(this);
+ }
+ }
+
+ protected async didCreateFile(file: Uri) {
+ await documentUpdate(this, await this.ws.openTextDocument(file));
+ }
+
+ protected async didDeleteFile(file: Uri) {
+ const id = testID(file, 'file');
+ function find(children: TestItemCollection): TestItem {
+ for (const item of collect(children)) {
+ if (item.id === id) {
+ return item;
+ }
+
+ const uri = Uri.parse(item.id);
+ if (!file.path.startsWith(uri.path)) {
+ continue;
+ }
+
+ const found = find(item.children);
+ if (found) {
+ return found;
+ }
+ }
+ }
+
+ const found = find(this.ctrl.items);
+ if (found) {
+ dispose(found);
+ disposeIfEmpty(found.parent);
+ }
+ }
+
+ protected async didChangeConfiguration(e: ConfigurationChangeEvent) {
+ let update = false;
+ for (const item of collect(this.ctrl.items)) {
+ if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
+ dispose(item);
+ update = true;
+ }
+ }
+
+ if (update) {
+ resolve(this);
+ }
+ }
+}
+
+// Construct an ID for an item. Exported for tests.
+// - Module: file:///path/to/mod?module
+// - Package: file:///path/to/mod/pkg?package
+// - File: file:///path/to/mod/file.go?file
+// - Test: file:///path/to/mod/file.go?test#TestXxx
+// - Benchmark: file:///path/to/mod/file.go?benchmark#BenchmarkXxx
+// - Example: file:///path/to/mod/file.go?example#ExampleXxx
+export function testID(uri: Uri, kind: string, name?: string): string {
+ uri = uri.with({ query: kind });
+ if (name) uri = uri.with({ fragment: name });
+ return uri.toString();
+}
+
+function collect(items: TestItemCollection): TestItem[] {
+ const r: TestItem[] = [];
+ items.forEach((i) => r.push(i));
+ return r;
+}
+
+function getChildren(parent: TestItem | TestItemCollection): TestItemCollection {
+ if ('children' in parent) {
+ return parent.children;
+ }
+ return parent;
+}
+
+function dispose(item: TestItem) {
+ item.parent.children.delete(item.id);
+}
+
+// Dispose of the item if it has no children, recursively. This facilitates
+// cleaning up package/file trees that contain no tests.
+function disposeIfEmpty(item: TestItem) {
+ // Don't dispose of empty top-level items
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'module' || uri.query === 'workspace' || (uri.query === 'package' && !item.parent)) {
+ return;
+ }
+
+ if (item.children.size > 0) {
+ return;
+ }
+
+ dispose(item);
+ disposeIfEmpty(item.parent);
+}
+
+// Dispose of the children of a test. Sub-tests and sub-benchmarks are
+// discovered emperically (from test output) not semantically (from code), so
+// there are situations where they must be discarded.
+function discardChildren(item: TestItem) {
+ item.canResolveChildren = false;
+ item.children.forEach(dispose);
+}
+
+// If a test/benchmark with children is relocated, update the children's
+// location.
+function relocateChildren(item: TestItem) {
+ for (const child of collect(item.children)) {
+ child.range = item.range;
+ relocateChildren(child);
+ }
+}
+
+// Retrieve or create an item for a Go module.
+async function getModule(expl: TestExplorer, uri: Uri): Promise<TestItem> {
+ const existing = expl.getItem(null, uri, 'module');
+ if (existing) {
+ return existing;
+ }
+
+ // Use the module name as the label
+ const goMod = Uri.joinPath(uri, 'go.mod');
+ const contents = await expl.ws.fs.readFile(goMod);
+ const modLine = contents.toString().split('\n', 2)[0];
+ const match = modLine.match(/^module (?<name>.*?)(?:\s|\/\/|$)/);
+ const item = expl.getOrCreateItem(null, match.groups.name, uri, 'module');
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Retrieve or create an item for a workspace folder that is not a module.
+async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise<TestItem> {
+ const existing = expl.getItem(null, ws.uri, 'workspace');
+ if (existing) {
+ return existing;
+ }
+
+ // Use the workspace folder name as the label
+ const item = expl.getOrCreateItem(null, ws.name, ws.uri, 'workspace');
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Retrieve or create an item for a Go package.
+async function getPackage(expl: TestExplorer, uri: Uri): Promise<TestItem> {
+ let item: TestItem;
+
+ const nested = getGoConfig(uri).get('testExplorerPackages') === 'nested';
+ const modDir = await getModFolderPath(uri, true);
+ const wsfolder = workspace.getWorkspaceFolder(uri);
+ if (modDir) {
+ // If the package is in a module, add it as a child of the module
+ let parent = await getModule(expl, uri.with({ path: modDir, query: '', fragment: '' }));
+ if (uri.path === parent.uri.path) {
+ return parent;
+ }
+
+ if (nested) {
+ const bits = path.relative(parent.uri.path, uri.path).split(path.sep);
+ while (bits.length > 1) {
+ const dir = bits.shift();
+ const dirUri = uri.with({ path: path.join(parent.uri.path, dir), query: '', fragment: '' });
+ parent = expl.getOrCreateItem(parent, dir, dirUri, 'package');
+ }
+ }
+
+ const label = uri.path.startsWith(parent.uri.path) ? uri.path.substring(parent.uri.path.length + 1) : uri.path;
+ item = expl.getOrCreateItem(parent, label, uri, 'package');
+ } else if (wsfolder) {
+ // If the package is in a workspace folder, add it as a child of the workspace
+ const workspace = await getWorkspace(expl, wsfolder);
+ const existing = expl.getItem(workspace, uri, 'package');
+ if (existing) {
+ return existing;
+ }
+
+ const label = uri.path.startsWith(wsfolder.uri.path)
+ ? uri.path.substring(wsfolder.uri.path.length + 1)
+ : uri.path;
+ item = expl.getOrCreateItem(workspace, label, uri, 'package');
+ } else {
+ // Otherwise, add it directly to the root
+ const existing = expl.getItem(null, uri, 'package');
+ if (existing) {
+ return existing;
+ }
+
+ const srcPath = path.join(getCurrentGoPath(uri), 'src');
+ const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path;
+ item = expl.getOrCreateItem(null, label, uri, 'package');
+ }
+
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Retrieve or create an item for a Go file.
+async function getFile(expl: TestExplorer, uri: Uri): Promise<TestItem> {
+ const dir = path.dirname(uri.path);
+ const pkg = await getPackage(expl, uri.with({ path: dir, query: '', fragment: '' }));
+ const existing = expl.getItem(pkg, uri, 'file');
+ if (existing) {
+ return existing;
+ }
+
+ const label = path.basename(uri.path);
+ const item = expl.getOrCreateItem(pkg, label, uri, 'file');
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Recursively process a Go AST symbol. If the symbol represents a test,
+// benchmark, or example function, a test item will be created for it, if one
+// does not already exist. If the symbol is not a function and contains
+// children, those children will be processed recursively.
+async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: Set<string>, symbol: DocumentSymbol) {
+ // Skip TestMain(*testing.M) - allow TestMain(*testing.T)
+ if (symbol.name === 'TestMain' && /\*testing.M\)/.test(symbol.detail)) {
+ return;
+ }
+
+ // Recursively process symbols that are nested
+ if (symbol.kind !== SymbolKind.Function) {
+ for (const sym of symbol.children) await processSymbol(expl, uri, file, seen, sym);
+ return;
+ }
+
+ const match = symbol.name.match(/^(?<type>Test|Example|Benchmark)/);
+ if (!match) {
+ return;
+ }
+
+ seen.add(symbol.name);
+
+ const kind = match.groups.type.toLowerCase();
+ const existing = expl.getItem(file, uri, kind, symbol.name);
+ if (existing) {
+ if (!existing.range.isEqual(symbol.range)) {
+ existing.range = symbol.range;
+ relocateChildren(existing);
+ }
+ return existing;
+ }
+
+ const item = expl.getOrCreateItem(file, symbol.name, uri, kind, symbol.name);
+ item.range = symbol.range;
+}
+
+// Processes a Go document, calling processSymbol for each symbol in the
+// document.
+//
+// Any previously existing tests that no longer have a corresponding symbol in
+// the file will be disposed. If the document contains no tests, it will be
+// disposed.
+async function processDocument(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) {
+ const seen = new Set<string>();
+ const item = await getFile(expl, doc.uri);
+ const symbols = await expl.provideDocumentSymbols(doc, null);
+ for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol);
+
+ for (const child of collect(item.children)) {
+ const uri = Uri.parse(child.id);
+ if (!seen.has(uri.fragment)) {
+ dispose(child);
+ continue;
+ }
+
+ if (ranges?.some((r) => !!child.range.intersection(r))) {
+ discardChildren(child);
+ }
+ }
+
+ disposeIfEmpty(item);
+}
+
+// Reasons to stop walking
+enum WalkStop {
+ None = 0, // Don't stop
+ Abort, // Abort the walk
+ Current, // Stop walking the current directory
+ Files, // Skip remaining files
+ Directories // Skip remaining directories
+}
+
+// Recursively walk a directory, breadth first.
+async function walk(
+ fs: TestExplorer.FileSystem,
+ uri: Uri,
+ cb: (dir: Uri, file: string, type: FileType) => Promise<WalkStop | undefined>
+): Promise<void> {
+ let dirs = [uri];
+
+ // While there are directories to be scanned
+ while (dirs.length > 0) {
+ const d = dirs;
+ dirs = [];
+
+ outer: for (const uri of d) {
+ const dirs2 = [];
+ let skipFiles = false,
+ skipDirs = false;
+
+ // Scan the directory
+ inner: for (const [file, type] of await fs.readDirectory(uri)) {
+ if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) {
+ continue;
+ }
+
+ // Ignore all dotfiles
+ if (file.startsWith('.')) {
+ continue;
+ }
+
+ if (type === FileType.Directory) {
+ dirs2.push(Uri.joinPath(uri, file));
+ }
+
+ const s = await cb(uri, file, type);
+ switch (s) {
+ case WalkStop.Abort:
+ // Immediately abort the entire walk
+ return;
+
+ case WalkStop.Current:
+ // Immediately abort the current directory
+ continue outer;
+
+ case WalkStop.Files:
+ // Skip all subsequent files in the current directory
+ skipFiles = true;
+ if (skipFiles && skipDirs) {
+ break inner;
+ }
+ break;
+
+ case WalkStop.Directories:
+ // Skip all subsequent directories in the current directory
+ skipDirs = true;
+ if (skipFiles && skipDirs) {
+ break inner;
+ }
+ break;
+ }
+ }
+
+ // Add subdirectories to the recursion list
+ dirs.push(...dirs2);
+ }
+ }
+}
+
+// Walk the workspace, looking for Go modules. Returns a map indicating paths
+// that are modules (value == true) and paths that are not modules but contain
+// Go files (value == false).
+async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise<Map<string, boolean>> {
+ const found = new Map<string, boolean>();
+ await walk(fs, uri, async (dir, file, type) => {
+ if (type !== FileType.File) {
+ return;
+ }
+
+ if (file === 'go.mod') {
+ // BUG(firelizard18): This does not create a separate entry for
+ // modules within a module. Thus, tests in a module within another
+ // module will appear under the top-level module's tree. This may or
+ // may not be acceptable.
+ found.set(dir.toString(), true);
+ return WalkStop.Current;
+ }
+
+ if (file.endsWith('.go')) {
+ found.set(dir.toString(), false);
+ }
+ });
+ return found;
+}
+
+// Walk the workspace, calling the callback for any directory that contains a Go
+// test file.
+async function walkPackages(fs: TestExplorer.FileSystem, uri: Uri, cb: (uri: Uri) => Promise<unknown>) {
+ await walk(fs, uri, async (dir, file) => {
+ if (file.endsWith('_test.go')) {
+ await cb(dir);
+ return WalkStop.Files;
+ }
+ });
+}
+
+// Handle opened documents, document changes, and file creation.
+async function documentUpdate(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) {
+ if (!doc.uri.path.endsWith('_test.go')) {
+ return;
+ }
+
+ if (doc.uri.scheme === 'git') {
+ // TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
+ return;
+ }
+
+ await processDocument(expl, doc, ranges);
+}
+
+// TestController.resolveChildrenHandler callback
+async function resolve(expl: TestExplorer, item?: TestItem) {
+ // Expand the root item - find all modules and workspaces
+ if (!item) {
+ // Dispose of package entries at the root if they are now part of a workspace folder
+ for (const item of collect(expl.ctrl.items)) {
+ const uri = Uri.parse(item.id);
+ if (uri.query !== 'package') {
+ continue;
+ }
+
+ if (expl.ws.getWorkspaceFolder(uri)) {
+ dispose(item);
+ }
+ }
+
+ // Create entries for all modules and workspaces
+ for (const folder of expl.ws.workspaceFolders || []) {
+ const found = await walkWorkspaces(expl.ws.fs, folder.uri);
+ let needWorkspace = false;
+ for (const [uri, isMod] of found.entries()) {
+ if (!isMod) {
+ needWorkspace = true;
+ continue;
+ }
+
+ await getModule(expl, Uri.parse(uri));
+ }
+
+ // If the workspace folder contains any Go files not in a module, create a workspace entry
+ if (needWorkspace) {
+ await getWorkspace(expl, folder);
+ }
+ }
+ return;
+ }
+
+ const uri = Uri.parse(item.id);
+
+ // The user expanded a module or workspace - find all packages
+ if (uri.query === 'module' || uri.query === 'workspace') {
+ await walkPackages(expl.ws.fs, uri, async (uri) => {
+ await getPackage(expl, uri);
+ });
+ }
+
+ // The user expanded a module or package - find all files
+ if (uri.query === 'module' || uri.query === 'package') {
+ for (const [file, type] of await expl.ws.fs.readDirectory(uri)) {
+ if (type !== FileType.File || !file.endsWith('_test.go')) {
+ continue;
+ }
+
+ await getFile(expl, Uri.joinPath(uri, file));
+ }
+ }
+
+ // The user expanded a file - find all functions
+ if (uri.query === 'file') {
+ const doc = await expl.ws.openTextDocument(uri.with({ query: '', fragment: '' }));
+ await processDocument(expl, doc);
+ }
+
+ // TODO(firelizzard18): If uri.query is test or benchmark, this is where we
+ // would discover sub tests or benchmarks, if that is feasible.
+}
+
+type CollectedTest = { item: TestItem; explicitlyIncluded: boolean };
+
+// Recursively find all tests, benchmarks, and examples within a
+// module/package/etc, minus exclusions. Map tests to the package they are
+// defined in, and track files.
+async function collectTests(
+ expl: TestExplorer,
+ item: TestItem,
+ explicitlyIncluded: boolean,
+ excluded: TestItem[],
+ functions: Map<string, CollectedTest[]>,
+ docs: Set<Uri>
+) {
+ for (let i = item; i.parent; i = i.parent) {
+ if (excluded.indexOf(i) >= 0) {
+ return;
+ }
+ }
+
+ const uri = Uri.parse(item.id);
+ if (!uri.fragment) {
+ if (item.children.size === 0) {
+ await resolve(expl, item);
+ }
+
+ for (const child of collect(item.children)) {
+ await collectTests(expl, child, false, excluded, functions, docs);
+ }
+ return;
+ }
+
+ const file = uri.with({ query: '', fragment: '' });
+ docs.add(file);
+
+ const dir = file.with({ path: path.dirname(uri.path) }).toString();
+ if (functions.has(dir)) {
+ functions.get(dir).push({ item, explicitlyIncluded });
+ } else {
+ functions.set(dir, [{ item, explicitlyIncluded }]);
+ }
+ return;
+}
+
+// TestRunOutput is a fake OutputChannel that forwards all test output to the test API
+// console.
+class TestRunOutput implements OutputChannel {
+ readonly name: string;
+ readonly lines: string[] = [];
+
+ constructor(private run: TestRun) {
+ this.name = `Test run at ${new Date()}`;
+ }
+
+ append(value: string) {
+ this.run.appendOutput(value);
+ }
+
+ appendLine(value: string) {
+ this.lines.push(value);
+ this.run.appendOutput(value + '\r\n');
+ }
+
+ clear() {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ show(...args: unknown[]) {}
+ hide() {}
+ dispose() {}
+}
+
+// Resolve a test name to a test item. If the test name is TestXxx/Foo, Foo is
+// created as a child of TestXxx. The same is true for TestXxx#Foo and
+// TestXxx/#Foo.
+function resolveTestName(expl: TestExplorer, tests: Record<string, TestItem>, name: string): TestItem | undefined {
+ if (!name) {
+ return;
+ }
+
+ const parts = name.split(/[#/]+/);
+ let test = tests[parts[0]];
+ if (!test) {
+ return;
+ }
+
+ for (const part of parts.slice(1)) {
+ test = expl.getOrCreateSubTest(test, part);
+ }
+ return test;
+}
+
+// Process benchmark events (see test_events.md)
+function consumeGoBenchmarkEvent(
+ expl: TestExplorer,
+ run: TestRun,
+ benchmarks: Record<string, TestItem>,
+ complete: Set<TestItem>,
+ e: GoTestOutput
+) {
+ if (e.Test) {
+ // Find (or create) the (sub)benchmark
+ const test = resolveTestName(expl, benchmarks, e.Test);
+ if (!test) {
+ return;
+ }
+
+ switch (e.Action) {
+ case 'fail': // Failed
+ run.failed(test, { message: 'Failed' });
+ complete.add(test);
+ break;
+
+ case 'skip': // Skipped
+ run.skipped(test);
+ complete.add(test);
+ break;
+ }
+
+ return;
+ }
+
+ // Ignore anything that's not an output event
+ if (!e.Output) {
+ return;
+ }
+
+ // On start: "BenchmarkFooBar"
+ // On complete: "BenchmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op"
+
+ // Extract the benchmark name and status
+ const m = e.Output.match(/^(?<name>Benchmark[/\w]+)(?:-(?<procs>\d+)\s+(?<result>.*))?(?:$|\n)/);
+ if (!m) {
+ // If the output doesn't start with `BenchmarkFooBar`, ignore it
+ return;
+ }
+
+ // Find (or create) the (sub)benchmark
+ const test = resolveTestName(expl, benchmarks, m.groups.name);
+ if (!test) {
+ return;
+ }
+
+ // If output includes benchmark results, the benchmark passed. If output
+ // only includes the benchmark name, the benchmark is running.
+ if (m.groups.result) {
+ run.passed(test);
+ complete.add(test);
+ vscode.commands.executeCommand('testing.showMostRecentOutput');
+ } else {
+ run.started(test);
+ }
+}
+
+// Pass any incomplete benchmarks (see test_events.md)
+function markComplete(items: Record<string, TestItem>, complete: Set<TestItem>, fn: (item: TestItem) => void) {
+ function mark(item: TestItem) {
+ if (!complete.has(item)) {
+ fn(item);
+ }
+ for (const child of collect(item.children)) {
+ mark(child);
+ }
+ }
+
+ for (const name in items) {
+ mark(items[name]);
+ }
+}
+
+// Process test events (see test_events.md)
+function consumeGoTestEvent(
+ expl: TestExplorer,
+ run: TestRun,
+ tests: Record<string, TestItem>,
+ record: Map<TestItem, string[]>,
+ complete: Set<TestItem>,
+ concat: boolean,
+ e: GoTestOutput
+) {
+ const test = resolveTestName(expl, tests, e.Test);
+ if (!test) {
+ return;
+ }
+
+ switch (e.Action) {
+ case 'cont':
+ case 'pause':
+ // ignore
+ break;
+
+ case 'run':
+ run.started(test);
+ break;
+
+ case 'pass':
+ // TODO(firelizzard18): add messages on pass, once that capability
+ // is added.
+ complete.add(test);
+ run.passed(test, e.Elapsed * 1000);
+ break;
+
+ case 'fail': {
+ complete.add(test);
+ const messages = parseOutput(test, record.get(test) || []);
+
+ if (!concat) {
+ run.failed(test, messages, e.Elapsed * 1000);
+ break;
+ }
+
+ const merged = new Map<string, TestMessage>();
+ for (const { message, location } of messages) {
+ const loc = `${location.uri}:${location.range.start.line}`;
+ if (merged.has(loc)) {
+ merged.get(loc).message += '\n' + message;
+ } else {
+ merged.set(loc, { message, location });
+ }
+ }
+
+ run.failed(test, Array.from(merged.values()), e.Elapsed * 1000);
+ break;
+ }
+
+ case 'skip':
+ complete.add(test);
+ run.skipped(test);
+ break;
+
+ case 'output':
+ if (/^(=== RUN|\s*--- (FAIL|PASS): )/.test(e.Output)) {
+ break;
+ }
+
+ if (record.has(test)) record.get(test).push(e.Output);
+ else record.set(test, [e.Output]);
+ break;
+ }
+}
+
+function parseOutput(test: TestItem, output: string[]): TestMessage[] {
+ const messages: TestMessage[] = [];
+
+ const uri = Uri.parse(test.id);
+ const gotI = output.indexOf('got:\n');
+ const wantI = output.indexOf('want:\n');
+ if (uri.query === 'example' && gotI >= 0 && wantI >= 0) {
+ const got = output.slice(gotI + 1, wantI).join('');
+ const want = output.slice(wantI + 1).join('');
+ const message = TestMessage.diff('Output does not match', want, got);
+ message.location = new Location(test.uri, test.range.start);
+ messages.push(message);
+ output = output.slice(0, gotI);
+ }
+
+ let current: Location;
+ const dir = Uri.joinPath(test.uri, '..');
+ for (const line of output) {
+ const m = line.match(/^\s*(?<file>.*\.go):(?<line>\d+): ?(?<message>.*\n)$/);
+ if (m) {
+ const file = Uri.joinPath(dir, m.groups.file);
+ const ln = Number(m.groups.line) - 1; // VSCode uses 0-based line numbering (internally)
+ current = new Location(file, new Position(ln, 0));
+ messages.push({ message: m.groups.message, location: current });
+ } else if (current) {
+ messages.push({ message: line, location: current });
+ }
+ }
+
+ return messages;
+}
+
+function isBuildFailure(output: string[]): boolean {
+ const rePkg = /^# (?<pkg>[\w/.-]+)(?: \[(?<test>[\w/.-]+).test\])?/;
+
+ // TODO(firelizzard18): Add more sophisticated check for build failures?
+ return output.some((x) => rePkg.test(x));
+}
+
+// Execute tests - TestController.runTest callback
+async function runTests(expl: TestExplorer, request: TestRunRequest, token: CancellationToken) {
+ const collected = new Map<string, CollectedTest[]>();
+ const docs = new Set<Uri>();
+ if (request.include) {
+ for (const item of request.include) {
+ await collectTests(expl, item, true, request.exclude || [], collected, docs);
+ }
+ } else {
+ const promises: Promise<unknown>[] = [];
+ expl.ctrl.items.forEach((item) => {
+ const p = collectTests(expl, item, true, request.exclude || [], collected, docs);
+ promises.push(p);
+ });
+ await Promise.all(promises);
+ }
+
+ // Save all documents that contain a test we're about to run, to ensure `go
+ // test` has the latest changes
+ await Promise.all(expl.ws.textDocuments.filter((x) => docs.has(x.uri)).map((x) => x.save()));
+
+ let hasBench = false,
+ hasNonBench = false;
+ for (const items of collected.values()) {
+ for (const { item } of items) {
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'benchmark') hasBench = true;
+ else hasNonBench = true;
+ }
+ }
+
+ const run = expl.ctrl.createTestRun(request);
+ const outputChannel = new TestRunOutput(run);
+ for (const [dir, items] of collected.entries()) {
+ const uri = Uri.parse(dir);
+ const isMod = await isModSupported(uri, true);
+ const goConfig = getGoConfig(uri);
+ const flags = getTestFlags(goConfig);
+ const includeBench = getGoConfig(uri).get('testExplorerRunBenchmarks');
+
+ // Separate tests and benchmarks and mark them as queued for execution.
+ // Clear any sub tests/benchmarks generated by a previous run.
+ const tests: Record<string, TestItem> = {};
+ const benchmarks: Record<string, TestItem> = {};
+ for (const { item, explicitlyIncluded } of items) {
+ const uri = Uri.parse(item.id);
+ if (/[/#]/.test(uri.fragment)) {
+ // running sub-tests is not currently supported
+ vscode.window.showErrorMessage(`Cannot run ${uri.fragment} - running sub-tests is not supported`);
+ continue;
+ }
+
+ // When the user clicks the run button on a package, they expect all
+ // of the tests within that package to run - they probably don't
+ // want to run the benchmarks. So if a benchmark is not explicitly
+ // selected, don't run benchmarks. But the user may disagree, so
+ // behavior can be changed with `go.testExplorerRunBenchmarks`.
+ // However, if the user clicks the run button on a file or package
+ // that contains benchmarks and nothing else, they likely expect
+ // those benchmarks to run.
+ if (uri.query === 'benchmark' && !explicitlyIncluded && !includeBench && !(hasBench && !hasNonBench)) {
+ continue;
+ }
+
+ item.error = null;
+ run.enqueued(item);
+ discardChildren(item);
+
+ if (uri.query === 'benchmark') {
+ benchmarks[uri.fragment] = item;
+ } else {
+ tests[uri.fragment] = item;
+ }
+ }
+
+ const record = new Map<TestItem, string[]>();
+ const testFns = Object.keys(tests);
+ const benchmarkFns = Object.keys(benchmarks);
+ const concat = goConfig.get<boolean>('testExplorerConcatenateMessages');
+
+ // Run tests
+ if (testFns.length > 0) {
+ const complete = new Set<TestItem>();
+ const success = await goTest({
+ goConfig,
+ flags,
+ isMod,
+ outputChannel,
+ dir: uri.fsPath,
+ functions: testFns,
+ cancel: token,
+ goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, complete, concat, e)
+ });
+ if (!success) {
+ if (isBuildFailure(outputChannel.lines)) {
+ markComplete(tests, new Set(), (item) => {
+ run.errored(item, { message: 'Compilation failed' });
+ item.error = 'Compilation failed';
+ });
+ } else {
+ markComplete(tests, complete, (x) => run.skipped(x));
+ }
+ }
+ }
+
+ // Run benchmarks
+ if (benchmarkFns.length > 0) {
+ const complete = new Set<TestItem>();
+ const success = await goTest({
+ goConfig,
+ flags,
+ isMod,
+ outputChannel,
+ dir: uri.fsPath,
+ functions: benchmarkFns,
+ isBenchmark: true,
+ cancel: token,
+ goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e)
+ });
+
+ // Explicitly complete any incomplete benchmarks (see test_events.md)
+ if (success) {
+ markComplete(benchmarks, complete, (x) => run.passed(x));
+ } else if (isBuildFailure(outputChannel.lines)) {
+ markComplete(benchmarks, new Set(), (item) => {
+ // TODO change to errored when that is added back
+ run.failed(item, { message: 'Compilation failed' });
+ item.error = 'Compilation failed';
+ });
+ } else {
+ markComplete(benchmarks, complete, (x) => run.skipped(x));
+ }
+ }
+ }
+
+ run.end();
+}
diff --git a/src/testUtils.ts b/src/testUtils.ts
index 77e812d..c33bad1 100644
--- a/src/testUtils.ts
+++ b/src/testUtils.ts
@@ -85,6 +85,14 @@
* Output channel for test output.
*/
outputChannel?: vscode.OutputChannel;
+ /**
+ * Can be used to terminate the test process.
+ */
+ cancel?: vscode.CancellationToken;
+ /**
+ * Output channel for JSON test output.
+ */
+ goTestOutputConsumer?: (_: GoTestOutput) => void;
}
export function getTestEnvVars(config: vscode.WorkspaceConfiguration): any {
@@ -236,10 +244,12 @@
* which is a subset of https://golang.org/cmd/test2json/#hdr-Output_Format
* and includes only the fields that we are using.
*/
-interface GoTestOutput {
+export interface GoTestOutput {
Action: string;
Output?: string;
Package?: string;
+ Test?: string;
+ Elapsed?: number; // seconds
}
/**
@@ -294,9 +304,16 @@
const outBuf = new LineBuffer();
const errBuf = new LineBuffer();
+ testconfig.cancel?.onCancellationRequested(() => killProcessTree(tp));
+
const testResultLines: string[] = [];
const processTestResultLine = addJSONFlag
- ? processTestResultLineInJSONMode(pkgMap, currentGoWorkspace, outputChannel)
+ ? processTestResultLineInJSONMode(
+ pkgMap,
+ currentGoWorkspace,
+ outputChannel,
+ testconfig.goTestOutputConsumer
+ )
: processTestResultLineInStandardMode(pkgMap, currentGoWorkspace, testResultLines, outputChannel);
outBuf.onLine((line) => processTestResultLine(line));
@@ -443,7 +460,7 @@
const outArgs = args.slice(0); // command to show
// if user set -v, set -json to emulate streaming test output
- const addJSONFlag = userFlags.includes('-v') && !userFlags.includes('-json');
+ const addJSONFlag = (userFlags.includes('-v') || testconfig.goTestOutputConsumer) && !userFlags.includes('-json');
if (addJSONFlag) {
args.push('-json'); // this is not shown to the user.
}
@@ -478,11 +495,15 @@
function processTestResultLineInJSONMode(
pkgMap: Map<string, string>,
currentGoWorkspace: string,
- outputChannel: vscode.OutputChannel
+ outputChannel: vscode.OutputChannel,
+ goTestOutputConsumer?: (_: GoTestOutput) => void
) {
return (line: string) => {
try {
const m = <GoTestOutput>JSON.parse(line);
+ if (goTestOutputConsumer) {
+ goTestOutputConsumer(m);
+ }
if (m.Action !== 'output' || !m.Output) {
return;
}
diff --git a/src/test_events.md b/src/test_events.md
new file mode 100644
index 0000000..b3abe93
--- /dev/null
+++ b/src/test_events.md
@@ -0,0 +1,38 @@
+# Go test events
+
+Running tests with the `-json` flag or passing test output through `go tool
+test2json1` will produce a stream of JSON events. Each event specifies an
+action, such as `run`, `pass`, `output`, etc. An event *may* specify what test
+it belongs to. The VSCode Go test controller must capture these events in order
+to notify VSCode of test output and lifecycle events.
+
+## Tests
+
+Processing test events generated by `TestXxx(*testing.T)` functions is easy.
+Events with an empty `Test` field can be ignored, and all other events have a
+meaningful `Action` field. Output is recorded, and run/pass/fail/skip events are
+converted to VSCode test API events.
+
+[go#37555](https://github.com/golang/go/issues/37555) did require special
+handling, but that only appeared in Go 1.14 and was backported to 1.14.1.
+
+## Benchmarks
+
+Test events generated by `BenchmarkXxx(*testing.B)` functions require
+significantly more processing. If a benchmark fails or is skipped, the `Test`
+and `Action` fields are populated appropriately. Otherwise, `Test` is empty and
+`Action` is always `output`. Thus, nominal lifecycle events (run/pass) must be
+deduced purely from test output. When a benchmark begins, an output such as
+`BenchmarkFooBar\n` is produced. When a benchmark completes, an output such as
+`BencmarkFooBar-4 123456 123.4 ns/op 123 B/op 12 allocs/op` is produced. No
+explicit `run` or `pass` events are generated. Thus:
+
+- When `BenchmarkFooBar\n` is seen, the benchmark will be marked as running
+- When an explicit fail/skip is seen, the benchmark will be marked as failed/skipped
+- When benchmark results are seen, the benchmark will be marked as passed
+
+Thus, a benchmark that does not produce results (and does not fail or skip) will
+never produce an event indicating that it has completed. Benchmarks that call
+`(*testing.B).Run` will not produce results. In practice, this means that any
+incomplete benchmarks must be explicitly marked as passed once `go test`
+returns.
\ No newline at end of file
diff --git a/test/gopls/index.ts b/test/gopls/index.ts
index f332137..7d7be4a 100644
--- a/test/gopls/index.ts
+++ b/test/gopls/index.ts
@@ -10,6 +10,7 @@
export function run(): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
+ grep: process.env.MOCHA_GREP,
ui: 'tdd'
});
diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts
new file mode 100644
index 0000000..645d73e
--- /dev/null
+++ b/test/integration/goTestExplorer.test.ts
@@ -0,0 +1,414 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import assert = require('assert');
+import path = require('path');
+import {
+ DocumentSymbol,
+ FileType,
+ TestItem,
+ Uri,
+ TextDocument,
+ SymbolKind,
+ Range,
+ Position,
+ TestItemCollection,
+ TextDocumentChangeEvent
+} from 'vscode';
+import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules';
+import { TestExplorer, testID } from '../../src/goTestExplorer';
+import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
+
+type Files = Record<string, string | { contents: string; language: string }>;
+
+interface TestCase {
+ workspace: string[];
+ files: Files;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function symbols(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);
+}
+
+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: Uri, modpath?: string) {
+ const dirs: Uri[] = [];
+ for (const [name, type] of ws.fs.dirs.get(dir.toString())) {
+ const uri = dir.with({ path: path.join(dir.path, name) });
+ if (type === FileType.Directory) {
+ dirs.push(uri);
+ } else if (name === 'go.mod') {
+ modpath = dir.path;
+ }
+ }
+ pkg2mod[dir.path] = modpath || '';
+ for (const dir of dirs) {
+ walk(dir, modpath);
+ }
+ }
+
+ // prevent getModFolderPath from actually doing anything;
+ for (const pkg in pkg2mod) delete pkg2mod[pkg];
+ walk(Uri.file('/'));
+
+ return { ctrl, expl, ws };
+}
+
+function assertTestItems(items: TestItemCollection, expect: string[]) {
+ const actual: string[] = [];
+ function walk(items: TestItemCollection) {
+ items.forEach((item) => {
+ actual.push(item.id);
+ walk(item.children);
+ });
+ }
+ walk(items);
+ assert.deepStrictEqual(actual, expect);
+}
+
+suite('Test Explorer', () => {
+ suite('Items', () => {
+ interface TC extends TestCase {
+ item?: ([string, string, string] | [string, string, string, string])[];
+ expect: string[];
+ }
+
+ const cases: Record<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: TestItem | undefined;
+ for (const [label, uri, kind, name] of itemData) {
+ const u = Uri.parse(uri);
+ const child = ctrl.createTestItem(testID(u, kind, name), label, u);
+ (item?.children || ctrl.items).add(child);
+ item = child;
+ }
+ await ctrl.resolveHandler(item);
+
+ const actual: string[] = [];
+ (item?.children || ctrl.items).forEach((x) => actual.push(x.id));
+ assert.deepStrictEqual(actual, expect);
+ });
+ }
+ });
+ }
+ });
+
+ suite('Events', () => {
+ suite('Document opened', () => {
+ class DUT extends TestExplorer {
+ async _didOpen(doc: TextDocument) {
+ await this.didOpenTextDocument(doc);
+ }
+ }
+
+ interface TC extends TestCase {
+ open: string;
+ expect: string[];
+ }
+
+ const cases: Record<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: 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);
+ });
+ }
+ });
+ });
+});
diff --git a/test/integration/index.ts b/test/integration/index.ts
index d6549f0..efc7404 100644
--- a/test/integration/index.ts
+++ b/test/integration/index.ts
@@ -9,6 +9,7 @@
import * as path from 'path';
export function run(): Promise<void> {
const mocha = new Mocha({
+ grep: process.env.MOCHA_GREP,
ui: 'tdd'
});
diff --git a/test/mocks/MockMemento.ts b/test/mocks/MockMemento.ts
index 5157fbe..bac98d0 100644
--- a/test/mocks/MockMemento.ts
+++ b/test/mocks/MockMemento.ts
@@ -25,4 +25,8 @@
public clear() {
this._value = {};
}
+
+ keys(): readonly string[] {
+ return Object.keys(this._value);
+ }
}
diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts
new file mode 100644
index 0000000..1d6cee8
--- /dev/null
+++ b/test/mocks/MockTest.ts
@@ -0,0 +1,297 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import path = require('path');
+import {
+ CancellationToken,
+ EndOfLine,
+ FileType,
+ MarkdownString,
+ Position,
+ Range,
+ TestController,
+ TestItem,
+ TestItemCollection,
+ TestRun,
+ TestRunProfile,
+ TestRunProfileKind,
+ TestRunRequest,
+ TextDocument,
+ TextLine,
+ Uri,
+ WorkspaceFolder
+} from 'vscode';
+import { TestExplorer } from '../../src/goTestExplorer';
+
+type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable<void> | void;
+
+class MockTestCollection implements TestItemCollection {
+ constructor(private item: MockTestItem | MockTestController) {}
+
+ private readonly m = new Map<string, MockTestItem>();
+
+ get size() {
+ return this.m.size;
+ }
+
+ forEach(fn: (item: TestItem, coll: TestItemCollection) => unknown) {
+ for (const item of this.m.values()) fn(item, this);
+ }
+
+ add(item: TestItem): void {
+ if (this.m.has(item.id)) {
+ throw new Error(`Test item ${item.id} already exists`);
+ }
+
+ if (!(item instanceof MockTestItem)) {
+ throw new Error('not a mock');
+ } else if (this.item instanceof MockTestItem) {
+ item.parent = this.item;
+ }
+
+ this.m.set(item.id, item);
+ }
+
+ delete(id: string): void {
+ this.m.delete(id);
+ }
+
+ get(id: string): TestItem {
+ return this.m.get(id);
+ }
+
+ replace(items: readonly TestItem[]): void {
+ throw new Error('not impelemented');
+ }
+}
+
+class MockTestItem implements TestItem {
+ private static idNum = 0;
+ private idNum: number;
+
+ constructor(public id: string, public label: string, public uri: Uri | undefined, public ctrl: MockTestController) {
+ this.idNum = MockTestItem.idNum;
+ MockTestItem.idNum++;
+ }
+
+ parent: TestItem | undefined;
+ canResolveChildren: boolean;
+ busy: boolean;
+ description?: string;
+ range?: Range;
+ error?: string | MarkdownString;
+ runnable: boolean;
+ debuggable: boolean;
+
+ children: MockTestCollection = new MockTestCollection(this);
+
+ invalidateResults(): void {}
+
+ dispose(): void {
+ if (this.parent instanceof MockTestItem) {
+ this.parent.children.delete(this.id);
+ }
+ }
+}
+
+class MockTestRunProfile implements TestRunProfile {
+ constructor(
+ public label: string,
+ public kind: TestRunProfileKind,
+ public runHandler: TestRunHandler,
+ public isDefault: boolean
+ ) {}
+
+ configureHandler?: () => void;
+ dispose(): void {}
+}
+
+export class MockTestController implements TestController {
+ id = 'go';
+ label = 'Go';
+ items = new MockTestCollection(this);
+
+ resolveHandler?: (item: TestItem) => void | Thenable<void>;
+
+ createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun {
+ throw new Error('Method not implemented.');
+ }
+
+ createRunProfile(
+ label: string,
+ kind: TestRunProfileKind,
+ runHandler: TestRunHandler,
+ isDefault?: boolean
+ ): TestRunProfile {
+ return new MockTestRunProfile(label, kind, runHandler, isDefault);
+ }
+
+ createTestItem(id: string, label: string, uri?: Uri): TestItem {
+ return new MockTestItem(id, label, uri, this);
+ }
+
+ dispose(): void {}
+}
+
+type DirEntry = [string, FileType];
+
+class MockTestFileSystem implements TestExplorer.FileSystem {
+ constructor(public dirs: Map<string, DirEntry[]>, public files: Map<string, MockTestDocument>) {}
+
+ readDirectory(uri: Uri): Thenable<[string, FileType][]> {
+ const k = uri.with({ query: '', fragment: '' }).toString();
+ return Promise.resolve(this.dirs.get(k) || []);
+ }
+
+ readFile(uri: Uri): Thenable<Uint8Array> {
+ const k = uri.with({ query: '', fragment: '' }).toString();
+ const s = this.files.get(k)?.getText();
+ return Promise.resolve(Buffer.from(s || ''));
+ }
+}
+
+function unindent(s: string): string {
+ let lines = s.split('\n');
+ if (/^\s*$/.test(lines[0])) lines = lines.slice(1);
+
+ const m = lines[0].match(/^\s+/);
+ if (!m) return s;
+ if (!lines.every((l) => /^\s*$/.test(l) || l.startsWith(m[0]))) return s;
+
+ for (const i in lines) {
+ lines[i] = lines[i].substring(m[0].length);
+ }
+ return lines.join('\n');
+}
+
+export class MockTestWorkspace implements TestExplorer.Workspace {
+ static from(folders: string[], contents: Record<string, string | { contents: string; language: string }>) {
+ const wsdirs: WorkspaceFolder[] = [];
+ const dirs = new Map<string, DirEntry[]>();
+ const files = new Map<string, MockTestDocument>();
+
+ for (const i in folders) {
+ const uri = Uri.parse(folders[i]);
+ wsdirs.push({ uri, index: Number(i), name: path.basename(uri.path) });
+ }
+
+ function push(uri: Uri, child: FileType) {
+ const entry: DirEntry = [path.basename(uri.path), child];
+ const dir = uri.with({ path: path.dirname(uri.path) });
+ if (dirs.has(dir.toString())) {
+ dirs.get(dir.toString()).push(entry);
+ return;
+ }
+
+ if (path.dirname(dir.path) !== dir.path) {
+ push(dir, FileType.Directory);
+ }
+ dirs.set(dir.toString(), [entry]);
+ }
+
+ for (const k in contents) {
+ const uri = Uri.parse(k);
+ const entry = contents[k];
+
+ let doc: MockTestDocument;
+ if (typeof entry === 'object') {
+ doc = new MockTestDocument(uri, unindent(entry.contents), entry.language);
+ } else if (path.basename(uri.path) === 'go.mod') {
+ doc = new MockTestDocument(uri, unindent(entry), 'go.mod');
+ } else {
+ doc = new MockTestDocument(uri, unindent(entry));
+ }
+
+ files.set(uri.toString(), doc);
+ push(uri, FileType.File);
+ }
+
+ return new this(wsdirs, new MockTestFileSystem(dirs, files));
+ }
+
+ constructor(public workspaceFolders: WorkspaceFolder[], public fs: MockTestFileSystem) {}
+
+ openTextDocument(uri: Uri): Thenable<TextDocument> {
+ return Promise.resolve(this.fs.files.get(uri.toString()));
+ }
+
+ getWorkspaceFolder(uri: Uri): WorkspaceFolder {
+ return this.workspaceFolders.filter((x) => x.uri === uri)[0];
+ }
+
+ textDocuments: TextDocument[] = [];
+}
+
+class MockTestDocument implements TextDocument {
+ constructor(
+ public uri: Uri,
+ private _contents: string,
+ public languageId: string = 'go',
+ public isUntitled: boolean = false,
+ public isDirty: boolean = false
+ ) {}
+
+ set contents(s: string) {
+ this._contents = s;
+ }
+
+ readonly version: number = 1;
+ readonly eol: EndOfLine = EndOfLine.LF;
+
+ get lineCount() {
+ return this._contents.split('\n').length;
+ }
+
+ get fileName() {
+ return path.basename(this.uri.path);
+ }
+
+ save(): Thenable<boolean> {
+ if (!this.isDirty) {
+ return Promise.resolve(false);
+ }
+
+ this.isDirty = false;
+ return Promise.resolve(true);
+ }
+
+ get isClosed(): boolean {
+ throw new Error('Method not implemented.');
+ }
+
+ lineAt(line: number): TextLine;
+ lineAt(position: Position): TextLine;
+ lineAt(position: any): TextLine {
+ throw new Error('Method not implemented.');
+ }
+
+ offsetAt(position: Position): number {
+ throw new Error('Method not implemented.');
+ }
+
+ positionAt(offset: number): Position {
+ throw new Error('Method not implemented.');
+ }
+
+ getText(range?: Range): string {
+ if (range) {
+ throw new Error('Method not implemented.');
+ }
+ return this._contents;
+ }
+
+ getWordRangeAtPosition(position: Position, regex?: RegExp): Range {
+ throw new Error('Method not implemented.');
+ }
+
+ validateRange(range: Range): Range {
+ throw new Error('Method not implemented.');
+ }
+
+ validatePosition(position: Position): Position {
+ throw new Error('Method not implemented.');
+ }
+}
diff --git a/test/runTest.ts b/test/runTest.ts
index de9eaa8..27a23f4 100644
--- a/test/runTest.ts
+++ b/test/runTest.ts
@@ -6,6 +6,9 @@
async function main() {
// We are in test mode.
process.env['VSCODE_GO_IN_TEST'] = '1';
+ if (process.argv.length > 2) {
+ process.env['MOCHA_GREP'] = process.argv[2];
+ }
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`