src/goTest: visualize profiles

Replace the virtual document populated via pprof -tree with a webview
with an iframe that shows the interface served by pprof -http.

Fixes golang/vscode-go#1747

Change-Id: I08ade5f8e080e984625c536856795c6bb4519c2e
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/345477
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/docs/commands.md b/docs/commands.md
index 316235e..ee71ec0 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -71,6 +71,10 @@
 
 Run a test and capture a profile
 
+### `Go Test: Delete Profile`
+
+Delete selected profile
+
 ### `Go: Benchmark Package`
 
 Runs all benchmarks in the package of the current file.
diff --git a/package.json b/package.json
index 0dcebe9..dce9eae 100644
--- a/package.json
+++ b/package.json
@@ -254,6 +254,13 @@
         "category": "Test"
       },
       {
+        "command": "go.test.deleteProfile",
+        "title": "Go Test: Delete Profile",
+        "shortTitle": "Delete",
+        "description": "Delete selected profile",
+        "category": "Test"
+      },
+      {
         "command": "go.benchmark.package",
         "title": "Go: Benchmark Package",
         "description": "Runs all benchmarks in the package of the current file."
@@ -2445,6 +2452,10 @@
         {
           "command": "go.test.captureProfile",
           "when": "false"
+        },
+        {
+          "command": "go.test.deleteProfile",
+          "when": "false"
         }
       ],
       "editor/context": [
@@ -2545,6 +2556,12 @@
           "when": "testId in go.tests && testId =~ /\\?(test|benchmark)/",
           "group": "profile"
         }
+      ],
+      "view/item/context": [
+        {
+          "command": "go.test.deleteProfile",
+          "when": "viewItem == go:test:file"
+        }
       ]
     },
     "views": {
diff --git a/src/goMain.ts b/src/goMain.ts
index c08f21e..02ed360 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -116,7 +116,7 @@
 import { ExtensionAPI } from './export';
 import extensionAPI from './extensionAPI';
 import { GoTestExplorer, isVscodeTestingAPIAvailable } from './goTest/explore';
-import { ProfileDocumentContentProvider } from './goToolPprof';
+import { killRunningPprof } from './goTest/profile';
 
 export let buildDiagnosticCollection: vscode.DiagnosticCollection;
 export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -341,10 +341,6 @@
 	}
 
 	ctx.subscriptions.push(
-		vscode.workspace.registerTextDocumentContentProvider('go-tool-pprof', new ProfileDocumentContentProvider())
-	);
-
-	ctx.subscriptions.push(
 		vscode.commands.registerCommand('go.subtest.cursor', (args) => {
 			const goConfig = getGoConfig();
 			subTestAtCursor(goConfig, args);
@@ -802,6 +798,7 @@
 export function deactivate() {
 	return Promise.all([
 		cancelRunningTests(),
+		killRunningPprof(),
 		Promise.resolve(cleanupTempDir()),
 		Promise.resolve(disposeGoStatusBar())
 	]);
diff --git a/src/goTest/explore.ts b/src/goTest/explore.ts
index b2078ee..765b244 100644
--- a/src/goTest/explore.ts
+++ b/src/goTest/explore.ts
@@ -75,7 +75,7 @@
 				}
 
 				try {
-					await inst.profiler.showProfiles(item);
+					await inst.profiler.show(item);
 				} catch (error) {
 					const m = 'Failed to open profiles';
 					outputChannel.appendLine(`${m}: ${error}`);
@@ -105,7 +105,26 @@
 					return;
 				}
 
-				await inst.profiler.showProfiles(item);
+				await inst.profiler.show(item);
+			})
+		);
+
+		context.subscriptions.push(
+			vscode.commands.registerCommand('go.test.deleteProfile', async (file) => {
+				if (!file) {
+					await vscode.window.showErrorMessage('No profile selected');
+					return;
+				}
+
+				try {
+					await inst.profiler.delete(file);
+				} catch (error) {
+					const m = 'Failed to delete profile';
+					outputChannel.appendLine(`${m}: ${error}`);
+					outputChannel.show();
+					await vscode.window.showErrorMessage(m);
+					return;
+				}
 			})
 		);
 
