src/goTest: add view for profiles
Updates golang/vscode-go#1641
Change-Id: I83233d4a64e98bc196d060cc1251a079eaeec22e
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/345470
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Carlos Amedee <carlos@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/docs/test-explorer.md b/docs/test-explorer.md
new file mode 100644
index 0000000..a20bfdd
--- /dev/null
+++ b/docs/test-explorer.md
@@ -0,0 +1,10 @@
+# Test explorer implementation (src/goTest)
+
+## Mapping tests
+
+`TestItem`s themselves cannot be used with `Map`s. For non-primitive (object)
+keys, Map uses strict equality. Two objects are only strictly equal to each
+other if they are the exact same object. Because of this, `TestItem`s cannot be
+used as map keys, as the extension host may provide different objects for the
+same test. Therefore, if we want to use `TestItem`s as a map key, we must use
+their ID instead.
\ No newline at end of file
diff --git a/package.json b/package.json
index 491cd9b..a97ce04 100644
--- a/package.json
+++ b/package.json
@@ -100,7 +100,8 @@
"onCommand:go.run.modinit",
"onDebugInitialConfigurations",
"onDebugResolve:go",
- "onWebviewPanel:welcomeGo"
+ "onWebviewPanel:welcomeGo",
+ "onView:go.test.profile"
],
"main": "./dist/goMain.js",
"capabilities": {
@@ -2520,6 +2521,17 @@
"group": "profile"
}
]
+ },
+ "views": {
+ "test": [
+ {
+ "id": "go.test.profile",
+ "name": "Profiles",
+ "contextualTitle": "Go",
+ "icon": "$(graph)",
+ "when": "go.hasProfiles"
+ }
+ ]
}
}
}
diff --git a/src/goTest/explore.ts b/src/goTest/explore.ts
index 2d9615e..778bee9 100644
--- a/src/goTest/explore.ts
+++ b/src/goTest/explore.ts
@@ -41,6 +41,7 @@
);
context.subscriptions.push(ctrl);
+ context.subscriptions.push(vscode.window.registerTreeDataProvider('go.test.profile', inst.profiler.view));
context.subscriptions.push(
vscode.commands.registerCommand('go.test.refresh', async (item) => {
@@ -219,7 +220,7 @@
const ws = this.workspace.getWorkspaceFolder(item.uri);
if (!ws) {
- dispose(item);
+ dispose(this.resolver, item);
}
});
}
@@ -246,8 +247,8 @@
const found = find(this.ctrl.items);
if (found) {
- dispose(found);
- disposeIfEmpty(found.parent);
+ dispose(this.resolver, found);
+ disposeIfEmpty(this.resolver, found.parent);
}
}
@@ -255,7 +256,7 @@
let update = false;
this.ctrl.items.forEach((item) => {
if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
- dispose(item);
+ dispose(this.resolver, item);
update = true;
}
});
diff --git a/src/goTest/profile.ts b/src/goTest/profile.ts
index ebdd13b..876dda7 100644
--- a/src/goTest/profile.ts
+++ b/src/goTest/profile.ts
@@ -2,7 +2,17 @@
* Copyright 2021 The Go Authors. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
-import { Memento, TestItem, Uri } from 'vscode';
+import {
+ EventEmitter,
+ Memento,
+ Range,
+ TestItem,
+ TextDocumentShowOptions,
+ TreeDataProvider,
+ TreeItem,
+ TreeItemCollapsibleState,
+ Uri
+} from 'vscode';
import vscode = require('vscode');
import { getTempFilePath } from '../util';
import { GoTestResolver } from './resolve';
@@ -13,7 +23,10 @@
const defaultOptions: ProfilingOptions = { kind: 'cpu' };
export class GoTestProfiler {
- private readonly lastRunFor = new Map<string, Run>();
+ public readonly view = new ProfileTreeDataProvider(this);
+
+ // Maps test IDs to profile files. See docs/test-explorer.md for details.
+ private readonly runs = new Map<string, File[]>();
constructor(private readonly resolver: GoTestResolver, private readonly workspaceState: Memento) {}
@@ -24,27 +37,28 @@
this.workspaceState.update(optionsMemento, v);
}
- preRun(options: ProfilingOptions, items: TestItem[]): string[] {
+ preRun(options: ProfilingOptions, item: TestItem): string[] {
const kind = Kind.get(options.kind);
- if (!kind) {
- items.forEach((x) => this.lastRunFor.delete(x.id));
- return [];
- }
+ if (!kind) return [];
const flags = [];
- const run = new Run(items, kind);
- flags.push(run.file.flag);
- items.forEach((x) => this.lastRunFor.set(x.id, run));
+ const run = new File(kind, item);
+ flags.push(run.flag);
+ if (this.runs.has(item.id)) this.runs.get(item.id).unshift(run);
+ else this.runs.set(item.id, [run]);
return flags;
}
postRun() {
// Update the list of tests that have profiles.
- vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.lastRunFor.keys()));
+ vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.runs.keys()));
+ vscode.commands.executeCommand('setContext', 'go.hasProfiles', this.runs.size > 0);
+
+ this.view.didRun();
}
hasProfileFor(id: string): boolean {
- return this.lastRunFor.has(id);
+ return this.runs.has(id);
}
async configure(): Promise<ProfilingOptions | undefined> {
@@ -68,13 +82,31 @@
return;
}
- const run = this.lastRunFor.get(item.id);
- if (!run) {
- await vscode.window.showErrorMessage(`${name} was not profiled the last time it was run`);
+ const runs = this.runs.get(item.id);
+ if (!runs || runs.length === 0) {
+ await vscode.window.showErrorMessage(`${name} has not been profiled`);
return;
}
- await run.file.show();
+ await runs[0].show();
+ }
+
+ // Tests that have been profiled
+ get tests() {
+ const items = Array.from(this.runs.keys());
+ items.sort((a: string, b: string) => {
+ const aWhen = this.runs.get(a)[0].when.getTime();
+ const bWhen = this.runs.get(b)[0].when.getTime();
+ return bWhen - aWhen;
+ });
+
+ // Filter out any tests that no longer exist
+ return items.map((x) => this.resolver.all.get(x)).filter((x) => x);
+ }
+
+ // Profiles associated with the given test
+ get(item: TestItem) {
+ return this.runs.get(item.id) || [];
}
}
@@ -103,23 +135,20 @@
static readonly Block = new Kind('block', 'Block', '--blockprofile');
}
-class Run {
+class File {
private static nextID = 0;
+ public readonly id = File.nextID++;
public readonly when = new Date();
- public readonly id = Run.nextID++;
- public readonly file: File;
- constructor(public readonly targets: TestItem[], kind: Kind) {
- this.file = new File(this, kind);
+ constructor(public readonly kind: Kind, public readonly target: TestItem) {}
+
+ get label() {
+ return `${this.kind.label} @ ${this.when.toTimeString()}`;
}
-}
-
-class File {
- constructor(public readonly run: Run, public readonly kind: Kind) {}
get name() {
- return `profile-${this.run.id}.${this.kind.id}.prof`;
+ return `profile-${this.id}.${this.kind.id}.prof`;
}
get flag(): string {
@@ -134,3 +163,48 @@
await vscode.window.showTextDocument(this.uri);
}
}
+
+type TreeElement = TestItem | File;
+
+class ProfileTreeDataProvider implements TreeDataProvider<TreeElement> {
+ private readonly didChangeTreeData = new EventEmitter<void | TreeElement>();
+ public readonly onDidChangeTreeData = this.didChangeTreeData.event;
+
+ constructor(private readonly profiler: GoTestProfiler) {}
+
+ didRun() {
+ this.didChangeTreeData.fire();
+ }
+
+ getTreeItem(element: TreeElement): TreeItem {
+ if (element instanceof File) {
+ const item = new TreeItem(element.label);
+ item.contextValue = 'file';
+ item.command = {
+ title: 'Open',
+ command: 'vscode.open',
+ arguments: [element.uri]
+ };
+ return item;
+ }
+
+ const item = new TreeItem(element.label, TreeItemCollapsibleState.Collapsed);
+ item.contextValue = 'test';
+ const options: TextDocumentShowOptions = {
+ preserveFocus: false,
+ selection: new Range(element.range.start, element.range.start)
+ };
+ item.command = {
+ title: 'Go to test',
+ command: 'vscode.open',
+ arguments: [element.uri, options]
+ };
+ return item;
+ }
+
+ getChildren(element?: TreeElement): TreeElement[] {
+ if (!element) return this.profiler.tests;
+ if (element instanceof File) return [];
+ return this.profiler.get(element);
+ }
+}
diff --git a/src/goTest/resolve.ts b/src/goTest/resolve.ts
index ead2a47..04286ef 100644
--- a/src/goTest/resolve.ts
+++ b/src/goTest/resolve.ts
@@ -37,6 +37,7 @@
}
export class GoTestResolver {
+ public readonly all = new Map<string, TestItem>();
public readonly isDynamicSubtest = new WeakSet<TestItem>();
public readonly isTestMethod = new WeakSet<TestItem>();
public readonly isTestSuiteFunc = new WeakSet<TestItem>();
@@ -75,7 +76,7 @@
}
if (this.workspace.getWorkspaceFolder(item.uri)) {
- dispose(item);
+ dispose(this, item);
}
});
@@ -212,16 +213,16 @@
item.children.forEach((child) => {
const { name } = GoTest.parseId(child.id);
if (!seen.has(name)) {
- dispose(child);
+ dispose(this, child);
return;
}
if (ranges?.some((r) => !!child.range.intersection(r))) {
- item.children.forEach(dispose);
+ item.children.forEach((x) => dispose(this, x));
}
});
- disposeIfEmpty(item);
+ disposeIfEmpty(this, item);
}
/* ***** Private ***** */
@@ -233,7 +234,10 @@
// Create an item.
private createItem(label: string, uri: Uri, kind: GoTestKind, name?: string): TestItem {
- return this.ctrl.createTestItem(GoTest.id(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+ const id = GoTest.id(uri, kind, name);
+ const item = this.ctrl.createTestItem(id, label, uri.with({ query: '', fragment: '' }));
+ this.all.set(id, item);
+ return item;
}
// Retrieve an item.
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
index e9c2178..a15b66d 100644
--- a/src/goTest/run.ts
+++ b/src/goTest/run.ts
@@ -200,7 +200,7 @@
// Remove subtests created dynamically from test output
item.children.forEach((child) => {
if (this.resolver.isDynamicSubtest.has(child)) {
- dispose(child);
+ dispose(this.resolver, child);
}
});
@@ -327,15 +327,21 @@
}
private async runGoTest(config: RunConfig): Promise<boolean> {
- const { run, options, pkg, functions, record, concat, flags, ...rest } = config;
+ const { run, options, pkg, functions, record, concat, ...rest } = config;
if (Object.keys(functions).length === 0) return true;
+ if (options.kind) {
+ if (Object.keys(functions).length > 1) {
+ throw new Error('Profiling more than one test at once is unsupported');
+ }
+ rest.flags.push(...this.profiler.preRun(options, Object.values(functions)[0]));
+ }
+
const complete = new Set<TestItem>();
const outputChannel = new TestRunOutput(run);
const success = await goTest({
...rest,
- flags: [...flags, ...this.profiler.preRun(options, Object.values(functions))],
outputChannel,
dir: pkg.uri.fsPath,
functions: Object.keys(functions),
diff --git a/src/goTest/utils.ts b/src/goTest/utils.ts
index 4d81422..9cc92c4 100644
--- a/src/goTest/utils.ts
+++ b/src/goTest/utils.ts
@@ -3,6 +3,7 @@
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
import * as vscode from 'vscode';
+import { GoTestResolver } from './resolve';
// GoTestKind indicates the Go construct represented by a test item.
//
@@ -91,13 +92,14 @@
return Promise.all(promises);
}
-export function dispose(item: vscode.TestItem) {
+export function dispose(resolver: GoTestResolver, item: vscode.TestItem) {
+ resolver.all.delete(item.id);
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) {
+export function disposeIfEmpty(resolver: GoTestResolver, item: vscode.TestItem) {
// Don't dispose of empty top-level items
const { kind } = GoTest.parseId(item.id);
if (kind === 'module' || kind === 'workspace' || (kind === 'package' && !item.parent)) {
@@ -108,6 +110,6 @@
return;
}
- dispose(item);
- disposeIfEmpty(item.parent);
+ dispose(resolver, item);
+ disposeIfEmpty(resolver, item.parent);
}