Skip to content

Commit ff28211

Browse files
committed
feat(@angular-devkit/build-angular): support karma with application builder
1 parent 53037ea commit ff28211

File tree

5 files changed

+467
-7
lines changed

5 files changed

+467
-7
lines changed

packages/angular_devkit/build_angular/src/builders/karma/index.ts

Lines changed: 214 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,34 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { assertCompatibleAngularVersion, purgeStaleBuildCache } from '@angular/build/private';
10-
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
9+
import {
10+
ResultKind,
11+
assertCompatibleAngularVersion,
12+
buildApplicationInternal,
13+
purgeStaleBuildCache,
14+
} from '@angular/build/private';
15+
import {
16+
BuilderContext,
17+
BuilderOutput,
18+
createBuilder,
19+
targetFromTargetString,
20+
} from '@angular-devkit/architect';
1121
import { strings } from '@angular-devkit/core';
22+
import { randomUUID } from 'crypto';
23+
import * as fs from 'fs/promises';
1224
import type { Config, ConfigOptions } from 'karma';
1325
import { createRequire } from 'module';
1426
import * as path from 'path';
1527
import { Observable, defaultIfEmpty, from, switchMap } from 'rxjs';
1628
import { Configuration } from 'webpack';
1729
import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs';
1830
import { ExecutionTransformer } from '../../transforms';
31+
import { findTestFiles } from '../../utils/test-files';
1932
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
2033
import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema';
34+
import { writeTestFiles } from '../web-test-runner/write-test-files';
2135
import { FindTestsPlugin } from './find-tests-plugin';
22-
import { Schema as KarmaBuilderOptions } from './schema';
36+
import { BuilderMode, Schema as KarmaBuilderOptions } from './schema';
2337

