Skip to content

Commit 11fab9c

Browse files
committedFeb 18, 2025·
feat(@angular/build): add application builder karma testing to package
An `application` only variant of the `karma` builder found within the `@angular-devkit/build-angular` package is now available within the `@angular/build` package as `@angular/build:karma`. This builder will only use the `application` builder found within `@angular/build` and does not provide the `builderMode` option as `application` would be the only valid value. Testing behavior is effectively equivalent to using the `@angular-devkit/build-angular:karma` builder with the `builderMode` option set to `application`. However, several options have been adjusted: * `builderMode` was removed * `fileReplacements` legacy structure (`src`/`replaceWith`) removed * `polyfills` only accepts an array of strings * `loader` has been added * `define` has been added * `externalDependencies` has been added
1 parent e6deb82 commit 11fab9c

33 files changed

+2056
-68
lines changed
 

‎.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
.npmrc=-1406867100
55
modules/testing/builder/package.json=973445093
66
package.json=-1990485513
7-
packages/angular/build/package.json=-1875938558
7+
packages/angular/build/package.json=517491420
88
packages/angular/cli/package.json=-803141029
99
packages/angular/pwa/package.json=1108903917
1010
packages/angular/ssr/package.json=1856194341

‎packages/angular/build/BUILD.bazel

+54
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ ts_json_schema(
2424
src = "src/builders/extract-i18n/schema.json",
2525
)
2626

