Skip to content

Commit 00eb7c3

Browse files
clydinhybrist
authored andcommitted
refactor(@angular-devkit/build-angular): convert karma builder to AsyncIterable
Refactor the Karma builder's `execute` function to return an `AsyncIterable` using a `ReadableStream`. This removes the RxJS dependency and aligns the builder with modern Angular CLI implementation patterns. Additionally, this change fixes a race condition where the Karma server could start even if the builder was cancelled during asynchronous initialization. An `isCancelled` flag is now used to ensure execution stops if a cancellation occurs before the server starts.
1 parent 45e9cff commit 00eb7c3

File tree

1 file changed

+63
-41
lines changed

1 file changed

+63
-41
lines changed

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

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,16 @@
77
*/
88

99
import { purgeStaleBuildCache } from '@angular/build/private';
10-
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
11-
import type { Config, ConfigOptions } from 'karma';
10+
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
11+
import type { Config, ConfigOptions, Server } from 'karma';
1212
import * as path from 'node:path';
13-
import { Observable, defaultIfEmpty, from, switchMap } from 'rxjs';
14-
import { Configuration } from 'webpack';
13+
import type { Configuration } from 'webpack';
1514
import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs';
16-
import { ExecutionTransformer } from '../../transforms';
15+
import type { ExecutionTransformer } from '../../transforms';
1716
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
18-
import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema';
17+
import { type Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema';
1918
import { FindTestsPlugin } from './find-tests-plugin';
20-
import { Schema as KarmaBuilderOptions } from './schema';
19+
import type { Schema as KarmaBuilderOptions } from './schema';
2120

2221
export type KarmaConfigOptions = ConfigOptions & {
2322
buildWebpack?: unknown;
@@ -33,9 +32,22 @@ export function execute(
3332
// The karma options transform cannot be async without a refactor of the builder implementation
3433
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
3534
} = {},
36-
): Observable<BuilderOutput> {
37-
return from(initializeBrowser(options, context, transforms.webpackConfiguration)).pipe(
38-
switchMap(async ([karma, webpackConfig]) => {
35+
): AsyncIterable<BuilderOutput> {
36+
let karmaServer: Server;
37+
let isCancelled = false;
38+
39+
return new ReadableStream({
40+
async start(controller) {
41+
const [karma, webpackConfig] = await initializeBrowser(
42+
options,
43+
context,
44+
transforms.webpackConfiguration,
45+
);
46+
47+
if (isCancelled) {
48+
return;
49+
}
50+
3951
const projectName = context.target?.project;
4052
if (!projectName) {
4153
throw new Error(`The 'karma' builder requires a target to be specified.`);
@@ -71,44 +83,54 @@ export function execute(
7183
logger: context.logger,
7284
};
7385

74-
const parsedKarmaConfig = await karma.config.parseConfig(
86+
const parsedKarmaConfig = (await karma.config.parseConfig(
7587
options.karmaConfig && path.resolve(context.workspaceRoot, options.karmaConfig),
7688
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
7789
{ promiseConfig: true, throwErrors: true },
78-
);
90+
)) as KarmaConfigOptions;
7991

80-
return [karma, parsedKarmaConfig] as [typeof karma, KarmaConfigOptions];
81-
}),
82-
switchMap(
83-
([karma, karmaConfig]) =>
84-
new Observable<BuilderOutput>((subscriber) => {
85-
// Pass onto Karma to emit BuildEvents.
86-
karmaConfig.buildWebpack ??= {};
87-
if (typeof karmaConfig.buildWebpack === 'object') {
88-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
89-
(karmaConfig.buildWebpack as any).failureCb ??= () =>
90-
subscriber.next({ success: false });
91-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92-
(karmaConfig.buildWebpack as any).successCb ??= () =>
93-
subscriber.next({ success: true });
94-
}
92+
if (isCancelled) {
93+
return;
94+
}
9595

96-
// Complete the observable once the Karma server returns.
97-
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
98-
subscriber.next({ success: exitCode === 0 });
99-
subscriber.complete();
100-
});
96+
const enqueue = (value: BuilderOutput) => {
97+
try {
98+
controller.enqueue(value);
99+
} catch {
100+
// Controller is already closed
101+
}
102+
};
101103

102-
const karmaStart = karmaServer.start();
104+
const close = () => {
105+
try {
106+
controller.close();
107+
} catch {
108+
// Controller is already closed
109+
}
110+
};
103111

104-
// Cleanup, signal Karma to exit.
105-
return () => {
106-
void karmaStart.then(() => karmaServer.stop());
107-
};
108-
}),
109-
),
110-
defaultIfEmpty({ success: false }),
111-
);
112+
// Pass onto Karma to emit BuildEvents.
113+
parsedKarmaConfig.buildWebpack ??= {};
114+
if (typeof parsedKarmaConfig.buildWebpack === 'object') {
115+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116+
(parsedKarmaConfig.buildWebpack as any).failureCb ??= () => enqueue({ success: false });
117+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
118+
(parsedKarmaConfig.buildWebpack as any).successCb ??= () => enqueue({ success: true });
119+
}
120+
121+
// Close the stream once the Karma server returns.
122+
karmaServer = new karma.Server(parsedKarmaConfig as Config, (exitCode) => {
123+
enqueue({ success: exitCode === 0 });
124+
close();
125+
});
126+
127+
await karmaServer.start();
128+
},
129+
async cancel() {
130+
isCancelled = true;
131+
await karmaServer?.stop();
132+
},
133+
});
112134
}
113135

114136
async function initializeBrowser(

0 commit comments

Comments
 (0)