Skip to content

Commit 54594b5

Browse files
committedSep 26, 2024·
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 25c4584 commit 54594b5

16 files changed

+852
-286
lines changed
 

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

+1
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[];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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+
]);
150+
151+
const outputPath = testDir;
152+
153+
// Build tests with `application` builder, using test files as entry points.
154+
const buildOutput = await first(
155+
buildApplicationInternal(
156+
{
157+
entryPoints,
158+
tsConfig: options.tsConfig,
159+
outputPath,
160+
aot: false,
161+
index: false,
162+
outputHashing: OutputHashing.None,
163+
optimization: false,
164+
sourceMap: {
165+
scripts: true,
166+
styles: true,
167+
vendor: true,
168+
},
169+
styles: options.styles,
170+
polyfills,
171+
webWorkerTsConfig: options.webWorkerTsConfig,
172+
},
173+
context,
174+
),
175+
);
176+
if (buildOutput.kind === ResultKind.Failure) {
177+
throw new ApplicationBuildError('Build failed');
178+
} else if (buildOutput.kind !== ResultKind.Full) {
179+
throw new ApplicationBuildError(
180+
'A full build result is required from the application builder.',
181+
);
182+
}
183+
184+
// Write test files
185+
await writeTestFiles(buildOutput.files, testDir);
186+
187+
karmaOptions.files ??= [];
188+
karmaOptions.files.push(
189+
// Serve polyfills first.
190+
{ pattern: `${testDir}/polyfills.js`, type: 'module' },
191+
// Allow loading of chunk-* files but don't include them all on load.
192+
{ pattern: `${testDir}/chunk-*.js`, type: 'module', included: false },
193+
// Allow loading of worker-* files but don't include them all on load.
194+
{ pattern: `${testDir}/worker-*.js`, type: 'module', included: false },
195+
// `zone.js/testing`, served but not included on page load.
196+
{ pattern: `${testDir}/testing.js`, type: 'module', included: false },
197+
// Serve remaining JS on page load, these are the test entrypoints.
198+
{ pattern: `${testDir}/*.js`, type: 'module' },
199+
);
200+
201+
if (options.styles?.length) {
202+
// Serve CSS outputs on page load, these are the global styles.
203+
karmaOptions.files.push({ pattern: `${testDir}/*.css`, type: 'css' });
204+
}
205+
206+
const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig(
207+
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
208+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
209+
{ promiseConfig: true, throwErrors: true },
210+
);
211+
212+
// Remove the webpack plugin/framework:
213+
// Alternative would be to make the Karma plugin "smart" but that's a tall order
214+
// with managing unneeded imports etc..
215+
const pluginLengthBefore = (parsedKarmaConfig.plugins ?? []).length;
216+
parsedKarmaConfig.plugins = (parsedKarmaConfig.plugins ?? []).filter(
217+
(plugin: string | InlinePluginDef) => {
218+
if (typeof plugin === 'string') {
219+
return plugin !== 'framework:@angular-devkit/build-angular';
220+
}
221+
222+
return !plugin['framework:@angular-devkit/build-angular'];
223+
},
224+
);
225+
parsedKarmaConfig.frameworks = parsedKarmaConfig.frameworks?.filter(
226+
(framework: string) => framework !== '@angular-devkit/build-angular',
227+
);
228+
const pluginLengthAfter = (parsedKarmaConfig.plugins ?? []).length;
229+
if (pluginLengthBefore !== pluginLengthAfter) {
230+
context.logger.warn(
231+
`Ignoring framework "@angular-devkit/build-angular" from karma config file because it's not compatible with the application builder.`,
232+
);
233+
}
234+
235+
// When using code-coverage, auto-add karma-coverage.
236+
// This was done as part of the karma plugin for webpack.
237+
if (
238+
options.codeCoverage &&
239+
!parsedKarmaConfig.reporters?.some((r: string) => r === 'coverage' || r === 'coverage-istanbul')
240+
) {
241+
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
242+
}
243+
244+
return [karma, parsedKarmaConfig];
245+
}
246+
247+
export async function writeTestFiles(files: Record<string, ResultFile>, testDir: string) {
248+
const directoryExists = new Set<string>();
249+
// Writes the test related output files to disk and ensures the containing directories are present
250+
await emitFilesToDisk(Object.entries(files), async ([filePath, file]) => {
251+
if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) {
252+
return;
253+
}
254+
255+
const fullFilePath = path.join(testDir, filePath);
256+
257+
// Ensure output subdirectories exist
258+
const fileBasePath = path.dirname(fullFilePath);
259+
if (fileBasePath && !directoryExists.has(fileBasePath)) {
260+
await fs.mkdir(fileBasePath, { recursive: true });
261+
directoryExists.add(fileBasePath);
262+
}
263+
264+
if (file.origin === 'memory') {
265+
// Write file contents
266+
await fs.writeFile(fullFilePath, file.contents);
267+
} else {
268+
// Copy file contents
269+
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
270+
}
271+
});
272+
}
273+
274+
function extractZoneTesting(
275+
polyfills: readonly string[] | string | undefined,
276+
): [polyfills: string[], hasZoneTesting: boolean] {
277+
if (typeof polyfills === 'string') {
278+
polyfills = [polyfills];
279+
}
280+
polyfills ??= [];
281+
282+
const polyfillsWithoutZoneTesting = polyfills.filter(
283+
(polyfill) => polyfill !== 'zone.js/testing',
284+
);
285+
const hasZoneTesting = polyfills.length !== polyfillsWithoutZoneTesting.length;
286+
287+
return [polyfillsWithoutZoneTesting, hasZoneTesting];
288+
}
289+
290+
/** Returns the first item yielded by the given generator and cancels the execution. */
291+
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
292+
for await (const value of generator) {
293+
return value;
294+
}
295+
296+
throw new Error('Expected generator to emit at least once.');
297+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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 { purgeStaleBuildCache } from '@angular/build/private';
10+
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
11+
import type { Config, ConfigOptions } from 'karma';
12+
import * as path from 'path';
13+
import { Observable, defaultIfEmpty, from, switchMap } from 'rxjs';
14+
import { Configuration } from 'webpack';
15+
import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs';
16+
import { ExecutionTransformer } from '../../transforms';
17+
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
18+
import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema';
19+
import { FindTestsPlugin } from './find-tests-plugin';
20+
import { Schema as KarmaBuilderOptions } from './schema';
21+
22+
export type KarmaConfigOptions = ConfigOptions & {
23+
buildWebpack?: unknown;
24+
configFile?: string;
25+
};
26+
27+
export function execute(
28+
options: KarmaBuilderOptions,
29+
context: BuilderContext,
30+
karmaOptions: KarmaConfigOptions,
31+
transforms: {
32+
webpackConfiguration?: ExecutionTransformer<Configuration>;
33+
// The karma options transform cannot be async without a refactor of the builder implementation
34+
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
35+
} = {},
36+
): Observable<BuilderOutput> {
37+
return from(initializeBrowser(options, context)).pipe(
38+
switchMap(async ([karma, webpackConfig]) => {
39+
const projectName = context.target?.project;
40+
if (!projectName) {
41+
throw new Error(`The 'karma' builder requires a target to be specified.`);
42+
}
43+
44+
const projectMetadata = await context.getProjectMetadata(projectName);
45+
const sourceRoot = (projectMetadata.sourceRoot ?? projectMetadata.root ?? '') as string;
46+
47+
if (!options.main) {
48+
webpackConfig.entry ??= {};
49+
if (typeof webpackConfig.entry === 'object' && !Array.isArray(webpackConfig.entry)) {
50+
if (Array.isArray(webpackConfig.entry['main'])) {
51+
webpackConfig.entry['main'].push(getBuiltInMainFile());
52+
} else {
53+
webpackConfig.entry['main'] = [getBuiltInMainFile()];
54+
}
55+
}
56+
}
57+
58+
webpackConfig.plugins ??= [];
59+
webpackConfig.plugins.push(
60+
new FindTestsPlugin({
61+
include: options.include,
62+
exclude: options.exclude,
63+
workspaceRoot: context.workspaceRoot,
64+
projectSourceRoot: path.join(context.workspaceRoot, sourceRoot),
65+
}),
66+
);
67+
68+
karmaOptions.buildWebpack = {
69+
options,
70+
webpackConfig,
71+
logger: context.logger,
72+
};
73+
74+
const parsedKarmaConfig = await karma.config.parseConfig(
75+
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
76+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
77+
{ promiseConfig: true, throwErrors: true },
78+
);
79+
80+
return [karma, parsedKarmaConfig] as [typeof karma, KarmaConfigOptions];
81+
}),
82+
switchMap(
83+
([karma, karmaConfig]) =>
84+
new Observable<BuilderOutput>((subscriber) => {
85+
// Pass onto Karma to emit BuildEvents.
86+
karmaConfig.buildWebpack ??= {};
87+
if (typeof karmaConfig.buildWebpack === 'object') {
88+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
89+
(karmaConfig.buildWebpack as any).failureCb ??= () =>
90+
subscriber.next({ success: false });
91+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92+
(karmaConfig.buildWebpack as any).successCb ??= () =>
93+
subscriber.next({ success: true });
94+
}
95+
96+
// Complete the observable once the Karma server returns.
97+
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
98+
subscriber.next({ success: exitCode === 0 });
99+
subscriber.complete();
100+
});
101+
102+
const karmaStart = karmaServer.start();
103+
104+
// Cleanup, signal Karma to exit.
105+
return () => {
106+
void karmaStart.then(() => karmaServer.stop());
107+
};
108+
}),
109+
),
110+
defaultIfEmpty({ success: false }),
111+
);
112+
}
113+
114+
async function initializeBrowser(
115+
options: KarmaBuilderOptions,
116+
context: BuilderContext,
117+
webpackConfigurationTransformer?: ExecutionTransformer<Configuration>,
118+
): Promise<[typeof import('karma'), Configuration]> {
119+
// Purge old build disk cache.
120+
await purgeStaleBuildCache(context);
121+
122+
const karma = await import('karma');
123+
124+
const { config } = await generateBrowserWebpackConfigFromContext(
125+
// only two properties are missing:
126+
// * `outputPath` which is fixed for tests
127+
// * `budgets` which might be incorrect due to extra dev libs
128+
{
129+
...(options as unknown as BrowserBuilderOptions),
130+
outputPath: '',
131+
budgets: undefined,
132+
optimization: false,
133+
buildOptimizer: false,
134+
aot: false,
135+
vendorChunk: true,
136+
namedChunks: true,
137+
extractLicenses: false,
138+
outputHashing: OutputHashing.None,
139+
// The webpack tier owns the watch behavior so we want to force it in the config.
140+
// When not in watch mode, webpack-dev-middleware will call `compiler.watch` anyway.
141+
// https://github.com/webpack/webpack-dev-middleware/blob/698c9ae5e9bb9a013985add6189ff21c1a1ec185/src/index.js#L65
142+
// https://github.com/webpack/webpack/blob/cde1b73e12eb8a77eb9ba42e7920c9ec5d29c2c9/lib/Compiler.js#L379-L388
143+
watch: true,
144+
},
145+
context,
146+
(wco) => [getCommonConfig(wco), getStylesConfig(wco)],
147+
);
148+
149+
return [karma, (await webpackConfigurationTransformer?.(config)) ?? config];
150+
}
151+
152+
function getBuiltInMainFile(): string {
153+
const content = Buffer.from(
154+
`
155+
import { getTestBed } from '@angular/core/testing';
156+
import {
157+
BrowserDynamicTestingModule,
158+
platformBrowserDynamicTesting,
159+
} from '@angular/platform-browser-dynamic/testing';
160+
161+
// Initialize the Angular testing environment.
162+
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
163+
errorOnUnknownElements: true,
164+
errorOnUnknownProperties: true
165+
});
166+
`,
167+
).toString('base64');
168+
169+
return `ng-virtual-main.js!=!data:text/javascript;base64,${content}`;
170+
}

