Skip to content

Commit 6839c5c

Browse files
committed
feat(@angular-devkit/build-angular): support karma with esbuild
Adds a new "builderMode" setting for Karma that can be used to switch between webpack ("browser") and esbuild ("application"). It supports a third value "detect" that will use the same bundler that's also used for development builds. The detect mode is modelled after the logic used for the dev-server builder. This initial implementation doesn't properly support `--watch` mode or code coverage.
1 parent 6aab17a commit 6839c5c

16 files changed

+857
-286
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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 { BuildOutputFileType } from '@angular/build';
10+
import {
11+
ResultFile,
12+
ResultKind,
13+
buildApplicationInternal,
14+
emitFilesToDisk,
15+
purgeStaleBuildCache,
16+
} from '@angular/build/private';
17+
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
18+
import { randomUUID } from 'crypto';
19+
import * as fs from 'fs/promises';
20+
import type { Config, ConfigOptions, InlinePluginDef } from 'karma';
21+
import * as path from 'path';
22+
import { Observable, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
23+
import { Configuration } from 'webpack';
24+
import { ExecutionTransformer } from '../../transforms';
25+
import { readTsconfig } from '../../utils/read-tsconfig';
26+
import { OutputHashing } from '../browser-esbuild/schema';
27+
import { findTests } from './find-tests';
28+
import { Schema as KarmaBuilderOptions } from './schema';
29+
30+
class ApplicationBuildError extends Error {
31+
constructor(message: string) {
32+
super(message);
33+
this.name = 'ApplicationBuildError';
34+
}
35+
}
36+
37+
export function execute(
38+
options: KarmaBuilderOptions,
39+
context: BuilderContext,
40+
karmaOptions: ConfigOptions,
41+
transforms: {
42+
webpackConfiguration?: ExecutionTransformer<Configuration>;
43+
// The karma options transform cannot be async without a refactor of the builder implementation
44+
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
45+
} = {},
46+
): Observable<BuilderOutput> {
47+
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
48+
switchMap(
49+
([karma, karmaConfig]) =>
50+
new Observable<BuilderOutput>((subscriber) => {
51+
// Complete the observable once the Karma server returns.
52+
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
53+
subscriber.next({ success: exitCode === 0 });
54+
subscriber.complete();
55+
});
56+
57+
const karmaStart = karmaServer.start();
58+
59+
// Cleanup, signal Karma to exit.
60+
return () => {
61+
void karmaStart.then(() => karmaServer.stop());
62+
};
63+
}),
64+
),
65+
catchError((err) => {
66+
if (err instanceof ApplicationBuildError) {
67+
return of({ success: false, message: err.message });
68+
}
69+
70+
throw err;
71+
}),
72+
defaultIfEmpty({ success: false }),
73+
);
74+
}
75+
76+
async function getProjectSourceRoot(context: BuilderContext): Promise<string> {
77+
// We have already validated that the project name is set before calling this function.
78+
const projectName = context.target?.project;
79+
if (!projectName) {
80+
return context.workspaceRoot;
81+
}
82+
83+
const projectMetadata = await context.getProjectMetadata(projectName);
84+
const sourceRoot = (projectMetadata.sourceRoot ?? projectMetadata.root ?? '') as string;
85+
86+
return path.join(context.workspaceRoot, sourceRoot);
87+
}
88+
89+
async function collectEntrypoints(
90+
options: KarmaBuilderOptions,
91+
context: BuilderContext,
92+
): Promise<[Set<string>, string[]]> {
93+
const projectSourceRoot = await getProjectSourceRoot(context);
94+
95+
// Glob for files to test.
96+
const testFiles = await findTests(
97+
options.include ?? [],
98+
options.exclude ?? [],
99+
context.workspaceRoot,
100+
projectSourceRoot,
101+
);
102+
103+
const entryPoints = new Set([
104+
...testFiles,
105+
'@angular-devkit/build-angular/src/builders/karma/init_test_bed.js',
106+
]);
107+
// Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine.
108+
const [polyfills, hasZoneTesting] = extractZoneTesting(options.polyfills);
109+
if (hasZoneTesting) {
110+
entryPoints.add('zone.js/testing');
111+
}
112+
113+
const tsConfigPath = path.resolve(context.workspaceRoot, options.tsConfig);
114+
const tsConfig = await readTsconfig(tsConfigPath);
115+
116+
const localizePackageInitEntryPoint = '@angular/localize/init';
117+
const hasLocalizeType = tsConfig.options.types?.some(
118+
(t) => t === '@angular/localize' || t === localizePackageInitEntryPoint,
119+
);
120+
121+
if (hasLocalizeType) {
122+
polyfills.push(localizePackageInitEntryPoint);
123+
}
124+
125+
return [entryPoints, polyfills];
126+
}
127+
128+
async function initializeApplication(
129+
options: KarmaBuilderOptions,
130+
context: BuilderContext,
131+
karmaOptions: ConfigOptions,
132+
transforms: {
133+
webpackConfiguration?: ExecutionTransformer<Configuration>;
134+
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
135+
} = {},
136+
): Promise<[typeof import('karma'), Config & ConfigOptions]> {
137+
if (transforms.webpackConfiguration) {
138+
context.logger.warn(
139+
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
140+
);
141+
}
142+
143+
const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
144+
145+
const [karma, [entryPoints, polyfills]] = await Promise.all([
146+
import('karma'),
147+
collectEntrypoints(options, context),
148+
fs.rm(testDir, { recursive: true, force: true }),
149+
// Purge old build disk cache:
150+
await purgeStaleBuildCache(context),
151+
]);
152+
153+
const outputPath = testDir;
154+
155+
// Build tests with `application` builder, using test files as entry points.
156+
// Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies.
157+
const buildOutput = await first(
158+
buildApplicationInternal(
159+
{
160+
entryPoints,
161+
tsConfig: options.tsConfig,
162+
outputPath,
163+
aot: false,
164+
index: false,
165+
outputHashing: OutputHashing.None,
166+
optimization: false,
167+
sourceMap: {
168+
scripts: true,
169+
styles: true,
170+
vendor: true,
171+
},
172+
styles: options.styles,
173+
polyfills,
174+
webWorkerTsConfig: options.webWorkerTsConfig,
175+
},
176+
context,
177+
),
178+
);
179+
if (buildOutput.kind === ResultKind.Failure) {
180+
throw new ApplicationBuildError('Build failed');
181+
} else if (buildOutput.kind !== ResultKind.Full) {
182+
throw new ApplicationBuildError(
183+
'A full build result is required from the application builder.',
184+
);
185+
}
186+
187+
// Write test files
188+
await writeTestFiles(buildOutput.files, testDir);
189+
190+
karmaOptions.files ??= [];
191+
karmaOptions.files.push(
192+
// Serve polyfills first.
193+
{ pattern: `${testDir}/polyfills.js`, type: 'module' },
194+
// Allow loading of chunk-* files but don't include them all on load.
195+
{ pattern: `${testDir}/chunk-*.js`, type: 'module', included: false },
196+
// Allow loading of worker-* files but don't include them all on load.
197+
{ pattern: `${testDir}/worker-*.js`, type: 'module', included: false },
198+
// `zone.js/testing`, served but not included on page load.
199+
{ pattern: `${testDir}/testing.js`, type: 'module', included: false },
200+
// Serve remaining JS on page load, these are the test entrypoints.
201+
{ pattern: `${testDir}/*.js`, type: 'module' },
202+
);
203+
204+
if (options.styles?.length) {
205+
// Serve CSS outputs on page load, these are the global styles.
206+
karmaOptions.files.push({ pattern: `${testDir}/*.css`, type: 'css' });
207+
}
208+
209+
const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig(
210+
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
211+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
212+
{ promiseConfig: true, throwErrors: true },
213+
);
214+
215+
// Remove the webpack plugin/framework:
216+
// Alternative would be to make the Karma plugin "smart" but that's a tall order
217+
// with managing unneeded imports etc..
218+
const pluginLengthBefore = (parsedKarmaConfig.plugins ?? []).length;
219+
parsedKarmaConfig.plugins = (parsedKarmaConfig.plugins ?? []).filter(
220+
(plugin: string | InlinePluginDef) => {
221+
if (typeof plugin === 'string') {
222+
return plugin !== 'framework:@angular-devkit/build-angular';
223+
}
224+
225+
return !plugin['framework:@angular-devkit/build-angular'];
226+
},
227+
);
228+
parsedKarmaConfig.frameworks = parsedKarmaConfig.frameworks?.filter(
229+
(framework: string) => framework !== '@angular-devkit/build-angular',
230+
);
231+
const pluginLengthAfter = (parsedKarmaConfig.plugins ?? []).length;
232+
if (pluginLengthBefore !== pluginLengthAfter) {
233+
context.logger.warn(
234+
`Ignoring framework "@angular-devkit/build-angular" from karma config file because it's not compatible with the application builder.`,
235+
);
236+
}
237+
238+
// When using code-coverage, auto-add karma-coverage.
239+
// This was done as part of the karma plugin for webpack.
240+
if (
241+
options.codeCoverage &&
242+
!parsedKarmaConfig.reporters?.some((r: string) => r === 'coverage' || r === 'coverage-istanbul')
243+
) {
244+
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
245+
}
246+
247+
return [karma, parsedKarmaConfig];
248+
}
249+
250+
export async function writeTestFiles(files: Record<string, ResultFile>, testDir: string) {
251+
const directoryExists = new Set<string>();
252+
// Writes the test related output files to disk and ensures the containing directories are present
253+
await emitFilesToDisk(Object.entries(files), async ([filePath, file]) => {
254+
if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) {
255+
return;
256+
}
257+
258+
const fullFilePath = path.join(testDir, filePath);
259+
260+
// Ensure output subdirectories exist
261+
const fileBasePath = path.dirname(fullFilePath);
262+
if (fileBasePath && !directoryExists.has(fileBasePath)) {
263+
await fs.mkdir(fileBasePath, { recursive: true });
264+
directoryExists.add(fileBasePath);
265+
}
266+
267+
if (file.origin === 'memory') {
268+
// Write file contents
269+
await fs.writeFile(fullFilePath, file.contents);
270+
} else {
271+
// Copy file contents
272+
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
273+
}
274+
});
275+
}
276+
277+
function extractZoneTesting(
278+
polyfills: readonly string[] | string | undefined,
279+
): [polyfills: string[], hasZoneTesting: boolean] {
280+
if (typeof polyfills === 'string') {
281+
polyfills = [polyfills];
282+
}
283+
polyfills ??= [];
284+
285+
const polyfillsWithoutZoneTesting = polyfills.filter(
286+
(polyfill) => polyfill !== 'zone.js/testing',
287+
);
288+
const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length;
289+
290+
return [polyfillsWithoutZoneTesting, hasZoneTesting];
291+
}
292+
293+
/** Returns the first item yielded by the given generator and cancels the execution. */
294+
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
295+
for await (const value of generator) {
296+
return value;
297+
}
298+
299+
throw new Error('Expected generator to emit at least once.');
300+
}

0 commit comments

Comments
 (0)