27+
ts_json_schema(
28+
name = "ng_karma_schema",
29+
src = "src/builders/karma/schema.json",
30+
)
31+
2732
ts_json_schema(
2833
name = "ng_packagr_schema",
2934
src = "src/builders/ng-packagr/schema.json",
@@ -63,6 +68,7 @@ ts_project(
6368
"//packages/angular/build:src/builders/application/schema.ts",
6469
"//packages/angular/build:src/builders/dev-server/schema.ts",
6570
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
71+
"//packages/angular/build:src/builders/karma/schema.ts",
6672
"//packages/angular/build:src/builders/ng-packagr/schema.ts",
6773
],
6874
data = RUNTIME_ASSETS,
@@ -85,6 +91,7 @@ ts_project(
8591
"//:node_modules/@babel/plugin-syntax-import-attributes",
8692
"//:node_modules/@inquirer/confirm",
8793
"//:node_modules/@types/babel__core",
94+
"//:node_modules/@types/karma",
8895
"//:node_modules/@types/less",
8996
"//:node_modules/@types/node",
9097
"//:node_modules/@types/picomatch",
@@ -99,6 +106,7 @@ ts_project(
99106
"//:node_modules/https-proxy-agent",
100107
"//:node_modules/istanbul-lib-instrument",
101108
"//:node_modules/jsonc-parser",
109+
"//:node_modules/karma",
102110
"//:node_modules/less",
103111
"//:node_modules/listr2",
104112
"//:node_modules/lmdb",
@@ -200,6 +208,39 @@ ts_project(
200208
],
201209
)
202210

211+
ts_project(
212+
name = "karma_integration_test_lib",
213+
testonly = True,
214+
srcs = glob(include = ["src/builders/karma/tests/**/*.ts"]),
215+
deps = [
216+
":build_rjs",
217+
"//packages/angular/build/private:private_rjs",
218+
"//modules/testing/builder:builder_rjs",
219+
":node_modules/@angular-devkit/architect",
220+
221+
# karma specific test deps
222+
"//:node_modules/karma-chrome-launcher",
223+
"//:node_modules/karma-coverage",
224+
"//:node_modules/karma-jasmine",
225+
"//:node_modules/karma-jasmine-html-reporter",
226+
"//:node_modules/puppeteer",
227+
228+
# Base dependencies for the karma in hello-world-app.
229+
"//:node_modules/@angular/common",
230+
"//:node_modules/@angular/compiler",
231+
"//:node_modules/@angular/compiler-cli",
232+
"//:node_modules/@angular/core",
233+
"//:node_modules/@angular/platform-browser",
234+
"//:node_modules/@angular/platform-browser-dynamic",
235+
"//:node_modules/@angular/router",
236+
"//:node_modules/rxjs",
237+
"//:node_modules/tslib",
238+
"//:node_modules/typescript",
239+
"//:node_modules/zone.js",
240+
"//:node_modules/buffer",
241+
],
242+
)
243+
203244
jasmine_test(
204245
name = "application_integration_tests",
205246
size = "large",
@@ -216,6 +257,19 @@ jasmine_test(
216257
shard_count = 10,
217258
)
218259

260+
jasmine_test(
261+
name = "karma_integration_tests",
262+
size = "large",
263+
data = [":karma_integration_test_lib_rjs"],
264+
env = {
265+
# TODO: Replace Puppeteer downloaded browsers with Bazel-managed browsers,
266+
# or standardize to avoid complex configuration like this!
267+
"PUPPETEER_DOWNLOAD_PATH": "../../../node_modules/puppeteer/downloads",
268+
},
269+
flaky = True,
270+
shard_count = 10,
271+
)
272+
219273
genrule(
220274
name = "license",
221275
srcs = ["//:LICENSE"],

‎packages/angular/build/builders.json

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
"schema": "./src/builders/extract-i18n/schema.json",
1616
"description": "Extract i18n messages from an application."
1717
},
18+
"karma": {
19+
"implementation": "./src/builders/karma",
20+
"schema": "./src/builders/karma/schema.json",
21+
"description": "Run Karma unit tests."
22+
},
1823
"ng-packagr": {
1924
"implementation": "./src/builders/ng-packagr/index",
2025
"schema": "./src/builders/ng-packagr/schema.json",

‎packages/angular/build/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@angular/platform-server": "0.0.0-ANGULAR-FW-PEER-DEP",
5959
"@angular/service-worker": "0.0.0-ANGULAR-FW-PEER-DEP",
6060
"@angular/ssr": "^0.0.0-PLACEHOLDER",
61+
"karma": "^6.4.0",
6162
"less": "^4.2.0",
6263
"ng-packagr": "0.0.0-NG-PACKAGR-PEER-DEP",
6364
"postcss": "^8.4.0",
@@ -77,6 +78,9 @@
7778
"@angular/ssr": {
7879
"optional": true
7980
},
81+
"karma": {
82+
"optional": true
83+
},
8084
"less": {
8185
"optional": true
8286
},

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

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

9-
import { BuildOutputFileType } from '@angular/build';
109
import {
1110
ApplicationBuilderInternalOptions,
1211
Result,
@@ -15,21 +14,22 @@ import {
1514
buildApplicationInternal,
1615
emitFilesToDisk,
1716
} from '@angular/build/private';
18-
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
17+
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
1918
import glob from 'fast-glob';
20-
import type { Config, ConfigOptions, FilePattern, InlinePluginDef } from 'karma';
19+
import type { Config, ConfigOptions, FilePattern, InlinePluginDef, Server } from 'karma';
2120
import { randomUUID } from 'node:crypto';
2221
import * as fs from 'node:fs/promises';
23-
import { IncomingMessage, ServerResponse } from 'node:http';
22+
import type { IncomingMessage, ServerResponse } from 'node:http';
23+
import { createRequire } from 'node:module';
2424
import * as path from 'node:path';
25-
import { Observable, Subscriber, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs';
26-
import { Configuration } from 'webpack';
27-
import { ExecutionTransformer } from '../../transforms';
28-
import { normalizeFileReplacements } from '../../utils';
29-
import { OutputHashing } from '../browser-esbuild/schema';
25+
import { ReadableStreamController } from 'node:stream/web';
26+
import { BuildOutputFileType } from '../../tools/esbuild/bundler-context';
27+
import { OutputHashing } from '../application/schema';
3028
import { findTests, getTestEntrypoints } from './find-tests';
3129
import { Schema as KarmaBuilderOptions } from './schema';
3230

31+
const localResolve = createRequire(__filename).resolve;
32+
3333
interface BuildOptions extends ApplicationBuilderInternalOptions {
3434
// We know that it's always a string since we set it.
3535
outputPath: string;
@@ -171,7 +171,7 @@ function injectKarmaReporter(
171171
buildOptions: BuildOptions,
172172
buildIterator: AsyncIterator<Result>,
173173
karmaConfig: Config & ConfigOptions,
174-
subscriber: Subscriber<BuilderOutput>,
174+
controller: ReadableStreamController<BuilderOutput>,
175175
) {
176176
const reporterName = 'angular-progress-notifier';
177177

@@ -205,7 +205,7 @@ function injectKarmaReporter(
205205
}
206206

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

228228
onRunComplete = function (_browsers: unknown, results: RunCompleteInfo) {
229229
if (results.exitCode === 0) {
230-
subscriber.next({ success: true });
230+
controller.enqueue({ success: true });
231231
} else {
232-
subscriber.next({ success: false });
232+
controller.enqueue({ success: false });
233233
}
234234
};
235235
}
@@ -255,44 +255,48 @@ export function execute(
255255
context: BuilderContext,
256256
karmaOptions: ConfigOptions,
257257
transforms: {
258-
webpackConfiguration?: ExecutionTransformer<Configuration>;
259258
// The karma options transform cannot be async without a refactor of the builder implementation
260259
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
261260
} = {},
262-
): Observable<BuilderOutput> {
263-
return from(initializeApplication(options, context, karmaOptions, transforms)).pipe(
264-
switchMap(
265-
([karma, karmaConfig, buildOptions, buildIterator]) =>
266-
new Observable<BuilderOutput>((subscriber) => {
267-
// If `--watch` is explicitly enabled or if we are keeping the Karma
268-
// process running, we should hook Karma into the build.
269-
if (buildIterator) {
270-
injectKarmaReporter(buildOptions, buildIterator, karmaConfig, subscriber);
271-
}
261+
): AsyncIterable<BuilderOutput> {
262+
let karmaServer: Server;
263+
264+
return new ReadableStream({
265+
async start(controller) {
266+
let init;
267+
try {
268+
init = await initializeApplication(options, context, karmaOptions, transforms);
269+
} catch (err) {
270+
if (err instanceof ApplicationBuildError) {
271+
controller.enqueue({ success: false, message: err.message });
272+
controller.close();
273+
274+
return;
275+
}
272276

273-
// Complete the observable once the Karma server returns.
274-
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
275-
subscriber.next({ success: exitCode === 0 });
276-
subscriber.complete();
277-
});
277+
throw err;
278+
}
279+
280+
const [karma, karmaConfig, buildOptions, buildIterator] = init;
278281

279-
const karmaStart = karmaServer.start();
280-
281-
// Cleanup, signal Karma to exit.
282-
return () => {
283-
void karmaStart.then(() => karmaServer.stop());
284-
};
285-
}),
286-
),
287-
catchError((err) => {
288-
if (err instanceof ApplicationBuildError) {
289-
return of({ success: false, message: err.message });
282+
// If `--watch` is explicitly enabled or if we are keeping the Karma
283+
// process running, we should hook Karma into the build.
284+
if (buildIterator) {
285+
injectKarmaReporter(buildOptions, buildIterator, karmaConfig, controller);
290286
}
291287

292-
throw err;
293-
}),
294-
defaultIfEmpty({ success: false }),
295-
);
288+
// Close the stream once the Karma server returns.
289+
karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
290+
controller.enqueue({ success: exitCode === 0 });
291+
controller.close();
292+
});
293+
294+
await karmaServer.start();
295+
},
296+
async cancel() {
297+
await karmaServer?.stop();
298+
},
299+
});
296300
}
297301

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

318-
const jasmineGlobalEntryPoint =
319-
'@angular-devkit/build-angular/src/builders/karma/jasmine_global.js';
320-
const jasmineGlobalCleanupEntrypoint =
321-
'@angular-devkit/build-angular/src/builders/karma/jasmine_global_cleanup.js';
322+
const jasmineGlobalEntryPoint = localResolve('./polyfills/jasmine_global.js');
323+
const jasmineGlobalCleanupEntrypoint = localResolve('./polyfills/jasmine_global_cleanup.js');
322324

323325
const zoneTestingEntryPoint = 'zone.js/testing';
324326
const polyfillsExludingZoneTesting = polyfills.filter((p) => p !== zoneTestingEntryPoint);
@@ -352,18 +354,11 @@ async function initializeApplication(
352354
context: BuilderContext,
353355
karmaOptions: ConfigOptions,
354356
transforms: {
355-
webpackConfiguration?: ExecutionTransformer<Configuration>;
356357
karmaOptions?: (options: ConfigOptions) => ConfigOptions;
357358
} = {},
358359
): Promise<
359360
[typeof import('karma'), Config & ConfigOptions, BuildOptions, AsyncIterator<Result> | null]
360361
> {
361-
if (transforms.webpackConfiguration) {
362-
context.logger.warn(
363-
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
364-
);
365-
}
366-
367362
const outputPath = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
368363
const projectSourceRoot = await getProjectSourceRoot(context);
369364

@@ -377,7 +372,7 @@ async function initializeApplication(
377372
if (options.main) {
378373
entryPoints.set(mainName, options.main);
379374
} else {
380-
entryPoints.set(mainName, '@angular-devkit/build-angular/src/builders/karma/init_test_bed.js');
375+
entryPoints.set(mainName, localResolve('./polyfills/init_test_bed.js'));
381376
}
382377

383378
const instrumentForCoverage = options.codeCoverage
@@ -416,9 +411,10 @@ async function initializeApplication(
416411
watch: options.watch ?? !karmaOptions.singleRun,
417412
stylePreprocessorOptions: options.stylePreprocessorOptions,
418413
inlineStyleLanguage: options.inlineStyleLanguage,
419-
fileReplacements: options.fileReplacements
420-
? normalizeFileReplacements(options.fileReplacements, './')
421-
: undefined,
414+
fileReplacements: options.fileReplacements,
415+
define: options.define,
416+
loader: options.loader,
417+
externalDependencies: options.externalDependencies,
422418
};
423419

424420
// Build tests with `application` builder, using test files as entry points.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
type Builder,
11+
type BuilderContext,
12+
type BuilderOutput,
13+
createBuilder,
14+
} from '@angular-devkit/architect';
15+
import type { ConfigOptions } from 'karma';
16+
import { createRequire } from 'node:module';
17+
import path from 'node:path';
18+
import type { Schema as KarmaBuilderOptions } from './schema';
19+
20+
export type KarmaConfigOptions = ConfigOptions & {
21+
buildWebpack?: unknown;
22+
configFile?: string;
23+
};
24+
25+
/**
26+
* @experimental Direct usage of this function is considered experimental.
27+
*/
28+
export async function* execute(
29+
options: KarmaBuilderOptions,
30+
context: BuilderContext,
31+
transforms: {
32+
// The karma options transform cannot be async without a refactor of the builder implementation
33+
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
34+
} = {},
35+
): AsyncIterable<BuilderOutput> {
36+
const { execute } = await import('./application_builder');
37+
const karmaOptions = getBaseKarmaOptions(options, context);
38+
39+
yield* execute(options, context, karmaOptions, transforms);
40+
}
41+
42+
function getBaseKarmaOptions(
43+
options: KarmaBuilderOptions,
44+
context: BuilderContext,
45+
): KarmaConfigOptions {
46+
let singleRun: boolean | undefined;
47+
if (options.watch !== undefined) {
48+
singleRun = !options.watch;
49+
}
50+
51+
// Determine project name from builder context target
52+
const projectName = context.target?.project;
53+
if (!projectName) {
54+
throw new Error(`The 'karma' builder requires a target to be specified.`);
55+
}
56+
57+
const karmaOptions: KarmaConfigOptions = options.karmaConfig
58+
? {}
59+
: getBuiltInKarmaConfig(context.workspaceRoot, projectName);
60+
61+
karmaOptions.singleRun = singleRun;
62+
63+
// Workaround https://github.com/angular/angular-cli/issues/28271, by clearing context by default
64+
// for single run executions. Not clearing context for multi-run (watched) builds allows the
65+
// Jasmine Spec Runner to be visible in the browser after test execution.
66+
karmaOptions.client ??= {};
67+
karmaOptions.client.clearContext ??= singleRun ?? false; // `singleRun` defaults to `false` per Karma docs.
68+
69+
// Convert browsers from a string to an array
70+
if (typeof options.browsers === 'string' && options.browsers) {
71+
karmaOptions.browsers = options.browsers.split(',');
72+
} else if (options.browsers === false) {
73+
karmaOptions.browsers = [];
74+
}
75+
76+
if (options.reporters) {
77+
// Split along commas to make it more natural, and remove empty strings.
78+
const reporters = options.reporters
79+
.reduce<string[]>((acc, curr) => acc.concat(curr.split(',')), [])
80+
.filter((x) => !!x);
81+
82+
if (reporters.length > 0) {
83+
karmaOptions.reporters = reporters;
84+
}
85+
}
86+
87+
return karmaOptions;
88+
}
89+
90+
function getBuiltInKarmaConfig(
91+
workspaceRoot: string,
92+
projectName: string,
93+
): ConfigOptions & Record<string, unknown> {
94+
let coverageFolderName = projectName.charAt(0) === '@' ? projectName.slice(1) : projectName;
95+
coverageFolderName = coverageFolderName.toLowerCase();
96+
97+
const workspaceRootRequire = createRequire(workspaceRoot + '/');
98+
99+
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
100+
return {
101+
basePath: '',
102+
frameworks: ['jasmine'],
103+
plugins: [
104+
'karma-jasmine',
105+
'karma-chrome-launcher',
106+
'karma-jasmine-html-reporter',
107+
'karma-coverage',
108+
].map((p) => workspaceRootRequire(p)),
109+
jasmineHtmlReporter: {
110+
suppressAll: true, // removes the duplicated traces
111+
},
112+
coverageReporter: {
113+
dir: path.join(workspaceRoot, 'coverage', coverageFolderName),
114+
subdir: '.',
115+
reporters: [{ type: 'html' }, { type: 'text-summary' }],
116+
},
117+
reporters: ['progress', 'kjhtml'],
118+
browsers: ['Chrome'],
119+
customLaunchers: {
120+
// Chrome configured to run in a bazel sandbox.
121+
// Disable the use of the gpu and `/dev/shm` because it causes Chrome to
122+
// crash on some environments.
123+
// See:
124+
// https://github.com/puppeteer/puppeteer/blob/v1.0.0/docs/troubleshooting.md#tips
125+
// https://stackoverflow.com/questions/50642308/webdriverexception-unknown-error-devtoolsactiveport-file-doesnt-exist-while-t
126+
ChromeHeadlessNoSandbox: {
127+
base: 'ChromeHeadless',
128+
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
129+
},
130+
},
131+
restartOnFileChange: true,
132+
};
133+
}
134+
135+
export type { KarmaBuilderOptions };
136+
137+
const builder: Builder<KarmaBuilderOptions> = createBuilder<KarmaBuilderOptions>(execute);
138+
139+
export default builder;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"title": "Karma Target",
4+
"description": "Karma target options for Build Facade.",
5+
"type": "object",
6+
"properties": {
7+
"main": {
8+
"type": "string",
9+
"description": "The name of the main entry-point file."
10+
},
11+
"tsConfig": {
12+
"type": "string",
13+
"description": "The name of the TypeScript configuration file."
14+
},
15+
"karmaConfig": {
16+
"type": "string",
17+
"description": "The name of the Karma configuration file."
18+
},
19+
"polyfills": {
20+
"description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'.",
21+
"type": "array",
22+
"items": {
23+
"type": "string",
24+
"uniqueItems": true
25+
},
26+
"default": []
27+
},
28+
"assets": {
29+
"type": "array",
30+
"description": "List of static application assets.",
31+
"default": [],
32+
"items": {
33+
"$ref": "#/definitions/assetPattern"
34+
}
35+
},
36+
"scripts": {
37+
"description": "Global scripts to be included in the build.",
38+
"type": "array",
39+
"default": [],
40+
"items": {
41+
"oneOf": [
42+
{
43+
"type": "object",
44+
"properties": {
45+
"input": {
46+
"type": "string",
47+
"description": "The file to include.",
48+
"pattern": "\\.[cm]?jsx?$"
49+
},
50+
"bundleName": {
51+
"type": "string",
52+
"pattern": "^[\\w\\-.]*$",
53+
"description": "The bundle name for this extra entry point."
54+
},
55+
"inject": {
56+
"type": "boolean",
57+
"description": "If the bundle will be referenced in the HTML file.",
58+
"default": true
59+
}
60+
},
61+
"additionalProperties": false,
62+
"required": ["input"]
63+
},
64+
{
65+
"type": "string",
66+
"description": "The file to include.",
67+
"pattern": "\\.[cm]?jsx?$"
68+
}
69+
]
70+
}
71+
},
72+
"styles": {
73+
"description": "Global styles to be included in the build.",
74+
"type": "array",
75+
"default": [],
76+
"items": {
77+
"oneOf": [
78+
{
79+
"type": "object",
80+
"properties": {
81+
"input": {
82+
"type": "string",
83+
"description": "The file to include.",
84+
"pattern": "\\.(?:css|scss|sass|less)$"
85+
},
86+
"bundleName": {
87+
"type": "string",
88+
"pattern": "^[\\w\\-.]*$",
89+
"description": "The bundle name for this extra entry point."
90+
},
91+
"inject": {
92+
"type": "boolean",
93+
"description": "If the bundle will be referenced in the HTML file.",
94+
"default": true
95+
}
96+
},
97+
"additionalProperties": false,
98+
"required": ["input"]
99+
},
100+
{
101+
"type": "string",
102+
"description": "The file to include.",
103+
"pattern": "\\.(?:css|scss|sass|less)$"
104+
}
105+
]
106+
}
107+
},
108+
"inlineStyleLanguage": {
109+
"description": "The stylesheet language to use for the application's inline component styles.",
110+
"type": "string",
111+
"default": "css",
112+
"enum": ["css", "less", "sass", "scss"]
113+
},
114+
"stylePreprocessorOptions": {
115+
"description": "Options to pass to style preprocessors.",
116+
"type": "object",
117+
"properties": {
118+
"includePaths": {
119+
"description": "Paths to include. Paths will be resolved to workspace root.",
120+
"type": "array",
121+
"items": {
122+
"type": "string"
123+
},
124+
"default": []
125+
},
126+
"sass": {
127+
"description": "Options to pass to the sass preprocessor.",
128+
"type": "object",
129+
"properties": {
130+
"fatalDeprecations": {
131+
"description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.",
132+
"type": "array",
133+
"items": {
134+
"type": "string"
135+
}
136+
},
137+
"silenceDeprecations": {
138+
"description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.",
139+
"type": "array",
140+
"items": {
141+
"type": "string"
142+
}
143+
},
144+
"futureDeprecations": {
145+
"description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.",
146+
"type": "array",
147+
"items": {
148+
"type": "string"
149+
}
150+
}
151+
},
152+
"additionalProperties": false
153+
}
154+
},
155+
"additionalProperties": false
156+
},
157+
"externalDependencies": {
158+
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.",
159+
"type": "array",
160+
"items": {
161+
"type": "string"
162+
},
163+
"default": []
164+
},
165+
"loader": {
166+
"description": "Defines the type of loader to use with a specified file extension when used with a JavaScript `import`. `text` inlines the content as a string; `binary` inlines the content as a Uint8Array; `file` emits the file and provides the runtime location of the file; `empty` considers the content to be empty and not include it in bundles.",
167+
"type": "object",
168+
"patternProperties": {
169+
"^\\.\\S+$": { "enum": ["text", "binary", "file", "empty"] }
170+
}
171+
},
172+
"define": {
173+
"description": "Defines global identifiers that will be replaced with a specified constant value when found in any JavaScript or TypeScript code including libraries. The value will be used directly. String values must be put in quotes. Identifiers within Angular metadata such as Component Decorators will not be replaced.",
174+
"type": "object",
175+
"additionalProperties": {
176+
"type": "string"
177+
}
178+
},
179+
"include": {
180+
"type": "array",
181+
"items": {
182+
"type": "string"
183+
},
184+
"default": ["**/*.spec.ts"],
185+
"description": "Globs of files to include, relative to project root. \nThere are 2 special cases:\n - when a path to directory is provided, all spec files ending \".spec.@(ts|tsx)\" will be included\n - when a path to a file is provided, and a matching spec file exists it will be included instead."
186+
},
187+
"exclude": {
188+
"type": "array",
189+
"items": {
190+
"type": "string"
191+
},
192+
"default": [],
193+
"description": "Globs of files to exclude, relative to the project root."
194+
},
195+
"sourceMap": {
196+
"description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.",
197+
"default": true,
198+
"oneOf": [
199+
{
200+
"type": "object",
201+
"properties": {
202+
"scripts": {
203+
"type": "boolean",
204+
"description": "Output source maps for all scripts.",
205+
"default": true
206+
},
207+
"styles": {
208+
"type": "boolean",
209+
"description": "Output source maps for all styles.",
210+
"default": true
211+
},
212+
"vendor": {
213+
"type": "boolean",
214+
"description": "Resolve vendor packages source maps.",
215+
"default": false
216+
}
217+
},
218+
"additionalProperties": false
219+
},
220+
{
221+
"type": "boolean"
222+
}
223+
]
224+
},
225+
"progress": {
226+
"type": "boolean",
227+
"description": "Log progress to the console while building.",
228+
"default": true
229+
},
230+
"watch": {
231+
"type": "boolean",
232+
"description": "Run build when files change."
233+
},
234+
"poll": {
235+
"type": "number",
236+
"description": "Enable and define the file watching poll time period in milliseconds."
237+
},
238+
"preserveSymlinks": {
239+
"type": "boolean",
240+
"description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set."
241+
},
242+
"browsers": {
243+
"description": "Override which browsers tests are run against. Set to `false` to not use any browser.",
244+
"oneOf": [
245+
{
246+
"type": "string",
247+
"description": "A comma seperate list of browsers to run tests against."
248+
},
249+
{
250+
"const": false,
251+
"type": "boolean",
252+
"description": "Does use run tests against a browser."
253+
}
254+
]
255+
},
256+
"codeCoverage": {
257+
"type": "boolean",
258+
"description": "Output a code coverage report.",
259+
"default": false
260+
},
261+
"codeCoverageExclude": {
262+
"type": "array",
263+
"description": "Globs to exclude from code coverage.",
264+
"items": {
265+
"type": "string"
266+
},
267+
"default": []
268+
},
269+
"fileReplacements": {
270+
"description": "Replace compilation source files with other compilation source files in the build.",
271+
"type": "array",
272+
"items": {
273+
"$ref": "#/definitions/fileReplacement"
274+
},
275+
"default": []
276+
},
277+
"reporters": {
278+
"type": "array",
279+
"description": "Karma reporters to use. Directly passed to the karma runner.",
280+
"items": {
281+
"type": "string"
282+
}
283+
},
284+
"webWorkerTsConfig": {
285+
"type": "string",
286+
"description": "TypeScript configuration for Web Worker modules."
287+
},
288+
"aot": {
289+
"type": "boolean",
290+
"description": "Run tests using Ahead of Time compilation.",
291+
"default": false
292+
}
293+
},
294+
"additionalProperties": false,
295+
"required": ["tsConfig"],
296+
"definitions": {
297+
"assetPattern": {
298+
"oneOf": [
299+
{
300+
"type": "object",
301+
"properties": {
302+
"glob": {
303+
"type": "string",
304+
"description": "The pattern to match."
305+
},
306+
"input": {
307+
"type": "string",
308+
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
309+
},
310+
"output": {
311+
"type": "string",
312+
"default": "",
313+
"description": "Absolute path within the output."
314+
},
315+
"ignore": {
316+
"description": "An array of globs to ignore.",
317+
"type": "array",
318+
"items": {
319+
"type": "string"
320+
}
321+
}
322+
},
323+
"additionalProperties": false,
324+
"required": ["glob", "input"]
325+
},
326+
{
327+
"type": "string"
328+
}
329+
]
330+
},
331+
"fileReplacement": {
332+
"type": "object",
333+
"properties": {
334+
"replace": {
335+
"type": "string",
336+
"pattern": "\\.(([cm]?[jt])sx?|json)$"
337+
},
338+
"with": {
339+
"type": "string",
340+
"pattern": "\\.(([cm]?[jt])sx?|json)$"
341+
}
342+
},
343+
"additionalProperties": false,
344+
"required": ["replace", "with"]
345+
}
346+
}
347+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { setTimeout } from 'node:timers/promises';
10+
import { tags } from '@angular-devkit/core';
11+
import { last, tap } from 'rxjs';
12+
import { execute } from '../../index';
13+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
14+
15+
// In each of the test below we'll have to call setTimeout to wait for the coverage
16+
// analysis to be done. This is because karma-coverage performs the analysis
17+
// asynchronously but the promise that it returns is not awaited by Karma.
18+
// Coverage analysis begins when onRunComplete() is invoked, and output files
19+
// are subsequently written to disk. For more information, see
20+
// https://github.com/karma-runner/karma-coverage/blob/32acafa90ed621abd1df730edb44ae55a4009c2c/lib/reporter.js#L221
21+
22+
const coveragePath = 'coverage/lcov.info';
23+
24+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
25+
describe('Behavior: "codeCoverage"', () => {
26+
beforeEach(async () => {
27+
await setupTarget(harness);
28+
});
29+
30+
it('should generate coverage report when file was previously processed by Babel', async () => {
31+
// Force Babel transformation.
32+
await harness.appendToFile('src/app/app.component.ts', '// async');
33+
34+
harness.useTarget('test', {
35+
...BASE_OPTIONS,
36+
codeCoverage: true,
37+
});
38+
39+
const { result } = await harness.executeOnce();
40+
expect(result?.success).toBeTrue();
41+
42+
await setTimeout(1000);
43+
harness.expectFile(coveragePath).toExist();
44+
});
45+
46+
it('should exit with non-zero code when coverage is below threshold', async () => {
47+
await harness.modifyFile('karma.conf.js', (content) =>
48+
content.replace(
49+
'coverageReporter: {',
50+
`coverageReporter: {
51+
check: {
52+
global: {
53+
statements: 100,
54+
lines: 100,
55+
branches: 100,
56+
functions: 100
57+
}
58+
},
59+
`,
60+
),
61+
);
62+
63+
await harness.appendToFile(
64+
'src/app/app.component.ts',
65+
`
66+
export function nonCovered(): boolean {
67+
return true;
68+
}
69+
`,
70+
);
71+
72+
harness.useTarget('test', {
73+
...BASE_OPTIONS,
74+
codeCoverage: true,
75+
});
76+
77+
await harness
78+
.execute()
79+
.pipe(
80+
// In incremental mode, karma-coverage does not have the ability to mark a
81+
// run as failed if code coverage does not pass. This is because it does
82+
// the coverage asynchoronously and Karma does not await the promise
83+
// returned by the plugin.
84+
85+
// However the program must exit with non-zero exit code.
86+
// This is a more common use case of coverage testing and must be supported.
87+
last(),
88+
tap((buildEvent) => expect(buildEvent.result?.success).toBeFalse()),
89+
)
90+
.toPromise();
91+
});
92+
93+
it('should remapped instrumented code back to the original source', async () => {
94+
await harness.modifyFile('karma.conf.js', (content) => content.replace('lcov', 'html'));
95+
96+
await harness.modifyFile('src/app/app.component.ts', (content) => {
97+
return content.replace(
98+
`title = 'app'`,
99+
tags.stripIndents`
100+
title = 'app';
101+
102+
async foo() {
103+
return 'foo';
104+
}
105+
`,
106+
);
107+
});
108+
109+
harness.useTarget('test', {
110+
...BASE_OPTIONS,
111+
codeCoverage: true,
112+
});
113+
114+
const { result } = await harness.executeOnce();
115+
expect(result?.success).toBeTrue();
116+
117+
await setTimeout(1000);
118+
119+
harness
120+
.expectFile('coverage/app.component.ts.html')
121+
.content.toContain(
122+
'<span class="fstat-no" title="function not covered" >async </span>foo()',
123+
);
124+
});
125+
});
126+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Behavior: "Errors"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it('should fail when there is a TypeScript error', async () => {
19+
harness.useTarget('test', {
20+
...BASE_OPTIONS,
21+
});
22+
23+
await harness.appendToFile('src/app/app.component.spec.ts', `console.lo('foo')`);
24+
25+
const { result } = await harness.executeOnce({
26+
outputLogsOnFailure: false,
27+
});
28+
29+
expect(result?.success).toBeFalse();
30+
});
31+
});
32+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Behavior: "fakeAsync"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it('loads zone.js/testing at the right time', async () => {
19+
await harness.writeFiles({
20+
'./src/app/app.component.ts': `
21+
import { Component } from '@angular/core';
22+
23+
@Component({
24+
selector: 'app-root',
25+
standalone: false,
26+
template: '<button (click)="changeMessage()" class="change">{{ message }}</button>',
27+
})
28+
export class AppComponent {
29+
message = 'Initial';
30+
31+
changeMessage() {
32+
setTimeout(() => {
33+
this.message = 'Changed';
34+
}, 1000);
35+
}
36+
}`,
37+
'./src/app/app.component.spec.ts': `
38+
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
39+
import { By } from '@angular/platform-browser';
40+
import { AppComponent } from './app.component';
41+
42+
describe('AppComponent', () => {
43+
beforeEach(() => TestBed.configureTestingModule({
44+
declarations: [AppComponent]
45+
}));
46+
47+
it('allows terrible things that break the most basic assumptions', fakeAsync(() => {
48+
const fixture = TestBed.createComponent(AppComponent);
49+
50+
const btn = fixture.debugElement
51+
.query(By.css('button.change'));
52+
53+
fixture.detectChanges();
54+
expect(btn.nativeElement.innerText).toBe('Initial');
55+
56+
btn.triggerEventHandler('click', null);
57+
58+
// Pre-tick: Still the old value.
59+
fixture.detectChanges();
60+
expect(btn.nativeElement.innerText).toBe('Initial');
61+
62+
tick(1500);
63+
64+
fixture.detectChanges();
65+
expect(btn.nativeElement.innerText).toBe('Changed');
66+
}));
67+
});`,
68+
});
69+
70+
harness.useTarget('test', {
71+
...BASE_OPTIONS,
72+
});
73+
74+
const { result } = await harness.executeOnce();
75+
expect(result?.success).toBeTrue();
76+
});
77+
});
78+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Behavior: "jasmine.clock()"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it('can install and uninstall the mock clock', async () => {
19+
await harness.writeFiles({
20+
'./src/app/app.component.spec.ts': `
21+
import { AppComponent } from './app.component';
22+
23+
describe('Using jasmine.clock()', () => {
24+
beforeEach(async () => {
25+
jasmine.clock().install();
26+
});
27+
28+
afterEach(() => {
29+
jasmine.clock().uninstall();
30+
});
31+
32+
it('runs a basic test case', () => {
33+
expect(!!AppComponent).toBe(true);
34+
});
35+
});`,
36+
});
37+
38+
harness.useTarget('test', {
39+
...BASE_OPTIONS,
40+
});
41+
42+
const { result } = await harness.executeOnce();
43+
expect(result?.success).toBeTrue();
44+
});
45+
});
46+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Behavior: "module commonjs"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it('should work when module is commonjs', async () => {
19+
harness.useTarget('test', {
20+
...BASE_OPTIONS,
21+
});
22+
23+
await harness.modifyFile('src/tsconfig.spec.json', (content) => {
24+
const tsConfig = JSON.parse(content);
25+
tsConfig.compilerOptions.module = 'commonjs';
26+
27+
return JSON.stringify(tsConfig);
28+
});
29+
30+
const { result } = await harness.executeOnce();
31+
32+
expect(result?.success).toBeTrue();
33+
});
34+
});
35+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { concatMap, count, debounceTime, distinctUntilChanged, take, timeout } from 'rxjs';
10+
import { execute } from '../../index';
11+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
12+
import { BuilderOutput } from '@angular-devkit/architect';
13+
14+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
15+
describe('Behavior: "Rebuilds"', () => {
16+
beforeEach(async () => {
17+
await setupTarget(harness);
18+
});
19+
20+
it('recovers from compilation failures in watch mode', async () => {
21+
harness.useTarget('test', {
22+
...BASE_OPTIONS,
23+
watch: true,
24+
});
25+
26+
const goodFile = await harness.readFile('src/app/app.component.spec.ts');
27+
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).withContext('Initial test run should succeed').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)
42+
.withContext('Test should fail after build error was introduced')
43+
.toBeFalse();
44+
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
45+
},
46+
async (result) => {
47+
expect(result?.success)
48+
.withContext('Test should succeed again after build error was fixed')
49+
.toBeTrue();
50+
},
51+
];
52+
53+
const buildCount = await harness
54+
.execute({ outputLogsOnFailure: false })
55+
.pipe(
56+
timeout(60000),
57+
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),
61+
concatMap(async ({ result }, index) => {
62+
await expectedSequence[index](result);
63+
}),
64+
take(expectedSequence.length),
65+
count(),
66+
)
67+
.toPromise();
68+
69+
expect(buildCount).toBe(expectedSequence.length);
70+
});
71+
});
72+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApp) => {
13+
describe('Behavior: "Specs"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it('supports multiple spec files with same basename', async () => {
19+
harness.useTarget('test', {
20+
...BASE_OPTIONS,
21+
});
22+
23+
const collidingBasename = 'collision.spec.ts';
24+
25+
// src/app/app.component.spec.ts conflicts with this one:
26+
await harness.writeFiles({
27+
[`src/app/a/foo-bar/${collidingBasename}`]: `/** Success! */`,
28+
[`src/app/a-foo/bar/${collidingBasename}`]: `/** Success! */`,
29+
[`src/app/a-foo-bar/${collidingBasename}`]: `/** Success! */`,
30+
[`src/app/b/${collidingBasename}`]: `/** Success! */`,
31+
});
32+
33+
const { result, logs } = await harness.executeOnce();
34+
35+
expect(result?.success).toBeTrue();
36+
37+
if (isApp) {
38+
const bundleLog = logs.find((log) =>
39+
log.message.includes('Application bundle generation complete.'),
40+
);
41+
expect(bundleLog?.message).toContain('spec-app-a-foo-bar-collision.spec.js');
42+
expect(bundleLog?.message).toContain('spec-app-a-foo-bar-collision-2.spec.js');
43+
expect(bundleLog?.message).toContain('spec-app-a-foo-bar-collision-3.spec.js');
44+
expect(bundleLog?.message).toContain('spec-app-b-collision.spec.js');
45+
}
46+
});
47+
});
48+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Option: "aot"', () => {
14+
it('enables aot', async () => {
15+
await setupTarget(harness);
16+
17+
await harness.writeFiles({
18+
'src/aot.spec.ts': `
19+
import { Component } from '@angular/core';
20+
21+
describe('Hello', () => {
22+
it('should *not* contain jit instructions', () => {
23+
@Component({
24+
template: 'Hello',
25+
})
26+
class Hello {}
27+
28+
expect((Hello as any).ɵcmp.template.toString()).not.toContain('jit');
29+
});
30+
});
31+
`,
32+
});
33+
34+
harness.useTarget('test', {
35+
...BASE_OPTIONS,
36+
aot: true,
37+
/** Cf. {@link ../builder-mode_spec.ts} */
38+
polyfills: ['zone.js', '@angular/localize/init', 'zone.js/testing'],
39+
});
40+
41+
const { result } = await harness.executeOnce();
42+
expect(result?.success).toBeTrue();
43+
});
44+
});
45+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Option: "assets"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it('includes assets', async () => {
19+
await harness.writeFiles({
20+
'./src/string-file-asset.txt': 'string-file-asset.txt',
21+
'./src/string-folder-asset/file.txt': 'string-folder-asset.txt',
22+
'./src/glob-asset.txt': 'glob-asset.txt',
23+
'./src/folder/folder-asset.txt': 'folder-asset.txt',
24+
'./src/output-asset.txt': 'output-asset.txt',
25+
'./src/app/app.module.ts': `
26+
import { BrowserModule } from '@angular/platform-browser';
27+
import { NgModule } from '@angular/core';
28+
import { HttpClientModule } from '@angular/common/http';
29+
import { AppComponent } from './app.component';
30+
31+
@NgModule({
32+
declarations: [
33+
AppComponent
34+
],
35+
imports: [
36+
BrowserModule,
37+
HttpClientModule
38+
],
39+
providers: [],
40+
bootstrap: [AppComponent]
41+
})
42+
export class AppModule { }
43+
`,
44+
'./src/app/app.component.ts': `
45+
import { Component } from '@angular/core';
46+
import { HttpClient } from '@angular/common/http';
47+
48+
@Component({
49+
selector: 'app-root',
50+
standalone: false,
51+
template: '<p *ngFor="let asset of assets">{{ asset.content }}</p>'
52+
})
53+
export class AppComponent {
54+
public assets = [
55+
{ path: './string-file-asset.txt', content: '' },
56+
{ path: './string-folder-asset/file.txt', content: '' },
57+
{ path: './glob-asset.txt', content: '' },
58+
{ path: './folder/folder-asset.txt', content: '' },
59+
{ path: './output-folder/output-asset.txt', content: '' },
60+
];
61+
constructor(private http: HttpClient) {
62+
this.assets.forEach(asset => http.get(asset.path, { responseType: 'text' })
63+
.subscribe(res => asset.content = res));
64+
}
65+
}`,
66+
'./src/app/app.component.spec.ts': `
67+
import { TestBed } from '@angular/core/testing';
68+
import { HttpClientModule } from '@angular/common/http';
69+
import { AppComponent } from './app.component';
70+
71+
describe('AppComponent', () => {
72+
beforeEach(() => TestBed.configureTestingModule({
73+
imports: [HttpClientModule],
74+
declarations: [AppComponent]
75+
}));
76+
77+
it('should create the app', async () => {
78+
const fixture = TestBed.createComponent(AppComponent);
79+
await fixture.whenStable();
80+
const app = fixture.debugElement.componentInstance;
81+
expect(app).toBeTruthy();
82+
});
83+
});`,
84+
});
85+
86+
harness.useTarget('test', {
87+
...BASE_OPTIONS,
88+
assets: [
89+
'src/string-file-asset.txt',
90+
'src/string-folder-asset',
91+
{ glob: 'glob-asset.txt', input: 'src/', output: '/' },
92+
{ glob: 'output-asset.txt', input: 'src/', output: '/output-folder' },
93+
{ glob: '**/*', input: 'src/folder', output: '/folder' },
94+
],
95+
});
96+
97+
const { result } = await harness.executeOnce();
98+
expect(result?.success).toBeTrue();
99+
});
100+
});
101+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
import { setTimeout } from 'node:timers/promises';
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
// In each of the test below we'll have to call setTimeout to wait for the coverage
13+
// analysis to be done. This is because karma-coverage performs the analysis
14+
// asynchronously but the promise that it returns is not awaited by Karma.
15+
// Coverage analysis begins when onRunComplete() is invoked, and output files
16+
// are subsequently written to disk. For more information, see
17+
// https://github.com/karma-runner/karma-coverage/blob/32acafa90ed621abd1df730edb44ae55a4009c2c/lib/reporter.js#L221
18+
19+
const coveragePath = 'coverage/lcov.info';
20+
21+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
22+
describe('Option: "codeCoverageExclude"', () => {
23+
beforeEach(async () => {
24+
await setupTarget(harness);
25+
});
26+
27+
it('should exclude file from coverage when set', async () => {
28+
harness.useTarget('test', {
29+
...BASE_OPTIONS,
30+
codeCoverage: true,
31+
codeCoverageExclude: ['**/app.component.ts'],
32+
});
33+
34+
const { result } = await harness.executeOnce();
35+
36+
expect(result?.success).toBeTrue();
37+
38+
await setTimeout(1000);
39+
harness.expectFile(coveragePath).content.not.toContain('app.component.ts');
40+
});
41+
42+
it('should exclude file from coverage when set when glob starts with a forward slash', async () => {
43+
harness.useTarget('test', {
44+
...BASE_OPTIONS,
45+
codeCoverage: true,
46+
codeCoverageExclude: ['/**/app.component.ts'],
47+
});
48+
49+
const { result } = await harness.executeOnce();
50+
51+
expect(result?.success).toBeTrue();
52+
53+
await setTimeout(1000);
54+
harness.expectFile(coveragePath).content.not.toContain('app.component.ts');
55+
});
56+
57+
it('should not exclude file from coverage when set', async () => {
58+
harness.useTarget('test', {
59+
...BASE_OPTIONS,
60+
codeCoverage: true,
61+
});
62+
63+
const { result } = await harness.executeOnce();
64+
65+
expect(result?.success).toBeTrue();
66+
67+
await setTimeout(1000);
68+
harness.expectFile(coveragePath).content.toContain('app.component.ts');
69+
});
70+
});
71+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { setTimeout } from 'node:timers/promises';
10+
import { execute } from '../../index';
11+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
12+
13+
// In each of the test below we'll have to call setTimeout to wait for the coverage
14+
// analysis to be done. This is because karma-coverage performs the analysis
15+
// asynchronously but the promise that it returns is not awaited by Karma.
16+
// Coverage analysis begins when onRunComplete() is invoked, and output files
17+
// are subsequently written to disk. For more information, see
18+
// https://github.com/karma-runner/karma-coverage/blob/32acafa90ed621abd1df730edb44ae55a4009c2c/lib/reporter.js#L221
19+
20+
const coveragePath = 'coverage/lcov.info';
21+
22+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
23+
describe('Option: "codeCoverage"', () => {
24+
beforeEach(async () => {
25+
await setupTarget(harness);
26+
});
27+
28+
it('should generate coverage report when option is set to true', async () => {
29+
harness.useTarget('test', {
30+
...BASE_OPTIONS,
31+
codeCoverage: true,
32+
});
33+
34+
const { result } = await harness.executeOnce();
35+
expect(result?.success).toBeTrue();
36+
37+
await setTimeout(1000);
38+
harness.expectFile(coveragePath).toExist();
39+
});
40+
41+
it('should not generate coverage report when option is set to false', async () => {
42+
harness.useTarget('test', {
43+
...BASE_OPTIONS,
44+
codeCoverage: false,
45+
});
46+
47+
const { result } = await harness.executeOnce();
48+
49+
expect(result?.success).toBeTrue();
50+
51+
await setTimeout(1000);
52+
harness.expectFile(coveragePath).toNotExist();
53+
});
54+
55+
it('should not generate coverage report when option is unset', async () => {
56+
harness.useTarget('test', {
57+
...BASE_OPTIONS,
58+
});
59+
60+
const { result } = await harness.executeOnce();
61+
62+
expect(result?.success).toBeTrue();
63+
64+
await setTimeout(1000);
65+
harness.expectFile(coveragePath).toNotExist();
66+
});
67+
68+
it(`should collect coverage from paths in 'sourceRoot'`, async () => {
69+
await harness.writeFiles({
70+
'./dist/my-lib/index.d.ts': `
71+
export declare const title = 'app';
72+
`,
73+
'./dist/my-lib/index.js': `
74+
export const title = 'app';
75+
`,
76+
'./src/app/app.component.ts': `
77+
import { Component } from '@angular/core';
78+
import { title } from 'my-lib';
79+
80+
@Component({
81+
selector: 'app-root',
82+
standalone: false,
83+
templateUrl: './app.component.html',
84+
styleUrls: ['./app.component.css']
85+
})
86+
export class AppComponent {
87+
title = title;
88+
}
89+
`,
90+
});
91+
await harness.modifyFile('tsconfig.json', (content) =>
92+
content.replace(
93+
/"baseUrl": ".\/",/,
94+
`
95+
"baseUrl": "./",
96+
"paths": {
97+
"my-lib": [
98+
"./dist/my-lib"
99+
]
100+
},
101+
`,
102+
),
103+
);
104+
105+
harness.useTarget('test', {
106+
...BASE_OPTIONS,
107+
codeCoverage: true,
108+
});
109+
const { result } = await harness.executeOnce();
110+
expect(result?.success).toBeTrue();
111+
112+
await setTimeout(1000);
113+
harness.expectFile(coveragePath).content.not.toContain('my-lib');
114+
});
115+
});
116+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Option: "exclude"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
beforeEach(async () => {
19+
await harness.writeFiles({
20+
'src/app/error.spec.ts': `
21+
describe('Error spec', () => {
22+
it('should error', () => {
23+
expect(false).toBe(true);
24+
});
25+
});`,
26+
});
27+
});
28+
29+
it(`should not exclude any spec when exclude is not supplied`, async () => {
30+
harness.useTarget('test', {
31+
...BASE_OPTIONS,
32+
});
33+
34+
const { result } = await harness.executeOnce();
35+
expect(result?.success).toBeFalse();
36+
});
37+
38+
it(`should exclude spec that matches the 'exclude' glob pattern`, async () => {
39+
harness.useTarget('test', {
40+
...BASE_OPTIONS,
41+
exclude: ['**/error.spec.ts'],
42+
});
43+
const { result } = await harness.executeOnce();
44+
expect(result?.success).toBeTrue();
45+
});
46+
47+
it(`should exclude spec that matches the 'exclude' pattern with a relative project root`, async () => {
48+
harness.useTarget('test', {
49+
...BASE_OPTIONS,
50+
exclude: ['src/app/error.spec.ts'],
51+
});
52+
53+
const { result } = await harness.executeOnce();
54+
expect(result?.success).toBeTrue();
55+
});
56+
57+
it(`should exclude spec that matches the 'exclude' pattern prefixed with a slash`, async () => {
58+
harness.useTarget('test', {
59+
...BASE_OPTIONS,
60+
exclude: ['/src/app/error.spec.ts'],
61+
});
62+
63+
const { result } = await harness.executeOnce();
64+
expect(result?.success).toBeTrue();
65+
});
66+
});
67+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Option: "include"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it(`should fail when includes doesn't match any files`, async () => {
19+
harness.useTarget('test', {
20+
...BASE_OPTIONS,
21+
include: ['abc.spec.ts', 'def.spec.ts'],
22+
});
23+
24+
const { result } = await harness.executeOnce();
25+
expect(result?.success).toBeFalse();
26+
});
27+
28+
[
29+
{
30+
test: 'relative path from workspace to spec',
31+
input: ['src/app/app.component.spec.ts'],
32+
},
33+
{
34+
test: 'relative path from workspace to file',
35+
input: ['src/app/app.component.ts'],
36+
},
37+
{
38+
test: 'relative path from project root to spec',
39+
input: ['app/services/test.service.spec.ts'],
40+
},
41+
{
42+
test: 'relative path from project root to file',
43+
input: ['app/services/test.service.ts'],
44+
},
45+
{
46+
test: 'relative path from workspace to directory',
47+
input: ['src/app/services'],
48+
},
49+
{
50+
test: 'relative path from project root to directory',
51+
input: ['app/services'],
52+
},
53+
{
54+
test: 'glob with spec suffix',
55+
input: ['**/*.pipe.spec.ts', '**/*.pipe.spec.ts', '**/*test.service.spec.ts'],
56+
},
57+
{
58+
test: 'glob with forward slash and spec suffix',
59+
input: ['/**/*test.service.spec.ts'],
60+
},
61+
].forEach((options, index) => {
62+
it(`should work with ${options.test} (${index})`, async () => {
63+
await harness.writeFiles({
64+
'src/app/services/test.service.spec.ts': `
65+
describe('TestService', () => {
66+
it('should succeed', () => {
67+
expect(true).toBe(true);
68+
});
69+
});`,
70+
'src/app/failing.service.spec.ts': `
71+
describe('FailingService', () => {
72+
it('should be ignored', () => {
73+
expect(true).toBe(false);
74+
});
75+
});`,
76+
'src/app/property.pipe.spec.ts': `
77+
describe('PropertyPipe', () => {
78+
it('should succeed', () => {
79+
expect(true).toBe(true);
80+
});
81+
});`,
82+
});
83+
84+
harness.useTarget('test', {
85+
...BASE_OPTIONS,
86+
include: options.input,
87+
});
88+
89+
const { result } = await harness.executeOnce();
90+
expect(result?.success).toBeTrue();
91+
});
92+
});
93+
});
94+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Option: "main"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
beforeEach(async () => {
19+
await harness.writeFiles({
20+
'src/magic.ts': `Object.assign(globalThis, {MAGIC_IS_REAL: true});`,
21+
'src/magic.spec.ts': `
22+
declare const MAGIC_IS_REAL: boolean;
23+
describe('Magic', () => {
24+
it('can be scientificially proven to be true', () => {
25+
expect(typeof MAGIC_IS_REAL).toBe('boolean');
26+
});
27+
});`,
28+
});
29+
// Remove this test, we don't expect it to pass with our setup script.
30+
await harness.removeFile('src/app/app.component.spec.ts');
31+
32+
// Add src/magic.ts to tsconfig.
33+
interface TsConfig {
34+
files: string[];
35+
}
36+
const tsConfig = JSON.parse(harness.readFile('src/tsconfig.spec.json')) as TsConfig;
37+
tsConfig.files.push('magic.ts');
38+
await harness.writeFile('src/tsconfig.spec.json', JSON.stringify(tsConfig));
39+
});
40+
41+
it('uses custom setup file', async () => {
42+
harness.useTarget('test', {
43+
...BASE_OPTIONS,
44+
main: './src/magic.ts',
45+
});
46+
47+
const { result } = await harness.executeOnce();
48+
expect(result?.success).toBeTrue();
49+
});
50+
});
51+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
13+
describe('Option: "styles"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
it(`processes 'styles.css' styles`, async () => {
19+
await harness.writeFiles({
20+
'src/styles.css': 'p {display: none}',
21+
'src/app/app.component.ts': `
22+
import { Component } from '@angular/core';
23+
24+
@Component({
25+
selector: 'app-root',
26+
standalone: false,
27+
template: '<p>Hello World</p>'
28+
})
29+
export class AppComponent {
30+
}
31+
`,
32+
'src/app/app.component.spec.ts': `
33+
import { TestBed } from '@angular/core/testing';
34+
import { AppComponent } from './app.component';
35+
36+
describe('AppComponent', () => {
37+
beforeEach(() => TestBed.configureTestingModule({
38+
declarations: [AppComponent]
39+
}));
40+
41+
it('should not contain text that is hidden via css', () => {
42+
const fixture = TestBed.createComponent(AppComponent);
43+
expect(fixture.nativeElement.innerText).not.toContain('Hello World');
44+
});
45+
});`,
46+
});
47+
48+
harness.useTarget('test', {
49+
...BASE_OPTIONS,
50+
styles: ['src/styles.css'],
51+
});
52+
53+
const { result } = await harness.executeOnce();
54+
expect(result?.success).toBeTrue();
55+
});
56+
57+
it('processes style with bundleName', async () => {
58+
await harness.writeFiles({
59+
'src/dark-theme.css': '',
60+
'src/app/app.module.ts': `
61+
import { BrowserModule } from '@angular/platform-browser';
62+
import { NgModule } from '@angular/core';
63+
import { HttpClientModule } from '@angular/common/http';
64+
import { AppComponent } from './app.component';
65+
@NgModule({
66+
declarations: [
67+
AppComponent
68+
],
69+
imports: [
70+
BrowserModule,
71+
HttpClientModule
72+
],
73+
providers: [],
74+
bootstrap: [AppComponent]
75+
})
76+
export class AppModule { }
77+
`,
78+
'src/app/app.component.ts': `
79+
import { Component } from '@angular/core';
80+
import { HttpClient } from '@angular/common/http';
81+
@Component({
82+
selector: 'app-root',
83+
standalone: false,
84+
template: '<p *ngFor="let asset of css">{{ asset.content }}</p>'
85+
})
86+
export class AppComponent {
87+
public assets = [
88+
{ path: './dark-theme.css', content: '' },
89+
];
90+
constructor(private http: HttpClient) {
91+
this.assets.forEach(asset => http.get(asset.path, { responseType: 'text' })
92+
.subscribe(res => asset.content = res));
93+
}
94+
}`,
95+
'src/app/app.component.spec.ts': `
96+
import { TestBed } from '@angular/core/testing';
97+
import { HttpClientModule } from '@angular/common/http';
98+
import { AppComponent } from './app.component';
99+
describe('AppComponent', () => {
100+
beforeEach(() => TestBed.configureTestingModule({
101+
imports: [HttpClientModule],
102+
declarations: [AppComponent]
103+
}));
104+
it('should create the app', () => {
105+
const fixture = TestBed.createComponent(AppComponent);
106+
const app = fixture.debugElement.componentInstance;
107+
expect(app).toBeTruthy();
108+
});
109+
});`,
110+
});
111+
112+
harness.useTarget('test', {
113+
...BASE_OPTIONS,
114+
styles: [
115+
{
116+
inject: false,
117+
input: 'src/dark-theme.css',
118+
bundleName: 'dark-theme',
119+
},
120+
],
121+
});
122+
123+
const { result } = await harness.executeOnce();
124+
expect(result?.success).toBeTrue();
125+
});
126+
127+
it('fails and shows an error if style does not exist', async () => {
128+
harness.useTarget('test', {
129+
...BASE_OPTIONS,
130+
styles: ['src/test-style-a.css'],
131+
});
132+
133+
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
134+
135+
expect(result?.success).toBeFalse();
136+
expect(logs).toContain(
137+
jasmine.objectContaining({
138+
level: 'error',
139+
message: jasmine.stringMatching(
140+
/(Can't|Could not) resolve ['"]src\/test-style-a.css['"]/,
141+
),
142+
}),
143+
);
144+
});
145+
});
146+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
11+
12+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
13+
describe('Option: "webWorkerTsConfig"', () => {
14+
beforeEach(async () => {
15+
await setupTarget(harness);
16+
});
17+
18+
beforeEach(async () => {
19+
await harness.writeFiles({
20+
'src/tsconfig.worker.json': `
21+
{
22+
"extends": "../tsconfig.json",
23+
"compilerOptions": {
24+
"outDir": "../out-tsc/worker",
25+
"lib": [
26+
"es2018",
27+
"webworker"
28+
],
29+
"types": []
30+
},
31+
"include": [
32+
"**/*.worker.ts",
33+
]
34+
}`,
35+
'src/app/app.worker.ts': `
36+
/// <reference lib="webworker" />
37+
38+
const prefix: string = 'Data: ';
39+
addEventListener('message', ({ data }) => {
40+
postMessage(prefix + data);
41+
});
42+
`,
43+
'src/app/app.component.ts': `
44+
import { Component } from '@angular/core';
45+
46+
@Component({
47+
selector: 'app-root',
48+
standalone: false,
49+
template: ''
50+
})
51+
export class AppComponent {
52+
worker = new Worker(new URL('./app.worker', import.meta.url));
53+
}
54+
`,
55+
'./src/app/app.component.spec.ts': `
56+
import { TestBed } from '@angular/core/testing';
57+
import { AppComponent } from './app.component';
58+
59+
describe('AppComponent', () => {
60+
beforeEach(() => TestBed.configureTestingModule({
61+
declarations: [AppComponent]
62+
}));
63+
64+
it('worker should be defined', () => {
65+
const fixture = TestBed.createComponent(AppComponent);
66+
const app = fixture.debugElement.componentInstance;
67+
expect(app.worker).toBeDefined();
68+
});
69+
});`,
70+
});
71+
});
72+
73+
// Web workers work with the application builder _without_ setting webWorkerTsConfig.
74+
if (isApplicationBuilder) {
75+
it(`should parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
76+
harness.useTarget('test', {
77+
...BASE_OPTIONS,
78+
webWorkerTsConfig: undefined,
79+
});
80+
81+
const { result } = await harness.executeOnce();
82+
expect(result?.success).toBeTrue();
83+
});
84+
} else {
85+
it(`should not parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
86+
harness.useTarget('test', {
87+
...BASE_OPTIONS,
88+
webWorkerTsConfig: undefined,
89+
});
90+
91+
await harness.writeFile(
92+
'./src/app/app.component.spec.ts',
93+
`
94+
import { TestBed } from '@angular/core/testing';
95+
import { AppComponent } from './app.component';
96+
97+
describe('AppComponent', () => {
98+
beforeEach(() => TestBed.configureTestingModule({
99+
declarations: [AppComponent]
100+
}));
101+
102+
it('worker should throw', () => {
103+
expect(() => TestBed.createComponent(AppComponent))
104+
.toThrowError(/Failed to construct 'Worker'/);
105+
});
106+
});`,
107+
);
108+
109+
const { result } = await harness.executeOnce();
110+
expect(result?.success).toBeTrue();
111+
});
112+
}
113+
114+
it(`should parse web workers when "webWorkerTsConfig" is set.`, async () => {
115+
harness.useTarget('test', {
116+
...BASE_OPTIONS,
117+
webWorkerTsConfig: 'src/tsconfig.worker.json',
118+
});
119+
120+
const { result } = await harness.executeOnce();
121+
expect(result?.success).toBeTrue();
122+
});
123+
});
124+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { Schema } from '../schema';
10+
import { BuilderHandlerFn } from '@angular-devkit/architect';
11+
import { json } from '@angular-devkit/core';
12+
import { ApplicationBuilderOptions as ApplicationSchema, buildApplication } from '@angular/build';
13+
import * as path from 'node:path';
14+
import { readFileSync } from 'node:fs';
15+
import {
16+
BuilderHarness,
17+
host,
18+
JasmineBuilderHarness,
19+
} from '../../../../../../../modules/testing/builder/src';
20+
21+
// TODO: Consider using package.json imports field instead of relative path
22+
// after the switch to rules_js.
23+
export * from '../../../../../../../modules/testing/builder/src';
24+
25+
export const KARMA_BUILDER_INFO = Object.freeze({
26+
name: '@angular/build:karma',
27+
schemaPath: __dirname + '/../schema.json',
28+
});
29+
30+
/**
31+
* Contains all required karma builder fields.
32+
* Also disables progress reporting to minimize logging output.
33+
*/
34+
export const BASE_OPTIONS = Object.freeze<Schema>({
35+
polyfills: ['./src/polyfills', 'zone.js/testing'],
36+
tsConfig: 'src/tsconfig.spec.json',
37+
karmaConfig: 'karma.conf.js',
38+
browsers: 'ChromeHeadlessCI',
39+
progress: false,
40+
watch: false,
41+
});
42+
43+
const optionSchemaCache = new Map<string, json.schema.JsonSchema>();
44+
45+
function getCachedSchema(options: { schemaPath: string }): json.schema.JsonSchema {
46+
let optionSchema = optionSchemaCache.get(options.schemaPath);
47+
if (optionSchema === undefined) {
48+
optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema;
49+
optionSchemaCache.set(options.schemaPath, optionSchema);
50+
}
51+
return optionSchema;
52+
}
53+
54+
/**
55+
* Contains all required application builder fields.
56+
* Also disables progress reporting to minimize logging output.
57+
*/
58+
export const APPLICATION_BASE_OPTIONS = Object.freeze<ApplicationSchema>({
59+
index: 'src/index.html',
60+
browser: 'src/main.ts',
61+
outputPath: 'dist',
62+
tsConfig: 'src/tsconfig.app.json',
63+
progress: false,
64+
65+
// Disable optimizations
66+
optimization: false,
67+
68+
// Enable polling (if a test enables watch mode).
69+
// This is a workaround for bazel isolation file watch not triggering in tests.
70+
poll: 100,
71+
});
72+
73+
// TODO: Remove and use import after Vite-based dev server is moved to new package
74+
export const APPLICATION_BUILDER_INFO = Object.freeze({
75+
name: '@angular/build:application',
76+
schemaPath: path.join(
77+
path.dirname(require.resolve('@angular/build/package.json')),
78+
'src/builders/application/schema.json',
79+
),
80+
});
81+
82+
/**
83+
* Adds a `build` target to a builder test harness for the application builder with the base options
84+
* used by the application builder tests.
85+
*
86+
* @param harness The builder harness to use when setting up the application builder target
87+
* @param extraOptions The additional options that should be used when executing the target.
88+
*/
89+
export async function setupApplicationTarget<T>(
90+
harness: BuilderHarness<T>,
91+
extraOptions?: Partial<ApplicationSchema>,
92+
): Promise<void> {
93+
const applicationSchema = getCachedSchema(APPLICATION_BUILDER_INFO);
94+
95+
harness.withBuilderTarget(
96+
'build',
97+
buildApplication,
98+
{
99+
...APPLICATION_BASE_OPTIONS,
100+
...extraOptions,
101+
},
102+
{
103+
builderName: APPLICATION_BUILDER_INFO.name,
104+
optionSchema: applicationSchema,
105+
},
106+
);
107+
108+
// For application-builder based targets, the localize polyfill needs to be explicit.
109+
await harness.appendToFile('src/polyfills.ts', `import '@angular/localize/init';`);
110+
}
111+
112+
/** Runs the test against both an application- and a browser-builder context. */
113+
export function describeKarmaBuilder<T>(
114+
builderHandler: BuilderHandlerFn<T & json.JsonObject>,
115+
options: { name?: string; schemaPath: string },
116+
specDefinitions: (
117+
harness: JasmineBuilderHarness<T>,
118+
setupTarget: typeof setupApplicationTarget,
119+
isApplicationTarget: true,
120+
) => void,
121+
) {
122+
const optionSchema = getCachedSchema(options);
123+
const harness = new JasmineBuilderHarness<T>(builderHandler, host, {
124+
builderName: options.name,
125+
optionSchema,
126+
});
127+
128+
describe(options.name || builderHandler.name, () => {
129+
beforeEach(async () => {
130+
await host.initialize().toPromise();
131+
132+
await harness.modifyFile('karma.conf.js', (content) => {
133+
return content
134+
.replace(`, '@angular-devkit/build-angular'`, '')
135+
.replace(`require('@angular-devkit/build-angular/plugins/karma'),`, '');
136+
});
137+
});
138+
afterEach(() => host.restore().toPromise());
139+
140+
specDefinitions(harness, setupApplicationTarget, true);
141+
});
142+
}

