Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 585eb89

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 585eb89

File tree

3 files changed

+175
-136
lines changed

3 files changed

+175
-136
lines changed

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 & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@
88

99
import type { BuilderOutput } from '@angular-devkit/architect';
1010
import assert from 'node:assert';
11-
import { readFile } from 'node:fs/promises';
1211
import path from 'node:path';
13-
import type { InlineConfig, Vitest, VitestPlugin } from 'vitest/node';
12+
import type { InlineConfig, Vitest } from 'vitest/node';
1413
import { assertIsError } from '../../../../utils/error';
1514
import { loadEsmModule } from '../../../../utils/load-esm';
1615
import { toPosixPath } from '../../../../utils/path';
@@ -24,22 +23,22 @@ import { NormalizedUnitTestBuilderOptions } from '../../options';
2423
import { findTests, getTestEntrypoints } from '../../test-discovery';
2524
import type { TestExecutor } from '../api';
2625
import { setupBrowserConfiguration } from './browser-provider';
26+
import { createVitestPlugins } from './plugins';
2727

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

3130
export class VitestExecutor implements TestExecutor {
3231
private vitest: Vitest | undefined;
3332
private readonly projectName: string;
3433
private readonly options: NormalizedUnitTestBuilderOptions;
35-
private buildResultFiles = new Map<string, ResultFile>();
34+
private readonly buildResultFiles = new Map<string, ResultFile>();
3635

3736
// This is a reverse map of the entry points created in `build-options.ts`.
3837
// It is used by the in-memory provider plugin to map the requested test file
3938
// path back to its bundled output path.
4039
// 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>();
40+
private readonly testFileToEntryPoint = new Map<string, string>();
41+
private readonly entryPointToTestFile = new Map<string, string>();
4342

4443
constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) {
4544
this.projectName = projectName;
@@ -135,134 +134,6 @@ export class VitestExecutor implements TestExecutor {
135134
return testSetupFiles;
136135
}
137136

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-
266137
private async initializeVitest(): Promise<Vitest> {
267138
const { codeCoverage, reporters, workspaceRoot, browsers, debug, watch } = this.options;
268139

@@ -296,7 +167,15 @@ export class VitestExecutor implements TestExecutor {
296167
);
297168

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

301180
const debugOptions = debug
302181
? {
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)