[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

perf(coverage): speed up v8 report generation (bounded-memory merge + precompiled globs) (#10506)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>

authored by

Alexandru Georoceanu
Claude Opus 4.8 (1M context)
Ari Perkkiö
and committed by
GitHub
(Jul 14, 2026, 10:39 AM +0300) b05e5d63 ad065a7d

+41 -24
+15 -17
packages/coverage-v8/src/provider.ts
··· 5 5 import { existsSync, promises as fs } from 'node:fs' 6 6 import { fileURLToPath } from 'node:url' 7 7 // @ts-expect-error -- untyped 8 - import { mergeProcessCovs } from '@bcoe/v8-coverage' 8 + import { mergeScriptCovs } from '@bcoe/v8-coverage' 9 9 import astV8ToIstanbul from 'ast-v8-to-istanbul' 10 10 import libCoverage from 'istanbul-lib-coverage' 11 11 import libReport from 'istanbul-lib-report' ··· 59 59 const start = debug.enabled ? performance.now() : 0 60 60 61 61 const coverageMap = this.createCoverageMap() 62 - let merged: RawCoverage = { result: [] } 63 62 63 + const mergedScripts = new Map<ScriptCoverageWithOffset['url'], ScriptCoverageWithOffset>() 64 64 const autoAttachSubprocess = this.options.autoAttachSubprocess 65 65 66 66 await this.readCoverageFiles<RawCoverage>({ 67 67 onFileRead(coverage) { 68 - merged = mergeProcessCovs([merged, coverage]) 68 + for (const script of coverage.result) { 69 + const previous = mergedScripts.get(script.url) 70 + const merged: typeof script = mergeScriptCovs(previous ? [previous, script] : [script]) 69 71 70 - // mergeProcessCovs sometimes loses autoAttachSubprocess 71 - const fromExtendedContext = autoAttachSubprocess ? coverage.result.filter(r => r.isExtendedContext) : [] 72 + const startOffset = previous?.startOffset || script.startOffset || 0 73 + const isExtendedContext = previous?.isExtendedContext || script.isExtendedContext 72 74 73 - // mergeProcessCovs sometimes loses startOffset, e.g. in vue 74 - merged.result.forEach((result) => { 75 - if (!result.startOffset) { 76 - const original = coverage.result.find(r => r.url === result.url) 77 - result.startOffset = original?.startOffset || 0 75 + merged.startOffset ||= startOffset 76 + 77 + if (autoAttachSubprocess && isExtendedContext) { 78 + merged.isExtendedContext = true 78 79 } 79 80 80 - if (autoAttachSubprocess && !result.isExtendedContext) { 81 - const actual = fromExtendedContext.find(r => r.url === result.url) 82 - result.isExtendedContext = actual?.isExtendedContext 83 - } 84 - }) 81 + mergedScripts.set(merged.url, merged) 82 + } 85 83 }, 86 84 onFinished: async (project, environment) => { 87 85 // Source maps can change based on projectName and transform mode. 88 86 // Coverage transform re-uses source maps so we need to separate transforms from each other. 89 87 const converted = await this.convertCoverage( 90 - merged, 88 + { result: Array.from(mergedScripts.values()) }, 91 89 project, 92 90 environment, 93 91 ) 94 92 95 93 coverageMap.merge(converted) 96 94 97 - merged = { result: [] } 95 + mergedScripts.clear() 98 96 }, 99 97 onDebug: debug, 100 98 })
+26 -7
packages/vitest/src/node/coverage.ts
··· 87 87 options!: ResolvedCoverageOptions 88 88 globCache: Map<string, boolean> = new Map() 89 89 autoUpdateMarker = '\n// __VITEST_COVERAGE_MARKER__' 90 + globMatchers?: { matchExclude: (file: string) => boolean; matchInclude: (file: string) => boolean } 90 91 91 92 coverageFiles: CoverageFiles = new Map() 92 93 pendingPromises: Promise<void>[] = [] ··· 109 110 } 110 111 111 112 const config = ctx._coverageOptions 113 + 114 + this.globMatchers = undefined 112 115 113 116 this.options = { 114 117 ...coverageConfigDefaults, ··· 175 178 176 179 const relativeFilename = matchingRoot ? relative(matchingRoot, filename) : filename 177 180 178 - if (pm.isMatch(relativeFilename, this.options.exclude, { dot: true })) { 181 + const { matchExclude, matchInclude } = this.getGlobMatchers() 182 + 183 + if (matchExclude(relativeFilename)) { 179 184 this.globCache.set(filename, false) 180 185 return false 181 186 } 182 187 183 188 // By default `coverage.include` matches all files, except "coverage.exclude" 184 - const glob = this.options.include || '**' 185 - 186 - let included = pm.isMatch(relativeFilename, glob, { 187 - dot: true, 188 - ignore: this.options.exclude, 189 - }) 189 + let included = matchInclude(relativeFilename) 190 190 191 191 if (included && this.changedFiles) { 192 192 included = this.changedFiles.includes(filename) ··· 195 195 this.globCache.set(filename, included) 196 196 197 197 return included 198 + } 199 + 200 + /** 201 + * Compile `coverage.include`/`coverage.exclude` into reusable matchers once. 202 + * `picomatch.isMatch(file, patterns, options)` recompiles the patterns on 203 + * every call, which dominates the filtering step on large test suites. 204 + */ 205 + private getGlobMatchers(): { matchExclude: (file: string) => boolean; matchInclude: (file: string) => boolean } { 206 + if (!this.globMatchers) { 207 + const exclude = this.options.exclude 208 + const include = this.options.include 209 + 210 + this.globMatchers = { 211 + matchExclude: exclude.length ? pm(exclude, { dot: true }) : () => false, 212 + matchInclude: include ? pm(include, { dot: true, ignore: exclude }) : () => true, 213 + } 214 + } 215 + 216 + return this.globMatchers 198 217 } 199 218 200 219 private async getUntestedFilesByRoot(