Skip to content

Commit 30efbb6

Browse files
committed
refactor(@angular/build): extract Vitest plugins from executor in unit-test
This commit refactors the Vitest test runner by extracting the complex plugin creation logic out of the main `VitestExecutor` class and into a dedicated `plugins.ts` module. This change reduces the complexity of the executor, making it easier to understand and maintain. The executor is now more focused on its core responsibility of managing the test execution lifecycle. Additionally, this commit introduces a `BrowserConfiguration` interface for better type safety and marks several executor properties as readonly to enforce immutability.
1 parent 6552dcf commit 30efbb6

3 files changed

Lines changed: 175 additions & 135 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
import { createRequire } from 'node:module';
1010

11+
export interface BrowserConfiguration {
12+
browser?: import('vitest/node').BrowserConfigOptions;
13+
errors?: string[];
14+
}
15+
1116
function findBrowserProvider(
1217
projectResolver: NodeJS.RequireResolve,
1318
): import('vitest/node').BrowserBuiltinProvider | undefined {
@@ -38,7 +43,7 @@ export function setupBrowserConfiguration(
3843
browsers: string[] | undefined,
3944
debug: boolean,
4045
projectSourceRoot: string,
41-
): { browser?: import('vitest/node').BrowserConfigOptions; errors?: string[] } {
46+
): BrowserConfiguration {
4247
if (browsers === undefined) {
4348
return {};
4449
}

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

Lines changed: 14 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { BuilderOutput } from '@angular-devkit/architect';
1010
import assert from 'node:assert';
1111
import { readFile } from 'node:fs/promises';
1212
import path from 'node:path';
13-
import type { InlineConfig, Vitest, VitestPlugin } from 'vitest/node';
13+
import type { InlineConfig, Vitest } from 'vitest/node';
1414
import { assertIsError } from '../../../../utils/error';
1515
import { loadEsmModule } from '../../../../utils/load-esm';
1616
import { toPosixPath } from '../../../../utils/path';
@@ -24,22 +24,22 @@ import { NormalizedUnitTestBuilderOptions } from '../../options';
2424
import { findTests, getTestEntrypoints } from '../../test-discovery';
2525
import type { TestExecutor } from '../api';
2626
import { setupBrowserConfiguration } from './browser-provider';
27+
import { createVitestPlugins } from './plugins';
2728

2829
type VitestCoverageOption = Exclude<InlineConfig['coverage'], undefined>;
29-
type VitestPlugins = Awaited<ReturnType<typeof VitestPlugin>>;
3030

3131
export class VitestExecutor implements TestExecutor {
3232
private vitest: Vitest | undefined;
3333
private readonly projectName: string;
3434
private readonly options: NormalizedUnitTestBuilderOptions;
35-
private buildResultFiles = new Map<string, ResultFile>();
35+
private readonly buildResultFiles = new Map<string, ResultFile>();
3636

3737
// This is a reverse map of the entry points created in `build-options.ts`.
3838
// It is used by the in-memory provider plugin to map the requested test file
3939
// path back to its bundled output path.
4040
// Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>`
41-
private testFileToEntryPoint = new Map<string, string>();
42-
private entryPointToTestFile = new Map<string, string>();
41+
private readonly testFileToEntryPoint = new Map<string, string>();
42+
private readonly entryPointToTestFile = new Map<string, string>();
4343

4444
constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) {
4545
this.projectName = projectName;
@@ -135,134 +135,6 @@ export class VitestExecutor implements TestExecutor {
135135
return testSetupFiles;
136136
}
137137

138-
private createVitestPlugins(
139-
testSetupFiles: string[],
140-
browserOptions: Awaited<ReturnType<typeof setupBrowserConfiguration>>,
141-
): VitestPlugins {
142-
const { workspaceRoot } = this.options;
143-
144-
return [
145-
{
146-
name: 'angular:project-init',
147-
// Type is incorrect. This allows a Promise<void>.
148-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
149-
configureVitest: async (context) => {
150-
// Create a subproject that can be configured with plugins for browser mode.
151-
// Plugins defined directly in the vite overrides will not be present in the
152-
// browser specific Vite instance.
153-
await context.injectTestProjects({
154-
test: {
155-
name: this.projectName,
156-
root: workspaceRoot,
157-
globals: true,
158-
setupFiles: testSetupFiles,
159-
// Use `jsdom` if no browsers are explicitly configured.
160-
// `node` is effectively no "environment" and the default.
161-
environment: browserOptions.browser ? 'node' : 'jsdom',
162-
browser: browserOptions.browser,
163-
include: this.options.include,
164-
...(this.options.exclude ? { exclude: this.options.exclude } : {}),
165-
},
166-
plugins: [
167-
{
168-
name: 'angular:test-in-memory-provider',
169-
enforce: 'pre',
170-
resolveId: (id, importer) => {
171-
if (importer && (id[0] === '.' || id[0] === '/')) {
172-
let fullPath;
173-
if (this.testFileToEntryPoint.has(importer)) {
174-
fullPath = toPosixPath(path.join(this.options.workspaceRoot, id));
175-
} else {
176-
fullPath = toPosixPath(path.join(path.dirname(importer), id));
177-
}
178-
179-
const relativePath = path.relative(this.options.workspaceRoot, fullPath);
180-
if (this.buildResultFiles.has(toPosixPath(relativePath))) {
181-
return fullPath;
182-
}
183-
}
184-
185-
if (this.testFileToEntryPoint.has(id)) {
186-
return id;
187-
}
188-
189-
assert(
190-
this.buildResultFiles.size > 0,
191-
'buildResult must be available for resolving.',
192-
);
193-
const relativePath = path.relative(this.options.workspaceRoot, id);
194-
if (this.buildResultFiles.has(toPosixPath(relativePath))) {
195-
return id;
196-
}
197-
},
198-
load: async (id) => {
199-
assert(
200-
this.buildResultFiles.size > 0,
201-
'buildResult must be available for in-memory loading.',
202-
);
203-
204-
// Attempt to load as a source test file.
205-
const entryPoint = this.testFileToEntryPoint.get(id);
206-
let outputPath;
207-
if (entryPoint) {
208-
outputPath = entryPoint + '.js';
209-
210-
// To support coverage exclusion of the actual test file, the virtual
211-
// test entry point only references the built and bundled intermediate file.
212-
return {
213-
code: `import "./${outputPath}";`,
214-
};
215-
} else {
216-
// Attempt to load as a built artifact.
217-
const relativePath = path.relative(this.options.workspaceRoot, id);
218-
outputPath = toPosixPath(relativePath);
219-
}
220-
221-
const outputFile = this.buildResultFiles.get(outputPath);
222-
if (outputFile) {
223-
const sourceMapPath = outputPath + '.map';
224-
const sourceMapFile = this.buildResultFiles.get(sourceMapPath);
225-
const code =
226-
outputFile.origin === 'memory'
227-
? Buffer.from(outputFile.contents).toString('utf-8')
228-
: await readFile(outputFile.inputPath, 'utf-8');
229-
const map = sourceMapFile
230-
? sourceMapFile.origin === 'memory'
231-
? Buffer.from(sourceMapFile.contents).toString('utf-8')
232-
: await readFile(sourceMapFile.inputPath, 'utf-8')
233-
: undefined;
234-
235-
return {
236-
code,
237-
map: map ? JSON.parse(map) : undefined,
238-
};
239-
}
240-
},
241-
},
242-
{
243-
name: 'angular:html-index',
244-
transformIndexHtml: () => {
245-
// Add all global stylesheets
246-
if (this.buildResultFiles.has('styles.css')) {
247-
return [
248-
{
249-
tag: 'link',
250-
attrs: { href: 'styles.css', rel: 'stylesheet' },
251-
injectTo: 'head',
252-
},
253-
];
254-
}
255-
256-
return [];
257-
},
258-
},
259-
],
260-
});
261-
},
262-
},
263-
];
264-
}
265-
266138
private async initializeVitest(): Promise<Vitest> {
267139
const { codeCoverage, reporters, workspaceRoot, browsers, debug, watch } = this.options;
268140

@@ -296,7 +168,15 @@ export class VitestExecutor implements TestExecutor {
296168
);
297169

298170
const testSetupFiles = this.prepareSetupFiles();
299-
const plugins = this.createVitestPlugins(testSetupFiles, browserOptions);
171+
const plugins = createVitestPlugins(this.options, testSetupFiles, browserOptions, {
172+
workspaceRoot,
173+
projectSourceRoot: this.options.projectSourceRoot,
174+
projectName: this.projectName,
175+
include: this.options.include,
176+
exclude: this.options.exclude,
177+
buildResultFiles: this.buildResultFiles,
178+
testFileToEntryPoint: this.testFileToEntryPoint,
179+
});
300180

301181
const debugOptions = debug
302182
? {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { readFile } from 'node:fs/promises';
11+
import path from 'node:path';
12+
import type { VitestPlugin } from 'vitest/node';
13+
import { toPosixPath } from '../../../../utils/path';
14+
import type { ResultFile } from '../../../application/results';
15+
import type { NormalizedUnitTestBuilderOptions } from '../../options';
16+
import type { BrowserConfiguration } from './browser-provider';
17+
18+
type VitestPlugins = Awaited<ReturnType<typeof VitestPlugin>>;
19+
20+
interface PluginOptions {
21+
workspaceRoot: string;
22+
projectSourceRoot: string;
23+
projectName: string;
24+
include?: string[];
25+
exclude?: string[];
26+
buildResultFiles: ReadonlyMap<string, ResultFile>;
27+
testFileToEntryPoint: ReadonlyMap<string, string>;
28+
}
29+
30+
export function createVitestPlugins(
31+
options: NormalizedUnitTestBuilderOptions,
32+
testSetupFiles: string[],
33+
browserOptions: BrowserConfiguration,
34+
pluginOptions: PluginOptions,
35+
): VitestPlugins {
36+
const { workspaceRoot, projectName, buildResultFiles, testFileToEntryPoint } = pluginOptions;
37+
38+
return [
39+
{
40+
name: 'angular:project-init',
41+
// Type is incorrect. This allows a Promise<void>.
42+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
43+
configureVitest: async (context) => {
44+
// Create a subproject that can be configured with plugins for browser mode.
45+
// Plugins defined directly in the vite overrides will not be present in the
46+
// browser specific Vite instance.
47+
await context.injectTestProjects({
48+
test: {
49+
name: projectName,
50+
root: workspaceRoot,
51+
globals: true,
52+
setupFiles: testSetupFiles,
53+
// Use `jsdom` if no browsers are explicitly configured.
54+
// `node` is effectively no "environment" and the default.
55+
environment: browserOptions.browser ? 'node' : 'jsdom',
56+
browser: browserOptions.browser,
57+
include: options.include,
58+
...(options.exclude ? { exclude: options.exclude } : {}),
59+
},
60+
plugins: [
61+
{
62+
name: 'angular:test-in-memory-provider',
63+
enforce: 'pre',
64+
resolveId: (id, importer) => {
65+
if (importer && (id[0] === '.' || id[0] === '/')) {
66+
let fullPath;
67+
if (testFileToEntryPoint.has(importer)) {
68+
fullPath = toPosixPath(path.join(workspaceRoot, id));
69+
} else {
70+
fullPath = toPosixPath(path.join(path.dirname(importer), id));
71+
}
72+
73+
const relativePath = path.relative(workspaceRoot, fullPath);
74+
if (buildResultFiles.has(toPosixPath(relativePath))) {
75+
return fullPath;
76+
}
77+
}
78+
79+
if (testFileToEntryPoint.has(id)) {
80+
return id;
81+
}
82+
83+
assert(buildResultFiles.size > 0, 'buildResult must be available for resolving.');
84+
const relativePath = path.relative(workspaceRoot, id);
85+
if (buildResultFiles.has(toPosixPath(relativePath))) {
86+
return id;
87+
}
88+
},
89+
load: async (id) => {
90+
assert(
91+
buildResultFiles.size > 0,
92+
'buildResult must be available for in-memory loading.',
93+
);
94+
95+
// Attempt to load as a source test file.
96+
const entryPoint = testFileToEntryPoint.get(id);
97+
let outputPath;
98+
if (entryPoint) {
99+
outputPath = entryPoint + '.js';
100+
101+
// To support coverage exclusion of the actual test file, the virtual
102+
// test entry point only references the built and bundled intermediate file.
103+
return {
104+
code: `import "./${outputPath}";`,
105+
};
106+
} else {
107+
// Attempt to load as a built artifact.
108+
const relativePath = path.relative(workspaceRoot, id);
109+
outputPath = toPosixPath(relativePath);
110+
}
111+
112+
const outputFile = buildResultFiles.get(outputPath);
113+
if (outputFile) {
114+
const sourceMapPath = outputPath + '.map';
115+
const sourceMapFile = buildResultFiles.get(sourceMapPath);
116+
const code =
117+
outputFile.origin === 'memory'
118+
? Buffer.from(outputFile.contents).toString('utf-8')
119+
: await readFile(outputFile.inputPath, 'utf-8');
120+
const map = sourceMapFile
121+
? sourceMapFile.origin === 'memory'
122+
? Buffer.from(sourceMapFile.contents).toString('utf-8')
123+
: await readFile(sourceMapFile.inputPath, 'utf-8')
124+
: undefined;
125+
126+
return {
127+
code,
128+
map: map ? JSON.parse(map) : undefined,
129+
};
130+
}
131+
},
132+
},
133+
{
134+
name: 'angular:html-index',
135+
transformIndexHtml: () => {
136+
// Add all global stylesheets
137+
if (buildResultFiles.has('styles.css')) {
138+
return [
139+
{
140+
tag: 'link',
141+
attrs: { href: 'styles.css', rel: 'stylesheet' },
142+
injectTo: 'head',
143+
},
144+
];
145+
}
146+
147+
return [];
148+
},
149+
},
150+
],
151+
});
152+
},
153+
},
154+
];
155+
}

0 commit comments

Comments
 (0)