@bahmutov/cypress-code-coverage
Version:
My version of Cypress code coverage plugin
294 lines (250 loc) • 8.8 kB
JavaScript
// @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