@bahmutov/cypress-code-coverage
Version:
My version of Cypress code coverage plugin
379 lines (330 loc) • 11.4 kB
JavaScript
/// <reference types="cypress" />
// @ts-check
const dayjs = require('dayjs')
var duration = require('dayjs/plugin/duration')
const {
excludeByUser,
filterSupportFilesFromCoverage,
} = require('./support-utils')
const { isPluginDisabled } = require('./common-utils')
dayjs.extend(duration)
function getCoverageConfig() {
const env = Cypress.env()
return env.coverage || {}
}
/**
* Sends collected code coverage object to the backend code
* via "cy.task".
*/
const sendCoverage = (coverage, pathname = '/') => {
const config = getCoverageConfig()
if (!config.quiet) {
logMessage(`Saving code coverage for **${pathname}**`)
}
let filteredCoverage = coverage
// console.log({ config })
// by default we do not filter anything from the code coverage object
// if the user gives a list of patters to filter, we filter the coverage object
if (config.exclude) {
filteredCoverage = excludeByUser(config.exclude, coverage)
} else if (Cypress.spec.specType === 'component') {
filteredCoverage = filterSupportFilesFromCoverage(coverage)
}
// stringify coverage object for speed
cy.task('combineCoverage', JSON.stringify(filteredCoverage), {
log: false,
})
}
/**
* Consistently logs the given string to the Command Log
* so the user knows the log message is coming from this plugin.
* @param {string} message String message to log.
*/
const logMessage = (message) => {
const logInstance = Cypress.log({
name: 'Coverage',
message,
})
}
const registerHooks = () => {
let windowCoverageObjects
const hasE2ECoverage = () => Boolean(windowCoverageObjects.length)
// @ts-ignore
const hasUnitTestCoverage = () => Boolean(window.__coverage__)
before(() => {
const config = getCoverageConfig()
let logInstance
if (!config.quiet) {
// we need to reset the coverage when running
// in the interactive mode, otherwise the counters will
// keep increasing every time we rerun the tests
logInstance = Cypress.log({
name: 'Coverage',
message: ['Reset [@bahmutov/cypress-code-coverage]'],
})
}
cy.task(
'resetCoverage',
{
// @ts-ignore
isInteractive: Cypress.config('isInteractive'),
specCovers: Cypress.env('specCovers'),
},
{ log: false },
).then(() => {
if (logInstance) {
logInstance.end()
}
})
})
beforeEach(() => {
const instrumentScripts = Cypress.env('coverage')?.instrument
if (instrumentScripts) {
// the user wants Cypress to instrument the application code
// by intercepting the script requests and instrumenting them on the fly
// https://github.com/istanbuljs/istanbuljs
// @ts-ignore
const { createInstrumenter } = require('istanbul-lib-instrument')
const instrumenter = createInstrumenter({
esModules: true,
compact: false,
preserveComments: true,
})
const baseUrl = Cypress.config('baseUrl')
// @ts-ignore
const proxyServer = Cypress.config('proxyServer') + '/'
cy.intercept(
{
method: 'GET',
resourceType: 'script',
url: instrumentScripts,
},
(req) => {
// remove the cache headers to force the server
// to return the script source code
// Idea: we could cache the instrumented code
// and let the browser receive 304 status code
delete req.headers['if-none-match']
delete req.headers['if-modified-since']
// @ts-ignore
req.continue((res) => {
const relativeUrl = req.url
// @ts-ignore
.replace(baseUrl, '')
.replace(proxyServer, '')
// console.log('instrumenting', relativeUrl)
// @ts-ignore
const instrumented = instrumenter.instrumentSync(
res.body,
relativeUrl,
)
res.body = instrumented
return res
})
},
)
}
// each object will have the coverage and url pathname
// to let the user know the coverage has been collected
windowCoverageObjects = []
const saveCoverageObject = (win) => {
// if application code has been instrumented, the app iframe "window" has an object
try {
const applicationSourceCoverage = win.__coverage__
if (!applicationSourceCoverage) {
return
}
if (
Cypress._.find(windowCoverageObjects, {
coverage: applicationSourceCoverage,
})
) {
// this application code coverage object is already known
// which can happen when combining `window:load` and `before` callbacks
return
}
if (Cypress.spec.specType === 'component') {
windowCoverageObjects.push({
coverage: applicationSourceCoverage,
pathname: Cypress.spec.relative,
})
} else {
windowCoverageObjects.push({
coverage: applicationSourceCoverage,
pathname: win.location.pathname,
})
}
} catch {
// do nothing, probably cross-origin access
}
}
// save reference to coverage for each app window loaded in the test
cy.on('window:load', saveCoverageObject)
// save reference if visiting a page inside a before() hook
cy.window({ log: false }).then(saveCoverageObject)
})
afterEach(() => {
// save coverage after the test
// because now the window coverage objects have been updated
windowCoverageObjects.forEach((cover) => {
sendCoverage(cover.coverage, cover.pathname)
})
const taskOptions = { spec: Cypress.spec }
if (Cypress.env('specCovers')) {
taskOptions.specCovers = Cypress.env('specCovers')
}
const config = getCoverageConfig()
if (!config.quiet) {
cy.log(`**Reporting coverage for** ${Cypress.spec.relative}`)
}
cy.task('reportSpecCovers', taskOptions, { log: false })
if (!hasE2ECoverage()) {
if (hasUnitTestCoverage()) {
if (!config.quiet) {
logMessage(`👉 Only found unit test code coverage.`)
}
} else {
const expectBackendCoverageOnly = Cypress._.get(
Cypress.env('codeCoverage'),
'expectBackendCoverageOnly',
false,
)
if (!expectBackendCoverageOnly) {
logMessage(`
⚠️ Could not find any coverage information in your application
by looking at the window coverage object.
Did you forget to instrument your application?
See [code-coverage#instrument-your-application](https://github.com/cypress-io/code-coverage#instrument-your-application)
`)
}
}
}
})
after(function collectBackendCoverage() {
let runningEndToEndTests, isIntegrationSpec
try {
// I wish I could fail the tests if there is no code coverage information
// but throwing an error here does not fail the test run due to
// https://github.com/cypress-io/cypress/issues/2296
// there might be server-side code coverage information
// we should grab it once after all tests finish
// @ts-ignore
const baseUrl = Cypress.config('baseUrl') || cy.state('window').origin
// @ts-ignore
runningEndToEndTests = baseUrl !== Cypress.config('proxyUrl')
const specType = Cypress._.get(Cypress.spec, 'specType', 'integration')
isIntegrationSpec = specType === 'integration'
} catch {
// probably cross-origin request
// cannot access the window
}
if (runningEndToEndTests && isIntegrationSpec) {
const baseUrl = Cypress.config('baseUrl')
if (baseUrl) {
// can only fetch server-side code coverage if we have a baseUrl
// we can only request server-side code coverage
// if we are running end-to-end tests,
// otherwise where do we send the request?
const url = Cypress._.get(
Cypress.env('codeCoverage'),
'url',
'/__coverage__',
)
cy.request({
url,
log: false,
failOnStatusCode: false,
})
.then((r) => {
return Cypress._.get(r, 'body.coverage', null)
})
.then((coverage) => {
if (!coverage) {
// we did not get code coverage - this is the
// original failed request
const expectBackendCoverageOnly = Cypress._.get(
Cypress.env('codeCoverage'),
'expectBackendCoverageOnly',
false,
)
if (expectBackendCoverageOnly) {
throw new Error(
`Expected to collect backend code coverage from ${url}`,
)
} else {
// we did not really expect to collect the backend code coverage
return
}
}
sendCoverage(coverage, 'backend')
})
}
}
})
after(function mergeUnitTestCoverage() {
// collect and merge frontend coverage
// if spec bundle has been instrumented (using Cypress preprocessor)
// then we will have unit test coverage
// NOTE: spec iframe is NOT reset between the tests, so we can grab
// the coverage information only once after all tests have finished
// @ts-ignore
if (window.__coverage__) {
const unitTestCoverage = filterSupportFilesFromCoverage(
// @ts-ignore
window.__coverage__,
)
sendCoverage(unitTestCoverage, 'component tests')
}
})
after(function generateReport() {
const config = getCoverageConfig()
let logInstance
if (!config.quiet) {
// when all tests finish, lets generate the coverage report
logInstance = Cypress.log({
name: 'Coverage',
message: ['Generating report [@bahmutov/cypress-code-coverage]'],
})
}
const options = {
specCovers: Cypress.env('specCovers'),
}
cy.task('coverageReport', options, {
timeout: dayjs.duration(3, 'minutes').asMilliseconds(),
log: false,
}).then((coverageReportFolder) => {
if (logInstance) {
logInstance.set('consoleProps', () => ({
'coverage report folder': coverageReportFolder,
}))
logInstance.end()
}
return coverageReportFolder
})
})
}
// to disable code coverage commands and save time
// pass environment variable coverage=false
// cypress run --env coverage=false
// or
// CYPRESS_coverage=false cypress run
// see https://on.cypress.io/environment-variables
// to avoid "coverage" env variable being case-sensitive, convert to lowercase
const cyEnvs = Cypress._.mapKeys(Cypress.env(), (value, key) =>
key.toLowerCase(),
)
const pluginDisabled = isPluginDisabled(cyEnvs)
if (pluginDisabled) {
console.log('Skipping code coverage hooks')
} else if (Cypress.env('codeCoverageTasksRegistered') !== true) {
// register a hook just to log a message
before(() => {
logMessage(`
⚠️ Code coverage tasks were not registered by the plugins file.
See [support issue](https://github.com/cypress-io/code-coverage/issues/179)
for possible workarounds.
`)
})
} else {
registerHooks()
}