diff --git a/src/goTest/profile.ts b/src/goTest/profile.ts
index 876dda7..f507bba 100644
--- a/src/goTest/profile.ts
+++ b/src/goTest/profile.ts
@@ -1,3 +1,4 @@
+/* eslint-disable node/no-unsupported-features/node-builtins */
 /*---------------------------------------------------------
  * Copyright 2021 The Go Authors. All rights reserved.
  * Licensed under the MIT License. See LICENSE in the project root for license information.
@@ -11,16 +12,30 @@
 	TreeDataProvider,
 	TreeItem,
 	TreeItemCollapsibleState,
-	Uri
+	Uri,
+	ViewColumn
 } from 'vscode';
 import vscode = require('vscode');
-import { getTempFilePath } from '../util';
+import { promises as fs } from 'fs';
+import { ChildProcess, spawn } from 'child_process';
+import { getBinPath, getTempFilePath } from '../util';
 import { GoTestResolver } from './resolve';
+import { killProcessTree } from '../utils/processUtils';
+import { correctBinname } from '../utils/pathUtils';
 
 export type ProfilingOptions = { kind?: Kind['id'] };
 
 const optionsMemento = 'testProfilingOptions';
 const defaultOptions: ProfilingOptions = { kind: 'cpu' };
+const pprofProcesses = new Set<ChildProcess>();
+
+export function killRunningPprof() {
+	return new Promise<boolean>((resolve) => {
+		pprofProcesses.forEach((proc) => killProcessTree(proc));
+		pprofProcesses.clear();
+		resolve(true);
+	});
+}
 
 export class GoTestProfiler {
 	public readonly view = new ProfileTreeDataProvider(this);
@@ -41,9 +56,8 @@
 		const kind = Kind.get(options.kind);
 		if (!kind) return [];
 
-		const flags = [];
 		const run = new File(kind, item);
-		flags.push(run.flag);
+		const flags = [...run.flags];
 		if (this.runs.has(item.id)) this.runs.get(item.id).unshift(run);
 		else this.runs.set(item.id, [run]);
 		return flags;
@@ -54,7 +68,7 @@
 		vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.runs.keys()));
 		vscode.commands.executeCommand('setContext', 'go.hasProfiles', this.runs.size > 0);
 
-		this.view.didRun();
+		this.view.fireDidChange();
 	}
 
 	hasProfileFor(id: string): boolean {
@@ -75,7 +89,23 @@
 		};
 	}
 
-	async showProfiles(item: TestItem) {
+	async delete(file: File) {
+		await file.delete();
+
+		const runs = this.runs.get(file.target.id);
+		if (!runs) return;
+
+		const i = runs.findIndex((x) => x === file);
+		if (i < 0) return;
+
+		runs.splice(i, 1);
+		if (runs.length === 0) {
+			this.runs.delete(file.target.id);
+		}
+		this.view.fireDidChange();
+	}
+
+	async show(item: TestItem) {
 		const { query: kind, fragment: name } = Uri.parse(item.id);
 		if (kind !== 'test' && kind !== 'benchmark' && kind !== 'example') {
 			await vscode.window.showErrorMessage('Selected item is not a test, benchmark, or example');
@@ -110,6 +140,85 @@
 	}
 }
 
+async function show(profile: string) {
+	const foundDot = await new Promise<boolean>((resolve, reject) => {
+		const proc = spawn(correctBinname('dot'), ['-V']);
+
+		proc.on('error', (err) => {
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			if ((err as any).code === 'ENOENT') resolve(false);
+			else reject(err);
+		});
+
+		proc.on('exit', (code, signal) => {
+			if (signal) reject(new Error(`Received signal ${signal}`));
+			else if (code) reject(new Error(`Exited with code ${code}`));
+			else resolve(true);
+		});
+	});
+	if (!foundDot) {
+		const r = await vscode.window.showErrorMessage(
+			'Failed to execute dot. Is Graphviz installed?',
+			'Open graphviz.org'
+		);
+		if (r) await vscode.env.openExternal(vscode.Uri.parse('https://graphviz.org/'));
+		return;
+	}
+
+	const proc = spawn(getBinPath('go'), ['tool', 'pprof', '-http=:', '-no_browser', profile]);
+	pprofProcesses.add(proc);
+
+	const port = await new Promise<string>((resolve, reject) => {
+		proc.on('error', (err) => {
+			pprofProcesses.delete(proc);
+			reject(err);
+		});
+
+		proc.on('exit', (code, signal) => {
+			pprofProcesses.delete(proc);
+			reject(signal || code);
+		});
+
+		let stderr = '';
+		function captureStdout(b: Buffer) {
+			stderr += b.toString('utf-8');
+
+			const m = stderr.match(/^Serving web UI on http:\/\/localhost:(?<port>\d+)\n/);
+			if (!m) return;
+
+			resolve(m.groups.port);
+			proc.stdout.off('data', captureStdout);
+		}
+
+		proc.stderr.on('data', captureStdout);
+	});
+
+	const panel = vscode.window.createWebviewPanel('go.profile', 'Profile', ViewColumn.Active);
+	panel.webview.options = { enableScripts: true };
+	panel.webview.html = `<html>
+		<head>
+			<style>
+				body {
+					padding: 0;
+					background: white;
+					overflow: hidden;
+				}
+
+				iframe {
+					border: 0;
+					width: 100%;
+					height: 100vh;
+				}
+			</style>
+		</head>
+		<body>
+			<iframe src="http://localhost:${port}"></iframe>
+		</body>
+	</html>`;
+
+	panel.onDidDispose(() => killProcessTree(proc));
+}
+
 class Kind {
 	private static byID = new Map<string, Kind>();
 
@@ -143,24 +252,30 @@
 
 	constructor(public readonly kind: Kind, public readonly target: TestItem) {}
 
+	async delete() {
+		return Promise.all(
+			[getTempFilePath(`${this.name}.prof`), getTempFilePath(`${this.name}.test`)].map((file) => fs.unlink(file))
+		);
+	}
+
 	get label() {
 		return `${this.kind.label} @ ${this.when.toTimeString()}`;
 	}
 
 	get name() {
-		return `profile-${this.id}.${this.kind.id}.prof`;
+		return `profile-${this.id}.${this.kind.id}`;
 	}
 
-	get flag(): string {
-		return `${this.kind.flag}=${getTempFilePath(this.name)}`;
+	get flags(): string[] {
+		return [this.kind.flag, getTempFilePath(`${this.name}.prof`), '-o', getTempFilePath(`${this.name}.test`)];
 	}
 
-	get uri(): Uri {
-		return Uri.from({ scheme: 'go-tool-pprof', path: getTempFilePath(this.name) });
+	get uri() {
+		return Uri.file(getTempFilePath(`${this.name}.prof`));
 	}
 
 	async show() {
-		await vscode.window.showTextDocument(this.uri);
+		await show(getTempFilePath(`${this.name}.prof`));
 	}
 }
 
@@ -172,14 +287,14 @@
 
 	constructor(private readonly profiler: GoTestProfiler) {}
 
-	didRun() {
+	fireDidChange() {
 		this.didChangeTreeData.fire();
 	}
 
 	getTreeItem(element: TreeElement): TreeItem {
 		if (element instanceof File) {
 			const item = new TreeItem(element.label);
-			item.contextValue = 'file';
+			item.contextValue = 'go:test:file';
 			item.command = {
 				title: 'Open',
 				command: 'vscode.open',
@@ -189,7 +304,7 @@
 		}
 
 		const item = new TreeItem(element.label, TreeItemCollapsibleState.Collapsed);
-		item.contextValue = 'test';
+		item.contextValue = 'go:test:test';
 		const options: TextDocumentShowOptions = {
 			preserveFocus: false,
 			selection: new Range(element.range.start, element.range.start)
diff --git a/src/goToolPprof.ts b/src/goToolPprof.ts
deleted file mode 100644
index d947006..0000000
--- a/src/goToolPprof.ts
+++ /dev/null
@@ -1,34 +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 { execFile } from 'child_process';
-import { window, CancellationToken, TextDocumentContentProvider, Uri } from 'vscode';
-import { outputChannel } from './goStatus';
-import { getBinPath } from './util';
-
-export class ProfileDocumentContentProvider implements TextDocumentContentProvider {
-	provideTextDocumentContent(uri: Uri, token: CancellationToken): Promise<string | undefined> {
-		return this.pprof(uri, token);
-	}
-
-	private pprof(uri: Uri, token: CancellationToken) {
-		const goBin = getBinPath('go');
-		return new Promise<string | undefined>((resolve) => {
-			const cp = execFile(goBin, ['tool', 'pprof', '-tree', uri.fsPath], async (err, stdout, stderr) => {
-				if (err || stderr) {
-					const m = 'Failed to execute `go tool pprof`';
-					if (err) outputChannel.appendLine(`${m}: ${err}`);
-					else outputChannel.append(`${m}:\n${stderr}`);
-					outputChannel.show();
-					await window.showErrorMessage(m);
-					resolve(void 0);
-				} else {
-					resolve(stdout);
-				}
-			});
-
-			token?.onCancellationRequested(() => cp.kill());
-		});
-	}
-}
diff --git a/test/integration/goTest.run.test.ts b/test/integration/goTest.run.test.ts
index 5db5448..56dc52c 100644
--- a/test/integration/goTest.run.test.ts
+++ b/test/integration/goTest.run.test.ts
@@ -56,7 +56,7 @@
 				'Failed to execute `go test`'
 			);
 			assert.strictEqual(stub.callCount, 1, 'expected one call to goTest');
-			assert(stub.lastCall.args[0].flags.some((x) => x.startsWith('--cpuprofile=')));
+			assert(stub.lastCall.args[0].flags.some((x) => x === '--cpuprofile'));
 			assert(testExplorer.profiler.hasProfileFor(test.id), 'Did not create profile for test');
 		});