2438
export type KarmaConfigOptions = ConfigOptions & {
2539
buildWebpack?: unknown;
@@ -30,10 +44,17 @@ async function initialize(
3044
options: KarmaBuilderOptions,
3145
context: BuilderContext,
3246
webpackConfigurationTransformer?: ExecutionTransformer<Configuration>,
33-
): Promise<[typeof import('karma'), Configuration]> {
47+
): Promise<[typeof import('karma'), Configuration | null]> {
3448
// Purge old build disk cache.
3549
await purgeStaleBuildCache(context);
3650

51+
const useEsbuild = await checkForEsbuild(options, context);
52+
if (useEsbuild) {
53+
const karma = await import('karma');
54+
55+
return [karma, null];
56+
}
57+
3758
const { config } = await generateBrowserWebpackConfigFromContext(
3859
// only two properties are missing:
3960
// * `outputPath` which is fixed for tests
@@ -92,9 +113,11 @@ export function execute(
92113
throw new Error(`The 'karma' builder requires a target to be specified.`);
93114
}
94115

116+
const useEsbuild = !webpackConfig;
117+
95118
const karmaOptions: KarmaConfigOptions = options.karmaConfig
96119
? {}
97-
: getBuiltInKarmaConfig(context.workspaceRoot, projectName);
120+
: getBuiltInKarmaConfig(context.workspaceRoot, projectName, useEsbuild);
98121

99122
karmaOptions.singleRun = singleRun;
100123

@@ -122,6 +145,116 @@ export function execute(
122145
}
123146
}
124147

148+
if (useEsbuild) {
149+
const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
150+
151+
// Parallelize startup work.
152+
const [testFiles] = await Promise.all([
153+
// Glob for files to test.
154+
findTestFiles(options.include ?? [], options.exclude ?? [], context.workspaceRoot),
155+
// Clean build output path.
156+
fs.rm(testDir, { recursive: true, force: true }),
157+
]);
158+
159+
const entryPoints = new Set([
160+
...testFiles,
161+
// 'jasmine-core/lib/jasmine-core/jasmine.js',
162+
'@angular-devkit/build-angular/src/builders/karma/init_test_bed.js',
163+
]);
164+
const outputPath = testDir;
165+
// Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine.
166+
const [polyfills, hasZoneTesting] = extractZoneTesting(options.polyfills);
167+
if (hasZoneTesting) {
168+
entryPoints.add('zone.js/testing');
169+
}
170+
// see: packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts
171+
polyfills.push('@angular/localize/init');
172+
173+
// Build tests with `application` builder, using test files as entry points.
174+
// Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies.
175+
const buildOutput = await first(
176+
buildApplicationInternal(
177+
{
178+
entryPoints,
179+
tsConfig: options.tsConfig,
180+
outputPath,
181+
aot: false,
182+
index: false,
183+
outputHashing: OutputHashing.None,
184+
optimization: false,
185+
externalDependencies: [
186+
// Resolved by `@web/test-runner` at runtime with dynamically generated code.
187+
// '@web/test-runner-core',
188+
],
189+
sourceMap: {
190+
scripts: true,
191+
styles: true,
192+
vendor: true,
193+
},
194+
polyfills,
195+
},
196+
context,
197+
),
198+
);
199+
if (buildOutput.kind === ResultKind.Failure) {
200+
// TODO: Forward {success: false}
201+
throw new Error('Build failed');
202+
} else if (buildOutput.kind !== ResultKind.Full) {
203+
// TODO: Forward {success: false}
204+
// return {
205+
// success: false,
206+
// error: 'A full build result is required from the application builder.',
207+
// };
208+
throw new Error('A full build result is required from the application builder.');
209+
}
210+
211+
// Write test files
212+
await writeTestFiles(buildOutput.files, testDir);
213+
214+
// TODO: Base this on the buildOutput.files to make it less fragile to exclude patterns?
215+
karmaOptions.files ??= [];
216+
karmaOptions.files = karmaOptions.files.concat(
217+
[`**/*.js`].map((pattern) => ({ pattern, type: 'module' })),
218+
);
219+
karmaOptions.exclude = (karmaOptions.exclude ?? []).concat([
220+
`polyfills.js`,
221+
`chunk-*.js`,
222+
// `!jasmine.js`,
223+
// `!jasmine_runner.js`,
224+
`testing.js`, // `zone.js/testing`
225+
'*.js',
226+
]);
227+
228+
const parsedKarmaConfig = await karma.config.parseConfig(
229+
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
230+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
231+
{ promiseConfig: true, throwErrors: true },
232+
);
233+
234+
// Remove the webpack plugin/framework:
235+
// Alternative would be to make the Karma plugin "smart" but that's a tall order
236+
// with managing unneeded imports etc..
237+
(parsedKarmaConfig as any).plugins = (parsedKarmaConfig as any).plugins.filter(
238+
(plugin: { [key: string]: unknown }) => {
239+
// Remove the webpack Karma plugin.
240+
return !plugin['framework:@angular-devkit/build-angular'];
241+
},
242+
);
243+
(parsedKarmaConfig as any).frameworks = (parsedKarmaConfig as any).frameworks.filter(
244+
(framework: string) => {
245+
// Remove the webpack "framework".
246+
return framework !== '@angular-devkit/build-angular';
247+
},
248+
);
249+
250+
// Add the plugin/framework for the application builder:
251+
// The options here are, to a certain extend:
252+
// - Run the dev server as a Karma plugin.
253+
// - Run the build separately and let Karma serve the files.
254+
255+
return [karma, parsedKarmaConfig] as [typeof karma, KarmaConfigOptions];
256+
}
257+
125258
if (!options.main) {
126259
webpackConfig.entry ??= {};
127260
if (typeof webpackConfig.entry === 'object' && !Array.isArray(webpackConfig.entry)) {
@@ -195,6 +328,7 @@ export function execute(
195328
function getBuiltInKarmaConfig(
196329
workspaceRoot: string,
197330
projectName: string,
331+
useEsbuild: boolean,
198332
): ConfigOptions & Record<string, unknown> {
199333
let coverageFolderName = projectName.charAt(0) === '@' ? projectName.slice(1) : projectName;
200334
if (/[A-Z]/.test(coverageFolderName)) {
@@ -206,13 +340,13 @@ function getBuiltInKarmaConfig(
206340
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
207341
return {
208342
basePath: '',
209-
frameworks: ['jasmine', '@angular-devkit/build-angular'],
343+
frameworks: ['jasmine', ...(useEsbuild ? [] : ['@angular-devkit/build-angular'])],
210344
plugins: [
211345
'karma-jasmine',
212346
'karma-chrome-launcher',
213347
'karma-jasmine-html-reporter',
214348
'karma-coverage',
215-
'@angular-devkit/build-angular/plugins/karma',
349+
...(useEsbuild ? [] : ['@angular-devkit/build-angular/plugins/karma']),
216350
].map((p) => workspaceRootRequire(p)),
217351
jasmineHtmlReporter: {
218352
suppressAll: true, // removes the duplicated traces
@@ -262,3 +396,76 @@ function getBuiltInMainFile(): string {
262396

263397
return `ng-virtual-main.js!=!data:text/javascript;base64,${content}`;
264398
}
399+
400+
async function checkForEsbuild(
401+
options: KarmaBuilderOptions,
402+
context: BuilderContext,
403+
): Promise<boolean> {
404+
if (options.builderMode !== BuilderMode.Detect) {
405+
return options.builderMode === BuilderMode.Application;
406+
}
407+
408+
// Look up the current project's build target using a development configuration.
409+
const buildTargetSpecifier = `::development`;
410+
const buildTarget = targetFromTargetString(
411+
buildTargetSpecifier,
412+
context.target?.project,
413+
'build',
414+
);
415+
416+
try {
417+
const developmentBuilderName = await context.getBuilderNameForTarget(buildTarget);
418+
419+
return isEsbuildBased(developmentBuilderName);
420+
} catch (e) {
421+
if (!(e instanceof Error) || e.message !== 'Project target does not exist.') {
422+
throw e;
423+
}
424+
// If we can't find a development builder, we can't use 'detect'.
425+
throw new Error(
426+
'Failed to detect the detect the builder used by the application. Please set builderMode explicitly.',
427+
);
428+
}
429+
}
430+
431+
function isEsbuildBased(
432+
builderName: string,
433+
): builderName is
434+
| '@angular/build:application'
435+
| '@angular-devkit/build-angular:application'
436+
| '@angular-devkit/build-angular:browser-esbuild' {
437+
if (
438+
builderName === '@angular/build:application' ||
439+
builderName === '@angular-devkit/build-angular:application' ||
440+
builderName === '@angular-devkit/build-angular:browser-esbuild'
441+
) {
442+
return true;
443+
}
444+
445+
return false;
446+
}
447+
448+
function extractZoneTesting(
449+
polyfills: readonly string[] | string | undefined,
450+
): [polyfills: string[], hasZoneTesting: boolean] {
451+
if (typeof polyfills === 'string') {
452+
polyfills = [polyfills];
453+
}
454+
polyfills ??= [];
455+
456+
const polyfillsWithoutZoneTesting = polyfills.filter(
457+
(polyfill) => polyfill !== 'zone.js/testing',
458+
);
459+
const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length;
460+
461+
return [polyfillsWithoutZoneTesting, hasZoneTesting];
462+
}
463+
464+
/** Returns the first item yielded by the given generator and cancels the execution. */
465+
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
466+
for await (const value of generator) {
467+
return value;
468+
}
469+
470+
throw new Error('Expected generator to emit at least once.');
471+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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 { getTestBed } from '@angular/core/testing';
10+
import {
11+
BrowserDynamicTestingModule,
12+
platformBrowserDynamicTesting,
13+
} from '@angular/platform-browser-dynamic/testing';
14+
15+
// Initialize the Angular testing environment.
16+
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
17+
errorOnUnknownElements: true,
18+
errorOnUnknownProperties: true,
19+
});

packages/angular_devkit/build_angular/src/builders/karma/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@
267267
"type": "string"
268268
}
269269
},
270+
"builderMode": {
271+
"type": "string",
272+
"description": "Determines how to build the code under test. If set to 'detect', attempts to follow the development builder.",
273+
"enum": ["detect", "browser", "application"],
274+
"default": "browser"
275+
},
270276
"webWorkerTsConfig": {
271277
"type": "string",
272278
"description": "TypeScript configuration for Web Worker modules."

0 commit comments

Comments
 (0)