"use strict"; import * as fs from "fs"; import * as glob from "glob"; import * as paths from "path"; import istanbul = require("istanbul"); import remapIstanbul = require("remap-istanbul"); // tslint:disable-next-line:no-var-requires const tty = require("tty"); // tslint:disable-next-line:no-var-requires const Mocha = require("mocha"); // Linux: prevent a weird NPE when mocha on Linux requires the window size from the TTY // Since we are not running in a tty environment, we just implementt he method statically if (!tty.getWindowSize) { tty.getWindowSize = (): number[] => { return [80, 75]; }; } let mocha = new Mocha({ ui: "tdd", useColors: true, }); function configure(mochaOpts): void { mocha = new Mocha(mochaOpts); } exports.configure = configure; function _mkDirIfExists(dir: string): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } } function _readCoverOptions(testsRoot: string): ITestRunnerOptions { const coverConfigPath = paths.join(testsRoot, "..", "..", "coverconfig.json"); let coverConfig: ITestRunnerOptions; if (fs.existsSync(coverConfigPath)) { const configContent = fs.readFileSync(coverConfigPath, "utf-8"); coverConfig = JSON.parse(configContent); } return coverConfig; } function run(testsRoot, clb): any { // Enable source map support require("source-map-support").install(); // Read configuration for the coverage file const coverOptions: ITestRunnerOptions = _readCoverOptions(testsRoot); if (coverOptions && coverOptions.enabled) { // Setup coverage pre-test, including post-test hook to report const coverageRunner = new CoverageRunner(coverOptions, testsRoot, clb); coverageRunner.setupCoverage(); } // Glob test files glob("**/**.test.js", { cwd: testsRoot }, (error, files): any => { if (error) { return clb(error); } try { // Fill into Mocha files.forEach((f): Mocha => { return mocha.addFile(paths.join(testsRoot, f)); }); // Run the tests let failureCount = 0; mocha.run() .on("fail", (test, err): void => { failureCount++; }) .on("end", (): void => { clb(undefined, failureCount); }); } catch (error) { return clb(error); } }); } exports.run = run; interface ITestRunnerOptions { enabled?: boolean; relativeCoverageDir: string; relativeSourcePath: string; ignorePatterns: string[]; includePid?: boolean; reports?: string[]; verbose?: boolean; } class CoverageRunner { private coverageVar: string = "$$cov_" + new Date().getTime() + "$$"; private transformer: any = undefined; private matchFn: any = undefined; private instrumenter: any = undefined; constructor(private options: ITestRunnerOptions, private testsRoot: string, private endRunCallback: any) { if (!options.relativeSourcePath) { return endRunCallback("Error - relativeSourcePath must be defined for code coverage to work"); } } public setupCoverage(): void { // Set up Code Coverage, hooking require so that instrumented code is returned this.instrumenter = new istanbul.Instrumenter({ coverageVariable: this.coverageVar }); const sourceRoot = paths.join(this.testsRoot, this.options.relativeSourcePath); // Glob source files const srcFiles = glob.sync("**/**.js", { cwd: sourceRoot, ignore: this.options.ignorePatterns, }); // Create a match function - taken from the run-with-cover.js in istanbul. const decache = require("decache"); const fileMap = {}; srcFiles.forEach( (file) => { const fullPath = paths.join(sourceRoot, file); fileMap[fullPath] = true; // On Windows, extension is loaded pre-test hooks and this mean we lose // our chance to hook the Require call. In order to instrument the code // we have to decache the JS file so on next load it gets instrumented. // This doesn"t impact tests, but is a concern if we had some integration // tests that relied on VSCode accessing our module since there could be // some shared global state that we lose. decache(fullPath); }); this.matchFn = (file): boolean => fileMap[file]; this.matchFn.files = Object.keys(fileMap); // Hook up to the Require function so that when this is called, if any of our source files // are required, the instrumented version is pulled in instead. These instrumented versions // write to a global coverage variable with hit counts whenever they are accessed this.transformer = this.instrumenter.instrumentSync.bind(this.instrumenter); const hookOpts = { verbose: false, extensions: [".js"]}; istanbul.hook.hookRequire(this.matchFn, this.transformer, hookOpts); // initialize the global variable to stop mocha from complaining about leaks global[this.coverageVar] = {}; // Hook the process exit event to handle reporting // Only report coverage if the process is exiting successfully process.on("exit", (code) => { this.reportCoverage(); }); } /** * Writes a coverage report. * Note that as this is called in the process exit callback, all calls must be synchronous. * * @returns {void} * * @memberOf CoverageRunner */ public reportCoverage(): void { istanbul.hook.unhookRequire(); let cov: any; if (typeof global[this.coverageVar] === "undefined" || Object.keys(global[this.coverageVar]).length === 0) { // tslint:disable:no-console console.error("No coverage information was collected, exit without writing coverage information"); return; } else { cov = global[this.coverageVar]; } // TODO consider putting this under a conditional flag // Files that are not touched by code ran by the test runner is manually instrumented, to // illustrate the missing coverage. this.matchFn.files.forEach( (file) => { if (!cov[file]) { this.transformer(fs.readFileSync(file, "utf-8"), file); // When instrumenting the code, istanbul will give each FunctionDeclaration a value of 1 in // coverState.s, presumably to compensate for function hoisting. We need to reset this, as the function // was not hoisted, as it was never loaded. Object.keys(this.instrumenter.coverState.s).forEach( (key) => { this.instrumenter.coverState.s[key] = 0; }); cov[file] = this.instrumenter.coverState; } }); // TODO Allow config of reporting directory with const reportingDir = paths.join(this.testsRoot, this.options.relativeCoverageDir); const includePid = this.options.includePid; const pidExt = includePid ? ("-" + process.pid) : ""; const coverageFile = paths.resolve(reportingDir, "coverage" + pidExt + ".json"); // yes, do this again since some test runners could clean the dir initially created _mkDirIfExists(reportingDir); fs.writeFileSync(coverageFile, JSON.stringify(cov), "utf8"); const remappedCollector = remapIstanbul.remap(cov, {warn: (warning) => { // We expect some warnings as any JS file without a typescript mapping will cause this. // By default, we"ll skip printing these to the console as it clutters it up if (this.options.verbose) { console.warn(warning); } }}); const reporter = new istanbul.Reporter(undefined, reportingDir); const reportTypes = (this.options.reports instanceof Array) ? this.options.reports : ["lcov"]; reporter.addAll(reportTypes); reporter.write(remappedCollector, true, () => { console.log(`reports written to ${reportingDir}`); }); } }