diff --git a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts index 2e6b487ab689..5c59ae05375b 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts @@ -8,6 +8,7 @@ import { BuildOutputFileType } from '@angular/build'; import { + ApplicationBuilderInternalOptions, ResultFile, ResultKind, buildApplicationInternal, @@ -19,13 +20,18 @@ import glob from 'fast-glob'; import * as fs from 'fs/promises'; import type { Config, ConfigOptions, InlinePluginDef } from 'karma'; import * as path from 'path'; -import { Observable, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs'; +import { Observable, Subscriber, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs'; import { Configuration } from 'webpack'; import { ExecutionTransformer } from '../../transforms'; import { OutputHashing } from '../browser-esbuild/schema'; import { findTests } from './find-tests'; import { Schema as KarmaBuilderOptions } from './schema'; +interface BuildOptions extends ApplicationBuilderInternalOptions { + // We know that it's always a string since we set it. + outputPath: string; +} + class ApplicationBuildError extends Error { constructor(message: string) { super(message); @@ -33,6 +39,76 @@ class ApplicationBuildError extends Error { } } +function injectKarmaReporter( + context: BuilderContext, + buildOptions: BuildOptions, + karmaConfig: Config & ConfigOptions, + subscriber: Subscriber, +) { + const reporterName = 'angular-progress-notifier'; + + interface RunCompleteInfo { + exitCode: number; + } + + interface KarmaEmitter { + refreshFiles(): void; + } + + class ProgressNotifierReporter { + static $inject = ['emitter']; + + constructor(private readonly emitter: KarmaEmitter) { + this.startWatchingBuild(); + } + + private startWatchingBuild() { + void (async () => { + for await (const buildOutput of buildApplicationInternal( + { + ...buildOptions, + watch: true, + }, + context, + )) { + if (buildOutput.kind === ResultKind.Failure) { + subscriber.next({ success: false, message: 'Build failed' }); + } else if ( + buildOutput.kind === ResultKind.Incremental || + buildOutput.kind === ResultKind.Full + ) { + await writeTestFiles(buildOutput.files, buildOptions.outputPath); + this.emitter.refreshFiles(); + } + } + })(); + } + + onRunComplete = function (_browsers: unknown, results: RunCompleteInfo) { + if (results.exitCode === 0) { + subscriber.next({ success: true }); + } else { + subscriber.next({ success: false }); + } + }; + } + + karmaConfig.reporters ??= []; + karmaConfig.reporters.push(reporterName); + + karmaConfig.plugins ??= []; + karmaConfig.plugins.push({ + [`reporter:${reporterName}`]: [ + 'factory', + Object.assign( + (...args: ConstructorParameters) => + new ProgressNotifierReporter(...args), + ProgressNotifierReporter, + ), + ], + }); +} + export function execute( options: KarmaBuilderOptions, context: BuilderContext, @@ -45,8 +121,12 @@ export function execute( ): Observable { return from(initializeApplication(options, context, karmaOptions, transforms)).pipe( switchMap( - ([karma, karmaConfig]) => + ([karma, karmaConfig, buildOptions]) => new Observable((subscriber) => { + if (options.watch) { + injectKarmaReporter(context, buildOptions, karmaConfig, subscriber); + } + // Complete the observable once the Karma server returns. const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => { subscriber.next({ success: exitCode === 0 }); @@ -122,24 +202,22 @@ async function initializeApplication( webpackConfiguration?: ExecutionTransformer; karmaOptions?: (options: ConfigOptions) => ConfigOptions; } = {}, -): Promise<[typeof import('karma'), Config & ConfigOptions]> { +): Promise<[typeof import('karma'), Config & ConfigOptions, BuildOptions]> { if (transforms.webpackConfiguration) { context.logger.warn( `This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`, ); } - const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID()); + const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID()); const projectSourceRoot = await getProjectSourceRoot(context); const [karma, entryPoints] = await Promise.all([ import('karma'), collectEntrypoints(options, context, projectSourceRoot), - fs.rm(testDir, { recursive: true, force: true }), + fs.rm(outputPath, { recursive: true, force: true }), ]); - const outputPath = testDir; - const instrumentForCoverage = options.codeCoverage ? createInstrumentationFilter( projectSourceRoot, @@ -147,30 +225,27 @@ async function initializeApplication( ) : undefined; + const buildOptions: BuildOptions = { + entryPoints, + tsConfig: options.tsConfig, + outputPath, + aot: false, + index: false, + outputHashing: OutputHashing.None, + optimization: false, + sourceMap: { + scripts: true, + styles: true, + vendor: true, + }, + instrumentForCoverage, + styles: options.styles, + polyfills: normalizePolyfills(options.polyfills), + webWorkerTsConfig: options.webWorkerTsConfig, + }; + // Build tests with `application` builder, using test files as entry points. - const buildOutput = await first( - buildApplicationInternal( - { - entryPoints, - tsConfig: options.tsConfig, - outputPath, - aot: false, - index: false, - outputHashing: OutputHashing.None, - optimization: false, - sourceMap: { - scripts: true, - styles: true, - vendor: true, - }, - instrumentForCoverage, - styles: options.styles, - polyfills: normalizePolyfills(options.polyfills), - webWorkerTsConfig: options.webWorkerTsConfig, - }, - context, - ), - ); + const buildOutput = await first(buildApplicationInternal(buildOptions, context)); if (buildOutput.kind === ResultKind.Failure) { throw new ApplicationBuildError('Build failed'); } else if (buildOutput.kind !== ResultKind.Full) { @@ -180,24 +255,24 @@ async function initializeApplication( } // Write test files - await writeTestFiles(buildOutput.files, testDir); + await writeTestFiles(buildOutput.files, buildOptions.outputPath); karmaOptions.files ??= []; karmaOptions.files.push( // Serve polyfills first. - { pattern: `${testDir}/polyfills.js`, type: 'module' }, + { pattern: `${outputPath}/polyfills.js`, type: 'module' }, // Allow loading of chunk-* files but don't include them all on load. - { pattern: `${testDir}/{chunk,worker}-*.js`, type: 'module', included: false }, + { pattern: `${outputPath}/{chunk,worker}-*.js`, type: 'module', included: false }, ); karmaOptions.files.push( // Serve remaining JS on page load, these are the test entrypoints. - { pattern: `${testDir}/*.js`, type: 'module' }, + { pattern: `${outputPath}/*.js`, type: 'module' }, ); if (options.styles?.length) { // Serve CSS outputs on page load, these are the global styles. - karmaOptions.files.push({ pattern: `${testDir}/*.css`, type: 'css' }); + karmaOptions.files.push({ pattern: `${outputPath}/*.css`, type: 'css' }); } const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig( @@ -238,7 +313,7 @@ async function initializeApplication( parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']); } - return [karma, parsedKarmaConfig]; + return [karma, parsedKarmaConfig, buildOptions]; } export async function writeTestFiles(files: Record, testDir: string) { diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/rebuilds_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/rebuilds_spec.ts index 542bdca20c81..a89f2dbd7c7a 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/rebuilds_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/rebuilds_spec.ts @@ -9,15 +9,10 @@ import { concatMap, count, debounceTime, take, timeout } from 'rxjs'; import { execute } from '../../index'; import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup'; +import { BuilderOutput } from '@angular-devkit/architect'; describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => { describe('Behavior: "Rebuilds"', () => { - if (isApplicationBuilder) { - beforeEach(() => { - pending('--watch not implemented yet for application builder'); - }); - } - beforeEach(async () => { await setupTarget(harness); }); @@ -30,37 +25,48 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isAppli const goodFile = await harness.readFile('src/app/app.component.spec.ts'); + interface OutputCheck { + (result: BuilderOutput | undefined): Promise; + } + + const expectedSequence: OutputCheck[] = [ + async (result) => { + // Karma run should succeed. + // Add a compilation error. + expect(result?.success).toBeTrue(); + // Add an syntax error to a non-main file. + await harness.appendToFile('src/app/app.component.spec.ts', `error`); + }, + async (result) => { + expect(result?.success).toBeFalse(); + await harness.writeFile('src/app/app.component.spec.ts', goodFile); + }, + async (result) => { + expect(result?.success).toBeTrue(); + }, + ]; + if (isApplicationBuilder) { + expectedSequence.unshift(async (result) => { + // This is the initial Karma run, it should succeed. + // For simplicity, we trigger a run the first time we build in watch mode. + expect(result?.success).toBeTrue(); + }); + } + const buildCount = await harness .execute({ outputLogsOnFailure: false }) .pipe( timeout(60000), debounceTime(500), concatMap(async ({ result }, index) => { - switch (index) { - case 0: - // Karma run should succeed. - // Add a compilation error. - expect(result?.success).toBeTrue(); - // Add an syntax error to a non-main file. - await harness.appendToFile('src/app/app.component.spec.ts', `error`); - break; - - case 1: - expect(result?.success).toBeFalse(); - await harness.writeFile('src/app/app.component.spec.ts', goodFile); - break; - - case 2: - expect(result?.success).toBeTrue(); - break; - } + await expectedSequence[index](result); }), - take(3), + take(expectedSequence.length), count(), ) .toPromise(); - expect(buildCount).toBe(3); + expect(buildCount).toBe(expectedSequence.length); }); }); });