feat(@angular-devkit/build-angular): karma+esbuild+watch

This introduces support for `--watch` when using the application
builder. It's tested as far as the relevant test case is concerned.
But I wouldn't be surprised if there's still some rough corners.
This commit is contained in:
Jan Martin 2024-09-27 14:27:19 -07:00 committed by Jan Olaf Martin
parent 32dd2f2dba
commit dcbdca85c7
2 changed files with 143 additions and 62 deletions

View File

@ -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<BuilderOutput>,
) {
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<typeof ProgressNotifierReporter>) =>
new ProgressNotifierReporter(...args),
ProgressNotifierReporter,
),
],
});
}
export function execute(
options: KarmaBuilderOptions,
context: BuilderContext,
@ -45,8 +121,12 @@ export function execute(
): Observable<BuilderOutput> {
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
switchMap(
([karma, karmaConfig]) =>
([karma, karmaConfig, buildOptions]) =>
new Observable<BuilderOutput>((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<Configuration>;
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<string, ResultFile>, testDir: string) {

View File

@ -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<void>;
}
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);
});
});
});