Skip to content

Commit 1c15b50

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 c21e889 commit 1c15b50

File tree

16 files changed

+839
-286
lines changed

16 files changed

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

0 commit comments

Comments
 (0)