Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /