From 9f1aabc222e35a062469dd0c008ac8ca795e2132 Mon Sep 17 00:00:00 2001 From: Julien Venet Date: Tue, 20 Oct 2026 08:28:15 -0400 Subject: [PATCH] fix(@angular/build): key the Vitest fsModuleCache on the served build output --- .../unit-test/runners/vitest/plugins.ts | 45 ++++++++++++++----- .../behavior/runner-config-vitest_spec.ts | 43 +++++++++++++++++- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index 72308b6b57d6..894df99bb47d 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -281,7 +281,6 @@ export async function createVitestConfigPlugin( test: { name: projectName, include, - fsModuleCache: false, // CLI provider browser options override, if present ...(browser ? { browser } : {}), // Only override if the user explicitly set it via CLI @@ -301,7 +300,6 @@ export async function createVitestConfigPlugin( return { test: { - fsModuleCache: false, coverage: await generateCoverageOption( options.coverage, testConfig?.coverage, @@ -335,12 +333,42 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] ); let vitestConfig: ResolvedConfig; + /** The build output path served for a module, whether it is a test entry point or a built artifact. */ + const getOutputPath = (id: string): string => { + const entryPoint = testFileToEntryPoint.get(id); + + return entryPoint ? entryPoint + '.js' : toPosixPath(path.relative(workspaceRoot, id)); + }; + return [ { name: 'angular:test-in-memory-provider', enforce: 'pre', configureVitest(context: VitestPluginContext) { vitestConfig = context.vitest.config; + + // Vitest keys its file system module cache on the file found on disk. A test entry point is + // served from its bundle instead, which also holds the application code it imports, so the + // key must follow the served build output, or the cache would return a stale bundle. + // Vitest 4.1 only has the experimental name, and 4.0 has no file system module cache at all. + const pluginContext: Partial = context; + const defineCacheKeyGenerator = + pluginContext.defineCacheKeyGenerator ?? + pluginContext.experimental_defineCacheKeyGenerator; + defineCacheKeyGenerator?.(({ id }) => { + const outputPath = getOutputPath(id); + const outputFile = buildResultFiles.get(outputPath); + if (!outputFile) { + return undefined; + } + if (outputFile.origin !== 'memory') { + return false; + } + + const sourceMapFile = buildResultFiles.get(outputPath + '.map'); + + return `${outputFile.hash}:${sourceMapFile?.origin === 'memory' ? sourceMapFile.hash : ''}`; + }); }, resolveId: (id: string, importer: string | undefined) => { // Fast path for test entry points. @@ -399,12 +427,9 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] async load(id: string) { assert(buildResultFiles.size > 0, 'buildResult must be available for in-memory loading.'); - // Attempt to load as a source test file. - const entryPoint = testFileToEntryPoint.get(id); - let outputPath; - if (entryPoint) { - outputPath = entryPoint + '.js'; - + // Attempt to load as a source test file, or else as a built artifact. + const outputPath = getOutputPath(id); + if (testFileToEntryPoint.has(id)) { // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates // the setup file itself between test files; wrapping it would cause the underlying bundle // to be cached, preventing per-test hooks from running on subsequent test files. @@ -417,10 +442,6 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] code: `import "./${outputPath}";`, }; } - } else { - // Attempt to load as a built artifact. - const relativePath = path.relative(workspaceRoot, id); - outputPath = toPosixPath(relativePath); } const outputFile = buildResultFiles.get(outputPath); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts index 53039adf53e8..75c96986cdc8 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { readdirSync } from 'node:fs'; import { execute } from '../../index'; import { BASE_OPTIONS, @@ -650,7 +651,47 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const secondRun = await harness.executeOnce(); expect(secondRun.result?.success).toBeTrue(); - harness.expectDirectory('.vitest-cache').toNotExist(); + harness.expectDirectory('.vitest-cache').toExist(); + }); + + it('should reuse the cached modules across runs when nothing changes', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + await harness.writeFiles({ + 'vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + fsModuleCache: true, + fsModuleCachePath: './.vitest-cache', + }, + }); + `, + 'src/app/value.ts': `export const VALUE = 'initial';`, + 'src/app/app.component.spec.ts': ` + import { test, expect } from 'vitest'; + import { VALUE } from './value'; + + test('should use the value from imported module', () => { + expect(VALUE).toBe('initial'); + }); + `, + }); + const cacheEntries = () => + readdirSync(harness.resolvePath('.vitest-cache'), { recursive: true }).sort(); + + const firstRun = await harness.executeOnce(); + expect(firstRun.result?.success).toBeTrue(); + const entriesAfterFirstRun = cacheEntries(); + expect(entriesAfterFirstRun.length).toBeGreaterThan(0); + + // An unchanged build produces the same bundles, so no new cache entry is written. + const secondRun = await harness.executeOnce(); + expect(secondRun.result?.success).toBeTrue(); + expect(cacheEntries()).toEqual(entriesAfterFirstRun); }); }); });