From ad8b42e68dec764bf464151502984a162309e58d Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:16:13 -0500 Subject: [PATCH 01/11] refactor(@angular-devkit/build-angular): convert karma builder to AsyncIterable Refactor the Karma builder's `execute` function to return an `AsyncIterable` using a `ReadableStream`. This removes the RxJS dependency and aligns the builder with modern Angular CLI implementation patterns. Additionally, this change fixes a race condition where the Karma server could start even if the builder was cancelled during asynchronous initialization. An `isCancelled` flag is now used to ensure execution stops if a cancellation occurs before the server starts. --- .../src/builders/karma/browser_builder.ts | 104 +++++++++++------- 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts index dc45da558527..1414594d64e5 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts @@ -7,17 +7,16 @@ */ import { purgeStaleBuildCache } from '@angular/build/private'; -import { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; -import type { Config, ConfigOptions } from 'karma'; +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { Config, ConfigOptions, Server } from 'karma'; import * as path from 'node:path'; -import { Observable, defaultIfEmpty, from, switchMap } from 'rxjs'; -import { Configuration } from 'webpack'; +import type { Configuration } from 'webpack'; import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs'; -import { ExecutionTransformer } from '../../transforms'; +import type { ExecutionTransformer } from '../../transforms'; import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config'; -import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema'; +import { type Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema'; import { FindTestsPlugin } from './find-tests-plugin'; -import { Schema as KarmaBuilderOptions } from './schema'; +import type { Schema as KarmaBuilderOptions } from './schema'; export type KarmaConfigOptions = ConfigOptions & { buildWebpack?: unknown; @@ -33,9 +32,22 @@ export function execute( // The karma options transform cannot be async without a refactor of the builder implementation karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions; } = {}, -): Observable { - return from(initializeBrowser(options, context, transforms.webpackConfiguration)).pipe( - switchMap(async ([karma, webpackConfig]) => { +): AsyncIterable { + let karmaServer: Server; + let isCancelled = false; + + return new ReadableStream({ + async start(controller) { + const [karma, webpackConfig] = await initializeBrowser( + options, + context, + transforms.webpackConfiguration, + ); + + if (isCancelled) { + return; + } + const projectName = context.target?.project; if (!projectName) { throw new Error(`The 'karma' builder requires a target to be specified.`); @@ -71,44 +83,54 @@ export function execute( logger: context.logger, }; - const parsedKarmaConfig = await karma.config.parseConfig( + const parsedKarmaConfig = (await karma.config.parseConfig( options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig), transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions, { promiseConfig: true, throwErrors: true }, - ); + )) as KarmaConfigOptions; - return [karma, parsedKarmaConfig] as [typeof karma, KarmaConfigOptions]; - }), - switchMap( - ([karma, karmaConfig]) => - new Observable((subscriber) => { - // Pass onto Karma to emit BuildEvents. - karmaConfig.buildWebpack ??= {}; - if (typeof karmaConfig.buildWebpack === 'object') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (karmaConfig.buildWebpack as any).failureCb ??= () => - subscriber.next({ success: false }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (karmaConfig.buildWebpack as any).successCb ??= () => - subscriber.next({ success: true }); - } + if (isCancelled) { + return; + } - // Complete the observable once the Karma server returns. - const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => { - subscriber.next({ success: exitCode === 0 }); - subscriber.complete(); - }); + const enqueue = (value: BuilderOutput) => { + try { + controller.enqueue(value); + } catch { + // Controller is already closed + } + }; - const karmaStart = karmaServer.start(); + const close = () => { + try { + controller.close(); + } catch { + // Controller is already closed + } + }; - // Cleanup, signal Karma to exit. - return () => { - void karmaStart.then(() => karmaServer.stop()); - }; - }), - ), - defaultIfEmpty({ success: false }), - ); + // Pass onto Karma to emit BuildEvents. + parsedKarmaConfig.buildWebpack ??= {}; + if (typeof parsedKarmaConfig.buildWebpack === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parsedKarmaConfig.buildWebpack as any).failureCb ??= () => enqueue({ success: false }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parsedKarmaConfig.buildWebpack as any).successCb ??= () => enqueue({ success: true }); + } + + // Close the stream once the Karma server returns. + karmaServer = new karma.Server(parsedKarmaConfig as Config, (exitCode) => { + enqueue({ success: exitCode === 0 }); + close(); + }); + + await karmaServer.start(); + }, + async cancel() { + isCancelled = true; + await karmaServer?.stop(); + }, + }); } async function initializeBrowser( From 327dc8165a1cd1944a19467df58c72e57637ae6e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:55:53 -0500 Subject: [PATCH 02/11] refactor(@angular-devkit/build-angular): decouple karma builder from webpack plugin callbacks Decouples the Karma builder from the Webpack plugin by removing the custom `successCb` and `failureCb` callback mechanism. The builder now idiomaticallly listens to the standard Karma `run_complete` event on the server instance to emit builder results. --- .../src/builders/karma/browser_builder.ts | 21 +++++++------------ .../src/tools/webpack/plugins/karma/karma.ts | 15 ------------- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts index 1414594d64e5..1ac82fb47c9f 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts @@ -8,7 +8,7 @@ import { purgeStaleBuildCache } from '@angular/build/private'; import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; -import type { Config, ConfigOptions, Server } from 'karma'; +import type { ConfigOptions, Server } from 'karma'; import * as path from 'node:path'; import type { Configuration } from 'webpack'; import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs'; @@ -83,11 +83,11 @@ export function execute( logger: context.logger, }; - const parsedKarmaConfig = (await karma.config.parseConfig( + const parsedKarmaConfig = await karma.config.parseConfig( options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig), transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions, { promiseConfig: true, throwErrors: true }, - )) as KarmaConfigOptions; + ); if (isCancelled) { return; @@ -109,21 +109,16 @@ export function execute( } }; - // Pass onto Karma to emit BuildEvents. - parsedKarmaConfig.buildWebpack ??= {}; - if (typeof parsedKarmaConfig.buildWebpack === 'object') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (parsedKarmaConfig.buildWebpack as any).failureCb ??= () => enqueue({ success: false }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (parsedKarmaConfig.buildWebpack as any).successCb ??= () => enqueue({ success: true }); - } - // Close the stream once the Karma server returns. - karmaServer = new karma.Server(parsedKarmaConfig as Config, (exitCode) => { + karmaServer = new karma.Server(parsedKarmaConfig, (exitCode) => { enqueue({ success: exitCode === 0 }); close(); }); + karmaServer.on('run_complete', (_, results) => { + enqueue({ success: results.exitCode === 0 }); + }); + await karmaServer.start(); }, async cancel() { diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index 4ce99ea56928..e4aa5ec1edae 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -25,8 +25,6 @@ const KARMA_APPLICATION_PATH = '_karma_webpack_'; let blocked: any[] = []; let isBlocked = false; let webpackMiddleware: any; -let successCb: () => void; -let failureCb: () => void; const init: any = (config: any, emitter: any) => { if (!config.buildWebpack) { @@ -37,8 +35,6 @@ const init: any = (config: any, emitter: any) => { } const options = config.buildWebpack.options as BuildOptions; const logger: logging.Logger = config.buildWebpack.logger || createConsoleLogger(); - successCb = config.buildWebpack.successCb; - failureCb = config.buildWebpack.failureCb; // Add a reporter that fixes sourcemap urls. if (normalizeSourceMaps(options.sourceMap).scripts) { @@ -132,9 +128,6 @@ const init: any = (config: any, emitter: any) => { // Finish Karma run early in case of compilation error. emitter.emit('run_complete', [], { exitCode: 1 }); - - // Emit a failure build event if there are compilation errors. - failureCb(); } }); @@ -224,14 +217,6 @@ const eventReporter: any = function (this: any, baseReporterDecorator: any, conf muteDuplicateReporterLogging(this, config); - this.onRunComplete = function (_browsers: any, results: any) { - if (results.exitCode === 0) { - successCb(); - } else { - failureCb(); - } - }; - // avoid duplicate failure message this.specFailure = () => {}; }; From e865c64b9b1dbf5b91a5f650d028b9564b3f41f2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:43:37 -0500 Subject: [PATCH 03/11] refactor(@angular-devkit/build-angular): convert karma builder entry to AsyncIterable Refactor the Karma builder's main `execute` function to return an `AsyncIterable` instead of an RxJS `Observable`. This continues the effort to reduce RxJS usage in the CLI builders and aligns the implementation with modern Angular CLI patterns. --- .../build_angular/src/builders/karma/index.ts | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/karma/index.ts b/packages/angular_devkit/build_angular/src/builders/karma/index.ts index ea79a9165771..e6db299395bd 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/index.ts @@ -17,7 +17,6 @@ import { strings } from '@angular-devkit/core'; import type { ConfigOptions } from 'karma'; import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { Observable, from, mergeMap } from 'rxjs'; import { Configuration } from 'webpack'; import { ExecutionTransformer } from '../../transforms'; import { normalizeFileReplacements } from '../../utils'; @@ -31,7 +30,7 @@ export type KarmaConfigOptions = ConfigOptions & { /** * @experimental Direct usage of this function is considered experimental. */ -export function execute( +export async function* execute( options: KarmaBuilderOptions, context: BuilderContext, transforms: { @@ -39,37 +38,35 @@ export function execute( // The karma options transform cannot be async without a refactor of the builder implementation karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions; } = {}, -): Observable { +): AsyncIterable { // Check Angular version. assertCompatibleAngularVersion(context.workspaceRoot); - return from(getExecuteWithBuilder(options, context)).pipe( - mergeMap(([useEsbuild, executeWithBuilder]) => { - if (useEsbuild) { - if (transforms.webpackConfiguration) { - context.logger.warn( - `This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`, - ); - } - - if (options.fileReplacements) { - options.fileReplacements = normalizeFileReplacements(options.fileReplacements, './'); - } - - if (typeof options.polyfills === 'string') { - options.polyfills = [options.polyfills]; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return executeWithBuilder(options as any, context, transforms); - } else { - const karmaOptions = getBaseKarmaOptions(options, context); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return executeWithBuilder(options as any, context, karmaOptions, transforms); - } - }), - ); + const [useEsbuild, executeWithBuilder] = await getExecuteWithBuilder(options, context); + + if (useEsbuild) { + if (transforms.webpackConfiguration) { + context.logger.warn( + `This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`, + ); + } + + if (options.fileReplacements) { + options.fileReplacements = normalizeFileReplacements(options.fileReplacements, './'); + } + + if (typeof options.polyfills === 'string') { + options.polyfills = [options.polyfills]; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yield* executeWithBuilder(options as any, context, transforms); + } else { + const karmaOptions = getBaseKarmaOptions(options, context); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yield* executeWithBuilder(options as any, context, karmaOptions, transforms); + } } function getBaseKarmaOptions( @@ -169,7 +166,7 @@ function getBuiltInKarmaConfig( } export type { KarmaBuilderOptions }; -export default createBuilder & KarmaBuilderOptions>(execute); +export default createBuilder(execute); async function getExecuteWithBuilder( options: KarmaBuilderOptions, From 9ee6986a8b1c4743b511a3b8bdb5b2c985ad9818 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:50:31 -0500 Subject: [PATCH 04/11] refactor(@angular-devkit/build-angular): remove as any usage in karma builder Refactor the Karma builder's `execute` function to directly handle dynamic imports within conditional blocks. This eliminates the need for `getExecuteWithBuilder` and the resulting union types that required `as any` casting. This improves type safety by allowing TypeScript to verify the arguments passed to the specific builder implementations (Esbuild vs Webpack). --- .../build_angular/src/builders/karma/index.ts | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/karma/index.ts b/packages/angular_devkit/build_angular/src/builders/karma/index.ts index e6db299395bd..81c8d1d58a21 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/index.ts @@ -42,9 +42,7 @@ export async function* execute( // Check Angular version. assertCompatibleAngularVersion(context.workspaceRoot); - const [useEsbuild, executeWithBuilder] = await getExecuteWithBuilder(options, context); - - if (useEsbuild) { + if (await checkForEsbuild(options, context)) { if (transforms.webpackConfiguration) { context.logger.warn( `This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`, @@ -59,13 +57,18 @@ export async function* execute( options.polyfills = [options.polyfills]; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - yield* executeWithBuilder(options as any, context, transforms); + const { executeKarmaBuilder } = await import('@angular/build'); + + yield* executeKarmaBuilder( + options as unknown as import('@angular/build').KarmaBuilderOptions, + context, + transforms, + ); } else { const karmaOptions = getBaseKarmaOptions(options, context); + const { execute } = await import('./browser_builder'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - yield* executeWithBuilder(options as any, context, karmaOptions, transforms); + yield* execute(options, context, karmaOptions, transforms); } } @@ -168,30 +171,6 @@ function getBuiltInKarmaConfig( export type { KarmaBuilderOptions }; export default createBuilder(execute); -async function getExecuteWithBuilder( - options: KarmaBuilderOptions, - context: BuilderContext, -): Promise< - [ - boolean, - ( - | (typeof import('@angular/build'))['executeKarmaBuilder'] - | (typeof import('./browser_builder'))['execute'] - ), - ] -> { - const useEsbuild = await checkForEsbuild(options, context); - let execute; - if (useEsbuild) { - const { executeKarmaBuilder } = await import('@angular/build'); - execute = executeKarmaBuilder; - } else { - const browserBuilderModule = await import('./browser_builder'); - execute = browserBuilderModule.execute; - } - - return [useEsbuild, execute]; -} async function checkForEsbuild( options: KarmaBuilderOptions, From ddfb3d0da855593fde16b1f8d4fba1fb55bdf4e2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:03:32 -0500 Subject: [PATCH 05/11] refactor(@angular-devkit/build-angular): maintain experimental public API for karma builder Adds an `executeKarmaBuilder` wrapper function in the package entry point that returns an RxJS `Observable`. This maintains backward compatibility for the experimental public API while the internal implementation has been migrated to use `AsyncIterable`. --- .../angular_devkit/build_angular/index.api.md | 16 +++++----- .../build_angular/src/builders/karma/index.ts | 1 - .../angular_devkit/build_angular/src/index.ts | 29 +++++++++++++++---- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/goldens/public-api/angular_devkit/build_angular/index.api.md b/goldens/public-api/angular_devkit/build_angular/index.api.md index cb46b4458351..0208e998a300 100644 --- a/goldens/public-api/angular_devkit/build_angular/index.api.md +++ b/goldens/public-api/angular_devkit/build_angular/index.api.md @@ -49,7 +49,7 @@ export type BrowserBuilderOptions = { i18nDuplicateTranslation?: I18NTranslation; i18nMissingTranslation?: I18NTranslation; index: IndexUnion; - inlineStyleLanguage?: InlineStyleLanguage; + inlineStyleLanguage?: InlineStyleLanguage_2; localize?: Localize; main: string; namedChunks?: boolean; @@ -58,16 +58,16 @@ export type BrowserBuilderOptions = { outputHashing?: OutputHashing; outputPath: string; poll?: number; - polyfills?: Polyfills; + polyfills?: Polyfills_2; preserveSymlinks?: boolean; progress?: boolean; resourcesOutputPath?: string; - scripts?: ScriptElement[]; + scripts?: ScriptElement_2[]; serviceWorker?: boolean; sourceMap?: SourceMapUnion; statsJson?: boolean; stylePreprocessorOptions?: StylePreprocessorOptions; - styles?: StyleElement[]; + styles?: StyleElement_2[]; subresourceIntegrity?: boolean; tsConfig: string; vendorChunk?: boolean; @@ -216,18 +216,18 @@ export type KarmaBuilderOptions = { exclude?: string[]; fileReplacements?: FileReplacement_2[]; include?: string[]; - inlineStyleLanguage?: InlineStyleLanguage_2; + inlineStyleLanguage?: InlineStyleLanguage; karmaConfig?: string; main?: string; poll?: number; - polyfills?: Polyfills_2; + polyfills?: Polyfills; preserveSymlinks?: boolean; progress?: boolean; reporters?: string[]; - scripts?: ScriptElement_2[]; + scripts?: ScriptElement[]; sourceMap?: SourceMapUnion_2; stylePreprocessorOptions?: StylePreprocessorOptions_2; - styles?: StyleElement_2[]; + styles?: StyleElement[]; tsConfig: string; watch?: boolean; webWorkerTsConfig?: string; diff --git a/packages/angular_devkit/build_angular/src/builders/karma/index.ts b/packages/angular_devkit/build_angular/src/builders/karma/index.ts index 81c8d1d58a21..ff54a8292ff2 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/index.ts @@ -171,7 +171,6 @@ function getBuiltInKarmaConfig( export type { KarmaBuilderOptions }; export default createBuilder(execute); - async function checkForEsbuild( options: KarmaBuilderOptions, context: BuilderContext, diff --git a/packages/angular_devkit/build_angular/src/index.ts b/packages/angular_devkit/build_angular/src/index.ts index 7f02b7753686..1ba9ce034544 100644 --- a/packages/angular_devkit/build_angular/src/index.ts +++ b/packages/angular_devkit/build_angular/src/index.ts @@ -6,6 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import { type Observable, from } from 'rxjs'; +import { + type KarmaBuilderOptions, + type KarmaConfigOptions, + execute as executeKarmaBuilderInternal, +} from './builders/karma'; +import type { ExecutionTransformer } from './transforms'; + export * from './transforms'; export { CrossOrigin, OutputHashing, Type } from './builders/browser/schema'; @@ -40,11 +49,21 @@ export { type ExtractI18nBuilderOptions, } from './builders/extract-i18n'; -export { - execute as executeKarmaBuilder, - type KarmaBuilderOptions, - type KarmaConfigOptions, -} from './builders/karma'; +/** + * @experimental Direct usage of this function is considered experimental. + */ +export function executeKarmaBuilder( + options: KarmaBuilderOptions, + context: BuilderContext, + transforms?: { + webpackConfiguration?: ExecutionTransformer; + karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions; + }, +): Observable { + return from(executeKarmaBuilderInternal(options, context, transforms)); +} + +export { type KarmaBuilderOptions, type KarmaConfigOptions }; export { execute as executeProtractorBuilder, From 66e8d2eddcbfbe0ea6170612923758d6df5c802b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:19:18 -0500 Subject: [PATCH 06/11] refactor(@angular-devkit/build-angular): move webpack compiler creation to karma builder Moves the instantiation of the Webpack Compiler from the Karma plugin to the Karma builder (`browser_builder`). This allows the builder to have full ownership of the compiler's lifecycle and configuration, including `singleRun` adjustments and output paths. The Karma plugin now receives the `compiler` instance directly instead of the configuration, reducing its responsibility to just integration logic. --- .../src/builders/karma/browser_builder.ts | 26 ++++++++++++-- .../src/tools/webpack/plugins/karma/karma.ts | 35 +++---------------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts index 1ac82fb47c9f..e9997b67ea0d 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts @@ -10,7 +10,7 @@ import { purgeStaleBuildCache } from '@angular/build/private'; import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; import type { ConfigOptions, Server } from 'karma'; import * as path from 'node:path'; -import type { Configuration } from 'webpack'; +import webpack, { Configuration } from 'webpack'; import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs'; import type { ExecutionTransformer } from '../../transforms'; import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config'; @@ -77,9 +77,31 @@ export function execute( }), ); + const KARMA_APPLICATION_PATH = '_karma_webpack_'; + webpackConfig.output ??= {}; + webpackConfig.output.path = `/${KARMA_APPLICATION_PATH}/`; + webpackConfig.output.publicPath = `/${KARMA_APPLICATION_PATH}/`; + + if (karmaOptions.singleRun) { + webpackConfig.plugins.unshift({ + apply: (compiler: webpack.Compiler) => { + compiler.hooks.afterEnvironment.tap('karma', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compiler.watchFileSystem = { watch: () => {} } as any; + }); + }, + }); + } + + // Remove the watch option to avoid the [DEP_WEBPACK_WATCH_WITHOUT_CALLBACK] warning. + // The compiler is initialized in watch mode by webpack-dev-middleware. + delete webpackConfig.watch; + + const compiler = webpack(webpackConfig); + karmaOptions.buildWebpack = { options, - webpackConfig, + compiler, logger: context.logger, }; diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index e4aa5ec1edae..faf611d640c2 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -10,7 +10,7 @@ // TODO: cleanup this file, it's copied as is from Angular CLI. import * as http from 'node:http'; import * as path from 'node:path'; -import webpack from 'webpack'; +import type { Compiler } from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; import { statsErrorsToString } from '../../utils/stats'; @@ -18,7 +18,6 @@ import { createConsoleLogger } from '@angular-devkit/core/node'; import { logging } from '@angular-devkit/core'; import { BuildOptions } from '../../../../utils/build-options'; import { normalizeSourceMaps } from '../../../../utils/index'; -import assert from 'node:assert'; const KARMA_APPLICATION_PATH = '_karma_webpack_'; @@ -67,16 +66,13 @@ const init: any = (config: any, emitter: any) => { config.reporters.push('coverage'); } - // Add webpack config. - const webpackConfig = config.buildWebpack.webpackConfig; + const compiler = config.buildWebpack.compiler as Compiler; const webpackMiddlewareConfig = { // Hide webpack output because its noisy. stats: false, publicPath: `/${KARMA_APPLICATION_PATH}/`, }; - // Use existing config if any. - config.webpack = { ...webpackConfig, ...config.webpack }; config.webpackMiddleware = { ...webpackMiddlewareConfig, ...config.webpackMiddleware }; // Our custom context and debug files list the webpack bundles directly instead of using @@ -90,29 +86,10 @@ const init: any = (config: any, emitter: any) => { config.middleware = config.middleware || []; config.middleware.push('@angular-devkit/build-angular--fallback'); - if (config.singleRun) { - // There's no option to turn off file watching in webpack-dev-server, but - // we can override the file watcher instead. - webpackConfig.plugins.unshift({ - apply: (compiler: any) => { - compiler.hooks.afterEnvironment.tap('karma', () => { - compiler.watchFileSystem = { watch: () => {} }; - }); - }, - }); - } - // Files need to be served from a custom path for Karma. - webpackConfig.output.path = `/${KARMA_APPLICATION_PATH}/`; - webpackConfig.output.publicPath = `/${KARMA_APPLICATION_PATH}/`; - - const compiler = webpack(webpackConfig, (error, stats) => { - if (error) { - throw error; - } - - if (stats?.hasErrors()) { + compiler.hooks.done.tap('karma', (stats) => { + if (stats.hasErrors()) { // Only generate needed JSON stats and when needed. - const statsJson = stats?.toJson({ + const statsJson = stats.toJson({ all: false, children: true, errors: true, @@ -136,8 +113,6 @@ const init: any = (config: any, emitter: any) => { callback?.(); } - assert(compiler, 'Webpack compiler factory did not return a compiler instance.'); - compiler.hooks.invalid.tap('karma', () => handler()); compiler.hooks.watchRun.tapAsync('karma', (_: any, callback: () => void) => handler(callback)); compiler.hooks.run.tapAsync('karma', (_: any, callback: () => void) => handler(callback)); From 769218d0c704b475c1a4bd0725db07d154e428de Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:23:57 -0500 Subject: [PATCH 07/11] refactor(@angular-devkit/build-angular): remove createConsoleLogger from karma plugin Removes the `createConsoleLogger` import and its usage as a fallback in the Karma plugin. The logger is now consistently provided by the builder via the `buildWebpack` configuration object. --- .../build_angular/src/tools/webpack/plugins/karma/karma.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index faf611d640c2..5e44b864ff18 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -14,7 +14,6 @@ import type { Compiler } from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; import { statsErrorsToString } from '../../utils/stats'; -import { createConsoleLogger } from '@angular-devkit/core/node'; import { logging } from '@angular-devkit/core'; import { BuildOptions } from '../../../../utils/build-options'; import { normalizeSourceMaps } from '../../../../utils/index'; @@ -33,7 +32,7 @@ const init: any = (config: any, emitter: any) => { ); } const options = config.buildWebpack.options as BuildOptions; - const logger: logging.Logger = config.buildWebpack.logger || createConsoleLogger(); + const logger: logging.Logger = config.buildWebpack.logger; // Add a reporter that fixes sourcemap urls. if (normalizeSourceMaps(options.sourceMap).scripts) { From 85ca6e5667c9dae7e58fce2383a964717e512a8e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:46:28 -0500 Subject: [PATCH 08/11] refactor(@angular-devkit/build-angular): optimize karma plugin hooks Consolidates the compiler hooks in the Karma plugin. Merges separate `compiler.hooks.done` taps into a single unified handler that manages error reporting, file refreshing, and blocking logic. --- .../src/tools/webpack/plugins/karma/karma.ts | 56 +++++++++---------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index 5e44b864ff18..a7c0c5dc2e6b 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -85,28 +85,6 @@ const init: any = (config: any, emitter: any) => { config.middleware = config.middleware || []; config.middleware.push('@angular-devkit/build-angular--fallback'); - compiler.hooks.done.tap('karma', (stats) => { - if (stats.hasErrors()) { - // Only generate needed JSON stats and when needed. - const statsJson = stats.toJson({ - all: false, - children: true, - errors: true, - warnings: true, - }); - - logger.error(statsErrorsToString(statsJson, { colors: true })); - - if (config.singleRun) { - // Notify potential listeners of the compile error. - emitter.emit('load_error'); - } - - // Finish Karma run early in case of compilation error. - emitter.emit('run_complete', [], { exitCode: 1 }); - } - }); - function handler(callback?: () => void): void { isBlocked = true; callback?.(); @@ -133,6 +111,32 @@ const init: any = (config: any, emitter: any) => { return new Promise((resolve) => { compiler.hooks.done.tap('karma', (stats) => { + if (stats.hasErrors()) { + lastCompilationHash = undefined; + + // Only generate needed JSON stats and when needed. + const statsJson = stats.toJson({ + all: false, + children: true, + errors: true, + warnings: true, + }); + + logger.error(statsErrorsToString(statsJson, { colors: true })); + + if (config.singleRun) { + // Notify potential listeners of the compile error. + emitter.emit('load_error'); + } + + // Finish Karma run early in case of compilation error. + emitter.emit('run_complete', [], { exitCode: 1 }); + } else if (stats.hash != lastCompilationHash) { + // Refresh karma only when there are no webpack errors, and if the compilation changed. + lastCompilationHash = stats.hash; + emitter.refreshFiles(); + } + if (isFirstRun) { // This is needed to block Karma from launching browsers before Webpack writes the assets in memory. // See the below: @@ -142,14 +146,6 @@ const init: any = (config: any, emitter: any) => { resolve(); } - if (stats.hasErrors()) { - lastCompilationHash = undefined; - } else if (stats.hash != lastCompilationHash) { - // Refresh karma only when there are no webpack errors, and if the compilation changed. - lastCompilationHash = stats.hash; - emitter.refreshFiles(); - } - unblock(); }); }); From f984b473bdb022b3ceb332a4ee05c19d0764e5fe Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:56:02 -0500 Subject: [PATCH 09/11] refactor(@angular-devkit/build-angular): remove redundant event reporter from karma plugin Removes the `@angular-devkit/build-angular--event-reporter` and its logic. The reporter was no longer performing significant work after the removal of custom callbacks, and its primary remaining function was a logging hack that is no longer required. --- .../src/tools/webpack/plugins/karma/karma.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index a7c0c5dc2e6b..230e4a99eee1 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -55,8 +55,6 @@ const init: any = (config: any, emitter: any) => { ); } - config.reporters.unshift('@angular-devkit/build-angular--event-reporter'); - // When using code-coverage, auto-add karma-coverage. if ( options.codeCoverage && @@ -181,18 +179,6 @@ function muteDuplicateReporterLogging(context: any, config: any) { } } -// Emits builder events. -const eventReporter: any = function (this: any, baseReporterDecorator: any, config: any) { - baseReporterDecorator(this); - - muteDuplicateReporterLogging(this, config); - - // avoid duplicate failure message - this.specFailure = () => {}; -}; - -eventReporter.$inject = ['baseReporterDecorator', 'config']; - // Strip the server address and webpack scheme (webpack://) from error log. const sourceMapReporter: any = function (this: any, baseReporterDecorator: any, config: any) { baseReporterDecorator(this); @@ -246,7 +232,6 @@ function fallbackMiddleware() { module.exports = { 'framework:@angular-devkit/build-angular': ['factory', init], 'reporter:@angular-devkit/build-angular--sourcemap-reporter': ['type', sourceMapReporter], - 'reporter:@angular-devkit/build-angular--event-reporter': ['type', eventReporter], 'middleware:@angular-devkit/build-angular--blocker': ['factory', requestBlocker], 'middleware:@angular-devkit/build-angular--fallback': ['factory', fallbackMiddleware], }; From e460df5ab38d952b1c9da7d9fe9ee67ca3631dd5 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:15:27 -0500 Subject: [PATCH 10/11] refactor(@angular-devkit/build-angular): apply strict typing and cleanup karma middleware Updates the `requestBlocker` and `fallbackMiddleware` functions in the Karma plugin to use strict `IncomingMessage`, `ServerResponse`, and `NextFunction` types from `node:http`. Also properly types the `webpackMiddleware` variable and ensures its `close` method is called with a callback in the exit handler, resolving a TypeScript error exposed by the stricter typing. --- .../src/tools/webpack/plugins/karma/karma.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index 230e4a99eee1..31387de0cd8d 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -8,7 +8,7 @@ /* eslint-disable */ // TODO: cleanup this file, it's copied as is from Angular CLI. -import * as http from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import * as path from 'node:path'; import type { Compiler } from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; @@ -20,9 +20,9 @@ import { normalizeSourceMaps } from '../../../../utils/index'; const KARMA_APPLICATION_PATH = '_karma_webpack_'; -let blocked: any[] = []; +let blocked: ((err?: unknown) => void)[] = []; let isBlocked = false; -let webpackMiddleware: any; +let webpackMiddleware: webpackDevMiddleware.API; const init: any = (config: any, emitter: any) => { if (!config.buildWebpack) { @@ -94,8 +94,7 @@ const init: any = (config: any, emitter: any) => { webpackMiddleware = webpackDevMiddleware(compiler, webpackMiddlewareConfig); emitter.on('exit', (done: any) => { - webpackMiddleware.close(); - compiler.close(() => done()); + webpackMiddleware.close(() => compiler.close(() => done())); }); function unblock() { @@ -153,7 +152,11 @@ init.$inject = ['config', 'emitter']; // Block requests until the Webpack compilation is done. function requestBlocker() { - return function (_request: any, _response: any, next: () => void) { + return function ( + _request: IncomingMessage, + _response: ServerResponse, + next: (err?: unknown) => void, + ) { if (isBlocked) { blocked.push(next); } else { @@ -203,7 +206,11 @@ sourceMapReporter.$inject = ['baseReporterDecorator', 'config']; // When a request is not found in the karma server, try looking for it from the webpack server root. function fallbackMiddleware() { - return function (request: http.IncomingMessage, response: http.ServerResponse, next: () => void) { + return function ( + request: IncomingMessage, + response: ServerResponse, + next: (err?: unknown) => void, + ) { if (webpackMiddleware) { if (request.url && !new RegExp(`\\/${KARMA_APPLICATION_PATH}\\/.*`).test(request.url)) { request.url = '/' + KARMA_APPLICATION_PATH + request.url; From 889390a178eac3fe3b6a1aed20652d98084ebba1 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:38:20 -0500 Subject: [PATCH 11/11] refactor(@angular-devkit/build-angular): remove redundant request blocker from karma plugin Removes the custom request blocker middleware and related hooks from the Karma plugin. `webpack-dev-middleware` already handles blocking requests until compilation is valid, making this custom logic redundant and unnecessary. --- .../src/tools/webpack/plugins/karma/karma.ts | 39 +------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts index 31387de0cd8d..4ae968ba41a5 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -20,8 +20,6 @@ import { normalizeSourceMaps } from '../../../../utils/index'; const KARMA_APPLICATION_PATH = '_karma_webpack_'; -let blocked: ((err?: unknown) => void)[] = []; -let isBlocked = false; let webpackMiddleware: webpackDevMiddleware.API; const init: any = (config: any, emitter: any) => { @@ -77,32 +75,15 @@ const init: any = (config: any, emitter: any) => { config.customContextFile = `${__dirname}/karma-context.html`; config.customDebugFile = `${__dirname}/karma-debug.html`; - // Add the request blocker and the webpack server fallback. - config.beforeMiddleware = config.beforeMiddleware || []; - config.beforeMiddleware.push('@angular-devkit/build-angular--blocker'); + // Add the webpack server fallback. config.middleware = config.middleware || []; config.middleware.push('@angular-devkit/build-angular--fallback'); - function handler(callback?: () => void): void { - isBlocked = true; - callback?.(); - } - - compiler.hooks.invalid.tap('karma', () => handler()); - compiler.hooks.watchRun.tapAsync('karma', (_: any, callback: () => void) => handler(callback)); - compiler.hooks.run.tapAsync('karma', (_: any, callback: () => void) => handler(callback)); - webpackMiddleware = webpackDevMiddleware(compiler, webpackMiddlewareConfig); emitter.on('exit', (done: any) => { webpackMiddleware.close(() => compiler.close(() => done())); }); - function unblock() { - isBlocked = false; - blocked.forEach((cb) => cb()); - blocked = []; - } - let lastCompilationHash: string | undefined; let isFirstRun = true; @@ -142,29 +123,12 @@ const init: any = (config: any, emitter: any) => { isFirstRun = false; resolve(); } - - unblock(); }); }); }; init.$inject = ['config', 'emitter']; -// Block requests until the Webpack compilation is done. -function requestBlocker() { - return function ( - _request: IncomingMessage, - _response: ServerResponse, - next: (err?: unknown) => void, - ) { - if (isBlocked) { - blocked.push(next); - } else { - next(); - } - }; -} - // Copied from "karma-jasmine-diff-reporter" source code: // In case, when multiple reporters are used in conjunction // with initSourcemapReporter, they both will show repetitive log @@ -239,6 +203,5 @@ function fallbackMiddleware() { module.exports = { 'framework:@angular-devkit/build-angular': ['factory', init], 'reporter:@angular-devkit/build-angular--sourcemap-reporter': ['type', sourceMapReporter], - 'middleware:@angular-devkit/build-angular--blocker': ['factory', requestBlocker], 'middleware:@angular-devkit/build-angular--fallback': ['factory', fallbackMiddleware], };