Skip to content

feat(@angular/build): add application builder karma testing to package #29640

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
.npmrc=-1406867100
modules/testing/builder/package.json=973445093
package.json=-1990485513
packages/angular/build/package.json=-1875938558
packages/angular/build/package.json=517491420
packages/angular/cli/package.json=-803141029
packages/angular/pwa/package.json=1108903917
packages/angular/ssr/package.json=1856194341
Expand Down
54 changes: 54 additions & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ ts_json_schema(
src = "src/builders/extract-i18n/schema.json",
)

ts_json_schema(
name = "ng_karma_schema",
src = "src/builders/karma/schema.json",
)

ts_json_schema(
name = "ng_packagr_schema",
src = "src/builders/ng-packagr/schema.json",
Expand Down Expand Up @@ -63,6 +68,7 @@ ts_project(
"//packages/angular/build:src/builders/application/schema.ts",
"//packages/angular/build:src/builders/dev-server/schema.ts",
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
"//packages/angular/build:src/builders/karma/schema.ts",
"//packages/angular/build:src/builders/ng-packagr/schema.ts",
],
data = RUNTIME_ASSETS,
Expand All @@ -85,6 +91,7 @@ ts_project(
"//:node_modules/@babel/plugin-syntax-import-attributes",
"//:node_modules/@inquirer/confirm",
"//:node_modules/@types/babel__core",
"//:node_modules/@types/karma",
"//:node_modules/@types/less",
"//:node_modules/@types/node",
"//:node_modules/@types/picomatch",
Expand All @@ -99,6 +106,7 @@ ts_project(
"//:node_modules/https-proxy-agent",
"//:node_modules/istanbul-lib-instrument",
"//:node_modules/jsonc-parser",
"//:node_modules/karma",
"//:node_modules/less",
"//:node_modules/listr2",
"//:node_modules/lmdb",
Expand Down Expand Up @@ -200,6 +208,39 @@ ts_project(
],
)

ts_project(
name = "karma_integration_test_lib",
testonly = True,
srcs = glob(include = ["src/builders/karma/tests/**/*.ts"]),
deps = [
":build_rjs",
"//packages/angular/build/private:private_rjs",
"//modules/testing/builder:builder_rjs",
":node_modules/@angular-devkit/architect",

# karma specific test deps
"//:node_modules/karma-chrome-launcher",
"//:node_modules/karma-coverage",
"//:node_modules/karma-jasmine",
"//:node_modules/karma-jasmine-html-reporter",
"//:node_modules/puppeteer",

# Base dependencies for the karma in hello-world-app.
"//:node_modules/@angular/common",
"//:node_modules/@angular/compiler",
"//:node_modules/@angular/compiler-cli",
"//:node_modules/@angular/core",
"//:node_modules/@angular/platform-browser",
"//:node_modules/@angular/platform-browser-dynamic",
"//:node_modules/@angular/router",
"//:node_modules/rxjs",
"//:node_modules/tslib",
"//:node_modules/typescript",
"//:node_modules/zone.js",
"//:node_modules/buffer",
],
)

jasmine_test(
name = "application_integration_tests",
size = "large",
Expand All @@ -216,6 +257,19 @@ jasmine_test(
shard_count = 10,
)

jasmine_test(
name = "karma_integration_tests",
size = "large",
data = [":karma_integration_test_lib_rjs"],
env = {
# TODO: Replace Puppeteer downloaded browsers with Bazel-managed browsers,
# or standardize to avoid complex configuration like this!
"PUPPETEER_DOWNLOAD_PATH": "../../../node_modules/puppeteer/downloads",
},
flaky = True,
shard_count = 10,
)

genrule(
name = "license",
srcs = ["//:LICENSE"],
Expand Down
5 changes: 5 additions & 0 deletions packages/angular/build/builders.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
"schema": "./src/builders/extract-i18n/schema.json",
"description": "Extract i18n messages from an application."
},
"karma": {
"implementation": "./src/builders/karma",
"schema": "./src/builders/karma/schema.json",
"description": "Run Karma unit tests."
},
"ng-packagr": {
"implementation": "./src/builders/ng-packagr/index",
"schema": "./src/builders/ng-packagr/schema.json",
Expand Down
4 changes: 4 additions & 0 deletions packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@angular/platform-server": "0.0.0-ANGULAR-FW-PEER-DEP",
"@angular/service-worker": "0.0.0-ANGULAR-FW-PEER-DEP",
"@angular/ssr": "^0.0.0-PLACEHOLDER",
"karma": "^6.4.0",
"less": "^4.2.0",
"ng-packagr": "0.0.0-NG-PACKAGR-PEER-DEP",
"postcss": "^8.4.0",
Expand All @@ -77,6 +78,9 @@
"@angular/ssr": {
"optional": true
},
"karma": {
"optional": true
},
"less": {
"optional": true
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { BuildOutputFileType } from '@angular/build';
import {
ApplicationBuilderInternalOptions,
Result,
Expand All @@ -15,21 +14,22 @@ import {
buildApplicationInternal,
emitFilesToDisk,
} from '@angular/build/private';
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import glob from 'fast-glob';
import type { Config, ConfigOptions, FilePattern, InlinePluginDef } from 'karma';
import type { Config, ConfigOptions, FilePattern, InlinePluginDef, Server } from 'karma';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import { IncomingMessage, ServerResponse } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import { Observable, Subscriber, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
import { Configuration } from 'webpack';
import { ExecutionTransformer } from '../../transforms';
import { normalizeFileReplacements } from '../../utils';
import { OutputHashing } from '../browser-esbuild/schema';
import { ReadableStreamController } from 'node:stream/web';
import { BuildOutputFileType } from '../../tools/esbuild/bundler-context';
import { OutputHashing } from '../application/schema';
import { findTests, getTestEntrypoints } from './find-tests';
import { Schema as KarmaBuilderOptions } from './schema';

const localResolve = createRequire(__filename).resolve;

interface BuildOptions extends ApplicationBuilderInternalOptions {
// We know that it's always a string since we set it.
outputPath: string;
Expand Down Expand Up @@ -171,7 +171,7 @@ function injectKarmaReporter(
buildOptions: BuildOptions,
buildIterator: AsyncIterator<Result>,
karmaConfig: Config & ConfigOptions,
subscriber: Subscriber<BuilderOutput>,
controller: ReadableStreamController<BuilderOutput>,
) {
const reporterName = 'angular-progress-notifier';

Expand Down Expand Up @@ -205,7 +205,7 @@ function injectKarmaReporter(
}

if (buildOutput.kind === ResultKind.Failure) {
subscriber.next({ success: false, message: 'Build failed' });
controller.enqueue({ success: false, message: 'Build failed' });
} else if (
buildOutput.kind === ResultKind.Incremental ||
buildOutput.kind === ResultKind.Full
Expand All @@ -227,9 +227,9 @@ function injectKarmaReporter(

onRunComplete = function (_browsers: unknown, results: RunCompleteInfo) {
if (results.exitCode === 0) {
subscriber.next({ success: true });
controller.enqueue({ success: true });
} else {
subscriber.next({ success: false });
controller.enqueue({ success: false });
}
};
}
Expand All @@ -255,44 +255,48 @@ export function execute(
context: BuilderContext,
karmaOptions: ConfigOptions,
transforms: {
webpackConfiguration?: ExecutionTransformer<Configuration>;
// The karma options transform cannot be async without a refactor of the builder implementation
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
} = {},
): Observable<BuilderOutput> {
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
switchMap(
([karma, karmaConfig, buildOptions, buildIterator]) =>
new Observable<BuilderOutput>((subscriber) => {
// If `--watch` is explicitly enabled or if we are keeping the Karma
// process running, we should hook Karma into the build.
if (buildIterator) {
injectKarmaReporter(buildOptions, buildIterator, karmaConfig, subscriber);
}
): AsyncIterable<BuilderOutput> {
let karmaServer: Server;

return new ReadableStream({
async start(controller) {
let init;
try {
init = await initializeApplication(options, context, karmaOptions, transforms);
} catch (err) {
if (err instanceof ApplicationBuildError) {
controller.enqueue({ success: false, message: err.message });
controller.close();

return;
}

// Complete the observable once the Karma server returns.
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
subscriber.next({ success: exitCode === 0 });
subscriber.complete();
});
throw err;
}

const [karma, karmaConfig, buildOptions, buildIterator] = init;

const karmaStart = karmaServer.start();

// Cleanup, signal Karma to exit.
return () => {
void karmaStart.then(() => karmaServer.stop());
};
}),
),
catchError((err) => {
if (err instanceof ApplicationBuildError) {
return of({ success: false, message: err.message });
// If `--watch` is explicitly enabled or if we are keeping the Karma
// process running, we should hook Karma into the build.
if (buildIterator) {
injectKarmaReporter(buildOptions, buildIterator, karmaConfig, controller);
}

throw err;
}),
defaultIfEmpty({ success: false }),
);
// Close the stream once the Karma server returns.
karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
controller.enqueue({ success: exitCode === 0 });
controller.close();
});

await karmaServer.start();
},
async cancel() {
await karmaServer?.stop();
},
});
}

async function getProjectSourceRoot(context: BuilderContext): Promise<string> {
Expand All @@ -315,10 +319,8 @@ function normalizePolyfills(polyfills: string | string[] | undefined): [string[]
polyfills = [];
}

const jasmineGlobalEntryPoint =
'@angular-devkit/build-angular/src/builders/karma/jasmine_global.js';
const jasmineGlobalCleanupEntrypoint =
'@angular-devkit/build-angular/src/builders/karma/jasmine_global_cleanup.js';
const jasmineGlobalEntryPoint = localResolve('./polyfills/jasmine_global.js');
const jasmineGlobalCleanupEntrypoint = localResolve('./polyfills/jasmine_global_cleanup.js');

const zoneTestingEntryPoint = 'zone.js/testing';
const polyfillsExludingZoneTesting = polyfills.filter((p) => p !== zoneTestingEntryPoint);
Expand Down Expand Up @@ -352,18 +354,11 @@ async function initializeApplication(
context: BuilderContext,
karmaOptions: ConfigOptions,
transforms: {
webpackConfiguration?: ExecutionTransformer<Configuration>;
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
} = {},
): Promise<
[typeof import('karma'), Config & ConfigOptions, BuildOptions, AsyncIterator<Result> | null]
> {
if (transforms.webpackConfiguration) {
context.logger.warn(
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
);
}

const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
const projectSourceRoot = await getProjectSourceRoot(context);

Expand All @@ -377,7 +372,7 @@ async function initializeApplication(
if (options.main) {
entryPoints.set(mainName, options.main);
} else {
entryPoints.set(mainName, '@angular-devkit/build-angular/src/builders/karma/init_test_bed.js');
entryPoints.set(mainName, localResolve('./polyfills/init_test_bed.js'));
}

const instrumentForCoverage = options.codeCoverage
Expand Down Expand Up @@ -416,9 +411,10 @@ async function initializeApplication(
watch: options.watch ?? !karmaOptions.singleRun,
stylePreprocessorOptions: options.stylePreprocessorOptions,
inlineStyleLanguage: options.inlineStyleLanguage,
fileReplacements: options.fileReplacements
? normalizeFileReplacements(options.fileReplacements, './')
: undefined,
fileReplacements: options.fileReplacements,
define: options.define,
loader: options.loader,
externalDependencies: options.externalDependencies,
};

// Build tests with `application` builder, using test files as entry points.
Expand Down
Loading
Loading