Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): karma+esbuild+watch
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jkrems committed Sep 30, 2024
1 parent 32dd2f2 commit dcbdca8
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { BuildOutputFileType } from '@angular/build';
import {
ApplicationBuilderInternalOptions,
ResultFile,
ResultKind,
buildApplicationInternal,
Expand All @@ -19,20 +20,95 @@ 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);
this.name = 'ApplicationBuildError';
}
}

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,
Expand All @@ -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 });
Expand Down Expand Up @@ -122,55 +202,50 @@ 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,
getInstrumentationExcludedPaths(context.workspaceRoot, options.codeCoverageExclude ?? []),
)
: 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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
});
});

0 comments on commit dcbdca8

Please sign in to comment.