‎packages/angular/build/src/private.ts

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { buildApplicationInternal } from './builders/application';
2626
export type { ApplicationBuilderInternalOptions } from './builders/application/options';
2727
export { type Result, type ResultFile, ResultKind } from './builders/application/results';
2828
export { serveWithVite } from './builders/dev-server/vite-server';
29+
export { execute as executeKarmaInternal } from './builders/karma/application_builder';
2930

3031
// Tools
3132
export * from './tools/babel/plugins';
@@ -82,3 +83,4 @@ export { augmentAppWithServiceWorker } from './utils/service-worker';
8283
export { type BundleStats, generateBuildStatsTable } from './utils/stats-table';
8384
export { getSupportedBrowsers } from './utils/supported-browsers';
8485
export { assertCompatibleAngularVersion } from './utils/version';
86+
export { findTests, getTestEntrypoints } from './builders/karma/find-tests';

‎packages/angular/cli/lib/config/workspace-schema.json

+23
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@
405405
"@angular/build:application",
406406
"@angular/build:dev-server",
407407
"@angular/build:extract-i18n",
408+
"@angular/build:karma",
408409
"@angular/build:ng-packagr",
409410
"@angular-devkit/build-angular:application",
410411
"@angular-devkit/build-angular:app-shell",
@@ -638,6 +639,28 @@
638639
}
639640
}
640641
},
642+
{
643+
"type": "object",
644+
"additionalProperties": false,
645+
"properties": {
646+
"builder": {
647+
"const": "@angular/build:karma"
648+
},
649+
"defaultConfiguration": {
650+
"type": "string",
651+
"description": "A default named configuration to use when a target configuration is not provided."
652+
},
653+
"options": {
654+
"$ref": "../../../../angular/build/src/builders/karma/schema.json"
655+
},
656+
"configurations": {
657+
"type": "object",
658+
"additionalProperties": {
659+
"$ref": "../../../../angular/build/src/builders/karma/schema.json"
660+
}
661+
}
662+
}
663+
},
641664
{
642665
"type": "object",
643666
"additionalProperties": false,

‎packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts

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

9+
import { findTests } from '@angular/build/private';
910
import { pluginName } from 'mini-css-extract-plugin';
1011
import assert from 'node:assert';
1112
import type { Compilation, Compiler } from 'webpack';
1213

13-
import { findTests } from './find-tests';
14-
1514
/**
1615
* The name of the plugin provided to Webpack when tapping Webpack compiler hooks.
1716
*/

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

+31-6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import * as path from 'node:path';
2020
import { Observable, from, mergeMap } from 'rxjs';
2121
import { Configuration } from 'webpack';
2222
import { ExecutionTransformer } from '../../transforms';
23+
import { normalizeFileReplacements } from '../../utils';
2324
import { BuilderMode, Schema as KarmaBuilderOptions } from './schema';
2425

2526
export type KarmaConfigOptions = ConfigOptions & {
@@ -46,7 +47,18 @@ export function execute(
4647
mergeMap(([useEsbuild, executeWithBuilder]) => {
4748
const karmaOptions = getBaseKarmaOptions(options, context, useEsbuild);
4849

49-
return executeWithBuilder.execute(options, context, karmaOptions, transforms);
50+
if (useEsbuild && transforms.webpackConfiguration) {
51+
context.logger.warn(
52+
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
53+
);
54+
}
55+
56+
if (useEsbuild && options.fileReplacements) {
57+
options.fileReplacements = normalizeFileReplacements(options.fileReplacements, './');
58+
}
59+
60+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61+
return executeWithBuilder(options as any, context, karmaOptions, transforms);
5062
}),
5163
);
5264
}
@@ -155,13 +167,26 @@ export default createBuilder<Record<string, string> & KarmaBuilderOptions>(execu
155167
async function getExecuteWithBuilder(
156168
options: KarmaBuilderOptions,
157169
context: BuilderContext,
158-
): Promise<[boolean, typeof import('./application_builder') | typeof import('./browser_builder')]> {
170+
): Promise<
171+
[
172+
boolean,
173+
(
174+
| (typeof import('@angular/build/private'))['executeKarmaInternal']
175+
| (typeof import('./browser_builder'))['execute']
176+
),
177+
]
178+
> {
159179
const useEsbuild = await checkForEsbuild(options, context);
160-
const executeWithBuilderModule = useEsbuild
161-
? import('./application_builder')
162-
: import('./browser_builder');
180+
let execute;
181+
if (useEsbuild) {
182+
const { executeKarmaInternal } = await import('@angular/build/private');
183+
execute = executeKarmaInternal;
184+
} else {
185+
const browserBuilderModule = await import('./browser_builder');
186+
execute = browserBuilderModule.execute;
187+
}
163188

164-
return [useEsbuild, await executeWithBuilderModule];
189+
return [useEsbuild, execute];
165190
}
166191

167192
async function checkForEsbuild(

0 commit comments

Comments
 (0)
Please sign in to comment.