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/browser_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/browser_builder.ts index dc45da558527..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 @@ -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 { ConfigOptions, Server } from 'karma'; import * as path from 'node:path'; -import { Observable, defaultIfEmpty, from, switchMap } from 'rxjs'; -import { Configuration } from 'webpack'; +import webpack, { 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.`); @@ -65,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, }; @@ -77,38 +111,43 @@ export function execute( { promiseConfig: true, throwErrors: true }, ); - 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 }), - ); + // Close the stream once the Karma server returns. + 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() { + isCancelled = true; + await karmaServer?.stop(); + }, + }); } async function initializeBrowser( 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..ff54a8292ff2 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,38 @@ 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); - } - }), - ); + 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.`, + ); + } + + if (options.fileReplacements) { + options.fileReplacements = normalizeFileReplacements(options.fileReplacements, './'); + } + + if (typeof options.polyfills === 'string') { + options.polyfills = [options.polyfills]; + } + + 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'); + + yield* execute(options, context, karmaOptions, transforms); + } } function getBaseKarmaOptions( @@ -169,32 +169,7 @@ function getBuiltInKarmaConfig( } export type { KarmaBuilderOptions }; -export default createBuilder & KarmaBuilderOptions>(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]; -} +export default createBuilder(execute); async function checkForEsbuild( options: KarmaBuilderOptions, 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, 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..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 @@ -8,25 +8,19 @@ /* 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 webpack from 'webpack'; +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'; -import assert from 'node:assert'; const KARMA_APPLICATION_PATH = '_karma_webpack_'; -let blocked: any[] = []; -let isBlocked = false; -let webpackMiddleware: any; -let successCb: () => void; -let failureCb: () => void; +let webpackMiddleware: webpackDevMiddleware.API; const init: any = (config: any, emitter: any) => { if (!config.buildWebpack) { @@ -36,9 +30,7 @@ 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; + const logger: logging.Logger = config.buildWebpack.logger; // Add a reporter that fixes sourcemap urls. if (normalizeSourceMaps(options.sourceMap).scripts) { @@ -61,8 +53,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 && @@ -71,16 +61,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 @@ -88,119 +75,60 @@ 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'); - 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()) { - // 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 }); - - // Emit a failure build event if there are compilation errors. - failureCb(); - } - }); - - function handler(callback?: () => void): void { - isBlocked = true; - 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)); - webpackMiddleware = webpackDevMiddleware(compiler, webpackMiddlewareConfig); emitter.on('exit', (done: any) => { - webpackMiddleware.close(); - compiler.close(() => done()); + webpackMiddleware.close(() => compiler.close(() => done())); }); - function unblock() { - isBlocked = false; - blocked.forEach((cb) => cb()); - blocked = []; - } - let lastCompilationHash: string | undefined; let isFirstRun = true; return new Promise((resolve) => { compiler.hooks.done.tap('karma', (stats) => { - if (isFirstRun) { - // This is needed to block Karma from launching browsers before Webpack writes the assets in memory. - // See the below: - // https://github.com/karma-runner/karma-chrome-launcher/issues/154#issuecomment-986661937 - // https://github.com/angular/angular-cli/issues/22495 - isFirstRun = false; - resolve(); - } - 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(); } - unblock(); + if (isFirstRun) { + // This is needed to block Karma from launching browsers before Webpack writes the assets in memory. + // See the below: + // https://github.com/karma-runner/karma-chrome-launcher/issues/154#issuecomment-986661937 + // https://github.com/angular/angular-cli/issues/22495 + isFirstRun = false; + resolve(); + } }); }); }; init.$inject = ['config', 'emitter']; -// Block requests until the Webpack compilation is done. -function requestBlocker() { - return function (_request: any, _response: any, next: () => 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 @@ -218,26 +146,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); - - this.onRunComplete = function (_browsers: any, results: any) { - if (results.exitCode === 0) { - successCb(); - } else { - failureCb(); - } - }; - - // 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); @@ -262,7 +170,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; @@ -291,7 +203,5 @@ 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], };