Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ export class VitestExecutor implements TestExecutor {
plugins: [
await createVitestConfigPlugin({
browser: browserOptions.browser,
cacheOptions,
coverage,
projectName,
projectSourceRoot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
VitestPluginContext,
} from 'vitest/node';
import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware';
import type { NormalizedCachedOptions } from '../../../../utils/normalize-cache';
import { toPosixPath } from '../../../../utils/path';
import { createProjectResolver } from '../../../../utils/resolve-project';
import type { ResultFile } from '../../../application/results';
Expand All @@ -44,6 +45,7 @@ type VitestCoverageOption = Exclude<InlineConfig['coverage'], undefined>;

interface VitestConfigPluginOptions {
browser: BrowserConfigOptions | undefined;
cacheOptions: NormalizedCachedOptions;
coverage: NormalizedUnitTestBuilderOptions['coverage'];
projectName: string;
projectSourceRoot: string;
Expand Down Expand Up @@ -255,6 +257,14 @@ export async function createVitestConfigPlugin(

const projectResolver = createProjectResolver(projectSourceRoot);

// Vitest enables its file system module cache by default and stores it in `node_modules`,
// regardless of `cache` and `cacheDir`. Follow the Angular CLI cache settings instead,
// unless the Vitest configuration file sets them.
const fsModuleCache = testConfig?.fsModuleCache ?? options.cacheOptions.enabled;
const fsModuleCachePath =
testConfig?.fsModuleCachePath ??
path.join(options.cacheOptions.path, 'vitest-module-cache');

const projectDefaults: Vite.UserConfig & UserWorkspaceConfig = {
extends: false,
test: {
Expand All @@ -263,6 +273,8 @@ export async function createVitestConfigPlugin(
// Default to `false` to align with the Karma/Jasmine experience.
isolate: false,
sequence: { setupFiles: 'list' },
fsModuleCache,
fsModuleCachePath,
},
optimizeDeps: {
noDiscovery: true,
Expand All @@ -281,7 +293,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
Expand All @@ -301,7 +312,9 @@ export async function createVitestConfigPlugin(

return {
test: {
fsModuleCache: false,
Comment thread
jv3n marked this conversation as resolved.
// The cache metadata is always read from the root configuration.
fsModuleCache,
fsModuleCachePath,
coverage: await generateCoverageOption(
options.coverage,
testConfig?.coverage,
Expand Down Expand Up @@ -335,12 +348,53 @@ 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));
};

// 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.
const isCoverageRedirect = (id: string): boolean =>
!!vitestConfig?.coverage?.enabled && testFileToEntryPoint.has(id) && !setupFileSet.has(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<VitestPluginContext> = context;
const defineCacheKeyGenerator =
pluginContext.defineCacheKeyGenerator ??
pluginContext.experimental_defineCacheKeyGenerator;
defineCacheKeyGenerator?.(({ id }) => {
const outputPath = getOutputPath(id);
// With coverage, `load` serves a redirect stub instead of the bundle, see below.
if (isCoverageRedirect(id)) {
return `coverage-redirect:${outputPath}`;
}

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.
Expand Down Expand Up @@ -399,28 +453,15 @@ 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';

// 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.
const isSetupFile = setupFileSet.has(id);
if (vitestConfig?.coverage?.enabled && !isSetupFile) {
// To support coverage exclusion of the actual test file, the virtual
// test entry point only references the built and bundled intermediate file.
// If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely.
return {
code: `import "./${outputPath}";`,
};
}
} else {
// Attempt to load as a built artifact.
const relativePath = path.relative(workspaceRoot, id);
outputPath = toPosixPath(relativePath);
// Attempt to load as a source test file, or else as a built artifact.
const outputPath = getOutputPath(id);
if (isCoverageRedirect(id)) {
// To support coverage exclusion of the actual test file, the virtual
// test entry point only references the built and bundled intermediate file.
// If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely.
return {
code: `import "./${outputPath}";`,
};
}

const outputFile = buildResultFiles.get(outputPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -650,7 +651,100 @@ 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);
});

it('should not write the module cache when the Angular CLI cache is disabled', async () => {
harness.useTarget('test', {
...BASE_OPTIONS,
});

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
harness.expectDirectory('node_modules/.vitest-cache').toNotExist();
});

it('should not serve the cached bundle of a test entry point once coverage is enabled', async () => {
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');
});
`,
});

harness.useTarget('test', {
...BASE_OPTIONS,
runnerConfig: 'vitest.config.mts',
});
const firstRun = await harness.executeOnce();
expect(firstRun.result?.success).toBeTrue();

harness.useTarget('test', {
...BASE_OPTIONS,
runnerConfig: 'vitest.config.mts',
coverage: true,
coverageReporters: ['json'] as any,
});
const secondRun = await harness.executeOnce();
expect(secondRun.result?.success).toBeTrue();

// The application code is only reported when the spec is served as the coverage redirect stub.
const coverageMap = JSON.parse(harness.readFile('coverage/test/coverage-final.json'));
expect(Object.keys(coverageMap).some((file) => file.endsWith('value.ts'))).toBeTrue();
});
});
});
Loading