Skip to content

Commit 43e7aae

Browse files
committedNov 5, 2024·
fix(@angular-devkit/build-angular): remove double-watch in karma
The Karma file watching was racing with the file writes done by the application builder. Since we already tell Karma when to reun via `.refeshFiles()`, disabling Karma's own file watcher should make things more reliable. This allows removing a weird special-case in the test case and removes the noisy "File chaned" logs generated by Karma. Fixes #28755 (cherry picked from commit faabbbf)
1 parent a568f19 commit 43e7aae

File tree

2 files changed

+61
-33
lines changed

2 files changed

+61
-33
lines changed
 

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

+49-21
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { BuildOutputFileType } from '@angular/build';
1010
import {
1111
ApplicationBuilderInternalOptions,
12+
Result,
1213
ResultFile,
1314
ResultKind,
1415
buildApplicationInternal,
@@ -42,6 +43,7 @@ class ApplicationBuildError extends Error {
4243
function injectKarmaReporter(
4344
context: BuilderContext,
4445
buildOptions: BuildOptions,
46+
buildIterator: AsyncIterator<Result>,
4547
karmaConfig: Config & ConfigOptions,
4648
subscriber: Subscriber<BuilderOutput>,
4749
) {
@@ -64,13 +66,15 @@ function injectKarmaReporter(
6466

6567
private startWatchingBuild() {
6668
void (async () => {
67-
for await (const buildOutput of buildApplicationInternal(
68-
{
69-
...buildOptions,
70-
watch: true,
71-
},
72-
context,
73-
)) {
69+
// This is effectively "for await of but skip what's already consumed".
70+
let isDone = false; // to mark the loop condition as "not constant".
71+
while (!isDone) {
72+
const { done, value: buildOutput } = await buildIterator.next();
73+
if (done) {
74+
isDone = true;
75+
break;
76+
}
77+
7478
if (buildOutput.kind === ResultKind.Failure) {
7579
subscriber.next({ success: false, message: 'Build failed' });
7680
} else if (
@@ -121,12 +125,12 @@ export function execute(
121125
): Observable<BuilderOutput> {
122126
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
123127
switchMap(
124-
([karma, karmaConfig, buildOptions]) =>
128+
([karma, karmaConfig, buildOptions, buildIterator]) =>
125129
new Observable<BuilderOutput>((subscriber) => {
126130
// If `--watch` is explicitly enabled or if we are keeping the Karma
127131
// process running, we should hook Karma into the build.
128-
if (options.watch ?? !karmaConfig.singleRun) {
129-
injectKarmaReporter(context, buildOptions, karmaConfig, subscriber);
132+
if (buildIterator) {
133+
injectKarmaReporter(context, buildOptions, buildIterator, karmaConfig, subscriber);
130134
}
131135

132136
// Complete the observable once the Karma server returns.
@@ -199,7 +203,9 @@ async function initializeApplication(
199203
webpackConfiguration?: ExecutionTransformer<Configuration>;
200204
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
201205
} = {},
202-
): Promise<[typeof import('karma'), Config & ConfigOptions, BuildOptions]> {
206+
): Promise<
207+
[typeof import('karma'), Config & ConfigOptions, BuildOptions, AsyncIterator<Result> | null]
208+
> {
203209
if (transforms.webpackConfiguration) {
204210
context.logger.warn(
205211
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
@@ -247,10 +253,14 @@ async function initializeApplication(
247253
styles: options.styles,
248254
polyfills: normalizePolyfills(options.polyfills),
249255
webWorkerTsConfig: options.webWorkerTsConfig,
256+
watch: options.watch ?? !karmaOptions.singleRun,
250257
};
251258

252259
// Build tests with `application` builder, using test files as entry points.
253-
const buildOutput = await first(buildApplicationInternal(buildOptions, context));
260+
const [buildOutput, buildIterator] = await first(
261+
buildApplicationInternal(buildOptions, context),
262+
{ cancel: !buildOptions.watch },
263+
);
254264
if (buildOutput.kind === ResultKind.Failure) {
255265
throw new ApplicationBuildError('Build failed');
256266
} else if (buildOutput.kind !== ResultKind.Full) {
@@ -265,28 +275,33 @@ async function initializeApplication(
265275
karmaOptions.files ??= [];
266276
karmaOptions.files.push(
267277
// Serve polyfills first.
268-
{ pattern: `${outputPath}/polyfills.js`, type: 'module' },
278+
{ pattern: `${outputPath}/polyfills.js`, type: 'module', watched: false },
269279
// Serve global setup script.
270-
{ pattern: `${outputPath}/${mainName}.js`, type: 'module' },
280+
{ pattern: `${outputPath}/${mainName}.js`, type: 'module', watched: false },
271281
// Serve all source maps.
272-
{ pattern: `${outputPath}/*.map`, included: false },
282+
{ pattern: `${outputPath}/*.map`, included: false, watched: false },
273283
);
274284

275285
if (hasChunkOrWorkerFiles(buildOutput.files)) {
276286
karmaOptions.files.push(
277287
// Allow loading of chunk-* files but don't include them all on load.
278-
{ pattern: `${outputPath}/{chunk,worker}-*.js`, type: 'module', included: false },
288+
{
289+
pattern: `${outputPath}/{chunk,worker}-*.js`,
290+
type: 'module',
291+
included: false,
292+
watched: false,
293+
},
279294
);
280295
}
281296

282297
karmaOptions.files.push(
283298
// Serve remaining JS on page load, these are the test entrypoints.
284-
{ pattern: `${outputPath}/*.js`, type: 'module' },
299+
{ pattern: `${outputPath}/*.js`, type: 'module', watched: false },
285300
);
286301

287302
if (options.styles?.length) {
288303
// Serve CSS outputs on page load, these are the global styles.
289-
karmaOptions.files.push({ pattern: `${outputPath}/*.css`, type: 'css' });
304+
karmaOptions.files.push({ pattern: `${outputPath}/*.css`, type: 'css', watched: false });
290305
}
291306

292307
const parsedKarmaConfig: Config & ConfigOptions = await karma.config.parseConfig(
@@ -327,7 +342,7 @@ async function initializeApplication(
327342
parsedKarmaConfig.reporters = (parsedKarmaConfig.reporters ?? []).concat(['coverage']);
328343
}
329344

330-
return [karma, parsedKarmaConfig, buildOptions];
345+
return [karma, parsedKarmaConfig, buildOptions, buildIterator];
331346
}
332347

333348
function hasChunkOrWorkerFiles(files: Record<string, unknown>): boolean {
@@ -364,9 +379,22 @@ export async function writeTestFiles(files: Record<string, ResultFile>, testDir:
364379
}
365380

366381
/** Returns the first item yielded by the given generator and cancels the execution. */
367-
async function first<T>(generator: AsyncIterable<T>): Promise<T> {
382+
async function first<T>(
383+
generator: AsyncIterable<T>,
384+
{ cancel }: { cancel: boolean },
385+
): Promise<[T, AsyncIterator<T> | null]> {
386+
if (!cancel) {
387+
const iterator: AsyncIterator<T> = generator[Symbol.asyncIterator]();
388+
const firstValue = await iterator.next();
389+
if (firstValue.done) {
390+
throw new Error('Expected generator to emit at least once.');
391+
}
392+
393+
return [firstValue.value, iterator];
394+
}
395+
368396
for await (const value of generator) {
369-
return value;
397+
return [value, null];
370398
}
371399

372400
throw new Error('Expected generator to emit at least once.');

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

+12-12
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { concatMap, count, debounceTime, take, timeout } from 'rxjs';
9+
import { concatMap, count, debounceTime, distinctUntilChanged, take, timeout } from 'rxjs';
1010
import { execute } from '../../index';
1111
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
1212
import { BuilderOutput } from '@angular-devkit/architect';
1313

14-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
14+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
1515
describe('Behavior: "Rebuilds"', () => {
1616
beforeEach(async () => {
1717
await setupTarget(harness);
@@ -33,31 +33,31 @@ describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isAppli
3333
async (result) => {
3434
// Karma run should succeed.
3535
// Add a compilation error.
36-
expect(result?.success).toBeTrue();
36+
expect(result?.success).withContext('Initial test run should succeed').toBeTrue();
3737
// Add an syntax error to a non-main file.
3838
await harness.appendToFile('src/app/app.component.spec.ts', `error`);
3939
},
4040
async (result) => {
41-
expect(result?.success).toBeFalse();
41+
expect(result?.success)
42+
.withContext('Test should fail after build error was introduced')
43+
.toBeFalse();
4244
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
4345
},
4446
async (result) => {
45-
expect(result?.success).toBeTrue();
47+
expect(result?.success)
48+
.withContext('Test should succeed again after build error was fixed')
49+
.toBeTrue();
4650
},
4751
];
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-
}
5552

5653
const buildCount = await harness
5754
.execute({ outputLogsOnFailure: false })
5855
.pipe(
5956
timeout(60000),
6057
debounceTime(500),
58+
// There may be a sequence of {success:true} events that should be
59+
// de-duplicated.
60+
distinctUntilChanged((prev, current) => prev.result?.success === current.result?.success),
6161
concatMap(async ({ result }, index) => {
6262
await expectedSequence[index](result);
6363
}),

0 commit comments

Comments
 (0)
Please sign in to comment.