Skip to content

Commit 6f6d39d

Browse files
committed
refactor(@angular/build): use in-memory provider for Vitest runner
The Vitest unit-test runner is updated to use a fully in-memory provider for test files and build artifacts. Previously, the runner would write bundled test files to a temporary directory on disk. This exposed intermediate build artifacts to the user in test output and incurred an unnecessary performance penalty from disk I/O. With this change, a custom Vite plugin now serves all test-related files (including test entry points, code chunks, and polyfills) directly from the in-memory build results. This provides two key benefits: 1. **Improved Developer Experience**: Vitest now operates on the original TypeScript source file paths. This ensures that test output, error messages, and stack traces correctly reference the files authored by the developer, simplifying debugging. 2. **Increased Performance**: By eliminating all disk writes and the need for a temporary output directory, the test setup is faster and more efficient, especially in watch mode.
1 parent a3def59 commit 6f6d39d

File tree

4 files changed

+168
-74
lines changed

4 files changed

+168
-74
lines changed

packages/angular/build/src/builders/unit-test/options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function normalizeOptions(
4444
// Target/configuration specified options
4545
buildTarget,
4646
include: options.include ?? ['**/*.spec.ts'],
47-
exclude: options.exclude ?? [],
47+
exclude: options.exclude,
4848
runnerName: runner,
4949
codeCoverage: options.codeCoverage
5050
? {

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,15 @@ export async function getVitestBuildOptions(
6161
options: NormalizedUnitTestBuilderOptions,
6262
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
6363
): Promise<RunnerOptions> {
64-
const { workspaceRoot, projectSourceRoot, include, exclude, watch, tsConfig, providersFile } =
65-
options;
64+
const {
65+
workspaceRoot,
66+
projectSourceRoot,
67+
include,
68+
exclude = [],
69+
watch,
70+
tsConfig,
71+
providersFile,
72+
} = options;
6673

6774
// Find test files
6875
const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot);

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

Lines changed: 158 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@
88

99
import type { BuilderOutput } from '@angular-devkit/architect';
1010
import assert from 'node:assert';
11-
import { randomUUID } from 'node:crypto';
12-
import { rmSync } from 'node:fs';
13-
import { rm } from 'node:fs/promises';
11+
import { readFile } from 'node:fs/promises';
1412
import path from 'node:path';
1513
import type { InlineConfig, Vitest } from 'vitest/node';
1614
import { assertIsError } from '../../../../utils/error';
1715
import { loadEsmModule } from '../../../../utils/load-esm';
1816
import { toPosixPath } from '../../../../utils/path';
19-
import { type FullResult, type IncrementalResult, ResultKind } from '../../../application/results';
20-
import { writeTestFiles } from '../../../karma/application_builder';
17+
import {
18+
type FullResult,
19+
type IncrementalResult,
20+
type ResultFile,
21+
ResultKind,
22+
} from '../../../application/results';
2123
import { NormalizedUnitTestBuilderOptions } from '../../options';
24+
import { findTests, getTestEntrypoints } from '../../test-discovery';
2225
import type { TestExecutor } from '../api';
2326
import { setupBrowserConfiguration } from './browser-provider';
2427

@@ -28,73 +31,94 @@ export class VitestExecutor implements TestExecutor {
2831
private vitest: Vitest | undefined;
2932
private readonly projectName: string;
3033
private readonly options: NormalizedUnitTestBuilderOptions;
31-
private readonly outputPath: string;
32-
private latestBuildResult: FullResult | IncrementalResult | undefined;
34+
private buildResultFiles = new Map<string, ResultFile>();
3335

34-
// Graceful shutdown signal handler
35-
// This is needed to remove the temporary output directory on Ctrl+C
36-
private readonly sigintListener = () => {
37-
rmSync(this.outputPath, { recursive: true, force: true });
38-
};
36+
// This is a reverse map of the entry points created in `build-options.ts`.
37+
// It is used by the in-memory provider plugin to map the requested test file
38+
// path back to its bundled output path.
39+
// Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>`
40+
private testFileToEntryPoint = new Map<string, string>();
41+
private entryPointToTestFile = new Map<string, string>();
3942

4043
constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) {
4144
this.projectName = projectName;
4245
this.options = options;
43-
this.outputPath = toPosixPath(path.join(options.workspaceRoot, generateOutputPath()));
44-
process.on('SIGINT', this.sigintListener);
4546
}
4647

4748
async *execute(buildResult: FullResult | IncrementalResult): AsyncIterable<BuilderOutput> {
48-
await writeTestFiles(buildResult.files, this.outputPath);
49+
if (buildResult.kind === ResultKind.Full) {
50+
this.buildResultFiles.clear();
51+
for (const [path, file] of Object.entries(buildResult.files)) {
52+
this.buildResultFiles.set(path, file);
53+
}
54+
} else {
55+
for (const file of buildResult.removed) {
56+
this.buildResultFiles.delete(file.path);
57+
}
58+
for (const [path, file] of Object.entries(buildResult.files)) {
59+
this.buildResultFiles.set(path, file);
60+
}
61+
}
4962

50-
this.latestBuildResult = buildResult;
63+
// The `getTestEntrypoints` function is used here to create the same mapping
64+
// that was used in `build-options.ts` to generate the build entry points.
65+
// This is a deliberate duplication to avoid a larger refactoring of the
66+
// builder's core interfaces to pass the entry points from the build setup
67+
// phase to the execution phase.
68+
if (this.testFileToEntryPoint.size === 0) {
69+
const { include, exclude = [], workspaceRoot, projectSourceRoot } = this.options;
70+
const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot);
71+
const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot });
72+
for (const [entryPoint, testFile] of entryPoints) {
73+
this.testFileToEntryPoint.set(testFile, entryPoint);
74+
this.entryPointToTestFile.set(entryPoint + '.js', testFile);
75+
}
76+
}
5177

