src/goTest: add single-test debugging

Change-Id: I2dac932cfe0854d57157353d5de6eae89158d87d
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/348571
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Suzy Mueller <suzmue@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
diff --git a/src/goTest.ts b/src/goTest.ts
index 748ff8c..09875ad 100644
--- a/src/goTest.ts
+++ b/src/goTest.ts
@@ -207,14 +207,23 @@
 
 /**
  * Debugs the test at cursor.
+ * @param editorOrDocument The text document (or editor) that defines the test.
+ * @param testFunctionName The name of the test function.
+ * @param testFunctions All test function symbols defined by the document.
+ * @param goConfig Go configuration, i.e. flags, tags, environment, etc.
+ * @param sessionID If specified, `sessionID` is added to the debug
+ * configuration and can be used to identify the debug session.
+ * @returns Whether the debug session was successfully started.
  */
-async function debugTestAtCursor(
-	editor: vscode.TextEditor,
+export async function debugTestAtCursor(
+	editorOrDocument: vscode.TextEditor | vscode.TextDocument,
 	testFunctionName: string,
 	testFunctions: vscode.DocumentSymbol[],
-	goConfig: vscode.WorkspaceConfiguration
+	goConfig: vscode.WorkspaceConfiguration,
+	sessionID?: string
 ) {
-	const args = getTestFunctionDebugArgs(editor.document, testFunctionName, testFunctions);
+	const doc = 'document' in editorOrDocument ? editorOrDocument.document : editorOrDocument;
+	const args = getTestFunctionDebugArgs(doc, testFunctionName, testFunctions);
 	const tags = getTestTags(goConfig);
 	const buildFlags = tags ? ['-tags', tags] : [];
 	const flagsFromConfig = getTestFlags(goConfig);
@@ -230,17 +239,18 @@
 		}
 		buildFlags.push(x);
 	});
-	const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
+	const workspaceFolder = vscode.workspace.getWorkspaceFolder(doc.uri);
 	const debugConfig: vscode.DebugConfiguration = {
 		name: 'Debug Test',
 		type: 'go',
 		request: 'launch',
 		mode: 'test',
-		program: path.dirname(editor.document.fileName),
+		program: path.dirname(doc.fileName),
 		env: goConfig.get('testEnvVars', {}),
 		envFile: goConfig.get('testEnvFile'),
 		args,
-		buildFlags: buildFlags.join(' ')
+		buildFlags: buildFlags.join(' '),
+		sessionID
 	};
 	lastDebugConfig = debugConfig;
 	lastDebugWorkspaceFolder = workspaceFolder;
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
index a15b66d..a004be2 100644
--- a/src/goTest/run.ts
+++ b/src/goTest/run.ts
@@ -4,6 +4,7 @@
  *--------------------------------------------------------*/
 import {
 	CancellationToken,
+	DebugSession,
 	Location,
 	OutputChannel,
 	Position,
@@ -20,10 +21,13 @@
 import { outputChannel } from '../goStatus';
 import { isModSupported } from '../goModules';
 import { getGoConfig } from '../config';
-import { getTestFlags, goTest, GoTestOutput } from '../testUtils';
+import { getBenchmarkFunctions, getTestFlags, getTestFunctions, goTest, GoTestOutput } from '../testUtils';
 import { GoTestResolver } from './resolve';
 import { dispose, forEachAsync, GoTest, Workspace } from './utils';
 import { GoTestProfiler, ProfilingOptions } from './profile';
+import { debugTestAtCursor } from '../goTest';
+
+let debugSessionID = 0;
 
 type CollectedTest = { item: TestItem; explicitlyIncluded?: boolean };
 
@@ -90,6 +94,21 @@
 			true
 		);
 
+		ctrl.createRunProfile(
+			'Go (Debug)',
+			TestRunProfileKind.Debug,
+			async (request, token) => {
+				try {
+					await this.debug(request, token);
+				} catch (error) {
+					const m = 'Failed to debug tests';
+					outputChannel.appendLine(`${m}: ${error}`);
+					await vscode.window.showErrorMessage(m);
+				}
+			},
+			true
+		);
+
 		const pprof = ctrl.createRunProfile(
 			'Go (Profile)',
 			TestRunProfileKind.Run,
@@ -112,6 +131,91 @@
 		};
 	}
 
+	async debug(request: TestRunRequest, token?: CancellationToken) {
+		if (!request.include) {
+			await vscode.window.showErrorMessage('The Go test explorer does not support debugging multiple tests');
+			return;
+		}
+
+		const collected = new Map<TestItem, CollectedTest[]>();
+		const files = new Set<TestItem>();
+		for (const item of request.include) {
+			await this.collectTests(item, true, request.exclude || [], collected, files);
+		}
+
+		const tests = Array.from(collected.values()).reduce((a, b) => a.concat(b), []);
+		if (tests.length > 1) {
+			await vscode.window.showErrorMessage('The Go test explorer does not support debugging multiple tests');
+			return;
+		}
+
+		const test = tests[0].item;
+		const { kind, name } = GoTest.parseId(test.id);
+		const doc = await vscode.workspace.openTextDocument(test.uri);
+		await doc.save();
+
+		const goConfig = getGoConfig(test.uri);
+		const getFunctions = kind === 'benchmark' ? getBenchmarkFunctions : getTestFunctions;
+		const testFunctions = await getFunctions(doc, token);
+
+		// TODO Can we get output from the debug session, in order to check for
+		// run/pass/fail events?
+
+		const id = `debug #${debugSessionID++} ${name}`;
+		const subs: vscode.Disposable[] = [];
+		const sessionPromise = new Promise<DebugSession>((resolve) => {
+			subs.push(
+				vscode.debug.onDidStartDebugSession((s) => {
+					if (s.configuration.sessionID === id) {
+						resolve(s);
+						subs.forEach((s) => s.dispose());
+					}
+				})
+			);
+
+			if (token) {
+				subs.push(
+					token.onCancellationRequested(() => {
+						resolve(null);
+						subs.forEach((s) => s.dispose());
+					})
+				);
+			}
+		});
+
+		const run = this.ctrl.createTestRun(request, `Debug ${name}`);
+		const started = await debugTestAtCursor(doc, name, testFunctions, goConfig, id);
+		if (!started) {
+			subs.forEach((s) => s.dispose());
+			run.end();
+			return;
+		}
+
+		const session = await sessionPromise;
+		if (!session) {
+			run.end();
+			return;
+		}
+
+		token.onCancellationRequested(() => vscode.debug.stopDebugging(session));
+
+		await new Promise<void>((resolve) => {
+			const sub = vscode.debug.onDidTerminateDebugSession(didTerminateSession);
+
+			token?.onCancellationRequested(() => {
+				resolve();
+				sub.dispose();
+			});
+
+			function didTerminateSession(s: DebugSession) {
+				if (s.id !== session.id) return;
+				resolve();
+				sub.dispose();
+			}
+		});
+		run.end();
+	}
+
 	// Execute tests - TestController.runTest callback
 	async run(request: TestRunRequest, token?: CancellationToken, options: ProfilingOptions = {}): Promise<boolean> {
 		const collected = new Map<TestItem, CollectedTest[]>();