@@ -19,6 +19,7 @@ import type {
1919 VitestPluginContext ,
2020} from 'vitest/node' ;
2121import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware' ;
22+ import type { NormalizedCachedOptions } from '../../../../utils/normalize-cache' ;
2223import { toPosixPath } from '../../../../utils/path' ;
2324import { createProjectResolver } from '../../../../utils/resolve-project' ;
2425import type { ResultFile } from '../../../application/results' ;
@@ -44,6 +45,7 @@ type VitestCoverageOption = Exclude<InlineConfig['coverage'], undefined>;
4445
4546interface VitestConfigPluginOptions {
4647 browser : BrowserConfigOptions | undefined ;
48+ cacheOptions : NormalizedCachedOptions ;
4749 coverage : NormalizedUnitTestBuilderOptions [ 'coverage' ] ;
4850 projectName : string ;
4951 projectSourceRoot : string ;
@@ -255,6 +257,14 @@ export async function createVitestConfigPlugin(
255257
256258 const projectResolver = createProjectResolver ( projectSourceRoot ) ;
257259
260+ // Vitest enables its file system module cache by default and stores it in `node_modules`,
261+ // regardless of `cache` and `cacheDir`. Follow the Angular CLI cache settings instead,
262+ // unless the Vitest configuration file sets them.
263+ const fsModuleCache = testConfig ?. fsModuleCache ?? options . cacheOptions . enabled ;
264+ const fsModuleCachePath =
265+ testConfig ?. fsModuleCachePath ??
266+ path . join ( options . cacheOptions . path , 'vitest-module-cache' ) ;
267+
258268 const projectDefaults : Vite . UserConfig & UserWorkspaceConfig = {
259269 extends : false ,
260270 test : {
@@ -263,6 +273,8 @@ export async function createVitestConfigPlugin(
263273 // Default to `false` to align with the Karma/Jasmine experience.
264274 isolate : false ,
265275 sequence : { setupFiles : 'list' } ,
276+ fsModuleCache,
277+ fsModuleCachePath,
266278 } ,
267279 optimizeDeps : {
268280 noDiscovery : true ,
@@ -281,7 +293,6 @@ export async function createVitestConfigPlugin(
281293 test : {
282294 name : projectName ,
283295 include,
284- fsModuleCache : false ,
285296 // CLI provider browser options override, if present
286297 ...( browser ? { browser } : { } ) ,
287298 // Only override if the user explicitly set it via CLI
@@ -301,7 +312,9 @@ export async function createVitestConfigPlugin(
301312
302313 return {
303314 test : {
304- fsModuleCache : false ,
315+ // The cache metadata is always read from the root configuration.
316+ fsModuleCache,
317+ fsModuleCachePath,
305318 coverage : await generateCoverageOption (
306319 options . coverage ,
307320 testConfig ?. coverage ,
@@ -335,12 +348,53 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
335348 ) ;
336349 let vitestConfig : ResolvedConfig ;
337350
351+ /** The build output path served for a module, whether it is a test entry point or a built artifact. */
352+ const getOutputPath = ( id : string ) : string => {
353+ const entryPoint = testFileToEntryPoint . get ( id ) ;
354+
355+ return entryPoint ? entryPoint + '.js' : toPosixPath ( path . relative ( workspaceRoot , id ) ) ;
356+ } ;
357+
358+ // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates
359+ // the setup file itself between test files; wrapping it would cause the underlying bundle
360+ // to be cached, preventing per-test hooks from running on subsequent test files.
361+ const isCoverageRedirect = ( id : string ) : boolean =>
362+ ! ! vitestConfig ?. coverage ?. enabled && testFileToEntryPoint . has ( id ) && ! setupFileSet . has ( id ) ;
363+
338364 return [
339365 {
340366 name : 'angular:test-in-memory-provider' ,
341367 enforce : 'pre' ,
342368 configureVitest ( context : VitestPluginContext ) {
343369 vitestConfig = context . vitest . config ;
370+
371+ // Vitest keys its file system module cache on the file found on disk. A test entry point is
372+ // served from its bundle instead, which also holds the application code it imports, so the
373+ // key must follow the served build output, or the cache would return a stale bundle.
374+ // Vitest 4.1 only has the experimental name, and 4.0 has no file system module cache at all.
375+ const pluginContext : Partial < VitestPluginContext > = context ;
376+ const defineCacheKeyGenerator =
377+ pluginContext . defineCacheKeyGenerator ??
378+ pluginContext . experimental_defineCacheKeyGenerator ;
379+ defineCacheKeyGenerator ?.( ( { id } ) => {
380+ const outputPath = getOutputPath ( id ) ;
381+ // With coverage, `load` serves a redirect stub instead of the bundle, see below.
382+ if ( isCoverageRedirect ( id ) ) {
383+ return `coverage-redirect:${ outputPath } ` ;
384+ }
385+
386+ const outputFile = buildResultFiles . get ( outputPath ) ;
387+ if ( ! outputFile ) {
388+ return undefined ;
389+ }
390+ if ( outputFile . origin !== 'memory' ) {
391+ return false ;
392+ }
393+
394+ const sourceMapFile = buildResultFiles . get ( outputPath + '.map' ) ;
395+
396+ return `${ outputFile . hash } :${ sourceMapFile ?. origin === 'memory' ? sourceMapFile . hash : '' } ` ;
397+ } ) ;
344398 } ,
345399 resolveId : ( id : string , importer : string | undefined ) => {
346400 // Fast path for test entry points.
@@ -399,28 +453,15 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
399453 async load ( id : string ) {
400454 assert ( buildResultFiles . size > 0 , 'buildResult must be available for in-memory loading.' ) ;
401455
402- // Attempt to load as a source test file.
403- const entryPoint = testFileToEntryPoint . get ( id ) ;
404- let outputPath ;
405- if ( entryPoint ) {
406- outputPath = entryPoint + '.js' ;
407-
408- // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates
409- // the setup file itself between test files; wrapping it would cause the underlying bundle
410- // to be cached, preventing per-test hooks from running on subsequent test files.
411- const isSetupFile = setupFileSet . has ( id ) ;
412- if ( vitestConfig ?. coverage ?. enabled && ! isSetupFile ) {
413- // To support coverage exclusion of the actual test file, the virtual
414- // test entry point only references the built and bundled intermediate file.
415- // If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely.
416- return {
417- code : `import "./${ outputPath } ";` ,
418- } ;
419- }
420- } else {
421- // Attempt to load as a built artifact.
422- const relativePath = path . relative ( workspaceRoot , id ) ;
423- outputPath = toPosixPath ( relativePath ) ;
456+ // Attempt to load as a source test file, or else as a built artifact.
457+ const outputPath = getOutputPath ( id ) ;
458+ if ( isCoverageRedirect ( id ) ) {
459+ // To support coverage exclusion of the actual test file, the virtual
460+ // test entry point only references the built and bundled intermediate file.
461+ // If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely.
462+ return {
463+ code : `import "./${ outputPath } ";` ,
464+ } ;
424465 }
425466
426467 const outputFile = buildResultFiles . get ( outputPath ) ;
0 commit comments