‎packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts

+2-105
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
*/
88

99
import assert from 'assert';
10-
import glob, { isDynamicPattern } from 'fast-glob';
11-
import { PathLike, constants, promises as fs } from 'fs';
1210
import { pluginName } from 'mini-css-extract-plugin';
13-
import { basename, dirname, extname, join, relative } from 'path';
1411
import type { Compilation, Compiler } from 'webpack';
1512

13+
import { findTests } from './find-tests';
14+
1615
/**
1716
* The name of the plugin provided to Webpack when tapping Webpack compiler hooks.
1817
*/
@@ -71,105 +70,3 @@ export class FindTestsPlugin {
7170
});
7271
}
7372
}
74-
75-
// go through all patterns and find unique list of files
76-
async function findTests(
77-
include: string[],
78-
exclude: string[],
79-
workspaceRoot: string,
80-
projectSourceRoot: string,
81-
): Promise<string[]> {
82-
const matchingTestsPromises = include.map((pattern) =>
83-
findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot),
84-
);
85-
const files = await Promise.all(matchingTestsPromises);
86-
87-
// Unique file names
88-
return [...new Set(files.flat())];
89-
}
90-
91-
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
92-
93-
const removeLeadingSlash = (pattern: string): string => {
94-
if (pattern.charAt(0) === '/') {
95-
return pattern.substring(1);
96-
}
97-
98-
return pattern;
99-
};
100-
101-
const removeRelativeRoot = (path: string, root: string): string => {
102-
if (path.startsWith(root)) {
103-
return path.substring(root.length);
104-
}
105-
106-
return path;
107-
};
108-
109-
async function findMatchingTests(
110-
pattern: string,
111-
ignore: string[],
112-
workspaceRoot: string,
113-
projectSourceRoot: string,
114-
): Promise<string[]> {
115-
// normalize pattern, glob lib only accepts forward slashes
116-
let normalizedPattern = normalizePath(pattern);
117-
normalizedPattern = removeLeadingSlash(normalizedPattern);
118-
119-
const relativeProjectRoot = normalizePath(relative(workspaceRoot, projectSourceRoot) + '/');
120-
121-
// remove relativeProjectRoot to support relative paths from root
122-
// such paths are easy to get when running scripts via IDEs
123-
normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot);
124-
125-
// special logic when pattern does not look like a glob
126-
if (!isDynamicPattern(normalizedPattern)) {
127-
if (await isDirectory(join(projectSourceRoot, normalizedPattern))) {
128-
normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`;
129-
} else {
130-
// see if matching spec file exists
131-
const fileExt = extname(normalizedPattern);
132-
// Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts`
133-
const potentialSpec = join(
134-
projectSourceRoot,
135-
dirname(normalizedPattern),
136-
`${basename(normalizedPattern, fileExt)}.spec${fileExt}`,
137-
);
138-
139-
if (await exists(potentialSpec)) {
140-
return [potentialSpec];
141-
}
142-
}
143-
}
144-
145-
// normalize the patterns in the ignore list
146-
const normalizedIgnorePatternList = ignore.map((pattern: string) =>
147-
removeRelativeRoot(removeLeadingSlash(normalizePath(pattern)), relativeProjectRoot),
148-
);
149-
150-
return glob(normalizedPattern, {
151-
cwd: projectSourceRoot,
152-
absolute: true,
153-
ignore: ['**/node_modules/**', ...normalizedIgnorePatternList],
154-
});
155-
}
156-
157-
async function isDirectory(path: PathLike): Promise<boolean> {
158-
try {
159-
const stats = await fs.stat(path);
160-
161-
return stats.isDirectory();
162-
} catch {
163-
return false;
164-
}
165-
}
166-
167-
async function exists(path: PathLike): Promise<boolean> {
168-
try {
169-
await fs.access(path, constants.F_OK);
170-
171-
return true;
172-
} catch {
173-
return false;
174-
}
175-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 glob, { isDynamicPattern } from 'fast-glob';
10+
import { PathLike, constants, promises as fs } from 'fs';
11+
import { basename, dirname, extname, join, relative } from 'path';
12+
13+
/* Go through all patterns and find unique list of files */
14+
export async function findTests(
15+
include: string[],
16+
exclude: string[],
17+
workspaceRoot: string,
18+
projectSourceRoot: string,
19+
): Promise<string[]> {
20+
const matchingTestsPromises = include.map((pattern) =>
21+
findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot),
22+
);
23+
const files = await Promise.all(matchingTestsPromises);
24+
25+
// Unique file names
26+
return [...new Set(files.flat())];
27+
}
28+
29+
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
30+
31+
const removeLeadingSlash = (pattern: string): string => {
32+
if (pattern.charAt(0) === '/') {
33+
return pattern.substring(1);
34+
}
35+
36+
return pattern;
37+
};
38+
39+
const removeRelativeRoot = (path: string, root: string): string => {
40+
if (path.startsWith(root)) {
41+
return path.substring(root.length);
42+
}
43+
44+
return path;
45+
};
46+
47+
async function findMatchingTests(
48+
pattern: string,
49+
ignore: string[],
50+
workspaceRoot: string,
51+
projectSourceRoot: string,
52+
): Promise<string[]> {
53+
// normalize pattern, glob lib only accepts forward slashes
54+
let normalizedPattern = normalizePath(pattern);
55+
normalizedPattern = removeLeadingSlash(normalizedPattern);
56+
57+
const relativeProjectRoot = normalizePath(relative(workspaceRoot, projectSourceRoot) + '/');
58+
59+
// remove relativeProjectRoot to support relative paths from root
60+
// such paths are easy to get when running scripts via IDEs
61+
normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot);
62+
63+
// special logic when pattern does not look like a glob
64+
if (!isDynamicPattern(normalizedPattern)) {
65+
if (await isDirectory(join(projectSourceRoot, normalizedPattern))) {
66+
normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`;
67+
} else {
68+
// see if matching spec file exists
69+
const fileExt = extname(normalizedPattern);
70+
// Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts`
71+
const potentialSpec = join(
72+
projectSourceRoot,
73+
dirname(normalizedPattern),
74+
`${basename(normalizedPattern, fileExt)}.spec${fileExt}`,
75+
);
76+
77+
if (await exists(potentialSpec)) {
78+
return [potentialSpec];
79+
}
80+
}
81+
}
82+
83+
// normalize the patterns in the ignore list
84+
const normalizedIgnorePatternList = ignore.map((pattern: string) =>
85+
removeRelativeRoot(removeLeadingSlash(normalizePath(pattern)), relativeProjectRoot),
86+
);
87+
88+
return glob(normalizedPattern, {
89+
cwd: projectSourceRoot,
90+
absolute: true,
91+
ignore: ['**/node_modules/**', ...normalizedIgnorePatternList],
92+
});
93+
}
94+
95+
async function isDirectory(path: PathLike): Promise<boolean> {
96+
try {
97+
const stats = await fs.stat(path);
98+
99+
return stats.isDirectory();
100+
} catch {
101+
return false;
102+
}
103+
}
104+
105+
async function exists(path: PathLike): Promise<boolean> {
106+
try {
107+
await fs.access(path, constants.F_OK);
108+
109+
return true;
110+
} catch {
111+
return false;
112+
}
113+
}

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

