Skip to content

Commit 45f2f84

Browse files
authored
feat: added a new CLI arg --merge-async to asynchronously and incrementally merge process coverage files to avoid OOM due to heap exhaustion (#469)
1 parent 2f36fe9 commit 45f2f84

9 files changed

+2077
-593
lines changed

lib/commands/report.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ exports.outputReport = async function (argv) {
3434
allowExternal: argv.allowExternal,
3535
src: argv.src,
3636
skipFull: argv.skipFull,
37-
excludeNodeModules: argv.excludeNodeModules
37+
excludeNodeModules: argv.excludeNodeModules,
38+
mergeAsync: argv.mergeAsync
3839
})
3940
await report.run()
4041
if (argv.checkCoverage) await checkCoverages(argv, report)

lib/parse-args.js

+6
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ function buildYargs (withCommands = false) {
152152
describe: 'supplying --allowExternal will cause c8 to allow files from outside of your cwd. This applies both to ' +
153153
'files discovered in coverage temp files and also src files discovered if using the --all flag.'
154154
})
155+
.options('merge-async', {
156+
default: false,
157+
type: 'boolean',
158+
describe: 'supplying --merge-async will merge all v8 coverage reports asynchronously and incrementally. ' +
159+
'This is to avoid OOM issues with Node.js runtime.'
160+
})
155161
.pkgConf('c8')
156162
.demandCommand(1)
157163
.check((argv) => {

lib/report.js

+109-32
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ const Exclude = require('test-exclude')
22
const libCoverage = require('istanbul-lib-coverage')
33
const libReport = require('istanbul-lib-report')
44
const reports = require('istanbul-reports')
5+
let readFile
6+
try {
7+
;({ readFile } = require('fs/promises'))
8+
} catch (err) {
9+
;({ readFile } = require('fs').promises)
10+
}
511
const { readdirSync, readFileSync, statSync } = require('fs')
612
const { isAbsolute, resolve, extname } = require('path')
713
const { pathToFileURL, fileURLToPath } = require('url')
@@ -30,7 +36,8 @@ class Report {
3036
src,
3137
allowExternal = false,
3238
skipFull,
33-
excludeNodeModules
39+
excludeNodeModules,
40+
mergeAsync
3441
}) {
3542
this.reporter = reporter
3643
this.reporterOptions = reporterOptions || {}
@@ -53,6 +60,7 @@ class Report {
5360
this.all = all
5461
this.src = this._getSrc(src)
5562
this.skipFull = skipFull
63+
this.mergeAsync = mergeAsync
5664
}
5765

5866
_getSrc (src) {
@@ -90,7 +98,13 @@ class Report {
9098
if (this._allCoverageFiles) return this._allCoverageFiles
9199

92100
const map = libCoverage.createCoverageMap()
93-
const v8ProcessCov = this._getMergedProcessCov()
101+
let v8ProcessCov
102+
103+
if (this.mergeAsync) {
104+
v8ProcessCov = await this._getMergedProcessCovAsync()
105+
} else {
106+
v8ProcessCov = this._getMergedProcessCov()
107+
}
94108
const resultCountPerPath = new Map()
95109
const possibleCjsEsmBridges = new Map()
96110

@@ -188,43 +202,106 @@ class Report {
188202
}
189203

190204
if (this.all) {
191-
const emptyReports = []
205+
const emptyReports = this._includeUncoveredFiles(fileIndex)
192206
v8ProcessCovs.unshift({
193207
result: emptyReports
194208
})
195-
const workingDirs = this.src
196-
const { extension } = this.exclude
197-
for (const workingDir of workingDirs) {
198-
this.exclude.globSync(workingDir).forEach((f) => {
199-
const fullPath = resolve(workingDir, f)
200-
if (!fileIndex.has(fullPath)) {
201-
const ext = extname(fullPath)
202-
if (extension.includes(ext)) {
203-
const stat = statSync(fullPath)
204-
const sourceMap = getSourceMapFromFile(fullPath)
205-
if (sourceMap) {
206-
this.sourceMapCache[pathToFileURL(fullPath)] = { data: sourceMap }
207-
}
208-
emptyReports.push({
209-
scriptId: 0,
210-
url: resolve(fullPath),
211-
functions: [{
212-
functionName: '(empty-report)',
213-
ranges: [{
214-
startOffset: 0,
215-
endOffset: stat.size,
216-
count: 0
217-
}],
218-
isBlockCoverage: true
219-
}]
220-
})
221-
}
209+
}
210+
211+
return mergeProcessCovs(v8ProcessCovs)
212+
}
213+
214+
/**
215+
* Returns the merged V8 process coverage.
216+
*
217+
* It asynchronously and incrementally reads and merges individual process coverages
218+
* generated by Node. This can be used via the `--merge-async` CLI arg. It's intended
219+
* to be used across a large multi-process test run.
220+
*
221+
* @return {ProcessCov} Merged V8 process coverage.
222+
* @private
223+
*/
224+
async _getMergedProcessCovAsync () {
225+
const { mergeProcessCovs } = require('@bcoe/v8-coverage')
226+
const fileIndex = new Set() // Set<string>
227+
let mergedCov = null
228+
for (const file of readdirSync(this.tempDirectory)) {
229+
try {
230+
const rawFile = await readFile(
231+
resolve(this.tempDirectory, file),
232+
'utf8'
233+
)
234+
let report = JSON.parse(rawFile)
235+
236+
if (this._isCoverageObject(report)) {
237+
if (report['source-map-cache']) {
238+
Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(report['source-map-cache']))
222239
}
223-
})
240+
report = this._normalizeProcessCov(report, fileIndex)
241+
if (mergedCov) {
242+
mergedCov = mergeProcessCovs([mergedCov, report])
243+
} else {
244+
mergedCov = mergeProcessCovs([report])
245+
}
246+
}
247+
} catch (err) {
248+
debuglog(`${err.stack}`)
224249
}
225250
}
226251

227-
return mergeProcessCovs(v8ProcessCovs)
252+
if (this.all) {
253+
const emptyReports = this._includeUncoveredFiles(fileIndex)
254+
const emptyReport = {
255+
result: emptyReports
256+
}
257+
258+
mergedCov = mergeProcessCovs([emptyReport, mergedCov])
259+
}
260+
261+
return mergedCov
262+
}
263+
264+
/**
265+
* Adds empty coverage reports to account for uncovered/untested code.
266+
* This is only done when the `--all` flag is present.
267+
*
268+
* @param {Set} fileIndex list of files that have coverage
269+
* @returns {Array} list of empty coverage reports
270+
*/
271+
_includeUncoveredFiles (fileIndex) {
272+
const emptyReports = []
273+
const workingDirs = this.src
274+
const { extension } = this.exclude
275+
for (const workingDir of workingDirs) {
276+
this.exclude.globSync(workingDir).forEach((f) => {
277+
const fullPath = resolve(workingDir, f)
278+
if (!fileIndex.has(fullPath)) {
279+
const ext = extname(fullPath)
280+
if (extension.includes(ext)) {
281+
const stat = statSync(fullPath)
282+
const sourceMap = getSourceMapFromFile(fullPath)
283+
if (sourceMap) {
284+
this.sourceMapCache[pathToFileURL(fullPath)] = { data: sourceMap }
285+
}
286+
emptyReports.push({
287+
scriptId: 0,
288+
url: resolve(fullPath),
289+
functions: [{
290+
functionName: '(empty-report)',
291+
ranges: [{
292+
startOffset: 0,
293+
endOffset: stat.size,
294+
count: 0
295+
}],
296+
isBlockCoverage: true
297+
}]
298+
})
299+
}
300+
}
301+
})
302+
}
303+
304+
return emptyReports
228305
}
229306

230307
/**

test/fixtures/disable-fs-promises.js

-4
This file was deleted.

0 commit comments

Comments
 (0)