Skip to content

Commit 4166c41

Browse files
authoredNov 28, 2023
fix(coverage): improve memory usage by writing temporary files on file system (#4603)
1 parent b766d34 commit 4166c41

File tree

11 files changed

+307
-129
lines changed

11 files changed

+307
-129
lines changed
 

Diff for: ‎docs/config/index.md

+9
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,15 @@ See [istanbul documentation](https://github.com/istanbuljs/nyc#ignoring-methods)
13231323

13241324
Watermarks for statements, lines, branches and functions. See [istanbul documentation](https://github.com/istanbuljs/nyc#high-and-low-watermarks) for more information.
13251325

1326+
#### coverage.processingConcurrency
1327+
1328+
- **Type:** `boolean`
1329+
- **Default:** `Math.min(20, os.cpu().length)`
1330+
- **Available for providers:** `'v8' | 'istanbul'`
1331+
- **CLI:** `--coverage.processingConcurrency=<number>`
1332+
1333+
Concurrency limit used when processing the coverage results.
1334+
13261335
#### coverage.customProviderModule
13271336

13281337
- **Type:** `string`

Diff for: ‎packages/coverage-istanbul/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"vitest": "^1.0.0-0"
4646
},
4747
"dependencies": {
48+
"debug": "^4.3.4",
4849
"istanbul-lib-coverage": "^3.2.2",
4950
"istanbul-lib-instrument": "^6.0.1",
5051
"istanbul-lib-report": "^3.0.1",
@@ -55,6 +56,7 @@
5556
"test-exclude": "^6.0.0"
5657
},
5758
"devDependencies": {
59+
"@types/debug": "^4.1.12",
5860
"@types/istanbul-lib-coverage": "^2.0.6",
5961
"@types/istanbul-lib-instrument": "^1.7.7",
6062
"@types/istanbul-lib-report": "^3.0.3",

Diff for: ‎packages/coverage-istanbul/src/provider.ts

+94-37
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/c
55
import { BaseCoverageProvider } from 'vitest/coverage'
66
import c from 'picocolors'
77
import { parseModule } from 'magicast'
8+
import createDebug from 'debug'
89
import libReport from 'istanbul-lib-report'
910
import reports from 'istanbul-reports'
10-
import type { CoverageMap, CoverageMapData } from 'istanbul-lib-coverage'
11+
import type { CoverageMap } from 'istanbul-lib-coverage'
1112
import libCoverage from 'istanbul-lib-coverage'
1213
import libSourceMaps from 'istanbul-lib-source-maps'
1314
import { type Instrumenter, createInstrumenter } from 'istanbul-lib-instrument'
@@ -17,7 +18,8 @@ import _TestExclude from 'test-exclude'
1718
import { COVERAGE_STORE_KEY } from './constants'
1819

1920
type Options = ResolvedCoverageOptions<'istanbul'>
20-
type CoverageByTransformMode = Record<AfterSuiteRunMeta['transformMode'], CoverageMapData[]>
21+
type Filename = string
22+
type CoverageFilesByTransformMode = Record<AfterSuiteRunMeta['transformMode'], Filename[]>
2123
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT
2224

2325
interface TestExclude {
@@ -35,6 +37,8 @@ interface TestExclude {
3537
}
3638

3739
const DEFAULT_PROJECT = Symbol.for('default-project')
40+
const debug = createDebug('vitest:coverage')
41+
let uniqueId = 0
3842

3943
export class IstanbulCoverageProvider extends BaseCoverageProvider implements CoverageProvider {
4044
name = 'istanbul'
@@ -44,13 +48,9 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
4448
instrumenter!: Instrumenter
4549
testExclude!: InstanceType<TestExclude>
4650

47-
/**
48-
* Coverage objects collected from workers.
49-
* Some istanbul utilizers write these into file system instead of storing in memory.
50-
* If storing in memory causes issues, we can simply write these into fs in `onAfterSuiteRun`
51-
* and read them back when merging coverage objects in `onAfterAllFilesRun`.
52-
*/
53-
coverages = new Map<ProjectName, CoverageByTransformMode>()
51+
coverageFiles = new Map<ProjectName, CoverageFilesByTransformMode>()
52+
coverageFilesDirectory!: string
53+
pendingPromises: Promise<void>[] = []
5454

5555
initialize(ctx: Vitest) {
5656
const config: CoverageIstanbulOptions = ctx.config.coverage
@@ -96,6 +96,8 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
9696
extension: this.options.extension,
9797
relativePath: !this.options.allowExternal,
9898
})
99+
100+
this.coverageFilesDirectory = resolve(this.options.reportsDirectory, '.tmp')
99101
}
100102

101103
resolveOptions() {
@@ -121,43 +123,79 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
121123
* backwards compatibility is a breaking change.
122124
*/
123125
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
126+
if (!coverage)
127+
return
128+
124129
if (transformMode !== 'web' && transformMode !== 'ssr')
125130
throw new Error(`Invalid transform mode: ${transformMode}`)
126131

127-
let entry = this.coverages.get(projectName || DEFAULT_PROJECT)
132+
let entry = this.coverageFiles.get(projectName || DEFAULT_PROJECT)
128133

129134
if (!entry) {
130135
entry = { web: [], ssr: [] }
131-
this.coverages.set(projectName || DEFAULT_PROJECT, entry)
136+
this.coverageFiles.set(projectName || DEFAULT_PROJECT, entry)
132137
}
133138

134-
entry[transformMode].push(coverage as CoverageMapData)
139+
const filename = resolve(this.coverageFilesDirectory, `coverage-${uniqueId++}.json`)
140+
entry[transformMode].push(filename)
141+
142+
const promise = fs.writeFile(filename, JSON.stringify(coverage), 'utf-8')
143+
this.pendingPromises.push(promise)
135144
}
136145

137146
async clean(clean = true) {
138147
if (clean && existsSync(this.options.reportsDirectory))
139148
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })
140149

141-
this.coverages = new Map()
150+
if (existsSync(this.coverageFilesDirectory))
151+
await fs.rm(this.coverageFilesDirectory, { recursive: true, force: true, maxRetries: 10 })
152+
153+
await fs.mkdir(this.coverageFilesDirectory, { recursive: true })
154+
155+
this.coverageFiles = new Map()
156+
this.pendingPromises = []
142157
}
143158

144159
async reportCoverage({ allTestsRun }: ReportContext = {}) {
145-
const coverageMaps = await Promise.all(
146-
Array.from(this.coverages.values()).map(coverages => [
147-
mergeAndTransformCoverage(coverages.ssr),
148-
mergeAndTransformCoverage(coverages.web),
149-
]).flat(),
150-
)
160+
const coverageMap = libCoverage.createCoverageMap({})
161+
let index = 0
162+
const total = this.pendingPromises.length
163+
164+
await Promise.all(this.pendingPromises)
165+
this.pendingPromises = []
166+
167+
for (const coveragePerProject of this.coverageFiles.values()) {
168+
for (const filenames of [coveragePerProject.ssr, coveragePerProject.web]) {
169+
const coverageMapByTransformMode = libCoverage.createCoverageMap({})
170+
171+
for (const chunk of toSlices(filenames, this.options.processingConcurrency)) {
172+
if (debug.enabled) {
173+
index += chunk.length
174+
debug('Covered files %d/%d', index, total)
175+
}
176+
177+
await Promise.all(chunk.map(async (filename) => {
178+
const contents = await fs.readFile(filename, 'utf-8')
179+
const coverage = JSON.parse(contents) as CoverageMap
180+
181+
coverageMapByTransformMode.merge(coverage)
182+
}))
183+
}
184+
185+
// Source maps can change based on projectName and transform mode.
186+
// Coverage transform re-uses source maps so we need to separate transforms from each other.
187+
const transformedCoverage = await transformCoverage(coverageMapByTransformMode)
188+
coverageMap.merge(transformedCoverage)
189+
}
190+
}
151191

152192
if (this.options.all && allTestsRun) {
153-
const coveredFiles = coverageMaps.map(map => map.files()).flat()
193+
const coveredFiles = coverageMap.files()
154194
const uncoveredCoverage = await this.getCoverageMapForUncoveredFiles(coveredFiles)
155195

156-
coverageMaps.push(await mergeAndTransformCoverage([uncoveredCoverage]))
196+
coverageMap.merge(await transformCoverage(uncoveredCoverage))
157197
}
158198

159-
const coverageMap = mergeCoverageMaps(...coverageMaps)
160-
161199
const context = libReport.createContext({
162200
dir: this.options.reportsDirectory,
163201
coverageMap,
@@ -206,6 +244,9 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
206244
})
207245
}
208246
}
247+
248+
await fs.rm(this.coverageFilesDirectory, { recursive: true })
249+
this.coverageFiles = new Map()
209250
}
210251

