Skip to content

Commit 3b7fd40

Browse files
committed
fix(@angular/build): key the Vitest fsModuleCache on the served build output
1 parent 0fd4404 commit 3b7fd40

3 files changed

Lines changed: 161 additions & 25 deletions

File tree

‎packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ export class VitestExecutor implements TestExecutor {
401401
plugins: [
402402
await createVitestConfigPlugin({
403403
browser: browserOptions.browser,
404+
cacheOptions,
404405
coverage,
405406
projectName,
406407
projectSourceRoot,

‎packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts‎

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
VitestPluginContext,
2020
} from 'vitest/node';
2121
import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware';
22+
import type { NormalizedCachedOptions } from '../../../../utils/normalize-cache';
2223
import { toPosixPath } from '../../../../utils/path';
2324
import { createProjectResolver } from '../../../../utils/resolve-project';
2425
import type { ResultFile } from '../../../application/results';
@@ -44,6 +45,7 @@ type VitestCoverageOption = Exclude<InlineConfig['coverage'], undefined>;
4445

4546
interface 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);

‎packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { readdirSync } from 'node:fs';
910
import { execute } from '../../index';
1011
import {
1112
BASE_OPTIONS,
@@ -650,7 +651,100 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
650651

651652
const secondRun = await harness.executeOnce();
652653
expect(secondRun.result?.success).toBeTrue();
653-
harness.expectDirectory('.vitest-cache').toNotExist();
654+
harness.expectDirectory('.vitest-cache').toExist();
655+
});
656+
657+
it('should reuse the cached modules across runs when nothing changes', async () => {
658+
harness.useTarget('test', {
659+
...BASE_OPTIONS,
660+
runnerConfig: 'vitest.config.mts',
661+
});
662+
663+
await harness.writeFiles({
664+
'vitest.config.mts': `
665+
import { defineConfig } from 'vitest/config';
666+
export default defineConfig({
667+
test: {
668+
fsModuleCache: true,
669+
fsModuleCachePath: './.vitest-cache',
670+
},
671+
});
672+
`,
673+
'src/app/value.ts': `export const VALUE = 'initial';`,
674+
'src/app/app.component.spec.ts': `
675+
import { test, expect } from 'vitest';
676+
import { VALUE } from './value';
677+
678+
test('should use the value from imported module', () => {
679+
expect(VALUE).toBe('initial');
680+
});
681+
`,
682+
});
683+
const cacheEntries = () =>
684+
readdirSync(harness.resolvePath('.vitest-cache'), { recursive: true }).sort();
685+
686+
const firstRun = await harness.executeOnce();
687+
expect(firstRun.result?.success).toBeTrue();
688+
const entriesAfterFirstRun = cacheEntries();
689+
expect(entriesAfterFirstRun.length).toBeGreaterThan(0);
690+
691+
// An unchanged build produces the same bundles, so no new cache entry is written.
692+
const secondRun = await harness.executeOnce();
693+
expect(secondRun.result?.success).toBeTrue();
694+
expect(cacheEntries()).toEqual(entriesAfterFirstRun);
695+
});
696+
697+
it('should not write the module cache when the Angular CLI cache is disabled', async () => {
698+
harness.useTarget('test', {
699+
...BASE_OPTIONS,
700+
});
701+
702+
const { result } = await harness.executeOnce();
703+
expect(result?.success).toBeTrue();
704+
harness.expectDirectory('node_modules/.vitest-cache').toNotExist();
705+
});
706+
707+
it('should not serve the cached bundle of a test entry point once coverage is enabled', async () => {
708+
await harness.writeFiles({
709+
'vitest.config.mts': `
710+
import { defineConfig } from 'vitest/config';
711+
export default defineConfig({
712+
test: {
713+
fsModuleCache: true,
714+
fsModuleCachePath: './.vitest-cache',
715+
},
716+
});
717+
`,
718+
'src/app/value.ts': `export const VALUE = 'initial';`,
719+
'src/app/app.component.spec.ts': `
720+
import { test, expect } from 'vitest';
721+
import { VALUE } from './value';
722+
723+
test('should use the value from imported module', () => {
724+
expect(VALUE).toBe('initial');
725+
});
726+
`,
727+
});
728+
729+
harness.useTarget('test', {
730+
...BASE_OPTIONS,
731+
runnerConfig: 'vitest.config.mts',
732+
});
733+
const firstRun = await harness.executeOnce();
734+
expect(firstRun.result?.success).toBeTrue();
735+
736+
harness.useTarget('test', {
737+
...BASE_OPTIONS,
738+
runnerConfig: 'vitest.config.mts',
739+
coverage: true,
740+
coverageReporters: ['json'] as any,
741+
});
742+
const secondRun = await harness.executeOnce();
743+
expect(secondRun.result?.success).toBeTrue();
744+
745+
// The application code is only reported when the spec is served as the coverage redirect stub.
746+
const coverageMap = JSON.parse(harness.readFile('coverage/test/coverage-final.json'));
747+
expect(Object.keys(coverageMap).some((file) => file.endsWith('value.ts'))).toBeTrue();
654748
});
655749
});
656750
});

0 commit comments

Comments
 (0)