Skip to content

Commit b2d7f32

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

2 files changed

Lines changed: 70 additions & 13 deletions

File tree

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,6 @@ export async function createVitestConfigPlugin(
281281
test: {
282282
name: projectName,
283283
include,
284-
fsModuleCache: false,
285284
// CLI provider browser options override, if present
286285
...(browser ? { browser } : {}),
287286
// Only override if the user explicitly set it via CLI
@@ -301,7 +300,6 @@ export async function createVitestConfigPlugin(
301300

302301
return {
303302
test: {
304-
fsModuleCache: false,
305303
coverage: await generateCoverageOption(
306304
options.coverage,
307305
testConfig?.coverage,
@@ -335,12 +333,37 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
335333
);
336334
let vitestConfig: ResolvedConfig;
337335

336+
/** The build output path served for a module, whether it is a test entry point or a built artifact. */
337+
const getOutputPath = (id: string): string => {
338+
const entryPoint = testFileToEntryPoint.get(id);
339+
340+
return entryPoint ? entryPoint + '.js' : toPosixPath(path.relative(workspaceRoot, id));
341+
};
342+
338343
return [
339344
{
340345
name: 'angular:test-in-memory-provider',
341346
enforce: 'pre',
342347
configureVitest(context: VitestPluginContext) {
343348
vitestConfig = context.vitest.config;
349+
350+
// Vitest keys its file system module cache on the file found on disk. A test entry point is
351+
// served from its bundle instead, which also holds the application code it imports, so the
352+
// key must follow the served build output, or the cache would return a stale bundle.
353+
context.defineCacheKeyGenerator(({ id }) => {
354+
const outputPath = getOutputPath(id);
355+
const outputFile = buildResultFiles.get(outputPath);
356+
if (!outputFile) {
357+
return undefined;
358+
}
359+
if (outputFile.origin !== 'memory') {
360+
return false;
361+
}
362+
363+
const sourceMapFile = buildResultFiles.get(outputPath + '.map');
364+
365+
return `${outputFile.hash}:${sourceMapFile?.origin === 'memory' ? sourceMapFile.hash : ''}`;
366+
});
344367
},
345368
resolveId: (id: string, importer: string | undefined) => {
346369
// Fast path for test entry points.
@@ -399,12 +422,9 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
399422
async load(id: string) {
400423
assert(buildResultFiles.size > 0, 'buildResult must be available for in-memory loading.');
401424

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-
425+
// Attempt to load as a source test file, or else as a built artifact.
426+
const outputPath = getOutputPath(id);
427+
if (testFileToEntryPoint.has(id)) {
408428
// Setup files must not be wrapped in a virtual import stub because Vitest only invalidates
409429
// the setup file itself between test files; wrapping it would cause the underlying bundle
410430
// to be cached, preventing per-test hooks from running on subsequent test files.
@@ -417,10 +437,6 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
417437
code: `import "./${outputPath}";`,
418438
};
419439
}
420-
} else {
421-
// Attempt to load as a built artifact.
422-
const relativePath = path.relative(workspaceRoot, id);
423-
outputPath = toPosixPath(relativePath);
424440
}
425441

426442
const outputFile = buildResultFiles.get(outputPath);

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

Lines changed: 42 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,47 @@ 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);
654695
});
655696
});
656697
});

0 commit comments

Comments
 (0)