UNPKG

one-double-zero

Version:

[![NPM version][npm-image]][npm-url] [![Build Status][build-image]][build-url] [![Coverage percentage][coveralls-image]][coveralls-url]

532 lines (505 loc) 23.8 kB
#!/usr/bin/env node import { createOneDoubleZero as createOneDoubleZero$1, unresolvable } from 'one-double-zero-core'; import { createOption, createCommand, createArgument } from 'commander'; import { dirname, resolve } from 'path/posix'; import { basename } from 'path'; import * as TOML from 'toml'; import { create } from 'istanbul-reports'; import { createContext } from 'istanbul-lib-report'; import { mergeProcessCovs } from '@bcoe/v8-coverage'; import { globSync } from 'glob'; import findUp from 'find-up'; import { foregroundChild } from 'foreground-child'; import * as Console from 'node:console'; import * as FS from 'node:fs'; import * as Process from 'node:process'; import * as Path from 'node:path'; /** * Creates and returns an instance of One Double Zero. * * @param logInfo A function executed by the created instance whenever it needs to log some information * @param readFile A function executed by the created instance whenever it needs to read the content of a file * @return The newly created instance of One Double Zero */ const createOneDoubleZero = (logInfo, readFile) => { const oneDoubleZeroCore = createOneDoubleZero$1(logInfo, readFile); const report = async (coverageMap, reporters, outputDirectory, watermarks) => { const context = { outputDirectory, coverageMap, watermarks }; for (const reporter of reporters) { reporter.execute(context); } }; return { ...oneDoubleZeroCore, report }; }; const defaultAppend = false; const defaultCoverageDirectory = '.odz_output/.coverage'; const defaultIgnoreUnhitSources = false; const defaultExcludedSources = []; const defaultLogLevel = "info"; const defaultPerFile = false; const defaultReporterOptions = {}; const defaultReporter = "text"; const defaultReportsDirectory = '.odz_output'; const defaultSkipFull = false; const defaultSources = ['**/*.{js,mjs,jsx,ts,mts,tsx}']; const defaultThreshold = 100; const defaultWatermark = [80, 100]; const getConfiguration = async (host) => { const configurationFilePath = host.findUp(['.odzrc.json', '.odzrc.toml'], host.currentWorkingDirectory); let partialConfiguration = {}; if (configurationFilePath) { const configurationFileName = basename(configurationFilePath); const content = host.readFile(configurationFilePath).toString(); if (configurationFileName === ".odzrc.json") { partialConfiguration = JSON.parse(content); } if (configurationFileName === ".odzrc.toml") { partialConfiguration = TOML.parse(content); } } const thresholds = { branches: defaultThreshold, lines: defaultThreshold, functions: defaultThreshold, statements: defaultThreshold, ...partialConfiguration.thresholds }; const watermarks = { branches: defaultWatermark, functions: defaultWatermark, lines: defaultWatermark, statements: defaultWatermark, ...partialConfiguration.watermarks }; const basePath = configurationFilePath ? dirname(configurationFilePath) : host.currentWorkingDirectory; return { ...{ append: defaultAppend, coverageDirectory: defaultCoverageDirectory, ignoreUnhitSources: defaultIgnoreUnhitSources, excludedSources: defaultExcludedSources, logLevel: defaultLogLevel, perFile: defaultPerFile, reporterOptions: defaultReporterOptions, reporters: [defaultReporter], reportsDirectory: defaultReportsDirectory, skipFull: defaultSkipFull, sources: defaultSources }, ...partialConfiguration, basePath, thresholds, watermarks }; }; const resolvePath = (path, basePath) => { return resolve(basePath, path); }; const getSources = (sourcePatterns, ignoredSourcePatterns, sourcesBasePath, ignoredSourcesBasePath, host) => { const includedSources = host.find(sourcePatterns, sourcesBasePath); const excludedSources = host.find(ignoredSourcePatterns, ignoredSourcesBasePath); return includedSources.filter((source) => !excludedSources.includes(source)); }; const createSourcesOption = () => { return createOption('-s, --sources <SOURCES...>', `An array of glob patterns used to resolve the files to compute coverage for.`); }; const createReportersOptions = () => { return createOption('-r --reporters <REPORTERS...>', `An array of reporter names that controls the reporters that should be executed.`).choices([ "clover", "cobertura", "html", "html-spa", "json", "json-summary", "lcov", "lcovonly", "none", "teamcity", "text", "text-lcov", "text-summary" ]); }; const placeholder$1 = '<REPORTS_DIRECTORY>'; const createReportsDirectoryOption = () => { return createOption(`-rd --reports-directory ${placeholder$1}`, `A string that controls the path of the directory where the reports are output to.`); }; const placeholder = '<COVERAGE_DIRECTORY>'; const createInputCoverageDirectoryOption = () => { return createCoverageDirectoryOption(`A string that controls the path of the directory where the coverage data is read from.`); }; const createOutputCoverageDirectoryOption = () => { return createCoverageDirectoryOption(`A string that controls the path of the directory where the coverage data is written to.`); }; const createCoverageDirectoryOption = (description) => { return createOption(`-cd, --coverage-directory ${placeholder}`, description); }; const hasASourceMapCache = (candidate) => { return candidate["source-map-cache"] !== undefined; }; const loadProcessCoverage = (fileNames, host) => { let processCoverage = { result: [] }; const sourceMaps = new Map; for (const fileName of fileNames) { const fileContent = host.readFile(fileName); const parsedV8Coverage = JSON.parse(fileContent.toString()); const currentCoverage = { result: parsedV8Coverage.result.filter((scriptCoverage) => { return scriptCoverage.url.startsWith('file:///'); }) }; const sourceMapCache = hasASourceMapCache(parsedV8Coverage) ? parsedV8Coverage["source-map-cache"] : {}; for (const name in sourceMapCache) { const { data, lineLengths } = sourceMapCache[name]; sourceMaps.set(name, { ...(data ? data : { file: null, mappings: '', names: [], sources: [], version: 3 }), /** * The line lengths are the line lengths of the code that is executed by the runtime (e.g. Node.js). * Tools, like ts-node, transpile before sending the code to the runtime for execution, which means that the code * executed by the runtime is whatever was transpiled by the tool. The line lengths allow us to create a fake code * (consisting of dots) of the correct shape and size, in order to correctly map back to the original source from * the source map. */ scriptContent: lineLengths.map((lineLength) => { return `${''.padEnd(lineLength, '.')}`; }).join('\n') }); } processCoverage = mergeProcessCovs([processCoverage, currentCoverage]); } return { scriptCoverages: processCoverage.result.map((scriptCoverage) => { return { functions: scriptCoverage.functions, url: scriptCoverage.url }; }), sourceMaps }; }; const report = (host, domain, configuration) => { let coverageFiles = host.readDirectory(configuration.coverageDirectory); if (coverageFiles === unresolvable) { host.logError(`Unresolvable coverage directory ${configuration.coverageDirectory}`); return Promise.resolve(); } const processCoverage = loadProcessCoverage(coverageFiles, host); return domain.getCoverageMap(configuration.sources, processCoverage, configuration.logLevel, configuration.ignoreUnhitSources) .then((coverageMap) => { const reporters = configuration.reporters.map((reporterName) => { const istanbulReporter = create(reporterName, { skipEmpty: false, skipFull: configuration.skipFull, ...configuration.reporterOptions[reporterName] }); return { execute: (context) => istanbulReporter.execute(createContext({ coverageMap: context.coverageMap, dir: context.outputDirectory, watermarks: context.watermarks })), name: reporterName }; }); return domain.report(coverageMap, reporters, configuration.reportsDirectory, configuration.watermarks); }); }; const banner = ` ████ █████ █████ ░░███ ███░░░███ ███░░░███ ░███ ███ ░░███ ███ ░░███ ░███ ░███ ░███░███ ░███ ░███ ░░███ ███ ░░███ ███ █████ ░░░█████░ ░░░█████░ ░░░░░ ░░░░░░ ░░░░░░ `; const outputBanner = (version, host) => { host.logInfo(`${banner} One Double Zero ${version} `); }; const createLogLevelOption = () => { return createOption('-ll, --log-level <LOG_LEVEL>', 'A string that controls the level of information output by One Double Zero.').choices([ 'info', 'debug' ]); }; const createIgnoreUnhitSourcesOptions = () => { return createOption('-i, --ignore-unhit-sources', `A flag that controls whether unhit source files should be ignored or not.`); }; const createExcludedSourcesOption = () => { return createOption('-S, --excluded-sources <EXCLUDED_SOURCES...>', `An array of glob patterns used to resolve the files to exclude from the files to compute coverage for.`); }; const createReportCommand = (version, host, domain) => { return createCommand('report') .description('Report coverage results') .passThroughOptions(true) .showHelpAfterError(true) .addOption(createSourcesOption()) .addOption(createInputCoverageDirectoryOption()) .addOption(createReportersOptions()) .addOption(createReportsDirectoryOption()) .addOption(createLogLevelOption()) .addOption(createIgnoreUnhitSourcesOptions()) .addOption(createExcludedSourcesOption()) .action((options) => { outputBanner(version, host); return getConfiguration(host) .then((configuration) => { const reportHandlerOptions = { coverageDirectory: options.coverageDirectory ? resolvePath(options.coverageDirectory, host.currentWorkingDirectory) : resolvePath(configuration.coverageDirectory, configuration.basePath), ignoreUnhitSources: options.ignoreUnhitSources !== undefined ? options.ignoreUnhitSources : configuration.ignoreUnhitSources, logLevel: options.logLevel || configuration.logLevel, reporterOptions: configuration.reporterOptions, reportsDirectory: options.reportsDirectory ? resolvePath(options.reportsDirectory, host.currentWorkingDirectory) : resolvePath(configuration.reportsDirectory, configuration.basePath), reporters: options.reporters || configuration.reporters, skipFull: configuration.skipFull, sources: getSources(options.sources || configuration.sources, options.excludedSources || configuration.excludedSources, options.sources ? host.currentWorkingDirectory : configuration.basePath, options.excludedSources ? host.currentWorkingDirectory : configuration.basePath, host), watermarks: configuration.watermarks }; return report(host, domain, reportHandlerOptions); }); }); }; const createThresholdOption = (key) => { const placeholder = `<${key.toUpperCase()}_THRESHOLD>`; const createShortFlag = () => { return `${key.substring(0, 1)}t`; }; return createOption(`-${createShortFlag()}, --${key}-threshold ${placeholder}`, `Controls the ${key} threshold the check command should compare the ${key} coverage to.`).argParser((value) => { let result = parseFloat(value); if (isNaN(result)) { result = defaultThreshold; } return result; }); }; const createPerFileOption = () => { return createOption('-pf, --per-file', `A flag that controls whether the coverage should be compared to the thresholds per file, or globally.`); }; // todo: use the Domain::check function const check = (host, domain, configuration) => { const { getCoverageMap } = domain; const { sources, coverageDirectory, thresholds, perFile } = configuration; const coverageFiles = host.readDirectory(coverageDirectory); if (coverageFiles === unresolvable) { host.logError(`Unresolvable coverage directory ${coverageDirectory}`); return Promise.resolve(); } const processCoverage = loadProcessCoverage(coverageFiles, host); return getCoverageMap(sources, processCoverage, configuration.logLevel, configuration.ignoreUnhitSources) .then((coverageMap) => { const comparisonResults = domain.compare(coverageMap, thresholds, perFile); for (const comparisonResult of comparisonResults) { const { actual, expectation, thresholdType, fileName } = comparisonResult; if (actual < expectation) { process.exitCode = 1; if (comparisonResult.fileName) { host.logError(`Coverage for ${thresholdType} (${actual}%) does not meet threshold (${expectation}%) for ${fileName}`); } else { host.logError(`Coverage for ${thresholdType} (${actual}%) does not meet global threshold (${expectation}%)`); } } } }); }; const createCheckCommand = (version, host, domain) => { return createCommand('check') .description('Check coverage against thresholds') .addOption(createThresholdOption("branches")) .addOption(createThresholdOption("functions")) .addOption(createThresholdOption("lines")) .addOption(createThresholdOption("statements")) .addOption(createSourcesOption()) .addOption(createIgnoreUnhitSourcesOptions()) .addOption(createExcludedSourcesOption()) .addOption(createInputCoverageDirectoryOption()) .addOption(createPerFileOption()) .addOption(createLogLevelOption()) .action((options) => { outputBanner(version, host); return getConfiguration(host) .then((configuration) => { const checkHandlerOptions = { coverageDirectory: options.coverageDirectory ? resolvePath(options.coverageDirectory, host.currentWorkingDirectory) : resolvePath(configuration.coverageDirectory, configuration.basePath), ignoreUnhitSources: options.ignoreUnhitSources !== undefined ? options.ignoreUnhitSources : configuration.ignoreUnhitSources, logLevel: options.logLevel || configuration.logLevel, perFile: options.perFile !== undefined ? options.perFile : configuration.perFile, sources: getSources(options.sources || configuration.sources, options.excludedSources || configuration.excludedSources, options.sources ? host.currentWorkingDirectory : configuration.basePath, options.excludedSources ? host.currentWorkingDirectory : configuration.basePath, host), thresholds: { branches: options.branchesThreshold !== undefined ? options.branchesThreshold : configuration.thresholds.branches, functions: options.functionsThreshold !== undefined ? options.functionsThreshold : configuration.thresholds.functions, lines: options.linesThreshold !== undefined ? options.linesThreshold : configuration.thresholds.lines, statements: options.statementsThreshold !== undefined ? options.statementsThreshold : configuration.thresholds.statements } }; return check(host, domain, checkHandlerOptions); }); }); }; const run = (host, configuration, command, onDone) => { const { removeDirectory, createDirectory, runCommand } = host; const { coverageDirectory } = configuration; if (!configuration.append) { removeDirectory(coverageDirectory); } createDirectory(coverageDirectory); return runCommand(command, coverageDirectory, onDone || (() => Promise.resolve())).then(); }; const createAppendOption = () => { return createOption('-a, --append', 'A flag that controls whether the run command should append the coverage data to the coverage directory instead of overwriting its content.'); }; const createRunCommand = (version, host) => { return createCommand('run') .description('Run a command and collect coverage data along the way') .addArgument(createArgument('command')) .addOption(createOutputCoverageDirectoryOption()) .addOption(createAppendOption()) .addOption(createLogLevelOption()) .action((_, options, command) => { outputBanner(version, host); return getConfiguration(host) .then((configuration) => { const runHandlerOptions = { append: options.append !== undefined ? options.append : configuration.append, coverageDirectory: options.coverageDirectory ? resolvePath(options.coverageDirectory, host.currentWorkingDirectory) : resolvePath(configuration.coverageDirectory, configuration.basePath), logLevel: options.logLevel || configuration.logLevel }; return run(host, runHandlerOptions, command.args, null); }); }); }; const createVersionCommand = (version, host) => { return createCommand('version') .description('Output the version') .action(() => { host.logInfo(version); }); }; const createMainCommand = (version, host, domain) => { return createCommand() .description('Run a command, collect coverage data along the way and report coverage results') .enablePositionalOptions(true) .passThroughOptions(true) .addCommand(createReportCommand(version, host, domain)) .addCommand(createCheckCommand(version, host, domain)) .addCommand(createRunCommand(version, host)) .addCommand(createVersionCommand(version, host)) .addArgument(createArgument('command')) .addOption(createSourcesOption()) .addOption(createOutputCoverageDirectoryOption()) .addOption(createReportersOptions()) .addOption(createReportsDirectoryOption()) .addOption(createAppendOption()) .addOption(createLogLevelOption()) .addOption(createIgnoreUnhitSourcesOptions()) .addOption(createExcludedSourcesOption()) .action((_, options, command) => { outputBanner(version, host); return getConfiguration(host) .then((configuration) => { const coverageDirectory = options.coverageDirectory ? resolvePath(options.coverageDirectory, host.currentWorkingDirectory) : resolvePath(configuration.coverageDirectory, configuration.basePath); const logLevel = options.logLevel || configuration.logLevel; const runHandlerOptions = { append: options.append !== undefined ? options.append : configuration.append, coverageDirectory}; const reportHandlerOptions = { coverageDirectory, ignoreUnhitSources: options.ignoreUnhitSources !== undefined ? options.ignoreUnhitSources : configuration.ignoreUnhitSources, logLevel, reporterOptions: configuration.reporterOptions, reportsDirectory: options.reportsDirectory ? resolvePath(options.reportsDirectory, host.currentWorkingDirectory) : resolvePath(configuration.reportsDirectory, configuration.basePath), reporters: options.reporters || configuration.reporters, skipFull: configuration.skipFull, sources: getSources(options.sources || configuration.sources, options.excludedSources || configuration.excludedSources, options.sources ? host.currentWorkingDirectory : configuration.basePath, options.excludedSources ? host.currentWorkingDirectory : configuration.basePath, host), watermarks: configuration.watermarks }; return run(host, runHandlerOptions, command.args, () => { return report(host, domain, reportHandlerOptions); }); }); }); }; const main = (version, host, argv) => { const domain = createOneDoubleZero(host.logInfo, host.readFile); const mainCommand = createMainCommand(version, host, domain); return mainCommand.parseAsync(argv).then(); }; const createNodeJSHost = (ConsoleModule, FSModule, ProcessModule, PathModule) => { return { get currentWorkingDirectory() { return ProcessModule.cwd(); }, createDirectory: (path) => { FSModule.mkdirSync(path, { recursive: true }); }, find: (patterns, basePath) => { return globSync(patterns, { absolute: true, cwd: basePath, nodir: true }); }, findUp: (names, cwd) => { const file = findUp.sync(names, { cwd }); return file || null; }, logError: ConsoleModule.error, logInfo: ConsoleModule.info, readDirectory: (directoryPath) => { try { return FSModule.readdirSync(directoryPath).map((fileName) => PathModule.resolve(directoryPath, fileName)); } catch (error) { return unresolvable; } }, readFile: (path) => { try { return FSModule.readFileSync(path); } catch (error) { return unresolvable; } }, removeDirectory: (path) => { FSModule.rmSync(path, { recursive: true, force: true }); }, runCommand: (command, coverageDirectory, onDone) => { return new Promise((resolve) => { const [program, ...args] = command; foregroundChild(program, args, { env: { ...process.env, NODE_V8_COVERAGE: coverageDirectory } }, async (code) => { // we execute onDone regardless of the execution result of the command await onDone(); // code is the exit code of the execution of onDone // process.exitCode is the exit code of the execution of onDone resolve(process.exitCode || code || 0); }); }); } }; }; main('1.1.1', createNodeJSHost(Console, FS, Process, Path), process.argv);