UNPKG

@bahmutov/cypress-code-coverage

Version:
294 lines (250 loc) 8.8 kB
// @ts-check const istanbul = require('istanbul-lib-coverage') const sortArray = require('sort-array') const { join, relative } = require('path') const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs') const execa = require('execa') const { showNycInfo, resolveRelativePaths, checkAllPathsNotFound, tryFindingLocalFiles, getNycOptions, includeAllFiles, getCoverage, updateSpecCovers, } = require('./task-utils') const { fixSourcePaths } = require('./support-utils') const { removePlaceholders } = require('./common-utils') const debug = require('debug')('code-coverage') // these are standard folder and file names used by NYC tools const processWorkingDirectory = process.cwd() // there might be custom "nyc" options in the user package.json // see https://github.com/istanbuljs/nyc#configuring-nyc // potentially there might be "nyc" options in other configuration files // it allows, but for now ignore those options const pkgFilename = join(processWorkingDirectory, 'package.json') const pkg = existsSync(pkgFilename) ? JSON.parse(readFileSync(pkgFilename, 'utf8')) : {} const scripts = pkg.scripts || {} const DEFAULT_CUSTOM_COVERAGE_SCRIPT_NAME = 'coverage:report' const customNycReportScript = scripts[DEFAULT_CUSTOM_COVERAGE_SCRIPT_NAME] const nycReportOptions = getNycOptions(processWorkingDirectory) const nycFilename = join(nycReportOptions['temp-dir'], 'out.json') function saveCoverage(coverage) { if (!existsSync(nycReportOptions.tempDir)) { mkdirSync(nycReportOptions.tempDir, { recursive: true }) debug('created folder %s for output coverage', nycReportOptions.tempDir) } writeFileSync(nycFilename, JSON.stringify(coverage, null, 2)) } function maybePrintFinalCoverageFiles(folder) { const jsonReportFilename = join(folder, 'coverage-final.json') if (!existsSync(jsonReportFilename)) { debug('Did not find final coverage file %s', jsonReportFilename) return } debug('Final coverage in %s', jsonReportFilename) const finalCoverage = JSON.parse(readFileSync(jsonReportFilename, 'utf8')) const finalCoverageKeys = Object.keys(finalCoverage) debug( 'There are %d key(s) in %s', finalCoverageKeys.length, jsonReportFilename, ) finalCoverageKeys.forEach((key) => { const s = finalCoverage[key].s || {} const statements = Object.keys(s) const totalStatements = statements.length let coveredStatements = 0 statements.forEach((statementKey) => { if (s[statementKey]) { coveredStatements += 1 } }) const hasStatements = totalStatements > 0 const allCovered = coveredStatements === totalStatements const coverageStatus = hasStatements ? (allCovered ? '✅' : '⚠️') : '❓' debug( '%s %s statements covered %d/%d', coverageStatus, key, coveredStatements, totalStatements, ) }) } const tasks = { /** * Clears accumulated code coverage information. * * Interactive mode with "cypress open" * - running a single spec or "Run all specs" needs to reset coverage * Headless mode with "cypress run" * - runs EACH spec separately, so we cannot reset the coverage * or we will lose the coverage from previous specs. */ resetCoverage(options = {}) { const { isInteractive, specCovers } = options debug('reset coverage %o', options) if (isInteractive || specCovers) { debug('reset code coverage in interactive mode') const coverageMap = istanbul.createCoverageMap({}) saveCoverage(coverageMap) } else { debug('not resetting code coverage') } /* Else: in headless mode, assume the coverage file was deleted before the `cypress run` command was called example: rm -rf .nyc_output || true */ return null }, /** * Combines coverage information from single test * with previously collected coverage. * * @param {string} sentCoverage Stringified coverage object sent by the test runner * @returns {null} Nothing is returned from this task */ combineCoverage(sentCoverage) { const coverage = JSON.parse(sentCoverage) debug('parsed sent coverage') fixSourcePaths(coverage) const previousCoverage = existsSync(nycFilename) ? JSON.parse(readFileSync(nycFilename, 'utf8')) : {} // previous code coverage object might have placeholder entries // for files that we have not seen yet, // but the user expects to include in the coverage report // the merge function messes up, so we should remove any placeholder entries // and re-insert them again when creating the report removePlaceholders(previousCoverage) const coverageMap = istanbul.createCoverageMap(previousCoverage) coverageMap.merge(coverage) saveCoverage(coverageMap) debug('wrote coverage file %s', nycFilename) return null }, reportSpecCovers(options) { debug('report spec covers %o', options) const { specCovers, spec } = options if (!specCovers) { return null } const specNumbers = [] const coverage = getCoverage() const coverageKeys = Object.keys(coverage) const cwd = process.cwd() coverageKeys.forEach((key) => { const fileCoverage = coverage[key] const sourceRelative = relative(cwd, fileCoverage.path) const s = fileCoverage.s || {} const statements = Object.keys(s) const totalStatements = statements.length let coveragePercentage = 0 if (totalStatements > 0) { let covered = 0 statements.forEach((k) => { const count = s[k] if (count > 0) { covered += 1 } }) coveragePercentage = Math.round((covered / totalStatements) * 100) } specNumbers.push({ name: sourceRelative, covered: coveragePercentage, }) // console.log('%s - %d', sourceRelative, ) }) const sorted = sortArray(specNumbers, { by: ['covered', 'name'], order: ['desc'], }) console.table(`spec ${spec.relative} covers`, sorted) // console.log('spec %s covers:', spec.relative) updateSpecCovers(spec.relative, sorted) return null }, /** * Saves coverage information as a JSON file and calls * NPM script to generate HTML report */ coverageReport(options = {}) { debug('coverage report %o', options) const { specCovers } = options if (specCovers) { debug('when using spec covers, skipping final report') return null } if (!existsSync(nycFilename)) { console.warn('Cannot find coverage file %s', nycFilename) console.warn('Skipping coverage report') return null } showNycInfo(nycFilename) const allSourceFilesMissing = checkAllPathsNotFound(nycFilename) if (allSourceFilesMissing) { tryFindingLocalFiles(nycFilename) } resolveRelativePaths(nycFilename) if (customNycReportScript) { debug( 'saving coverage report using script "%s" from package.json, command: %s', DEFAULT_CUSTOM_COVERAGE_SCRIPT_NAME, customNycReportScript, ) debug('current working directory is %s', process.cwd()) return execa('npm', ['run', DEFAULT_CUSTOM_COVERAGE_SCRIPT_NAME], { stdio: 'inherit', }) } if (nycReportOptions.all) { debug('nyc needs to report on all included files') includeAllFiles(nycFilename, nycReportOptions) } debug('calling NYC reporter with options %o', nycReportOptions) debug('current working directory is %s', process.cwd()) const NYC = require('nyc') const nyc = new NYC(nycReportOptions) const returnReportFolder = () => { const reportFolder = nycReportOptions['report-dir'] debug( 'after reporting, returning the report folder name %s', reportFolder, ) maybePrintFinalCoverageFiles(reportFolder) return reportFolder } return nyc.report().then(returnReportFolder) }, } /** * Registers code coverage collection and reporting tasks. * Sets an environment variable to tell the browser code that it can * send the coverage. * @example ``` // your plugins file module.exports = (on, config) => { require('cypress/code-coverage/task')(on, config) // IMPORTANT to return the config object // with the any changed environment variables return config } ``` */ function registerCodeCoverageTasks(on, config) { debug('registering code coverage tasks') on('task', tasks) // set a variable to let the hooks running in the browser // know that they can send coverage commands config.env.codeCoverageTasksRegistered = true return config } module.exports = registerCodeCoverageTasks