Skip to content

Commit f71e5c8

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 f71e5c8

File tree

16 files changed

+854
-286
lines changed

16 files changed

+854
-286
lines changed

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

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

0 commit comments

Comments
 (0)