Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added a new CLI arg --merge-async to asynchronously and incrementally merge process coverage files to avoid OOM due to heap exhaustion #469

Merged
merged 5 commits into from May 26, 2023
Merged
3 changes: 2 additions & 1 deletion lib/commands/report.js
Expand Up @@ -34,7 +34,8 @@ exports.outputReport = async function (argv) {
allowExternal: argv.allowExternal,
src: argv.src,
skipFull: argv.skipFull,
excludeNodeModules: argv.excludeNodeModules
excludeNodeModules: argv.excludeNodeModules,
mergeAsync: argv.mergeAsync
})
await report.run()
if (argv.checkCoverage) await checkCoverages(argv, report)
Expand Down
6 changes: 6 additions & 0 deletions lib/parse-args.js
Expand Up @@ -152,6 +152,12 @@ function buildYargs (withCommands = false) {
describe: 'supplying --allowExternal will cause c8 to allow files from outside of your cwd. This applies both to ' +
'files discovered in coverage temp files and also src files discovered if using the --all flag.'
})
.options('merge-async', {
default: false,
type: 'boolean',
describe: 'supplying --merge-async will merge all v8 coverage reports asynchronously and incrementally. ' +
'This is to avoid OOM issues with Node.js runtime.'
})
.pkgConf('c8')
.demandCommand(1)
.check((argv) => {
Expand Down
131 changes: 99 additions & 32 deletions lib/report.js
Expand Up @@ -2,6 +2,12 @@ const Exclude = require('test-exclude')
const libCoverage = require('istanbul-lib-coverage')
const libReport = require('istanbul-lib-report')
const reports = require('istanbul-reports')
let readFile
try {
;({ readFile } = require('fs/promises'))
} catch (err) {
;({ readFile } = require('fs').promises)
}
const { readdirSync, readFileSync, statSync } = require('fs')
const { isAbsolute, resolve, extname } = require('path')
const { pathToFileURL, fileURLToPath } = require('url')
Expand Down Expand Up @@ -30,7 +36,8 @@ class Report {
src,
allowExternal = false,
skipFull,
excludeNodeModules
excludeNodeModules,
mergeAsync
}) {
this.reporter = reporter
this.reporterOptions = reporterOptions || {}
Expand All @@ -53,6 +60,7 @@ class Report {
this.all = all
this.src = this._getSrc(src)
this.skipFull = skipFull
this.mergeAsync = mergeAsync
}

_getSrc (src) {
Expand Down Expand Up @@ -90,7 +98,13 @@ class Report {
if (this._allCoverageFiles) return this._allCoverageFiles

const map = libCoverage.createCoverageMap()
const v8ProcessCov = this._getMergedProcessCov()
let v8ProcessCov

if (this.mergeAsync) {
v8ProcessCov = await this._getMergedProcessCovAsync()
} else {
v8ProcessCov = this._getMergedProcessCov()
}
const resultCountPerPath = new Map()
const possibleCjsEsmBridges = new Map()

Expand Down Expand Up @@ -188,43 +202,96 @@ class Report {
}

if (this.all) {
const emptyReports = []
const emptyReports = this._includeUncoveredFiles(fileIndex)
v8ProcessCovs.unshift({
result: emptyReports
})
const workingDirs = this.src
const { extension } = this.exclude
for (const workingDir of workingDirs) {
this.exclude.globSync(workingDir).forEach((f) => {
const fullPath = resolve(workingDir, f)
if (!fileIndex.has(fullPath)) {
const ext = extname(fullPath)
if (extension.includes(ext)) {
const stat = statSync(fullPath)
const sourceMap = getSourceMapFromFile(fullPath)
if (sourceMap) {
this.sourceMapCache[pathToFileURL(fullPath)] = { data: sourceMap }
}
emptyReports.push({
scriptId: 0,
url: resolve(fullPath),
functions: [{
functionName: '(empty-report)',
ranges: [{
startOffset: 0,
endOffset: stat.size,
count: 0
}],
isBlockCoverage: true
}]
})
}
}

return mergeProcessCovs(v8ProcessCovs)
}

async _getMergedProcessCovAsync () {
bizob2828 marked this conversation as resolved.
Show resolved Hide resolved
const { mergeProcessCovs } = require('@bcoe/v8-coverage')
const fileIndex = new Set() // Set<string>
let mergedCov = null
for (const file of readdirSync(this.tempDirectory)) {
try {
const rawFile = await readFile(
resolve(this.tempDirectory, file),
'utf8'
)
let report = JSON.parse(rawFile)

if (this._isCoverageObject(report)) {
if (report['source-map-cache']) {
Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(report['source-map-cache']))
}
})
report = this._normalizeProcessCov(report, fileIndex)
if (mergedCov) {
mergedCov = mergeProcessCovs([mergedCov, report])
} else {
mergedCov = mergeProcessCovs([report])
}
}
} catch (err) {
debuglog(`${err.stack}`)
}
}

return mergeProcessCovs(v8ProcessCovs)
if (this.all) {
const emptyReports = this._includeUncoveredFiles(fileIndex)
const emptyReport = {
result: emptyReports
}

mergedCov = mergeProcessCovs([emptyReport, mergedCov])
}

return mergedCov
}

/**
* Adds empty coverage reports to account for uncovered/untested code.
* This is only done when the `--all` flag is present.
*
* @param {Set} fileIndex list of files that have coverage
* @returns {Array} list of empty coverage reports
*/
_includeUncoveredFiles (fileIndex) {
const emptyReports = []
const workingDirs = this.src
const { extension } = this.exclude
for (const workingDir of workingDirs) {
this.exclude.globSync(workingDir).forEach((f) => {
const fullPath = resolve(workingDir, f)
if (!fileIndex.has(fullPath)) {
const ext = extname(fullPath)
if (extension.includes(ext)) {
const stat = statSync(fullPath)
const sourceMap = getSourceMapFromFile(fullPath)
if (sourceMap) {
this.sourceMapCache[pathToFileURL(fullPath)] = { data: sourceMap }
}
emptyReports.push({
scriptId: 0,
url: resolve(fullPath),
functions: [{
functionName: '(empty-report)',
ranges: [{
startOffset: 0,
endOffset: stat.size,
count: 0
}],
isBlockCoverage: true
}]
})
}
}
})
}

return emptyReports
}

/**
Expand Down
4 changes: 0 additions & 4 deletions test/fixtures/disable-fs-promises.js

This file was deleted.