+113-164
Original file line numberDiff line numberDiff line change
@@ -6,64 +6,27 @@
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 { assertCompatibleAngularVersion } from '@angular/build/private';
10+
import {
11+
BuilderContext,
12+
BuilderOutput,
13+
createBuilder,
14+
targetFromTargetString,
15+
} from '@angular-devkit/architect';
1116
import { strings } from '@angular-devkit/core';
12-
import type { Config, ConfigOptions } from 'karma';
17+
import type { ConfigOptions } from 'karma';
1318
import { createRequire } from 'module';
1419
import * as path from 'path';
15-
import { Observable, defaultIfEmpty, from, switchMap } from 'rxjs';
20+
import { Observable, from, mergeMap } from 'rxjs';
1621
import { Configuration } from 'webpack';
17-
import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs';
1822
import { ExecutionTransformer } from '../../transforms';
19-
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
20-
import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema';
21-
import { FindTestsPlugin } from './find-tests-plugin';
22-
import { Schema as KarmaBuilderOptions } from './schema';
23+
import { BuilderMode, Schema as KarmaBuilderOptions } from './schema';
2324

2425
export type KarmaConfigOptions = ConfigOptions & {
2526
buildWebpack?: unknown;
2627
configFile?: string;
2728
};
2829

29-
async function initialize(
30-
options: KarmaBuilderOptions,
31-
context: BuilderContext,
32-
webpackConfigurationTransformer?: ExecutionTransformer<Configuration>,
33-
): Promise<[typeof import('karma'), Configuration]> {
34-
// Purge old build disk cache.
35-
await purgeStaleBuildCache(context);
36-
37-
const { config } = await generateBrowserWebpackConfigFromContext(
38-
// only two properties are missing:
39-
// * `outputPath` which is fixed for tests
40-
// * `budgets` which might be incorrect due to extra dev libs
41-
{
42-
...(options as unknown as BrowserBuilderOptions),
43-
outputPath: '',
44-
budgets: undefined,
45-
optimization: false,
46-
buildOptimizer: false,
47-
aot: false,
48-
vendorChunk: true,
49-
namedChunks: true,
50-
extractLicenses: false,
51-
outputHashing: OutputHashing.None,
52-
// The webpack tier owns the watch behavior so we want to force it in the config.
53-
// When not in watch mode, webpack-dev-middleware will call `compiler.watch` anyway.
54-
// https://github.com/webpack/webpack-dev-middleware/blob/698c9ae5e9bb9a013985add6189ff21c1a1ec185/src/index.js#L65
55-
// https://github.com/webpack/webpack/blob/cde1b73e12eb8a77eb9ba42e7920c9ec5d29c2c9/lib/Compiler.js#L379-L388
56-
watch: true,
57-
},
58-
context,
59-
(wco) => [getCommonConfig(wco), getStylesConfig(wco)],
60-
);
61-
62-
const karma = await import('karma');
63-
64-
return [karma, (await webpackConfigurationTransformer?.(config)) ?? config];
65-
}
66-
6730
/**
6831
* @experimental Direct usage of this function is considered experimental.
6932
*/
@@ -79,122 +42,68 @@ export function execute(
7942
// Check Angular version.
8043
assertCompatibleAngularVersion(context.workspaceRoot);
8144

45+
return from(getExecuteWithBuilder(options, context)).pipe(
46+
mergeMap(([useEsbuild, executeWithBuilder]) => {
47+
const karmaOptions = getBaseKarmaOptions(options, context, useEsbuild);
48+
49+
return executeWithBuilder.execute(options, context, karmaOptions, transforms);
50+
}),
51+
);
52+
}
53+
54+
function getBaseKarmaOptions(
55+
options: KarmaBuilderOptions,
56+
context: BuilderContext,
57+
useEsbuild: boolean,
58+
): KarmaConfigOptions {
8259
let singleRun: boolean | undefined;
8360
if (options.watch !== undefined) {
8461
singleRun = !options.watch;
8562
}
8663

87-
return from(initialize(options, context, transforms.webpackConfiguration)).pipe(
88-
switchMap(async ([karma, webpackConfig]) => {
89-
// Determine project name from builder context target
90-
const projectName = context.target?.project;
91-
if (!projectName) {
92-
throw new Error(`The 'karma' builder requires a target to be specified.`);
93-
}
94-
95-
const karmaOptions: KarmaConfigOptions = options.karmaConfig
96-
? {}
97-
: getBuiltInKarmaConfig(context.workspaceRoot, projectName);
98-
99-
karmaOptions.singleRun = singleRun;
100-
101-
// Workaround https://github.com/angular/angular-cli/issues/28271, by clearing context by default
102-
// for single run executions. Not clearing context for multi-run (watched) builds allows the
103-
// Jasmine Spec Runner to be visible in the browser after test execution.
104-
karmaOptions.client ??= {};
105-
karmaOptions.client.clearContext ??= singleRun ?? false; // `singleRun` defaults to `false` per Karma docs.
106-
107-
// Convert browsers from a string to an array
108-
if (typeof options.browsers === 'string' && options.browsers) {
109-
karmaOptions.browsers = options.browsers.split(',');
110-
} else if (options.browsers === false) {
111-
karmaOptions.browsers = [];
112-
}
113-
114-
if (options.reporters) {
115-
// Split along commas to make it more natural, and remove empty strings.
116-
const reporters = options.reporters
117-
.reduce<string[]>((acc, curr) => acc.concat(curr.split(',')), [])
118-
.filter((x) => !!x);
119-
120-
if (reporters.length > 0) {
121-
karmaOptions.reporters = reporters;
122-
}
123-
}
124-
125-
if (!options.main) {
126-
webpackConfig.entry ??= {};
127-
if (typeof webpackConfig.entry === 'object' && !Array.isArray(webpackConfig.entry)) {
128-
if (Array.isArray(webpackConfig.entry['main'])) {
129-
webpackConfig.entry['main'].push(getBuiltInMainFile());
130-
} else {
131-
webpackConfig.entry['main'] = [getBuiltInMainFile()];
132-
}
133-
}
134-
}
135-
136-
const projectMetadata = await context.getProjectMetadata(projectName);
137-
const sourceRoot = (projectMetadata.sourceRoot ?? projectMetadata.root ?? '') as string;
64+
// Determine project name from builder context target
65+
const projectName = context.target?.project;
66+
if (!projectName) {
67+
throw new Error(`The 'karma' builder requires a target to be specified.`);
68+
}
13869

139-
webpackConfig.plugins ??= [];
140-
webpackConfig.plugins.push(
141-
new FindTestsPlugin({
142-
include: options.include,
143-
exclude: options.exclude,
144-
workspaceRoot: context.workspaceRoot,
145-
projectSourceRoot: path.join(context.workspaceRoot, sourceRoot),
146-
}),
147-
);
70+
const karmaOptions: KarmaConfigOptions = options.karmaConfig
71+
? {}
72+
: getBuiltInKarmaConfig(context.workspaceRoot, projectName, useEsbuild);
14873

149-
karmaOptions.buildWebpack = {
150-
options,
151-
webpackConfig,
152-
logger: context.logger,
153-
};
74+
karmaOptions.singleRun = singleRun;
15475

155-
const parsedKarmaConfig = await karma.config.parseConfig(
156-
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
157-
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
158-
{ promiseConfig: true, throwErrors: true },
159-
);
76+
// Workaround https://github.com/angular/angular-cli/issues/28271, by clearing context by default
77+
// for single run executions. Not clearing context for multi-run (watched) builds allows the
78+
// Jasmine Spec Runner to be visible in the browser after test execution.
79+
karmaOptions.client ??= {};
80+
karmaOptions.client.clearContext ??= singleRun ?? false; // `singleRun` defaults to `false` per Karma docs.
16081

161-
return [karma, parsedKarmaConfig] as [typeof karma, KarmaConfigOptions];
162-
}),
163-
switchMap(
164-
([karma, karmaConfig]) =>
165-
new Observable<BuilderOutput>((subscriber) => {
166-
// Pass onto Karma to emit BuildEvents.
167-
karmaConfig.buildWebpack ??= {};
168-
if (typeof karmaConfig.buildWebpack === 'object') {
169-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
170-
(karmaConfig.buildWebpack as any).failureCb ??= () =>
171-
subscriber.next({ success: false });
172-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
173-
(karmaConfig.buildWebpack as any).successCb ??= () =>
174-
subscriber.next({ success: true });
175-
}
82+
// Convert browsers from a string to an array
83+
if (typeof options.browsers === 'string' && options.browsers) {
84+
karmaOptions.browsers = options.browsers.split(',');
85+
} else if (options.browsers === false) {
86+
karmaOptions.browsers = [];
87+
}
17688

177-
// Complete the observable once the Karma server returns.
178-
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
179-
subscriber.next({ success: exitCode === 0 });
180-
subscriber.complete();
181-
});
89+
if (options.reporters) {
90+
// Split along commas to make it more natural, and remove empty strings.
91+
const reporters = options.reporters
92+
.reduce<string[]>((acc, curr) => acc.concat(curr.split(',')), [])
93+
.filter((x) => !!x);
18294

183-
const karmaStart = karmaServer.start();
95+
if (reporters.length > 0) {
96+
karmaOptions.reporters = reporters;
97+
}
98+
}
18499

185-
// Cleanup, signal Karma to exit.
186-
return () => {
187-
void karmaStart.then(() => karmaServer.stop());
188-
};
189-
}),
190-
),
191-
defaultIfEmpty({ success: false }),
192-
);
100+
return karmaOptions;
193101
}
194102

