blob: 3c33ee948472ae77a43226a029671bf6f0dedf1d [file] [log] [blame]
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------*/
import cp = require('child_process');
import path = require('path');
import util = require('util');
import vscode = require('vscode');
import { applyCodeCoverageToAllEditors } from './goCover';
import { getCurrentPackage } from './goModules';
import { GoDocumentSymbolProvider } from './goOutline';
import { getNonVendorPackages } from './goPackages';
import { envPath, getCurrentGoWorkspaceFromGOPATH, parseEnvFile } from './goPath';
import {
getBinPath,
getCurrentGoPath,
getGoVersion,
getTempFilePath,
getToolsEnvVars,
killTree,
LineBuffer,
resolvePath
} from './util';
const outputChannel = vscode.window.createOutputChannel('Go Tests');
const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
statusBarItem.command = 'go.test.cancel';
statusBarItem.text = '$(x) Cancel Running Tests';
/**
* testProcesses holds a list of currently running test processes.
*/
const runningTestProcesses: cp.ChildProcess[] = [];
const testFuncRegex = /^Test.*|^Example.*/;
const testMethodRegex = /^\(([^)]+)\)\.(Test.*)$/;
const benchmarkRegex = /^Benchmark.*/;
/**
* Input to goTest.
*/
export interface TestConfig {
/**
* The working directory for `go test`.
*/
dir: string;
/**
* Configuration for the Go extension
*/
goConfig: vscode.WorkspaceConfiguration;
/**
* Test flags to override the testFlags and buildFlags from goConfig.
*/
flags: string[];
/**
* Specific function names to test.
*/
functions?: string[];
/**
* Test was not requested explicitly. The output should not appear in the UI.
*/
background?: boolean;
/**
* Run all tests from all sub directories under `dir`
*/
includeSubDirectories?: boolean;
/**
* Whether this is a benchmark.
*/
isBenchmark?: boolean;
/**
* Whether the tests are being run in a project that uses Go modules
*/
isMod?: boolean;
/**
* Whether code coverage should be generated and applied.
*/
applyCodeCoverage?: boolean;
}
export function getTestEnvVars(config: vscode.WorkspaceConfiguration): any {
const envVars = getToolsEnvVars();
const testEnvConfig = config['testEnvVars'] || {};
let fileEnv: { [key: string]: any } = {};
let testEnvFile = config['testEnvFile'];
if (testEnvFile) {
testEnvFile = resolvePath(testEnvFile);
try {
fileEnv = parseEnvFile(testEnvFile);
} catch (e) {
console.log(e);
}
}
Object.keys(fileEnv).forEach(
(key) => (envVars[key] = typeof fileEnv[key] === 'string' ? resolvePath(fileEnv[key]) : fileEnv[key])
);
Object.keys(testEnvConfig).forEach(
(key) =>
(envVars[key] =
typeof testEnvConfig[key] === 'string' ? resolvePath(testEnvConfig[key]) : testEnvConfig[key])
);
return envVars;
}
export function getTestFlags(goConfig: vscode.WorkspaceConfiguration, args?: any): string[] {
let testFlags: string[] = goConfig['testFlags'] || goConfig['buildFlags'] || [];
testFlags = testFlags.map((x) => resolvePath(x)); // Use copy of the flags, dont pass the actual object from config
return args && args.hasOwnProperty('flags') && Array.isArray(args['flags']) ? args['flags'] : testFlags;
}
export function getTestTags(goConfig: vscode.WorkspaceConfiguration): string {
return goConfig['testTags'] !== null ? goConfig['testTags'] : goConfig['buildTags'];
}
/**
* Returns all Go unit test functions in the given source file.
*
* @param the URI of a Go source file.
* @return test function symbols for the source file.
*/
export function getTestFunctions(
doc: vscode.TextDocument,
token: vscode.CancellationToken
): Thenable<vscode.DocumentSymbol[]> {
const documentSymbolProvider = new GoDocumentSymbolProvider(true);
return documentSymbolProvider
.provideDocumentSymbols(doc, token)
.then((symbols) => symbols[0].children)
.then((symbols) => {
const testify = symbols.some(
(sym) => sym.kind === vscode.SymbolKind.Namespace && sym.name === '"github.com/stretchr/testify/suite"'
);
return symbols.filter(
(sym) =>
sym.kind === vscode.SymbolKind.Function &&
(testFuncRegex.test(sym.name) || (testify && testMethodRegex.test(sym.name)))
);
});
}
/**
* Extracts test method name of a suite test function.
* For example a symbol with name "(*testSuite).TestMethod" will return "TestMethod".
*
* @param symbolName Symbol Name to extract method name from.
*/
export function extractInstanceTestName(symbolName: string): string {
const match = symbolName.match(testMethodRegex);
if (!match || match.length !== 3) {
return null;
}
return match[2];
}
/**
* Gets the appropriate debug arguments for a debug session on a test function.
* @param document The document containing the tests
* @param testFunctionName The test function to get the debug args
* @param testFunctions The test functions found in the document
*/
export function getTestFunctionDebugArgs(
document: vscode.TextDocument,
testFunctionName: string,
testFunctions: vscode.DocumentSymbol[]
): string[] {
if (benchmarkRegex.test(testFunctionName)) {
return ['-test.bench', '^' + testFunctionName + '$', '-test.run', 'a^'];
}
const instanceMethod = extractInstanceTestName(testFunctionName);
if (instanceMethod) {
const testFns = findAllTestSuiteRuns(document, testFunctions);
const testSuiteRuns = ['-test.run', `^${testFns.map((t) => t.name).join('|')}$`];
const testSuiteTests = ['-testify.m', `^${instanceMethod}$`];
return [...testSuiteRuns, ...testSuiteTests];
} else {
return ['-test.run', `^${testFunctionName}$`];
}
}
/**
* Finds test methods containing "suite.Run()" call.
*
* @param doc Editor document
* @param allTests All test functions
*/
export function findAllTestSuiteRuns(
doc: vscode.TextDocument,
allTests: vscode.DocumentSymbol[]
): vscode.DocumentSymbol[] {
// get non-instance test functions
const testFunctions = allTests.filter((t) => !testMethodRegex.test(t.name));
// filter further to ones containing suite.Run()
return testFunctions.filter((t) => doc.getText(t.range).includes('suite.Run('));
}
/**
* Returns all Benchmark functions in the given source file.
*
* @param the URI of a Go source file.
* @return benchmark function symbols for the source file.
*/
export function getBenchmarkFunctions(
doc: vscode.TextDocument,
token: vscode.CancellationToken
): Thenable<vscode.DocumentSymbol[]> {
const documentSymbolProvider = new GoDocumentSymbolProvider();
return documentSymbolProvider
.provideDocumentSymbols(doc, token)
.then((symbols) => symbols[0].children)
.then((symbols) =>
symbols.filter((sym) => sym.kind === vscode.SymbolKind.Function && benchmarkRegex.test(sym.name))
);
}
/**
* Runs go test and presents the output in the 'Go' channel.
*
* @param goConfig Configuration for the Go extension.
*/
export async function goTest(testconfig: TestConfig): Promise<boolean> {
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
// lose valuable output.
if (runningTestProcesses.length < 1) {
outputChannel.clear();
}
if (!testconfig.background) {
outputChannel.show(true);
}
const testTags: string = getTestTags(testconfig.goConfig);
const args: Array<string> = ['test'];
const testType: string = testconfig.isBenchmark ? 'Benchmarks' : 'Tests';
if (testconfig.isBenchmark) {
args.push('-benchmem', '-run=^$');
} else {
args.push('-timeout', testconfig.goConfig['testTimeout']);
if (testconfig.applyCodeCoverage) {
args.push('-coverprofile=' + tmpCoverPath);
}
}
if (testTags && testconfig.flags.indexOf('-tags') === -1) {
args.push('-tags', testTags);
}
const testEnvVars = getTestEnvVars(testconfig.goConfig);
const goRuntimePath = getBinPath('go');
if (!goRuntimePath) {
vscode.window.showErrorMessage(
`Failed to run "go test" as the "go" binary cannot be found in either GOROOT(${process.env['GOROOT']}) or PATH(${envPath})`
);
return Promise.resolve();
}
const currentGoWorkspace = testconfig.isMod
? ''
: getCurrentGoWorkspaceFromGOPATH(getCurrentGoPath(), testconfig.dir);
let targets = targetArgs(testconfig);
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 {
pkgMapPromise = getGoVersion().then((goVersion) => {
if (goVersion.gt('1.8')) {
targets = ['./...'];
return null; // We dont need mapping, as we can derive the absolute paths from package path
}
return getNonVendorPackages(testconfig.dir).then((pkgMap) => {
targets = Array.from(pkgMap.keys());
return pkgMap; // We need the individual package paths to pass to `go test`
});
});
}
}
Promise.all([pkgMapPromise, getCurrentPackagePromise]).then(
([pkgMap, currentPackage]) => {
if (!pkgMap) {
pkgMap = new Map<string, string>();
}
// Use the package name to be in the args to enable running tests in symlinked directories
if (!testconfig.includeSubDirectories && currentPackage) {
targets.splice(0, 0, currentPackage);
}
const outTargets = args.slice(0);
if (targets.length > 4) {
outTargets.push('<long arguments omitted>');
} else {
outTargets.push(...targets);
}
args.push(...targets);
// ensure that user provided flags are appended last (allow use of -args ...)
// ignore user provided -run flag if we are already using it
if (args.indexOf('-run') > -1) {
removeRunFlag(testconfig.flags);
}
args.push(...testconfig.flags);
outTargets.push(...testconfig.flags);
outputChannel.appendLine(['Running tool:', goRuntimePath, ...outTargets].join(' '));
outputChannel.appendLine('');
const tp = cp.spawn(goRuntimePath, args, { env: testEnvVars, cwd: testconfig.dir });
const outBuf = new LineBuffer();
const errBuf = new LineBuffer();
// 1=ok/FAIL, 2=package, 3=time/(cached)
const packageResultLineRE = /^(ok|FAIL)[ \t]+(.+?)[ \t]+([0-9\.]+s|\(cached\))/;
const lineWithErrorRE = /^(\t|\s\s\s\s)\S/;
const testResultLines: string[] = [];
const processTestResultLine = (line: string) => {
testResultLines.push(line);
const result = line.match(packageResultLineRE);
if (result && (pkgMap.has(result[2]) || currentGoWorkspace)) {
const hasTestFailed = line.startsWith('FAIL');
const packageNameArr = result[2].split('/');
const baseDir = pkgMap.get(result[2]) || path.join(currentGoWorkspace, ...packageNameArr);
testResultLines.forEach((testResultLine) => {
if (hasTestFailed && lineWithErrorRE.test(testResultLine)) {
outputChannel.appendLine(expandFilePathInOutput(testResultLine, baseDir));
} else {
outputChannel.appendLine(testResultLine);
}
});
testResultLines.splice(0);
}
};
// go test emits test results on stdout, which contain file names relative to the package under test
outBuf.onLine((line) => processTestResultLine(line));
outBuf.onDone((last) => {
if (last) {
processTestResultLine(last);
}
// If there are any remaining test result lines, emit them to the output channel.
if (testResultLines.length > 0) {
testResultLines.forEach((line) => outputChannel.appendLine(line));
}
});
// go test emits build errors on stderr, which contain paths relative to the cwd
errBuf.onLine((line) => outputChannel.appendLine(expandFilePathInOutput(line, testconfig.dir)));
errBuf.onDone((last) => last && outputChannel.appendLine(expandFilePathInOutput(last, testconfig.dir)));
tp.stdout.on('data', (chunk) => outBuf.append(chunk.toString()));
tp.stderr.on('data', (chunk) => errBuf.append(chunk.toString()));
statusBarItem.show();
tp.on('close', (code, signal) => {
outBuf.done();
errBuf.done();
const index = runningTestProcesses.indexOf(tp, 0);
if (index > -1) {
runningTestProcesses.splice(index, 1);
}
if (!runningTestProcesses.length) {
statusBarItem.hide();
}
resolve(code === 0);
});
runningTestProcesses.push(tp);
},
(err) => {
outputChannel.appendLine(`Error: ${testType} failed.`);
outputChannel.appendLine(err);
resolve(false);
}
);
});
if (testconfig.applyCodeCoverage) {
await applyCodeCoverageToAllEditors(tmpCoverPath, testconfig.dir);
}
return testResult;
}
/**
* Reveals the output channel in the UI.
*/
export function showTestOutput() {
outputChannel.show(true);
}
/**
* Iterates the list of currently running test processes and kills them all.
*/
export function cancelRunningTests(): Thenable<boolean> {
return new Promise<boolean>((resolve, reject) => {
runningTestProcesses.forEach((tp) => {
killTree(tp.pid);
});
// All processes are now dead. Empty the array to prepare for the next run.
runningTestProcesses.splice(0, runningTestProcesses.length);
resolve(true);
});
}
function expandFilePathInOutput(output: string, cwd: string): string {
const lines = output.split('\n');
for (let i = 0; i < lines.length; i++) {
const matches = lines[i].match(/^\s*(.+.go):(\d+):/);
if (matches && matches[1] && !path.isAbsolute(matches[1])) {
lines[i] = lines[i].replace(matches[1], path.join(cwd, matches[1]));
}
}
return lines.join('\n');
}
/**
* Get the test target arguments.
*
* @param testconfig Configuration for the Go extension.
*/
function targetArgs(testconfig: TestConfig): Array<string> {
let params: string[] = [];
if (testconfig.functions) {
if (testconfig.isBenchmark) {
params = ['-bench', util.format('^(%s)$', testconfig.functions.join('|'))];
} else {
let testFunctions = testconfig.functions;
let testifyMethods = testFunctions.filter((fn) => testMethodRegex.test(fn));
if (testifyMethods.length > 0) {
// filter out testify methods
testFunctions = testFunctions.filter((fn) => !testMethodRegex.test(fn));
testifyMethods = testifyMethods.map(extractInstanceTestName);
}
// we might skip the '-run' param when running only testify methods, which will result
// in running all the test methods, but one of them should call testify's `suite.Run(...)`
// which will result in the correct thing to happen
if (testFunctions.length > 0) {
params = params.concat(['-run', util.format('^(%s)$', testFunctions.join('|'))]);
}
if (testifyMethods.length > 0) {
params = params.concat(['-testify.m', util.format('^(%s)$', testifyMethods.join('|'))]);
}
}
return params;
}
if (testconfig.isBenchmark) {
params = ['-bench', '.'];
}
return params;
}
function removeRunFlag(flags: string[]): void {
const index: number = flags.indexOf('-run');
if (index !== -1) {
flags.splice(index, 2);
}
}