Skip to content

Commit 587e845

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

File tree

7 files changed

+579
-12
lines changed

7 files changed

+579
-12
lines changed

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

Lines changed: 303 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,35 @@
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';
12-
import type { Config, ConfigOptions } from 'karma';
22+
import { randomUUID } from 'crypto';
23+
import * as fs from 'fs/promises';
24+
import type { Config, ConfigOptions, InlinePluginDef } 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';
37+
import { readFileSync } from 'fs';
2338

2439
export type KarmaConfigOptions = ConfigOptions & {
2540
buildWebpack?: unknown;
@@ -30,10 +45,17 @@ async function initialize(
3045
options: KarmaBuilderOptions,
3146
context: BuilderContext,
3247
webpackConfigurationTransformer?: ExecutionTransformer<Configuration>,
33-
): Promise<[typeof import('karma'), Configuration]> {
48+
): Promise<[typeof import('karma'), Configuration | null]> {
3449
// Purge old build disk cache.
3550
await purgeStaleBuildCache(context);
3651

52+
const useEsbuild = await checkForEsbuild(options, context);
53+
if (useEsbuild) {
54+
const karma = await import('karma');
55+
56+
return [karma, null];
57+
}
58+
3759
const { config } = await generateBrowserWebpackConfigFromContext(
3860
// only two properties are missing:
3961
// * `outputPath` which is fixed for tests
@@ -64,6 +86,199 @@ async function initialize(
6486
return [karma, (await webpackConfigurationTransformer?.(config)) ?? config];
6587
}
6688

89+
async function createEsbuildConfig(
90+
options: KarmaBuilderOptions,
91+
context: BuilderContext,
92+
karma: typeof import('karma'),
93+
karmaOptions: KarmaConfigOptions,
94+
transforms: {
95+
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
96+
} = {},
97+
) {
98+
const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
99+
100+
// Parallelize startup work.
101+
const [testFiles] = await Promise.all([
102+
// Glob for files to test.
103+
findTestFiles(options.include ?? [], options.exclude ?? [], context.workspaceRoot),
104+
// Clean build output path.
105+
fs.rm(testDir, { recursive: true, force: true }),
106+
]);
107+
108+
const entryPoints = new Set([
109+
...testFiles,
110+
// 'jasmine-core/lib/jasmine-core/jasmine.js',
111+
'@angular-devkit/build-angular/src/builders/karma/init_test_bed.js',
112+
]);
113+
const outputPath = testDir;
114+
// Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine.
115+
const [polyfills, hasZoneTesting] = extractZoneTesting(options.polyfills);
116+
if (hasZoneTesting) {
117+
entryPoints.add('zone.js/testing');
118+
}
119+
// see: packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts
120+
// TODO: Make this relative to tsConfig.
121+
// const localizePackageInitEntryPoint = '@angular/localize/init';
122+
// const hasLocalizeType = tsConfig.options.types?.some(
123+
// (t) => t === '@angular/localize' || t === localizePackageInitEntryPoint,
124+
// );
125+
126+
// if (hasLocalizeType) {
127+
// entryPoints['main'] = [localizePackageInitEntryPoint];
128+
// }
129+
polyfills.push('@angular/localize/init');
130+
131+
// Build tests with `application` builder, using test files as entry points.
132+
// Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies.
133+
const buildOutput = await first(
134+
buildApplicationInternal(
135+
{
136+
entryPoints,
137+
tsConfig: options.tsConfig,
138+
outputPath,
139+
aot: false,
140+
index: false,
141+
outputHashing: OutputHashing.None,
142+
optimization: false,
143+
externalDependencies: [
144+
// Resolved by `@web/test-runner` at runtime with dynamically generated code.
145+
// '@web/test-runner-core',
146+
],
147+
sourceMap: {
148+
scripts: true,
149+
styles: true,
150+
vendor: true,
151+
},
152+
polyfills,
153+
},
154+
context,
155+
),
156+
);
157+
if (buildOutput.kind === ResultKind.Failure) {
158+
// TODO: Forward {success: false}
159+
throw new Error('Build failed');
160+
} else if (buildOutput.kind !== ResultKind.Full) {
161+
// TODO: Forward {success: false}
162+
// return {
163+
// success: false,
164+
// error: 'A full build result is required from the application builder.',
165+
// };
166+
throw new Error('A full build result is required from the application builder.');
167+
}
168+
169+
// Write test files
170+
await writeTestFiles(buildOutput.files, testDir);
171+
172+
// TODO: Base this on the buildOutput.files to make it less fragile to exclude patterns?
173+
karmaOptions.files ??= [];
174+
karmaOptions.files = karmaOptions.files.concat(
175+
[`${testDir}/polyfills.js`].map((pattern) => ({ pattern, type: 'module' })),
176+
);
177+
karmaOptions.files = karmaOptions.files.concat(
178+
[
179+
`${testDir}/chunk-*.js`,
180+
`${testDir}/testing.js`, // `zone.js/testing`
181+
].map((pattern) => ({ pattern, type: 'module', included: false })),
182+
);
183+
karmaOptions.files = karmaOptions.files.concat(
184+
[`${testDir}/*.js`].map((pattern) => ({ pattern, type: 'module' })),
185+
);
186+
187+
const parsedKarmaConfig: Config & KarmaConfigOptions = await karma.config.parseConfig(
188+
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
189+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
190+
{ promiseConfig: true, throwErrors: true },
191+
);
192+
193+
// Remove the webpack plugin/framework:
194+
// Alternative would be to make the Karma plugin "smart" but that's a tall order
195+
// with managing unneeded imports etc..
196+
parsedKarmaConfig.plugins = (parsedKarmaConfig.plugins ?? [])
197+
.filter((plugin: string | InlinePluginDef) => {
198+
if (typeof plugin === 'string') {
199+
return plugin !== 'framework:@angular-devkit/build-angular';
200+
}
201+
202+
return !plugin['framework:@angular-devkit/build-angular'];
203+
})
204+
.concat([
205+
{
206+
['preprocessor:attach-source-maps']: [
207+
'factory',
208+
Object.assign(
209+
() => {
210+
interface BasicSourceMap {
211+
file?: string;
212+
sourcesContent?: unknown;
213+
}
214+
interface KarmaFile {
215+
path: string;
216+
readonly originalPath: string;
217+
encodings: { [key: string]: Buffer };
218+
type: unknown;
219+
sourceMap?: BasicSourceMap;
220+
}
221+
222+
return (
223+
content: Buffer,
224+
file: KarmaFile,
225+
done: (err: Error | null, content: Buffer) => void,
226+
) => {
227+
if (file.path.startsWith(`${testDir}/`) && file.path.endsWith('.js')) {
228+
// Attempt to load source map.
229+
const sourceMapPath = `${file.path}.map`;
230+
const mapBytes = readFileSync(sourceMapPath, 'utf8');
231+
file.sourceMap = JSON.parse(mapBytes) as BasicSourceMap;
232+
delete file.sourceMap.sourcesContent;
233+
file.sourceMap.file = file.path; // basename?
234+
}
235+
236+
done(null, content);
237+
};
238+
},
239+
{ '$inject': [] },
240+
),
241+
],
242+
},
243+
]);
244+
parsedKarmaConfig.frameworks = parsedKarmaConfig.frameworks?.filter(
245+
(framework: string) => framework !== '@angular-devkit/build-angular',
246+
);
247+
248+
// When using code-coverage, auto-add karma-coverage.
249+
// This was done as part of the karma plugin for webpack.
250+
if (
251+
options.codeCoverage &&
252+
!parsedKarmaConfig.reporters?.some((r: string) => r === 'coverage' || r === 'coverage-istanbul')
253+
) {
254+
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
255+
}
256+
257+
/*
258+
const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? '');
259+
const sourceRoot = projectMetadata.sourceRoot as string | undefined;
260+
const projectSourceRoot = sourceRoot ? path.join(workspaceRoot, sourceRoot) : undefined;
261+
*/
262+
263+
// Add preprocessor to instrument code for coverage gathering.
264+
if (options.codeCoverage) {
265+
parsedKarmaConfig.preprocessors ??= {};
266+
// sourceRoot ?? projectRoot
267+
/*
268+
{
269+
includedBasePath: sourceRoot ?? projectRoot,
270+
excludedPaths: getInstrumentationExcludedPaths(root, codeCoverageExclude),
271+
}
272+
*/
273+
parsedKarmaConfig.preprocessors[`${testDir}/!(polyfills|testing).js`] = [
274+
'attach-source-maps',
275+
'coverage',
276+
];
277+
}
278+
279+
return [karma, parsedKarmaConfig] as [typeof karma, KarmaConfigOptions];
280+
}
281+
67282
/**
68283
* @experimental Direct usage of this function is considered experimental.
69284
*/
@@ -92,9 +307,11 @@ export function execute(
92307
throw new Error(`The 'karma' builder requires a target to be specified.`);
93308
}
94309

310+
const useEsbuild = !webpackConfig;
311+
95312
const karmaOptions: KarmaConfigOptions = options.karmaConfig
96313
? {}
97-
: getBuiltInKarmaConfig(context.workspaceRoot, projectName);
314+
: getBuiltInKarmaConfig(context.workspaceRoot, projectName, useEsbuild);
98315

99316
karmaOptions.singleRun = singleRun;
100317

@@ -122,6 +339,10 @@ export function execute(
122339
}
123340
}
124341

342+
if (useEsbuild) {
343+
return createEsbuildConfig(options, context, karma, karmaOptions, transforms);
344+
}
345+
125346
if (!options.main) {
126347
webpackConfig.entry ??= {};
127348
if (typeof webpackConfig.entry === 'object' && !Array.isArray(webpackConfig.entry)) {
@@ -195,6 +416,7 @@ export function execute(
195416
function getBuiltInKarmaConfig(
196417
workspaceRoot: string,
197418
projectName: string,
419+
useEsbuild: boolean,
198420
): ConfigOptions & Record<string, unknown> {
199421
let coverageFolderName = projectName.charAt(0) === '@' ? projectName.slice(1) : projectName;
200422
if (/[A-Z]/.test(coverageFolderName)) {
@@ -206,13 +428,13 @@ function getBuiltInKarmaConfig(
206428
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
207429
return {
208430
basePath: '',
209-
frameworks: ['jasmine', '@angular-devkit/build-angular'],
431+
frameworks: ['jasmine', ...(useEsbuild ? [] : ['@angular-devkit/build-angular'])],
210432
plugins: [
211433
'karma-jasmine',
212434
'karma-chrome-launcher',
213435
'karma-jasmine-html-reporter',
214436
'karma-coverage',
215-
'@angular-devkit/build-angular/plugins/karma',
437+
...(useEsbuild ? [] : ['@angular-devkit/build-angular/plugins/karma']),
216438
].map((p) => workspaceRootRequire(p)),
217439
jasmineHtmlReporter: {
218440
suppressAll: true, // removes the duplicated traces
@@ -262,3 +484,76 @@ function getBuiltInMainFile(): string {
262484

263485
return `ng-virtual-main.js!=!data:text/javascript;base64,${content}`;
264486
}
487+
488+
async function checkForEsbuild(
489+
options: KarmaBuilderOptions,
490+
context: BuilderContext,
491+
): Promise<boolean> {
492+
if (options.builderMode !== BuilderMode.Detect) {
493+
return options.builderMode === BuilderMode.Application;
494+
}
495+
496+
// Look up the current project's build target using a development configuration.
497+
const buildTargetSpecifier = `::development`;
498+
const buildTarget = targetFromTargetString(
499+
buildTargetSpecifier,
500+
context.target?.project,
501+
'build',
502+
);
503+
504+
try {
505+
const developmentBuilderName = await context.getBuilderNameForTarget(buildTarget);
506+
507+
return isEsbuildBased(developmentBuilderName);
508+
} catch (e) {
509+
if (!(e instanceof Error) || e.message !== 'Project target does not exist.') {
510+
throw e;
511+
}
512+
// If we can't find a development builder, we can't use 'detect'.
513+
throw new Error(
514+
'Failed to detect the detect the builder used by the application. Please set builderMode explicitly.',
515+
);
516+
}
517+
}
518+
519+
function isEsbuildBased(
520+
builderName: string,
521+
): builderName is
522+
| '@angular/build:application'
523+
| '@angular-devkit/build-angular:application'
524+
| '@angular-devkit/build-angular:browser-esbuild' {
525+
if (
526+
builderName === '@angular/build:application' ||
527+
builderName === '@angular-devkit/build-angular:application' ||
528+
builderName === '@angular-devkit/build-angular:browser-esbuild'
529+
) {
530+
return true;
531+
}
532+
533+
return false;
534+
}
535+
536+
function extractZoneTesting(
537+
polyfills: readonly string[] | string | undefined,
538+
): [polyfills: string[], hasZoneTesting: boolean] {
539+
if (typeof polyfills === 'string') {
540+
polyfills = [polyfills];
541+
}
542+
polyfills ??= [];
543+
544+
const polyfillsWithoutZoneTesting = polyfills.filter(
545+
(polyfill) => polyfill !== 'zone.js/testing',
546+
);
547+
const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length;
548+
549+
return [polyfillsWithoutZoneTesting, hasZoneTesting];
550+
}
551+
552+
/** Returns the first item yielded by the given generator and cancels the execution. */
553+
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
554+
for await (const value of generator) {
555+
return value;
556+
}
557+
558+
throw new Error('Expected generator to emit at least once.');
559+
}

0 commit comments

Comments
 (0)