Skip to content

Commit 54594b5

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 25c4584 commit 54594b5

16 files changed

+852
-286
lines changed

goldens/public-api/angular_devkit/build_angular/index.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export interface FileReplacement {
213213
export interface KarmaBuilderOptions {
214214
assets?: AssetPattern_2[];
215215
browsers?: Browsers;
216+
builderMode?: BuilderMode;
216217
codeCoverage?: boolean;
217218
codeCoverageExclude?: string[];
218219
exclude?: string[];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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+
]);
150+
151+
const outputPath = testDir;
152+
153+
// Build tests with `application` builder, using test files as entry points.
154+
const buildOutput = await first(
155+
buildApplicationInternal(
156+
{
157+
entryPoints,
158+
tsConfig: options.tsConfig,
159+
outputPath,
160+
aot: false,
161+
index: false,
162+
outputHashing: OutputHashing.None,
163+
optimization: false,
164+
sourceMap: {
165+
scripts: true,
166+
styles: true,
167+
vendor: true,
168+
},
169+
styles: options.styles,
170+
polyfills,
171+
webWorkerTsConfig: options.webWorkerTsConfig,
172+
},
173+
context,
174+
),
175+
);
176+
if (buildOutput.kind === ResultKind.Failure) {
177+
throw new ApplicationBuildError('Build failed');
178+
} else if (buildOutput.kind !== ResultKind.Full) {
179+
throw new ApplicationBuildError(
180+
'A full build result is required from the application builder.',
181+
);
182+
}
183+
184+
// Write test files
185+
await writeTestFiles(buildOutput.files, testDir);
186+
187+
karmaOptions.files ??= [];
188+
karmaOptions.files.push(
189+
// Serve polyfills first.
190+
{ pattern: `${testDir}/polyfills.js`, type: 'module' },
191+
// Allow loading of chunk-* files but don't include them all on load.
192+
{ pattern: `${testDir}/chunk-*.js`, type: 'module', included: false },
193+
// Allow loading of worker-* files but don't include them all on load.
194+
{ pattern: `${testDir}/worker-*.js`, type: 'module', included: false },
195+
// `zone.js/testing`, served but not included on page load.
196+
{ pattern: `${testDir}/testing.js`, type: 'module', included: false },
197+
// Serve remaining JS on page load, these are the test entrypoints.
198+
{ pattern: `${testDir}/*.js`, type: 'module' },
199+
);
200+
201+
if (options.styles?.length) {
202+
// Serve CSS outputs on page load, these are the global styles.
203+
karmaOptions.files.push({ pattern: `${testDir}/*.css`, type: 'css' });
204+
}
205+
206+
const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig(
207+
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
208+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
209+
{ promiseConfig: true, throwErrors: true },
210+
);
211+
212+
// Remove the webpack plugin/framework:
213+
// Alternative would be to make the Karma plugin "smart" but that's a tall order
214+
// with managing unneeded imports etc..
215+
const pluginLengthBefore = (parsedKarmaConfig.plugins ?? []).length;
216+
parsedKarmaConfig.plugins = (parsedKarmaConfig.plugins ?? []).filter(
217+
(plugin: string | InlinePluginDef) => {
218+
if (typeof plugin === 'string') {
219+
return plugin !== 'framework:@angular-devkit/build-angular';
220+
}
221+
222+
return !plugin['framework:@angular-devkit/build-angular'];
223+
},
224+
);
225+
parsedKarmaConfig.frameworks = parsedKarmaConfig.frameworks?.filter(
226+
(framework: string) => framework !== '@angular-devkit/build-angular',
227+
);
228+
const pluginLengthAfter = (parsedKarmaConfig.plugins ?? []).length;
229+
if (pluginLengthBefore !== pluginLengthAfter) {
230+
context.logger.warn(
231+
`Ignoring framework "@angular-devkit/build-angular" from karma config file because it's not compatible with the application builder.`,
232+
);
233+
}
234+
235+
// When using code-coverage, auto-add karma-coverage.
236+
// This was done as part of the karma plugin for webpack.
237+
if (
238+
options.codeCoverage &&
239+
!parsedKarmaConfig.reporters?.some((r: string) => r === 'coverage' || r === 'coverage-istanbul')
240+
) {
241+
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
242+
}
243+
244+
return [karma, parsedKarmaConfig];
245+
}
246+
247+
export async function writeTestFiles(files: Record<string, ResultFile>, testDir: string) {
248+
const directoryExists = new Set<string>();
249+
// Writes the test related output files to disk and ensures the containing directories are present
250+
await emitFilesToDisk(Object.entries(files), async ([filePath, file]) => {
251+
if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) {
252+
return;
253+
}
254+
255+
const fullFilePath = path.join(testDir, filePath);
256+
257+
// Ensure output subdirectories exist
258+
const fileBasePath = path.dirname(fullFilePath);
259+
if (fileBasePath && !directoryExists.has(fileBasePath)) {
260+
await fs.mkdir(fileBasePath, { recursive: true });
261+
directoryExists.add(fileBasePath);
262+
}
263+
264+
if (file.origin === 'memory') {
265+
// Write file contents
266+
await fs.writeFile(fullFilePath, file.contents);
267+
} else {
268+
// Copy file contents
269+
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
270+
}
271+
});
272+
}
273+
274+
function extractZoneTesting(
275+
polyfills: readonly string[] | string | undefined,
276+
): [polyfills: string[], hasZoneTesting: boolean] {
277+
if (typeof polyfills === 'string') {
278+
polyfills = [polyfills];
279+
}
280+
polyfills ??= [];
281+
282+
const polyfillsWithoutZoneTesting = polyfills.filter(
283+
(polyfill) => polyfill !== 'zone.js/testing',
284+
);
285+
const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length;
286+
287+
return [polyfillsWithoutZoneTesting, hasZoneTesting];
288+
}
289+
290+
/** Returns the first item yielded by the given generator and cancels the execution. */
291+
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
292+
for await (const value of generator) {
293+
return value;
294+
}
295+
296+
throw new Error('Expected generator to emit at least once.');
297+
}

0 commit comments

Comments
 (0)