211252
async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
@@ -218,31 +259,31 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
218259

219260
const coverageMap = libCoverage.createCoverageMap({})
220261

221-
for (const filename of uncoveredFiles) {
262+
// Note that these cannot be run parallel as synchronous instrumenter.lastFileCoverage
263+
// returns the coverage of the last transformed file
264+
for (const [index, filename] of uncoveredFiles.entries()) {
265+
debug('Uncovered file %s %d/%d', filename, index, uncoveredFiles.length)
266+
267+
// Make sure file is not served from cache
268+
// so that instrumenter loads up requested file coverage
269+
if (this.ctx.vitenode.fetchCache.has(filename))
270+
this.ctx.vitenode.fetchCache.delete(filename)
271+
222272
await this.ctx.vitenode.transformRequest(filename)
223273

224274
const lastCoverage = this.instrumenter.lastFileCoverage()
225275
coverageMap.addFileCoverage(lastCoverage)
226276
}
227277

228-
return coverageMap.data
278+
return coverageMap
229279
}
230280
}
231281

232-
async function mergeAndTransformCoverage(coverages: CoverageMapData[]) {
233-
const mergedCoverage = mergeCoverageMaps(...coverages)
234-
includeImplicitElseBranches(mergedCoverage)
282+
async function transformCoverage(coverageMap: CoverageMap) {
283+
includeImplicitElseBranches(coverageMap)
235284

236285
const sourceMapStore = libSourceMaps.createSourceMapStore()
237-
return await sourceMapStore.transformCoverage(mergedCoverage)
238-
}
239-
240-
function mergeCoverageMaps(...coverageMaps: (CoverageMap | CoverageMapData)[]) {
241-
return coverageMaps.reduce<CoverageMap>((coverage, previousCoverageMap) => {
242-
const map = libCoverage.createCoverageMap(coverage)
243-
map.merge(previousCoverageMap)
244-
return map
245-
}, libCoverage.createCoverageMap({}))
286+
return await sourceMapStore.transformCoverage(coverageMap)
246287
}
247288

248289
/**
@@ -302,3 +343,19 @@ function hasTerminalReporter(reporters: Options['reporter']) {
302343
|| reporter === 'text-lcov'
303344
|| reporter === 'teamcity')
304345
}
346+
347+
function toSlices<T>(array: T[], size: number): T[][] {
348+
return array.reduce<T[][]>((chunks, item) => {
349+
const index = Math.max(0, chunks.length - 1)
350+
const lastChunk = chunks[index] || []
351+
chunks[index] = lastChunk
352+
353+
if (lastChunk.length >= size)
354+
chunks.push([item])
355+
356+
else
357+
lastChunk.push(item)
358+
359+
return chunks
360+
}, [])
361+
}

Diff for: ‎packages/coverage-v8/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"dependencies": {
4848
"@ampproject/remapping": "^2.2.1",
4949
"@bcoe/v8-coverage": "^0.2.3",
50+
"debug": "^4.3.4",
5051
"istanbul-lib-coverage": "^3.2.2",
5152
"istanbul-lib-report": "^3.0.1",
5253
"istanbul-lib-source-maps": "^4.0.1",
@@ -59,6 +60,7 @@
5960
"v8-to-istanbul": "^9.2.0"
6061
},
6162
"devDependencies": {
63+
"@types/debug": "^4.1.12",
6264
"@types/istanbul-lib-coverage": "^2.0.6",
6365
"@types/istanbul-lib-report": "^3.0.3",
6466
"@types/istanbul-lib-source-maps": "^4.0.4",

0 commit comments

Comments
 (0)
Please sign in to comment.