src/goTest: adjust file matching to handle interleaved stdout/stderr

Fixes golang/vscode-go#2549

Change-Id: Ib74397cc45ba8a37f8d3a8b579c745d5327d9401
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/455156
Reviewed-by: Jamal Carvalho <jamal@golang.org>
Auto-Submit: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
index 4147da9..7f32f4b 100644
--- a/src/goTest/run.ts
+++ b/src/goTest/run.ts
@@ -27,6 +27,7 @@
 import { GoTestProfiler, ProfilingOptions } from './profile';
 import { debugTestAtCursor } from '../goTest';
 import { GoExtensionContext } from '../context';
+import path = require('path');
 
 let debugSessionID = 0;
 
@@ -634,7 +635,7 @@
 				for (const { message, location } of messages) {
 					const loc = `${location?.uri}:${location?.range.start.line}`;
 					if (merged.has(loc)) {
-						merged.get(loc)!.message += '\n' + message;
+						merged.get(loc)!.message += '' + message;
 					} else {
 						merged.set(loc, { message, location });
 					}
@@ -660,6 +661,9 @@
 		}
 	}
 
+	// parseOutput returns build/test error messages associated with source locations.
+	// Location info is inferred heuristically by applying a simple pattern matching
+	// over the output strings from `go test -json` `output` type action events.
 	parseOutput(test: TestItem, output: string[]): TestMessage[] {
 		const messages: TestMessage[] = [];
 
@@ -679,11 +683,27 @@
 
 		let current: Location | undefined;
 		if (!test.uri) return messages;
-		const dir = Uri.joinPath(test.uri, '..');
+		const dir = Uri.joinPath(test.uri, '..').fsPath;
+		// TODO(hyangah): handle panic messages specially.
+
+		// Extract the location info from output message.
+		// This is not trivial since both the test output and any output/print
+		// from the tested program are reported as `output` type test events
+		// and not distinguishable. stdout/stderr output from the tested program
+		// makes this more trickier.
+		//
+		// Here we assume that test output messages are line-oriented, precede
+		// with a file name and line number, and end with new lines.
 		for (const line of output) {
-			const m = line.match(/^\s*(?<file>.*\.go):(?<line>\d+): ?(?<message>.*\n)$/);
+			// ^(?:.*\s+|\s*) - non-greedy match of any chars followed by a space or, a space.
+			// (?<file>\S+\.go):(?<line>\d+):  - gofile:line: followed by a space.
+			// (?<message>.\n)$ - all remaining message up to $.
+			const m = line.match(/^.*\s+(?<file>\S+\.go):(?<line>\d+): (?<message>.*\n)$/);
 			if (m?.groups) {
-				const file = Uri.joinPath(dir, m.groups.file);
+				const file =
+					m.groups.file && path.isAbsolute(m.groups.file)
+						? Uri.file(m.groups.file)
+						: Uri.file(path.join(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 });
diff --git a/test/integration/goTest.run.test.ts b/test/integration/goTest.run.test.ts
index 24b192a..7c8028a 100644
--- a/test/integration/goTest.run.test.ts
+++ b/test/integration/goTest.run.test.ts
@@ -1,7 +1,7 @@
 import assert = require('assert');
 import path = require('path');
 import sinon = require('sinon');
-import { Uri, workspace } from 'vscode';
+import { Range, TestItem, Uri, workspace } from 'vscode';
 import * as testUtils from '../../src/testUtils';
 import { forceDidOpenTextDocument } from './goTest.utils';
 import { GoTestExplorer } from '../../src/goTest/explore';
@@ -13,6 +13,46 @@
 
 	let testExplorer: GoTestExplorer;
 
+	suite('parseOutput', () => {
+		const ctx = MockExtensionContext.new();
+		suiteSetup(async () => {
+			testExplorer = GoTestExplorer.setup(ctx, {});
+		});
+		suiteTeardown(() => ctx.teardown());
+
+		function testParseOutput(output: string, expected: { file: string; line: number; msg: string }[]) {
+			const uri = Uri.parse('file:///path/to/mod/file.go');
+			const id = GoTest.id(uri, 'test', 'TestXXX');
+			const ti = { id, uri, range: new Range(1, 0, 100, 0) } as TestItem;
+			const testMsgs = testExplorer.runner.parseOutput(ti, [output]);
+			const got = testMsgs.map((m) => {
+				return {
+					file: m.location?.uri.fsPath,
+					line: m.location?.range.start.line,
+					msg: m.message
+				};
+			});
+			assert.strictEqual(JSON.stringify(got), JSON.stringify(expected));
+		}
+		test('no line info ', () => testParseOutput(' foo \n', []));
+		test('file path without preceding space', () => testParseOutput('file.go:7: foo\n', [])); // valid test message starts with a space.
+		test('valid test message format', () =>
+			testParseOutput('  file.go:7: foo\n', [{ file: '/path/to/mod/file.go', line: 6, msg: 'foo\n' }]));
+		test('message without ending newline', () =>
+			testParseOutput(
+				'  file.go:7: foo ', // valid test message contains a new line.
+				[]
+			));
+		test('user print message before test message', () =>
+			testParseOutput('random print file.go:8: foo\n', [
+				{ file: '/path/to/mod/file.go', line: 7, msg: 'foo\n' }
+			]));
+		test('multiple file locs in one line', () =>
+			testParseOutput('file.go:1: line1 . file.go:2: line2 \n', [
+				{ file: '/path/to/mod/file.go', line: 1, msg: 'line2 \n' }
+			]));
+	});
+
 	suite('Profile', () => {
 		const sandbox = sinon.createSandbox();
 		const ctx = MockExtensionContext.new();