Skip to content

Commit 54a2bdb

Browse files
authored
feat: allow coverage providers to store data in fs module cache (#11747)
1 parent 9f9b1cf commit 54a2bdb

5 files changed

Lines changed: 136 additions & 13 deletions

File tree

‎packages/vitest/src/node/cache/fsModuleCache.ts‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class FileSystemModuleCache {
4343
private rootCache: string
4444
private metadataFilePath: string
4545

46-
private version = '1.0.0-beta.8'
46+
private version = '1.0.0-beta.9'
4747
private fsCacheRoots = new WeakMap<ResolvedConfig, string>()
4848
private fsEnvironmentHashMap = new WeakMap<DevEnvironment, string>()
4949
private fsCacheKeyGenerators = new WeakMap<ResolvedConfig, Set<CacheKeyIdGenerator>>()
@@ -152,6 +152,8 @@ export class FileSystemModuleCache {
152152
deps: meta.deps,
153153
dynamicDeps: meta.dynamicDeps,
154154
staticMocks: meta.staticMocks,
155+
hasCoverageData: meta.hasCoverageData,
156+
coverageData: meta.hasCoverageData ? await readCoverageData(cachedFilePath) : undefined,
155157
}
156158
}
157159

@@ -161,6 +163,7 @@ export class FileSystemModuleCache {
161163
transformResult: TransformResult | null,
162164
imports: CachedModuleImports = { urls: [], ids: {} },
163165
mappings: boolean = false,
166+
coverageData?: unknown,
164167
): Promise<void> {
165168
if ('code' in fetchResult) {
166169
const result = {
@@ -172,15 +175,40 @@ export class FileSystemModuleCache {
172175
deps: transformResult?.deps,
173176
dynamicDeps: transformResult?.dynamicDeps,
174177
staticMocks: transformResult?.__vitestStaticMocks,
175-
} satisfies Omit<CachedInlineModuleMeta, 'code'>
178+
hasCoverageData: coverageData !== undefined,
179+
} satisfies Omit<CachedInlineModuleMeta, 'code' | 'coverageData'>
176180
debugFs?.(`${c.yellow('[write]')} ${fetchResult.id} is cached in ${cachedFilePath}`)
177-
await atomicWriteFile(
178-
cachedFilePath,
179-
`${fetchResult.code}${cacheComment}${this.toBase64(result)}`,
180-
)
181+
182+
await Promise.all([
183+
// Coverage data is stored in separate file so that it's not transferred to the workers
184+
result.hasCoverageData &&
185+
atomicWriteFile(getCoverageDataPath(cachedFilePath), JSON.stringify(coverageData)),
186+
187+
atomicWriteFile(
188+
cachedFilePath,
189+
`${fetchResult.code}${cacheComment}${this.toBase64(result)}`,
190+
),
191+
])
181192
}
182193
}
183194

195+
getCoverageData(environment: DevEnvironment, id: string): unknown {
196+
const coverageProvider = this.vitest.coverageProvider
197+
198+
if (!this.vitest.config.coverage.enabled || !coverageProvider?.requiresTransform?.(id)) {
199+
return
200+
}
201+
202+
return coverageProvider.getModuleCacheData?.(environment, id)
203+
}
204+
205+
/**
206+
* Passes the data of the coverage provider back to it when the cached module is used
207+
*/
208+
restoreCoverageData(environment: DevEnvironment, id: string, data: unknown): void {
209+
this.vitest.coverageProvider?.restoreModuleCacheData?.(environment, id, data)
210+
}
211+
184212
private toBase64(obj: unknown) {
185213
const json = stringify(obj)
186214
return Buffer.from(json).toString('base64')
@@ -242,9 +270,13 @@ export class FileSystemModuleCache {
242270
const config = environment.config
243271
// coverage provider is dynamic, so we also clear the whole cache if
244272
// vitest.enableCoverage/vitest.disableCoverage is called
245-
const coverageAffectsCache = String(
246-
this.vitest.config.coverage.enabled && this.vitest.coverageProvider?.requiresTransform?.(id),
247-
)
273+
const coverageProvider = this.vitest.coverageProvider
274+
275+
const coverageAffectsCache =
276+
this.vitest.config.coverage.enabled && coverageProvider?.requiresTransform?.(id)
277+
? `${coverageProvider.name}@${coverageProvider.version}`
278+
: 'false'
279+
248280
let environmentHash = this.fsEnvironmentHashMap.get(environment)
249281
if (!environmentHash) {
250282
const cacheConfig = JSON.stringify(
@@ -441,6 +473,8 @@ export interface CachedInlineModuleMeta {
441473
deps?: string[]
442474
dynamicDeps?: string[]
443475
staticMocks?: StaticMockCall[] | null
476+
hasCoverageData?: boolean
477+
coverageData?: unknown
444478
}
445479

446480
export interface CachedModuleImports {
@@ -591,3 +625,13 @@ function tryStatSync(file: string): fs.Stats | undefined {
591625
// Ignore errors
592626
}
593627
}
628+
629+
async function readCoverageData(cachedFilePath: string): Promise<unknown> {
630+
try {
631+
return JSON.parse(await readFile(getCoverageDataPath(cachedFilePath), 'utf-8'))
632+
} catch {}
633+
}
634+
635+
function getCoverageDataPath(cachedFilePath: string): string {
636+
return `${cachedFilePath}.coverage.json`
637+
}

‎packages/vitest/src/node/environments/transformService.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,15 @@ export class ModuleTransformService {
220220
const imports = this.getCachedImports(environment, moduleGraphModule)
221221
const map = moduleGraphModule.transformResult?.map
222222
const mappings = map && !('version' in map) && map.mappings === ''
223+
const coverageData = this.fsCache.getCoverageData(environment, moduleGraphModule.id!)
223224

224225
const cachedResult = await this.cacheResult(
225226
result,
226227
cachePath,
227228
moduleGraphModule.transformResult,
228229
imports,
229230
!!mappings,
231+
coverageData,
230232
)
231233
// remember where the code is stored on disk so that repeat fetches and the
232234
// `fetchWarmModules` snapshot can point at it in this session already, not
@@ -363,6 +365,11 @@ export class ModuleTransformService {
363365
return
364366
}
365367

368+
// Notify coverage provider that cached results were read
369+
if (cachedModule.hasCoverageData) {
370+
this.fsCache.restoreCoverageData(environment, cachedModule.id, cachedModule.coverageData)
371+
}
372+
366373
// keep the module graph in sync
367374
let map: Rollup.SourceMap | null | { mappings: '' } = extractSourceMap(cachedModule.code)
368375
if (map && cachedModule.file) {
@@ -508,6 +515,7 @@ export class ModuleTransformService {
508515
transformResult: TransformResult | null,
509516
imports?: CachedModuleImports,
510517
mappings = false,
518+
coverageData?: unknown,
511519
): Promise<FetchResult | FetchCachedFileSystemResult> {
512520
const returnResult = 'code' in result ? getCachedResult(result, cachePath) : result
513521

@@ -516,7 +524,7 @@ export class ModuleTransformService {
516524
}
517525

518526
const savePromise = this.fsCache
519-
.saveCachedModule(cachePath, result, transformResult, imports, mappings)
527+
.saveCachedModule(cachePath, result, transformResult, imports, mappings, coverageData)
520528
.then(() => returnResult)
521529
.catch((error) => {
522530
debugFs?.(`failed to cache ${cachePath}, serving it inline: ${error}`)

‎packages/vitest/src/node/types/coverage.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReportOptions } from '@vitest/istanbul-lib-report'
22
import type { Arrayable } from '@vitest/utils'
3-
import type { ESTree, TransformResult as ViteTransformResult } from 'vite'
3+
import type { DevEnvironment, ESTree, TransformResult as ViteTransformResult } from 'vite'
44
import type { AfterSuiteRunMeta } from '../../types/general'
55
import type {
66
RuntimeCoverageModuleLoader,
@@ -57,6 +57,27 @@ export interface CoverageProvider {
5757
*/
5858
requiresTransform?: (id: string) => boolean
5959

60+
/**
61+
* Version of the coverage provider. Can be used as cache key in various caching mechanisms.
62+
* @experimental
63+
*/
64+
version?: string
65+
66+
/**
67+
* Return data that should be stored with the transformed module in `fsModuleCache`,
68+
* e.g. data collected in `onFileTransform`. The data must be JSON serializable.
69+
* When the module is later loaded from `fsModuleCache`, `onFileTransform` is not called.
70+
* The data is passed to `restoreModuleCacheData` instead.
71+
* @experimental
72+
*/
73+
getModuleCacheData?: (environment: DevEnvironment, id: string) => unknown
74+
75+
/**
76+
* Called with the data returned by `getModuleCacheData` when the module is loaded from `fsModuleCache`.
77+
* @experimental
78+
*/
79+
restoreModuleCacheData?: (environment: DevEnvironment, id: string, data: unknown) => void
80+
6081
/** Callback that's called when the coverage is enabled via a programmatic `enableCoverage` API. */
6182
onEnabled?: () => void | Promise<void>
6283
}

‎test/coverage-test/fixtures/custom-provider.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class CustomCoverageProvider implements CoverageProvider {
4848
calls: Set<string> = new Set()
4949
coverageReports: Set<string> = new Set()
5050
transformedFiles: Set<string> = new Set()
51+
restoredFiles: Set<string> = new Set()
5152

5253
initialize(ctx: Vitest) {
5354
this.options = ctx.config.coverage
@@ -83,6 +84,7 @@ class CustomCoverageProvider implements CoverageProvider {
8384
calls: Array.from(this.calls.values()),
8485
coverageReports: Array.from(this.coverageReports.values()).sort(),
8586
transformedFiles: Array.from(this.transformedFiles.values()).sort(),
87+
restoredFiles: Array.from(this.restoredFiles.values()).sort(),
8688
}, null, 2)
8789

8890
if (existsSync('./coverage')) {
@@ -103,6 +105,18 @@ class CustomCoverageProvider implements CoverageProvider {
103105
return { code }
104106
}
105107

108+
requiresTransform(id: string) {
109+
return /\/fixtures\/src\//.test(normalizeFilename(id))
110+
}
111+
112+
getModuleCacheData(_environment: unknown, id: string) {
113+
return { cachedBy: this.name, filename: normalizeFilename(id).split('?')[0] }
114+
}
115+
116+
restoreModuleCacheData(_environment: unknown, id: string, data: unknown) {
117+
this.restoredFiles.add(`${normalizeFilename(id).split('?')[0]} ${JSON.stringify(data)}`)
118+
}
119+
106120
resolveOptions(): ResolvedCoverageOptions {
107121
this.calls.add('resolveOptions')
108122

‎test/coverage-test/test/custom-provider.custom.test.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFileSync } from 'node:fs'
1+
import { readFileSync, rmSync } from 'node:fs'
22
import { expect } from 'vitest'
33
import { runVitest, test } from '../utils'
44

@@ -26,7 +26,43 @@ test('custom provider', async () => {
2626
"transformedFiles": [
2727
"<process-cwd>/fixtures/src/even.ts",
2828
"<process-cwd>/fixtures/src/math.ts"
29-
]
29+
],
30+
"restoredFiles": []
3031
}"
3132
`)
3233
})
34+
35+
test('custom provider restores its data from fs module cache', async () => {
36+
const fsModuleCachePath = './node_modules/.vitest-custom-provider-cache'
37+
rmSync(fsModuleCachePath, { recursive: true, force: true })
38+
39+
const config = {
40+
include: ['fixtures/test/math.test.ts', 'fixtures/test/even.test.ts'],
41+
fsModuleCache: true,
42+
fsModuleCachePath,
43+
}
44+
45+
// first run transforms the files and stores their data in the cache
46+
await runVitest(config)
47+
const first = JSON.parse(readFileSync('./coverage/custom-coverage-provider-report.json', 'utf-8'))
48+
49+
expect(first.transformedFiles).toEqual([
50+
'<process-cwd>/fixtures/src/even.ts',
51+
'<process-cwd>/fixtures/src/math.ts',
52+
])
53+
expect(first.restoredFiles).toEqual([])
54+
55+
// second run loads the files from the cache and doesn't transform them
56+
await runVitest(config)
57+
const second = JSON.parse(
58+
readFileSync('./coverage/custom-coverage-provider-report.json', 'utf-8'),
59+
)
60+
61+
expect(second.transformedFiles).toEqual([])
62+
expect(second.restoredFiles).toEqual([
63+
'<process-cwd>/fixtures/src/even.ts {"cachedBy":"custom-coverage-provider","filename":"<process-cwd>/fixtures/src/even.ts"}',
64+
'<process-cwd>/fixtures/src/math.ts {"cachedBy":"custom-coverage-provider","filename":"<process-cwd>/fixtures/src/math.ts"}',
65+
])
66+
67+
rmSync(fsModuleCachePath, { recursive: true, force: true })
68+
})

0 commit comments

Comments
 (0)