src/testUtils: fix filename rewrites in modules mode test output

goTest processes failed test outputs and rewrites file names of
log message location with their absolute path names. That allows
VS Code to linkify the log message locations so users can easily
access the source location where the log message was produced.

This CL fixes this rewrite logic broken in modules mode.
When running tests in non-recursive mode, goTest forgot to map
the current package.

New tests for this rewrite logic were added. Since the test
output is printed in the output channel, we intercept it for testing
using the fake output channel.

Updates golang/vscode-go#316

Change-Id: I1e10c7bd63565c2e7f19576dea05e67b2e6bb96c
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/242539
Reviewed-by: Rebecca Stambler <rstambler@golang.org>
diff --git a/src/goPackages.ts b/src/goPackages.ts
index 182cd90..f072ef1 100644
--- a/src/goPackages.ts
+++ b/src/goPackages.ts
@@ -258,7 +258,8 @@
 /**
  * Returns mapping between import paths and folder paths for all packages under given folder (vendor will be excluded)
  */
-export function getNonVendorPackages(currentFolderPath: string): Promise<Map<string, string>> {
+export function getNonVendorPackages(
+	currentFolderPath: string, recursive: boolean = true): Promise<Map<string, string>> {
 	const goRuntimePath = getBinPath('go');
 	if (!goRuntimePath) {
 		console.warn(
@@ -267,9 +268,10 @@
 		return;
 	}
 	return new Promise<Map<string, string>>((resolve, reject) => {
+		const target = recursive ? './...' : '.';
 		const childProcess = cp.spawn(
 			goRuntimePath,
-			['list', '-f', 'ImportPath: {{.ImportPath}} FolderPath: {{.Dir}}', './...'],
+			['list', '-f', 'ImportPath: {{.ImportPath}} FolderPath: {{.Dir}}', target],
 			{ cwd: currentFolderPath, env: toolExecutionEnvironment() }
 		);
 		const chunks: any[] = [];
diff --git a/src/testUtils.ts b/src/testUtils.ts
index f384c39..fe7acfc 100644
--- a/src/testUtils.ts
+++ b/src/testUtils.ts
@@ -23,7 +23,7 @@
 import { envPath, getCurrentGoRoot, getCurrentGoWorkspaceFromGOPATH, parseEnvFile } from './utils/goPath';
 import {killProcessTree} from './utils/processUtils';
 
-const outputChannel = vscode.window.createOutputChannel('Go Tests');
+const testOutputChannel = vscode.window.createOutputChannel('Go Tests');
 const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
 statusBarItem.command = 'go.test.cancel';
 statusBarItem.text = '$(x) Cancel Running Tests';
@@ -77,6 +77,10 @@
 	 * Whether code coverage should be generated and applied.
 	 */
 	applyCodeCoverage?: boolean;
+	/**
+	 * Output channel for test output.
+	 */
+	outputChannel?: vscode.OutputChannel;
 }
 
 export function getTestEnvVars(config: vscode.WorkspaceConfiguration): any {
@@ -229,6 +233,10 @@
  * @param goConfig Configuration for the Go extension.
  */
 export async function goTest(testconfig: TestConfig): Promise<boolean> {
+	let outputChannel = testOutputChannel;
+	if (testconfig.outputChannel) {
+		outputChannel = testconfig.outputChannel;
+	}
 	const tmpCoverPath = getTempFilePath('go-code-cover');
 	const testResult = await new Promise<boolean>(async (resolve, reject) => {
 		// We do not want to clear it if tests are already running, as that could
@@ -267,23 +275,21 @@
 			return Promise.resolve();
 		}
 
-		const currentGoWorkspace = testconfig.isMod
-			? ''
-			: getCurrentGoWorkspaceFromGOPATH(getCurrentGoPath(), testconfig.dir);
-		let targets = targetArgs(testconfig);
+		let targets = testconfig.includeSubDirectories ? ['./...'] : targetArgs(testconfig);
+
+		let currentGoWorkspace = '';
 		let getCurrentPackagePromise = Promise.resolve('');
-		if (testconfig.isMod) {
-			getCurrentPackagePromise = getCurrentPackage(testconfig.dir);
-		} else if (currentGoWorkspace) {
-			getCurrentPackagePromise = Promise.resolve(testconfig.dir.substr(currentGoWorkspace.length + 1));
-		}
 		let pkgMapPromise: Promise<Map<string, string> | null> = Promise.resolve(null);
-		if (testconfig.includeSubDirectories) {
-			if (testconfig.isMod) {
-				targets = ['./...'];
-				// We need the mapping to get absolute paths for the files in the test output
-				pkgMapPromise = getNonVendorPackages(testconfig.dir);
-			} else {
+
+		if (testconfig.isMod) {
+			// We need the mapping to get absolute paths for the files in the test output.
+			pkgMapPromise = getNonVendorPackages(testconfig.dir, !!testconfig.includeSubDirectories);
+		} else {  // GOPATH mode
+			currentGoWorkspace = getCurrentGoWorkspaceFromGOPATH(getCurrentGoPath(), testconfig.dir);
+			if (currentGoWorkspace) {
+				getCurrentPackagePromise = Promise.resolve(testconfig.dir.substr(currentGoWorkspace.length + 1));
+			}
+			if (testconfig.includeSubDirectories) {
 				pkgMapPromise = getGoVersion().then((goVersion) => {
 					if (goVersion.gt('1.8')) {
 						targets = ['./...'];
@@ -332,7 +338,7 @@
 				const errBuf = new LineBuffer();
 
 				// 1=ok/FAIL, 2=package, 3=time/(cached)
-				const packageResultLineRE = /^(ok|FAIL)[ \t]+(.+?)[ \t]+([0-9\.]+s|\(cached\))/;
+				const packageResultLineRE = /^(ok|FAIL)\s+(\S+)\s+([0-9\.]+s|\(cached\))/;
 				const lineWithErrorRE = /^(\t|\s\s\s\s)\S/;
 				const testResultLines: string[] = [];
 
@@ -411,7 +417,7 @@
  * Reveals the output channel in the UI.
  */
 export function showTestOutput() {
-	outputChannel.show(true);
+	testOutputChannel.show(true);
 }
 
 /**
diff --git a/test/fixtures/goTestTest/a_test.go b/test/fixtures/goTestTest/a_test.go
new file mode 100644
index 0000000..66a943a
--- /dev/null
+++ b/test/fixtures/goTestTest/a_test.go
@@ -0,0 +1,10 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestA(t *testing.T) {
+	t.Log("log")
+	t.Errorf("error")
+}
diff --git a/test/fixtures/goTestTest/b/b_test.go b/test/fixtures/goTestTest/b/b_test.go
new file mode 100644
index 0000000..2240f6f
--- /dev/null
+++ b/test/fixtures/goTestTest/b/b_test.go
@@ -0,0 +1,8 @@
+package main
+
+import "testing"
+
+func TestB(t *testing.T) {
+	t.Log("log")
+	t.Error("error")
+}
diff --git a/test/fixtures/goTestTest/go.mod b/test/fixtures/goTestTest/go.mod
new file mode 100644
index 0000000..24872e7
--- /dev/null
+++ b/test/fixtures/goTestTest/go.mod
@@ -0,0 +1,3 @@
+module example.com/a
+
+go 1.14
diff --git a/test/integration/statusbar.test.ts b/test/integration/statusbar.test.ts
index 8838485..0420342 100644
--- a/test/integration/statusbar.test.ts
+++ b/test/integration/statusbar.test.ts
@@ -168,8 +168,9 @@
 		}
 	});
 
-	this.afterAll(() => {
+	this.afterAll(async () => {
 		ourutil.rmdirRecursive(tmpRoot);
+		await updateGoVarsFromConfig();
 	});
 
 	this.beforeEach(() => {
diff --git a/test/integration/test.test.ts b/test/integration/test.test.ts
new file mode 100644
index 0000000..c2ef69f
--- /dev/null
+++ b/test/integration/test.test.ts
@@ -0,0 +1,125 @@
+/*---------------------------------------------------------
+ * Copyright 2020 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+
+'use strict';
+
+import * as assert from 'assert';
+import fs = require('fs-extra');
+import os = require('os');
+import path = require('path');
+import sinon = require('sinon');
+import vscode = require('vscode');
+import { getTestFlags, goTest } from '../../src/testUtils';
+import { rmdirRecursive } from '../../src/util';
+
+suite('Test Go Test', function () {
+	this.timeout(10000);
+
+	const sourcePath = path.join(__dirname, '..', '..', '..', 'test', 'fixtures', 'goTestTest');
+
+	let tmpGopath: string;
+	let repoPath: string;
+
+	let previousEnv: any;
+
+	setup(() => {
+		previousEnv = Object.assign({}, process.env);
+	});
+
+	teardown(async () => {
+		process.env = previousEnv;
+		rmdirRecursive(tmpGopath);
+	});
+
+	function setupRepo(modulesMode: boolean) {
+		tmpGopath = fs.mkdtempSync(path.join(os.tmpdir(), 'go-test-test'));
+		fs.mkdirSync(path.join(tmpGopath, 'src'));
+		repoPath = path.join(tmpGopath, 'src', 'goTestTest');
+		fs.copySync(sourcePath, repoPath, {
+			recursive: true,
+			filter: (src: string): boolean => {
+				if (modulesMode) {
+					return true;
+				}
+				return path.basename(src) !== 'go.mod';  // skip go.mod file.
+			},
+		});
+		process.env.GOPATH = tmpGopath;
+	}
+
+	async function runTest(
+		input: { isMod: boolean, includeSubDirectories: boolean },
+		wantFiles: string[]) {
+
+		fs.copySync(sourcePath, repoPath, { recursive: true });
+
+		const config = Object.create(vscode.workspace.getConfiguration('go'));
+		const outputChannel = new FakeOutputChannel();
+
+		const testConfig = {
+			goConfig: config,
+			outputChannel,
+			dir: repoPath,
+			flags: getTestFlags(config),
+			isMod: input.isMod,
+			includeSubDirectories: input.includeSubDirectories,
+		};
+		try {
+			const result = await goTest(testConfig);
+			assert.equal(result, false);  // we expect tests to fail.
+		} catch (e) {
+			console.log('exception: ${e}');
+		}
+
+		const testOutput = outputChannel.toString();
+		for (const want of wantFiles) {
+			assert.ok(testOutput.includes(want), `\nFully resolved file path "${want}" not found in \n${testOutput}`);
+		}
+	}
+
+	test('resolves file names in logs (modules)', async () => {
+		setupRepo(true);
+		await runTest(
+			{ isMod: true, includeSubDirectories: true },
+			[path.join(repoPath, 'a_test.go'), path.join(repoPath, 'b', 'b_test.go')]);
+		await runTest(
+			{ isMod: true, includeSubDirectories: false },
+			[path.join(repoPath, 'a_test.go')]);
+	});
+
+	test('resolves file names in logs (GOPATH)', async () => {
+		setupRepo(true);
+		await runTest(
+			{ isMod: true, includeSubDirectories: true },
+			[path.join(repoPath, 'a_test.go'), path.join(repoPath, 'b', 'b_test.go')]);
+		await runTest(
+			{ isMod: true, includeSubDirectories: false },
+			[path.join(repoPath, 'a_test.go')]);
+	});
+});
+
+// FakeOutputChannel is a fake output channel used to buffer
+// the output of the tested language client in an in-memory
+// string array until cleared.
+class FakeOutputChannel implements vscode.OutputChannel {
+	public name = 'FakeOutputChannel';
+	public show = sinon.fake(); // no-empty
+	public hide = sinon.fake(); // no-empty
+	public dispose = sinon.fake();  // no-empty
+
+	private buf = [] as string[];
+
+	public append = (v: string) => this.enqueue(v);
+	public appendLine = (v: string) => this.enqueue(v);
+	public clear = () => { this.buf = []; };
+	public toString = () => {
+		return this.buf.join('\n');
+	}
+
+	private enqueue = (v: string) => {
+		if (this.buf.length > 1024) { this.buf.shift(); }
+		this.buf.push(v.trim());
+	}
+}