5278
// Initialize Vitest if not already present.
5379
this.vitest ??= await this.initializeVitest();
5480
const vitest = this.vitest;
5581

5682
let testResults;
5783
if (buildResult.kind === ResultKind.Incremental) {
58-
const addedFiles = buildResult.added.map((file) => path.join(this.outputPath, file));
59-
const modifiedFiles = buildResult.modified.map((file) => path.join(this.outputPath, file));
60-
61-
if (addedFiles.length === 0 && modifiedFiles.length === 0) {
62-
yield { success: true };
63-
64-
return;
84+
// To rerun tests, Vitest needs the original test file paths, not the output paths.
85+
const modifiedSourceFiles = new Set<string>();
86+
for (const modifiedFile of buildResult.modified) {
87+
// The `modified` files in the build result are the output paths.
88+
// We need to find the original source file path to pass to Vitest.
89+
const source = this.entryPointToTestFile.get(modifiedFile);
90+
if (source) {
91+
modifiedSourceFiles.add(source);
92+
}
6593
}
6694

67-
// If new files are added, use `start` to trigger test discovery.
68-
// Also pass modified files to `start` to ensure they are re-run.
69-
if (addedFiles.length > 0) {
70-
await vitest.start([...addedFiles, ...modifiedFiles]);
71-
} else {
72-
// For modified files only, use the more efficient `rerunTestSpecifications`
73-
const specsToRerun = modifiedFiles.flatMap((file) => vitest.getModuleSpecifications(file));
74-
75-
if (specsToRerun.length > 0) {
76-
modifiedFiles.forEach((file) => vitest.invalidateFile(file));
77-
testResults = await vitest.rerunTestSpecifications(specsToRerun);
95+
const specsToRerun = [];
96+
for (const file of modifiedSourceFiles) {
97+
vitest.invalidateFile(file);
98+
const specs = vitest.getModuleSpecifications(file);
99+
if (specs) {
100+
specsToRerun.push(...specs);
78101
}
79102
}
103+
104+
if (specsToRerun.length > 0) {
105+
testResults = await vitest.rerunTestSpecifications(specsToRerun);
106+
}
80107
}
81108

82109
// Check if all the tests pass to calculate the result
83-
const testModules = testResults?.testModules;
110+
const testModules = testResults?.testModules ?? this.vitest.state.getTestModules();
84111

85-
yield { success: testModules?.every((testModule) => testModule.ok()) ?? true };
112+
yield { success: testModules.every((testModule) => testModule.ok()) };
86113
}
87114

88115
async [Symbol.asyncDispose](): Promise<void> {
89-
process.off('SIGINT', this.sigintListener);
90116
await this.vitest?.close();
91-
await rm(this.outputPath, { recursive: true, force: true });
92117
}
93118

94119
private async initializeVitest(): Promise<Vitest> {
95120
const { codeCoverage, reporters, workspaceRoot, setupFiles, browsers, debug, watch } =
96121
this.options;
97-
const { outputPath, projectName, latestBuildResult } = this;
98122

99123
let vitestNodeModule;
100124
try {
@@ -120,14 +144,16 @@ export class VitestExecutor implements TestExecutor {
120144
throw new Error(browserOptions.errors.join('\n'));
121145
}
122146

123-
assert(latestBuildResult, 'buildResult must be available before initializing vitest');
147+
assert(
148+
this.buildResultFiles.size > 0,
149+
'buildResult must be available before initializing vitest',
150+
);
124151
// Add setup file entries for TestBed initialization and project polyfills
125152
const testSetupFiles = ['init-testbed.js', ...setupFiles];
126153

127154
// TODO: Provide additional result metadata to avoid needing to extract based on filename
128-
const polyfillsFile = Object.keys(latestBuildResult.files).find((f) => f === 'polyfills.js');
129-
if (polyfillsFile) {
130-
testSetupFiles.unshift(polyfillsFile);
155+
if (this.buildResultFiles.has('polyfills.js')) {
156+
testSetupFiles.unshift('polyfills.js');
131157
}
132158

133159
const debugOptions = debug
@@ -145,12 +171,12 @@ export class VitestExecutor implements TestExecutor {
145171
// Disable configuration file resolution/loading
146172
config: false,
147173
root: workspaceRoot,
148-
project: ['base', projectName],
174+
project: ['base', this.projectName],
149175
name: 'base',
150176
include: [],
151177
reporters: reporters ?? ['default'],
152178
watch,
153-
coverage: generateCoverageOption(codeCoverage, workspaceRoot, this.outputPath),
179+
coverage: generateCoverageOption(codeCoverage),
154180
...debugOptions,
155181
},
156182
{
@@ -162,39 +188,111 @@ export class VitestExecutor implements TestExecutor {
162188
plugins: [
163189
{
164190
name: 'angular:project-init',
165-
async configureVitest(context) {
191+
// Type is incorrect. This allows a Promise<void>.
192+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
193+
configureVitest: async (context) => {
166194
// Create a subproject that can be configured with plugins for browser mode.
167195
// Plugins defined directly in the vite overrides will not be present in the
168196
// browser specific Vite instance.
169197
const [project] = await context.injectTestProjects({
170198
test: {
171-
name: projectName,
172-
root: outputPath,
199+
name: this.projectName,
200+
root: workspaceRoot,
173201
globals: true,
174202
setupFiles: testSetupFiles,
175203
// Use `jsdom` if no browsers are explicitly configured.
176204
// `node` is effectively no "environment" and the default.
177205
environment: browserOptions.browser ? 'node' : 'jsdom',
178206
browser: browserOptions.browser,
207+
include: this.options.include,
208+
...(this.options.exclude ? { exclude: this.options.exclude } : {}),
179209
},
180210
plugins: [
181211
{
182-
name: 'angular:html-index',
183-
transformIndexHtml: () => {
212+
name: 'angular:test-in-memory-provider',
213+
enforce: 'pre',
214+
resolveId: (id, importer) => {
215+
if (importer && id.startsWith('.')) {
216+
let fullPath;
217+
let relativePath;
218+
if (this.testFileToEntryPoint.has(importer)) {
219+
fullPath = toPosixPath(path.join(this.options.workspaceRoot, id));
220+
relativePath = path.normalize(id);
221+
} else {
222+
fullPath = toPosixPath(path.join(path.dirname(importer), id));
223+
relativePath = path.relative(this.options.workspaceRoot, fullPath);
224+
}
225+
if (this.buildResultFiles.has(toPosixPath(relativePath))) {
226+
return fullPath;
227+
}
228+
}
229+
230+
if (this.testFileToEntryPoint.has(id)) {
231+
return id;
232+
}
233+
184234
assert(
185-
latestBuildResult,
186-
'buildResult must be available for HTML index transformation.',
235+
this.buildResultFiles.size > 0,
236+
'buildResult must be available for resolving.',
187237
);
188-
// Add all global stylesheets
189-
const styleFiles = Object.entries(latestBuildResult.files).filter(
190-
([file]) => file === 'styles.css',
238+
const relativePath = path.relative(this.options.workspaceRoot, id);
239+
if (this.buildResultFiles.has(toPosixPath(relativePath))) {
240+
return id;
241+
}
242+
},
243+
load: async (id) => {
244+
assert(
245+
this.buildResultFiles.size > 0,
246+
'buildResult must be available for in-memory loading.',
191247
);
192248

193-
return styleFiles.map(([href]) => ({
194-
tag: 'link',
195-
attrs: { href, rel: 'stylesheet' },
196-
injectTo: 'head',
197-
}));
249+
// Attempt to load as a source test file.
250+
const entryPoint = this.testFileToEntryPoint.get(id);
251+
let outputPath;
252+
if (entryPoint) {
253+
outputPath = entryPoint + '.js';
254+
} else {
255+
// Attempt to load as a built artifact.
256+
const relativePath = path.relative(this.options.workspaceRoot, id);
257+
outputPath = toPosixPath(relativePath);
258+
}
259+
260+
const outputFile = this.buildResultFiles.get(outputPath);
261+
if (outputFile) {
262+
const sourceMapPath = outputPath + '.map';
263+
const sourceMapFile = this.buildResultFiles.get(sourceMapPath);
264+
const code =
265+
outputFile.origin === 'memory'
266+
? Buffer.from(outputFile.contents).toString('utf-8')
267+
: await readFile(outputFile.inputPath, 'utf-8');
268+
const map = sourceMapFile
269+
? sourceMapFile.origin === 'memory'
270+
? Buffer.from(sourceMapFile.contents).toString('utf-8')
271+
: await readFile(sourceMapFile.inputPath, 'utf-8')
272+
: undefined;
273+
274+
return {
275+
code,
276+
map: map ? JSON.parse(map) : undefined,
277+
};
278+
}
279+
},
280+
},
281+
{
282+
name: 'angular:html-index',
283+
transformIndexHtml: () => {
284+
// Add all global stylesheets
285+
if (this.buildResultFiles.has('styles.css')) {
286+
return [
287+
{
288+
tag: 'link',
289+
attrs: { href: 'styles.css', rel: 'stylesheet' },
290+
injectTo: 'head',
291+
},
292+
];
293+
}
294+
295+
return [];
198296
},
199297
},
200298
],
@@ -216,17 +314,8 @@ export class VitestExecutor implements TestExecutor {
216314
}
217315
}
218316

219-
function generateOutputPath(): string {
220-
const datePrefix = new Date().toISOString().replaceAll(/[-:.]/g, '');
221-
const uuidSuffix = randomUUID().slice(0, 8);
222-
223-
return path.join('dist', 'test-out', `${datePrefix}-${uuidSuffix}`);
224-
}
225-
226317
function generateCoverageOption(
227318
codeCoverage: NormalizedUnitTestBuilderOptions['codeCoverage'],
228-
workspaceRoot: string,
229-
outputPath: string,
230319
): VitestCoverageOption {
231320
if (!codeCoverage) {
232321
return {
@@ -237,7 +326,6 @@ function generateCoverageOption(
237326
return {
238327
enabled: true,
239328
excludeAfterRemap: true,
240-
include: [`${toPosixPath(path.relative(workspaceRoot, outputPath))}/**`],
241329
// Special handling for `reporter` due to an undefined value causing upstream failures
242330
...(codeCoverage.reporters
243331
? ({ reporter: codeCoverage.reporters } satisfies VitestCoverageOption)

packages/angular/build/src/builders/unit-test/schema.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
"items": {
4040
"type": "string"
4141
},
42-
"default": [],
4342
"description": "Globs of files to exclude, relative to the project root."
4443
},
4544
"watch": {

0 commit comments

Comments
 (0)