UNPKG

vscodetestcover

Version:

A Mocha test runner with code coverage support for VSCode Extensions.

230 lines (199 loc) 9.58 kB
/* -------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import * as Mocha from 'mocha'; import * as iLibInstrument from 'istanbul-lib-instrument'; import * as iLibCoverage from 'istanbul-lib-coverage'; import * as iLibReport from 'istanbul-lib-report'; import * as iReports from 'istanbul-reports'; import * as iLibHook from 'istanbul-lib-hook'; import * as iLibSourceMaps from 'istanbul-lib-source-maps'; import * as glob from 'glob'; import decache from 'decache'; let mocha = new Mocha({ ui: 'tdd', useColors: true }); let testOptions: ITestCoverOptions; export function configure(mochaOpts: Mocha.MochaOptions, testOpts: ITestCoverOptions): void { mocha = new Mocha(mochaOpts); testOptions = testOpts; } class CoverageRunner { private coverageVar: string = '$$cov_' + new Date().getTime() + '$$'; private transformer: iLibHook.Transformer; private unhookRequire: () => void; private matchFn: any; private instrumenter: iLibInstrument.Instrumenter; constructor(private options: ITestRunnerOptions, private testsRoot: string, 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 = iLibInstrument.createInstrumenter({ coverageVariable: this.coverageVar }); let sourceRoot = path.join(this.testsRoot, this.options.relativeSourcePath); // Glob source files let srcFiles = glob.sync('**/**.js', { ignore: this.options.ignorePatterns, cwd: sourceRoot }); // Create a match function - taken from the run-with-cover.js in istanbul. let fileMap = {}; srcFiles.forEach(file => { let fullPath = path.join(sourceRoot, file); // Windows paths are (normally) case insensitive so convert to lower case // since sometimes the paths returned by the glob and the require hooks // are different casings. if (os.platform() === 'win32') { fullPath = fullPath.toLocaleLowerCase(); } 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 = function (file: string): boolean { // Windows paths are (normally) case insensitive so convert to lower case // since sometimes the paths returned by the glob and the require hooks // are different casings. if (os.platform() === 'win32') { file = file.toLocaleLowerCase(); } return 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 = (code: string, options: iLibHook.TransformerOptions): string => { // Try to find a .map file let map = undefined; try { map = JSON.parse(fs.readFileSync(`${options.filename}.map`).toString()); } catch (err) { // missing source map... } // Windows paths are (normally) case insensitive so convert to lower case // since sometimes the paths returned by the glob and the require hooks // are different casings. if (os.platform() === 'win32') { options.filename = options.filename.toLocaleLowerCase(); } return this.instrumenter.instrumentSync(code, options.filename, map); } let hookOpts = { verbose: false, extensions: ['.js'] }; this.unhookRequire = iLibHook.hookRequire(this.matchFn, this.transformer, hookOpts); // initialize the global variable to stop mocha from complaining about leaks global[this.coverageVar] = {}; } /** * 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 { this.unhookRequire(); let cov: any; if (typeof global[this.coverageVar] === 'undefined' || Object.keys(global[this.coverageVar]).length === 0) { 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'), { filename: 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.fileCoverage.s).forEach(key => { this.instrumenter.fileCoverage.s[key] = 0; }); cov[file] = this.instrumenter.fileCoverage; } }); // Convert the report to the mapped source files const mapStore = iLibSourceMaps.createSourceMapStore(); const coverageMap = mapStore.transformCoverage(iLibCoverage.createCoverageMap(global[this.coverageVar])).map; // TODO Allow config of reporting directory with let reportingDir = path.join(this.testsRoot, this.options.relativeCoverageDir); const context = iLibReport.createContext({ dir: reportingDir, coverageMap: coverageMap }); const tree = context.getTree('flat'); const reportTypes = (this.options.reports instanceof Array) ? this.options.reports : ['lcovonly']; // Cast to any since create only takes specific values but we don't know what the user passed in. // We'll let the lib error out if an invalid value is passed in. reportTypes.forEach(reportType => tree.visit(iReports.create(<any>reportType), context)); } } function readCoverOptions(testsRoot: string): ITestRunnerOptions { let coverConfigPath = path.join(testsRoot, testOptions.coverConfig); let coverConfig: ITestRunnerOptions = undefined; if (fs.existsSync(coverConfigPath)) { let configContent = fs.readFileSync(coverConfigPath).toString(); coverConfig = JSON.parse(configContent); } return coverConfig; } export function run(testsRoot: string, clb): any { // Read configuration for the coverage file let coverOptions: ITestRunnerOptions = readCoverOptions(testsRoot); if (coverOptions && coverOptions.enabled) { // Setup coverage pre-test, including post-test hook to report let coverageRunner = new CoverageRunner(coverOptions, testsRoot, clb); coverageRunner.setupCoverage(); mocha.suite.afterAll(() => { coverageRunner.reportCoverage(); }) } // Glob test files glob('**/**.test.js', { cwd: testsRoot }, function (error, files): any { if (error) { return clb(error); } try { // Fill into Mocha files.forEach(function (f): Mocha { return mocha.addFile(path.join(testsRoot, f)); }); // Run the tests mocha.run((failureCount) => { clb(undefined, failureCount); }); } catch (error) { return clb(error); } }); } export interface ITestCoverOptions { /** * Relative path to the coverage config file with configuration * options for the test runner options. */ coverConfig: string; } interface ITestRunnerOptions { enabled?: boolean; relativeCoverageDir: string; relativeSourcePath: string; ignorePatterns: string[]; reports?: string[]; verbose?: boolean; }