195103
function getBuiltInKarmaConfig(
196104
workspaceRoot: string,
197105
projectName: string,
106+
useEsbuild: boolean,
198107
): ConfigOptions & Record<string, unknown> {
199108
let coverageFolderName = projectName.charAt(0) === '@' ? projectName.slice(1) : projectName;
200109
if (/[A-Z]/.test(coverageFolderName)) {
@@ -206,13 +115,13 @@ function getBuiltInKarmaConfig(
206115
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
207116
return {
208117
basePath: '',
209-
frameworks: ['jasmine', '@angular-devkit/build-angular'],
118+
frameworks: ['jasmine', ...(useEsbuild ? [] : ['@angular-devkit/build-angular'])],
210119
plugins: [
211120
'karma-jasmine',
212121
'karma-chrome-launcher',
213122
'karma-jasmine-html-reporter',
214123
'karma-coverage',
215-
'@angular-devkit/build-angular/plugins/karma',
124+
...(useEsbuild ? [] : ['@angular-devkit/build-angular/plugins/karma']),
216125
].map((p) => workspaceRootRequire(p)),
217126
jasmineHtmlReporter: {
218127
suppressAll: true, // removes the duplicated traces
@@ -243,22 +152,62 @@ function getBuiltInKarmaConfig(
243152
export type { KarmaBuilderOptions };
244153
export default createBuilder<Record<string, string> & KarmaBuilderOptions>(execute);
245154

246-
function getBuiltInMainFile(): string {
247-
const content = Buffer.from(
248-
`
249-
import { getTestBed } from '@angular/core/testing';
250-
import {
251-
BrowserDynamicTestingModule,
252-
platformBrowserDynamicTesting,
253-
} from '@angular/platform-browser-dynamic/testing';
155+
async function getExecuteWithBuilder(
156+
options: KarmaBuilderOptions,
157+
context: BuilderContext,
158+
): Promise<[boolean, typeof import('./application_builder') | typeof import('./browser_builder')]> {
159+
const useEsbuild = await checkForEsbuild(options, context);
160+
const executeWithBuilderModule = useEsbuild
161+
? import('./application_builder')
162+
: import('./browser_builder');
163+
164+
return [useEsbuild, await executeWithBuilderModule];
165+
}
254166

255-
// Initialize the Angular testing environment.
256-
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
257-
errorOnUnknownElements: true,
258-
errorOnUnknownProperties: true
259-
});
260-
`,
261-
).toString('base64');
167+
async function checkForEsbuild(
168+
options: KarmaBuilderOptions,
169+
context: BuilderContext,
170+
): Promise<boolean> {
171+
if (options.builderMode !== BuilderMode.Detect) {
172+
return options.builderMode === BuilderMode.Application;
173+
}
174+
175+
// Look up the current project's build target using a development configuration.
176+
const buildTargetSpecifier = `::development`;
177+
const buildTarget = targetFromTargetString(
178+
buildTargetSpecifier,
179+
context.target?.project,
180+
'build',
181+
);
182+
183+
try {
184+
const developmentBuilderName = await context.getBuilderNameForTarget(buildTarget);
185+
186+
return isEsbuildBased(developmentBuilderName);
187+
} catch (e) {
188+
if (!(e instanceof Error) || e.message !== 'Project target does not exist.') {
189+
throw e;
190+
}
191+
// If we can't find a development builder, we can't use 'detect'.
192+
throw new Error(
193+
'Failed to detect the builder used by the application. Please set builderMode explicitly.',
194+
);
195+
}
196+
}
197+
198+
function isEsbuildBased(
199+
builderName: string,
200+
): builderName is
201+
| '@angular/build:application'
202+
| '@angular-devkit/build-angular:application'
203+
| '@angular-devkit/build-angular:browser-esbuild' {
204+
if (
205+
builderName === '@angular/build:application' ||
206+
builderName === '@angular-devkit/build-angular:application' ||
207+
builderName === '@angular-devkit/build-angular:browser-esbuild'
208+
) {
209+
return true;
210+
}
262211

263-
return `ng-virtual-main.js!=!data:text/javascript;base64,${content}`;
212+
return false;
264213
}
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

+6
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."

‎packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/code-coverage_spec.ts

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ const coveragePath = 'coverage/lcov.info';
2323

2424
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
2525
describe('Behavior: "codeCoverage"', () => {
26+
if (isApplicationBuilder) {
27+
beforeEach(() => {
28+
pending('Code coverage not implemented yet for application builder');
29+
});
30+
}
31+
2632
beforeEach(() => {
2733
setupTarget(harness);
2834
});

‎packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/rebuilds_spec.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ import { concatMap, count, debounceTime, take, timeout } from 'rxjs';
1010
import { execute } from '../../index';
1111
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
1212

13-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
1414
describe('Behavior: "Rebuilds"', () => {
15+
if (isApplicationBuilder) {
16+
beforeEach(() => {
17+
pending('--watch not implemented yet for application builder');
18+
});
19+
}
20+
1521
beforeEach(() => {
1622
setupTarget(harness);
1723
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
import { BuilderMode } from '../../schema';
12+
13+
const ESBUILD_LOG_TEXT = 'Application bundle generation complete.';
14+
15+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationTarget) => {
16+
describe('option: "builderMode"', () => {
17+
beforeEach(() => {
18+
setupTarget(harness);
19+
});
20+
21+
it('"application" always uses esbuild', async () => {
22+
harness.useTarget('test', {
23+
...BASE_OPTIONS,
24+
builderMode: BuilderMode.Application,
25+
});
26+
27+
const { result, logs } = await harness.executeOnce();
28+
expect(result?.success).toBeTrue();
29+
expect(logs).toContain(
30+
jasmine.objectContaining({
31+
message: jasmine.stringMatching(ESBUILD_LOG_TEXT),
32+
}),
33+
);
34+
});
35+
36+
it('"browser" always uses webpack', async () => {
37+
harness.useTarget('test', {
38+
...BASE_OPTIONS,
39+
builderMode: BuilderMode.Browser,
40+
});
41+
42+
const { result, logs } = await harness.executeOnce();
43+
expect(result?.success).toBeTrue();
44+
expect(logs).not.toContain(
45+
jasmine.objectContaining({
46+
message: jasmine.stringMatching(ESBUILD_LOG_TEXT),
47+
}),
48+
);
49+
});
50+
51+
it('"detect" follows configuration of the development builder', async () => {
52+
harness.useTarget('test', {
53+
...BASE_OPTIONS,
54+
builderMode: BuilderMode.Detect,
55+
});
56+
57+
const { result, logs } = await harness.executeOnce();
58+
expect(result?.success).toBeTrue();
59+
if (isApplicationTarget) {
60+
expect(logs).toContain(
61+
jasmine.objectContaining({
62+
message: jasmine.stringMatching(ESBUILD_LOG_TEXT),
63+
}),
64+
);
65+
} else {
66+
expect(logs).not.toContain(
67+
jasmine.objectContaining({
68+
message: jasmine.stringMatching(ESBUILD_LOG_TEXT),
69+
}),
70+
);
71+
}
72+
});
73+
});
74+
});

‎packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage-exclude_spec.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup
1818

1919
const coveragePath = 'coverage/lcov.info';
2020

21-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
21+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
2222
describe('Option: "codeCoverageExclude"', () => {
23+
if (isApplicationBuilder) {
24+
beforeEach(() => {
25+
pending('Code coverage not implemented yet for application builder');
26+
});
27+
}
28+
2329
beforeEach(() => {
2430
setupTarget(harness);
2531
});

‎packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage_spec.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup
1919

2020
const coveragePath = 'coverage/lcov.info';
2121

22-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
22+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
2323
describe('Option: "codeCoverage"', () => {
24+
if (isApplicationBuilder) {
25+
beforeEach(() => {
26+
pending('Code coverage not implemented yet for application builder');
27+
});
28+
}
29+
2430
beforeEach(() => {
2531
setupTarget(harness);
2632
});

‎packages/angular_devkit/build_angular/src/builders/karma/tests/options/styles_spec.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
134134
expect(logs).toContain(
135135
jasmine.objectContaining({
136136
level: 'error',
137-
message: jasmine.stringMatching(`Can't resolve 'src/test-style-a.css'`),
137+
message: jasmine.stringMatching(
138+
/(Can't|Could not) resolve ['"]src\/test-style-a.css['"]/,
139+
),
138140
}),
139141
);
140142
});

‎packages/angular_devkit/build_angular/src/builders/karma/tests/options/web-worker-tsconfig_spec.ts

+25-12
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { execute } from '../../index';
1010
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
1111

12-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
1313
describe('Option: "webWorkerTsConfig"', () => {
1414
beforeEach(() => {
1515
setupTarget(harness);
@@ -69,15 +69,27 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
6969
});
7070
});
7171

72-
it(`should not parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
73-
harness.useTarget('test', {
74-
...BASE_OPTIONS,
75-
webWorkerTsConfig: undefined,
72+
// Web workers work with the application builder _without_ setting webWorkerTsConfig.
73+
if (isApplicationBuilder) {
74+
it(`should parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
75+
harness.useTarget('test', {
76+
...BASE_OPTIONS,
77+
webWorkerTsConfig: undefined,
78+
});
79+
80+
const { result } = await harness.executeOnce();
81+
expect(result?.success).toBeTrue();
7682
});
83+
} else {
84+
it(`should not parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
85+
harness.useTarget('test', {
86+
...BASE_OPTIONS,
87+
webWorkerTsConfig: undefined,
88+
});
7789

78-
await harness.writeFile(
79-
'./src/app/app.component.spec.ts',
80-
`
90+
await harness.writeFile(
91+
'./src/app/app.component.spec.ts',
92+
`
8193
import { TestBed } from '@angular/core/testing';
8294
import { AppComponent } from './app.component';
8395
@@ -91,11 +103,12 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
91103
.toThrowError(/Failed to construct 'Worker'/);
92104
});
93105
});`,
94-
);
106+
);
95107

96-
const { result } = await harness.executeOnce();
97-
expect(result?.success).toBeTrue();
98-
});
108+
const { result } = await harness.executeOnce();
109+
expect(result?.success).toBeTrue();
110+
});
111+
}
99112

100113
it(`should parse web workers when "webWorkerTsConfig" is set.`, async () => {
101114
harness.useTarget('test', {

‎packages/angular_devkit/build_angular/src/builders/karma/tests/setup.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { Schema } from '../schema';
9+
import { BuilderMode, Schema } from '../schema';
1010
import { BuilderHandlerFn } from '@angular-devkit/architect';
1111
import { json } from '@angular-devkit/core';
1212
import { ApplicationBuilderOptions as ApplicationSchema, buildApplication } from '@angular/build';
@@ -41,6 +41,7 @@ export const BASE_OPTIONS = Object.freeze<Schema>({
4141
browsers: 'ChromeHeadlessCI',
4242
progress: false,
4343
watch: false,
44+
builderMode: BuilderMode.Detect,
4445
});
4546

4647
const optionSchemaCache = new Map<string, json.schema.JsonSchema>();

0 commit comments

Comments
 (0)
Please sign in to comment.