Skip to content

Commit dcbdca8

Browse files
committed
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.
1 parent 32dd2f2 commit dcbdca8

File tree

2 files changed

+143
-62
lines changed

2 files changed

+143
-62
lines changed

packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts

+111-36
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { BuildOutputFileType } from '@angular/build';
1010
import {
11+
ApplicationBuilderInternalOptions,
1112
ResultFile,
1213
ResultKind,
1314
buildApplicationInternal,
@@ -19,20 +20,95 @@ import glob from 'fast-glob';
1920
import * as fs from 'fs/promises';
2021
import type { Config, ConfigOptions, InlinePluginDef } from 'karma';
2122
import * as path from 'path';
22-
import { Observable, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
23+
import { Observable, Subscriber, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
2324
import { Configuration } from 'webpack';
2425
import { ExecutionTransformer } from '../../transforms';
2526
import { OutputHashing } from '../browser-esbuild/schema';
2627
import { findTests } from './find-tests';
2728
import { Schema as KarmaBuilderOptions } from './schema';
2829

30+
interface BuildOptions extends ApplicationBuilderInternalOptions {
31+
// We know that it's always a string since we set it.
32+
outputPath: string;
33+
}
34+
2935
class ApplicationBuildError extends Error {
3036
constructor(message: string) {
3137
super(message);
3238
this.name = 'ApplicationBuildError';
3339
}
3440
}
3541

42+
function injectKarmaReporter(
43+
context: BuilderContext,
44+
buildOptions: BuildOptions,
45+
karmaConfig: Config & ConfigOptions,
46+
subscriber: Subscriber<BuilderOutput>,
47+
) {
48+
const reporterName = 'angular-progress-notifier';
49+
50+
interface RunCompleteInfo {
51+
exitCode: number;
52+
}
53+
54+
interface KarmaEmitter {
55+
refreshFiles(): void;
56+
}
57+
58+
class ProgressNotifierReporter {
59+
static $inject = ['emitter'];
60+
61+
constructor(private readonly emitter: KarmaEmitter) {
62+
this.startWatchingBuild();
63+
}
64+
65+
private startWatchingBuild() {
66+
void (async () => {
67+
for await (const buildOutput of buildApplicationInternal(
68+
{
69+
...buildOptions,
70+
watch: true,
71+
},
72+
context,
73+
)) {
74+
if (buildOutput.kind === ResultKind.Failure) {
75+
subscriber.next({ success: false, message: 'Build failed' });
76+
} else if (
77+
buildOutput.kind === ResultKind.Incremental ||
78+
buildOutput.kind === ResultKind.Full
79+
) {
80+
await writeTestFiles(buildOutput.files, buildOptions.outputPath);
81+
this.emitter.refreshFiles();
82+
}
83+
}
84+
})();
85+
}
86+
87+
onRunComplete = function (_browsers: unknown, results: RunCompleteInfo) {
88+
if (results.exitCode === 0) {
89+
subscriber.next({ success: true });
90+
} else {
91+
subscriber.next({ success: false });
92+
}
93+
};
94+
}
95+
96+
karmaConfig.reporters ??= [];
97+
karmaConfig.reporters.push(reporterName);
98+
99+
karmaConfig.plugins ??= [];
100+
karmaConfig.plugins.push({
101+
[`reporter:${reporterName}`]: [
102+
'factory',
103+
Object.assign(
104+
(...args: ConstructorParameters<typeof ProgressNotifierReporter>) =>
105+
new ProgressNotifierReporter(...args),
106+
ProgressNotifierReporter,
107+
),
108+
],
109+
});
110+
}
111+
36112
export function execute(
37113
options: KarmaBuilderOptions,
38114
context: BuilderContext,
@@ -45,8 +121,12 @@ export function execute(
45121
): Observable<BuilderOutput> {
46122
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
47123
switchMap(
48-
([karma, karmaConfig]) =>
124+
([karma, karmaConfig, buildOptions]) =>
49125
new Observable<BuilderOutput>((subscriber) => {
126+
if (options.watch) {
127+
injectKarmaReporter(context, buildOptions, karmaConfig, subscriber);
128+
}
129+
50130
// Complete the observable once the Karma server returns.
51131
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
52132
subscriber.next({ success: exitCode === 0 });
@@ -122,55 +202,50 @@ async function initializeApplication(
122202
webpackConfiguration?: ExecutionTransformer<Configuration>;
123203
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
124204
} = {},
125-
): Promise<[typeof import('karma'), Config & ConfigOptions]> {
205+
): Promise<[typeof import('karma'), Config & ConfigOptions, BuildOptions]> {
126206
if (transforms.webpackConfiguration) {
127207
context.logger.warn(
128208
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
129209
);
130210
}
131211

132-
const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
212+
const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
133213
const projectSourceRoot = await getProjectSourceRoot(context);
134214

135215
const [karma, entryPoints] = await Promise.all([
136216
import('karma'),
137217
collectEntrypoints(options, context, projectSourceRoot),
138-
fs.rm(testDir, { recursive: true, force: true }),
218+
fs.rm(outputPath, { recursive: true, force: true }),
139219
]);
140220

141-
const outputPath = testDir;
142-
143221
const instrumentForCoverage = options.codeCoverage
144222
? createInstrumentationFilter(
145223
projectSourceRoot,
146224
getInstrumentationExcludedPaths(context.workspaceRoot, options.codeCoverageExclude ?? []),
147225
)
148226
: undefined;
149227

228+
const buildOptions: BuildOptions = {
229+
entryPoints,
230+
tsConfig: options.tsConfig,
231+
outputPath,
232+
aot: false,
233+
index: false,
234+
outputHashing: OutputHashing.None,
235+
optimization: false,
236+
sourceMap: {
237+
scripts: true,
238+
styles: true,
239+
vendor: true,
240+
},
241+
instrumentForCoverage,
242+
styles: options.styles,
243+
polyfills: normalizePolyfills(options.polyfills),
244+
webWorkerTsConfig: options.webWorkerTsConfig,
245+
};
246+
150247
// Build tests with `application` builder, using test files as entry points.
151-
const buildOutput = await first(
152-
buildApplicationInternal(
153-
{
154-
entryPoints,
155-
tsConfig: options.tsConfig,
156-
outputPath,
157-
aot: false,
158-
index: false,
159-
outputHashing: OutputHashing.None,
160-
optimization: false,
161-
sourceMap: {
162-
scripts: true,
163-
styles: true,
164-
vendor: true,
165-
},
166-
instrumentForCoverage,
167-
styles: options.styles,
168-
polyfills: normalizePolyfills(options.polyfills),
169-
webWorkerTsConfig: options.webWorkerTsConfig,
170-
},
171-
context,
172-
),
173-
);
248+
const buildOutput = await first(buildApplicationInternal(buildOptions, context));
174249
if (buildOutput.kind === ResultKind.Failure) {
175250
throw new ApplicationBuildError('Build failed');
176251
} else if (buildOutput.kind !== ResultKind.Full) {
@@ -180,24 +255,24 @@ async function initializeApplication(
180255
}
181256

182257
// Write test files
183-
await writeTestFiles(buildOutput.files, testDir);
258+
await writeTestFiles(buildOutput.files, buildOptions.outputPath);
184259

185260
karmaOptions.files ??= [];
186261
karmaOptions.files.push(
187262
// Serve polyfills first.
188-
{ pattern: `${testDir}/polyfills.js`, type: 'module' },
263+
{ pattern: `${outputPath}/polyfills.js`, type: 'module' },
189264
// Allow loading of chunk-* files but don't include them all on load.
190-
{ pattern: `${testDir}/{chunk,worker}-*.js`, type: 'module', included: false },
265+
{ pattern: `${outputPath}/{chunk,worker}-*.js`, type: 'module', included: false },
191266
);
192267

193268
karmaOptions.files.push(
194269
// Serve remaining JS on page load, these are the test entrypoints.
195-
{ pattern: `${testDir}/*.js`, type: 'module' },
270+
{ pattern: `${outputPath}/*.js`, type: 'module' },
196271
);
197272

198273
if (options.styles?.length) {
199274
// Serve CSS outputs on page load, these are the global styles.
200-
karmaOptions.files.push({ pattern: `${testDir}/*.css`, type: 'css' });
275+
karmaOptions.files.push({ pattern: `${outputPath}/*.css`, type: 'css' });
201276
}
202277

203278
const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig(
@@ -238,7 +313,7 @@ async function initializeApplication(
238313
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
239314
}
240315

241-
return [karma, parsedKarmaConfig];
316+
return [karma, parsedKarmaConfig, buildOptions];
242317
}
243318

244319
export async function writeTestFiles(files: Record<string, ResultFile>, testDir: string) {

packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/rebuilds_spec.ts

+32-26
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,10 @@
99
import { concatMap, count, debounceTime, take, timeout } from 'rxjs';
1010
import { execute } from '../../index';
1111
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
12+
import { BuilderOutput } from '@angular-devkit/architect';
1213

1314
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
1415
describe('Behavior: "Rebuilds"', () => {
15-
if (isApplicationBuilder) {
16-
beforeEach(() => {
17-
pending('--watch not implemented yet for application builder');
18-
});
19-
}
20-
2116
beforeEach(async () => {
2217
await setupTarget(harness);
2318
});
@@ -30,37 +25,48 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isAppli
3025

3126
const goodFile = await harness.readFile('src/app/app.component.spec.ts');
3227

28+
interface OutputCheck {
29+
(result: BuilderOutput | undefined): Promise<void>;
30+
}
31+
32+
const expectedSequence: OutputCheck[] = [
33+
async (result) => {
34+
// Karma run should succeed.
35+
// Add a compilation error.
36+
expect(result?.success).toBeTrue();
37+
// Add an syntax error to a non-main file.
38+
await harness.appendToFile('src/app/app.component.spec.ts', `error`);
39+
},
40+
async (result) => {
41+
expect(result?.success).toBeFalse();
42+
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
43+
},
44+
async (result) => {
45+
expect(result?.success).toBeTrue();
46+
},
47+
];
48+
if (isApplicationBuilder) {
49+
expectedSequence.unshift(async (result) => {
50+
// This is the initial Karma run, it should succeed.
51+
// For simplicity, we trigger a run the first time we build in watch mode.
52+
expect(result?.success).toBeTrue();
53+
});
54+
}
55+
3356
const buildCount = await harness
3457
.execute({ outputLogsOnFailure: false })
3558
.pipe(
3659
timeout(60000),
3760
debounceTime(500),
3861
concatMap(async ({ result }, index) => {
39-
switch (index) {
40-
case 0:
41-
// Karma run should succeed.
42-
// Add a compilation error.
43-
expect(result?.success).toBeTrue();
44-
// Add an syntax error to a non-main file.
45-
await harness.appendToFile('src/app/app.component.spec.ts', `error`);
46-
break;
47-
48-
case 1:
49-
expect(result?.success).toBeFalse();
50-
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
51-
break;
52-
53-
case 2:
54-
expect(result?.success).toBeTrue();
55-
break;
56-
}
62+
await expectedSequence[index](result);
5763
}),
58-
take(3),
64+
take(expectedSequence.length),
5965
count(),
6066
)
6167
.toPromise();
6268

63-
expect(buildCount).toBe(3);
69+
expect(buildCount).toBe(expectedSequence.length);
6470
});
6571
});
6672
});

0 commit comments

Comments
 (0)