mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-15 18:13:38 +08:00
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
This commit is contained in:
parent
e6deb82c6c
commit
11fab9c7dd
@ -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
|
||||
|
@ -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",
|
||||
@ -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,
|
||||
@ -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",
|
||||
@ -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",
|
||||
@ -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",
|
||||
@ -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"],
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
@ -77,6 +78,9 @@
|
||||
"@angular/ssr": {
|
||||
"optional": true
|
||||
},
|
||||
"karma": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
|
@ -6,7 +6,6 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { BuildOutputFileType } from '@angular/build';
|
||||
import {
|
||||
ApplicationBuilderInternalOptions,
|
||||
Result,
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
// Complete the observable once the Karma server returns.
|
||||
const karmaServer = new karma.Server(karmaConfig as Config, (exitCode) => {
|
||||
subscriber.next({ success: exitCode === 0 });
|
||||
subscriber.complete();
|
||||
});
|
||||
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();
|
||||
|
||||
const karmaStart = karmaServer.start();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup, signal Karma to exit.
|
||||
return () => {
|
||||
void karmaStart.then(() => karmaServer.stop());
|
||||
};
|
||||
}),
|
||||
),
|
||||
catchError((err) => {
|
||||
if (err instanceof ApplicationBuildError) {
|
||||
return of({ success: false, message: err.message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}),
|
||||
defaultIfEmpty({ success: false }),
|
||||
);
|
||||
const [karma, karmaConfig, buildOptions, buildIterator] = init;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
@ -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);
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
@ -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.
|
139
packages/angular/build/src/builders/karma/index.ts
Normal file
139
packages/angular/build/src/builders/karma/index.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {
|
||||
type Builder,
|
||||
type BuilderContext,
|
||||
type BuilderOutput,
|
||||
createBuilder,
|
||||
} from '@angular-devkit/architect';
|
||||
import type { ConfigOptions } from 'karma';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import type { Schema as KarmaBuilderOptions } from './schema';
|
||||
|
||||
export type KarmaConfigOptions = ConfigOptions & {
|
||||
buildWebpack?: unknown;
|
||||
configFile?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @experimental Direct usage of this function is considered experimental.
|
||||
*/
|
||||
export async function* execute(
|
||||
options: KarmaBuilderOptions,
|
||||
context: BuilderContext,
|
||||
transforms: {
|
||||
// The karma options transform cannot be async without a refactor of the builder implementation
|
||||
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
|
||||
} = {},
|
||||
): AsyncIterable<BuilderOutput> {
|
||||
const { execute } = await import('./application_builder');
|
||||
const karmaOptions = getBaseKarmaOptions(options, context);
|
||||
|
||||
yield* execute(options, context, karmaOptions, transforms);
|
||||
}
|
||||
|
||||
function getBaseKarmaOptions(
|
||||
options: KarmaBuilderOptions,
|
||||
context: BuilderContext,
|
||||
): KarmaConfigOptions {
|
||||
let singleRun: boolean | undefined;
|
||||
if (options.watch !== undefined) {
|
||||
singleRun = !options.watch;
|
||||
}
|
||||
|
||||
// Determine project name from builder context target
|
||||
const projectName = context.target?.project;
|
||||
if (!projectName) {
|
||||
throw new Error(`The 'karma' builder requires a target to be specified.`);
|
||||
}
|
||||
|
||||
const karmaOptions: KarmaConfigOptions = options.karmaConfig
|
||||
? {}
|
||||
: getBuiltInKarmaConfig(context.workspaceRoot, projectName);
|
||||
|
||||
karmaOptions.singleRun = singleRun;
|
||||
|
||||
// Workaround https://github.com/angular/angular-cli/issues/28271, by clearing context by default
|
||||
// for single run executions. Not clearing context for multi-run (watched) builds allows the
|
||||
// Jasmine Spec Runner to be visible in the browser after test execution.
|
||||
karmaOptions.client ??= {};
|
||||
karmaOptions.client.clearContext ??= singleRun ?? false; // `singleRun` defaults to `false` per Karma docs.
|
||||
|
||||
// Convert browsers from a string to an array
|
||||
if (typeof options.browsers === 'string' && options.browsers) {
|
||||
karmaOptions.browsers = options.browsers.split(',');
|
||||
} else if (options.browsers === false) {
|
||||
karmaOptions.browsers = [];
|
||||
}
|
||||
|
||||
if (options.reporters) {
|
||||
// Split along commas to make it more natural, and remove empty strings.
|
||||
const reporters = options.reporters
|
||||
.reduce<string[]>((acc, curr) => acc.concat(curr.split(',')), [])
|
||||
.filter((x) => !!x);
|
||||
|
||||
if (reporters.length > 0) {
|
||||
karmaOptions.reporters = reporters;
|
||||
}
|
||||
}
|
||||
|
||||
return karmaOptions;
|
||||
}
|
||||
|
||||
function getBuiltInKarmaConfig(
|
||||
workspaceRoot: string,
|
||||
projectName: string,
|
||||
): ConfigOptions & Record<string, unknown> {
|
||||
let coverageFolderName = projectName.charAt(0) === '@' ? projectName.slice(1) : projectName;
|
||||
coverageFolderName = coverageFolderName.toLowerCase();
|
||||
|
||||
const workspaceRootRequire = createRequire(workspaceRoot + '/');
|
||||
|
||||
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
|
||||
return {
|
||||
basePath: '',
|
||||
frameworks: ['jasmine'],
|
||||
plugins: [
|
||||
'karma-jasmine',
|
||||
'karma-chrome-launcher',
|
||||
'karma-jasmine-html-reporter',
|
||||
'karma-coverage',
|
||||
].map((p) => workspaceRootRequire(p)),
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true, // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: path.join(workspaceRoot, 'coverage', coverageFolderName),
|
||||
subdir: '.',
|
||||
reporters: [{ type: 'html' }, { type: 'text-summary' }],
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
browsers: ['Chrome'],
|
||||
customLaunchers: {
|
||||
// Chrome configured to run in a bazel sandbox.
|
||||
// Disable the use of the gpu and `/dev/shm` because it causes Chrome to
|
||||
// crash on some environments.
|
||||
// See:
|
||||
// https://github.com/puppeteer/puppeteer/blob/v1.0.0/docs/troubleshooting.md#tips
|
||||
// https://stackoverflow.com/questions/50642308/webdriverexception-unknown-error-devtoolsactiveport-file-doesnt-exist-while-t
|
||||
ChromeHeadlessNoSandbox: {
|
||||
base: 'ChromeHeadless',
|
||||
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
},
|
||||
},
|
||||
restartOnFileChange: true,
|
||||
};
|
||||
}
|
||||
|
||||
export type { KarmaBuilderOptions };
|
||||
|
||||
const builder: Builder<KarmaBuilderOptions> = createBuilder<KarmaBuilderOptions>(execute);
|
||||
|
||||
export default builder;
|
347
packages/angular/build/src/builders/karma/schema.json
Normal file
347
packages/angular/build/src/builders/karma/schema.json
Normal file
@ -0,0 +1,347 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"title": "Karma Target",
|
||||
"description": "Karma target options for Build Facade.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"main": {
|
||||
"type": "string",
|
||||
"description": "The name of the main entry-point file."
|
||||
},
|
||||
"tsConfig": {
|
||||
"type": "string",
|
||||
"description": "The name of the TypeScript configuration file."
|
||||
},
|
||||
"karmaConfig": {
|
||||
"type": "string",
|
||||
"description": "The name of the Karma configuration file."
|
||||
},
|
||||
"polyfills": {
|
||||
"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'.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"description": "List of static application assets.",
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/assetPattern"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"description": "Global scripts to be included in the build.",
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "The file to include.",
|
||||
"pattern": "\\.[cm]?jsx?$"
|
||||
},
|
||||
"bundleName": {
|
||||
"type": "string",
|
||||
"pattern": "^[\\w\\-.]*$",
|
||||
"description": "The bundle name for this extra entry point."
|
||||
},
|
||||
"inject": {
|
||||
"type": "boolean",
|
||||
"description": "If the bundle will be referenced in the HTML file.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["input"]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The file to include.",
|
||||
"pattern": "\\.[cm]?jsx?$"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"description": "Global styles to be included in the build.",
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "The file to include.",
|
||||
"pattern": "\\.(?:css|scss|sass|less)$"
|
||||
},
|
||||
"bundleName": {
|
||||
"type": "string",
|
||||
"pattern": "^[\\w\\-.]*$",
|
||||
"description": "The bundle name for this extra entry point."
|
||||
},
|
||||
"inject": {
|
||||
"type": "boolean",
|
||||
"description": "If the bundle will be referenced in the HTML file.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["input"]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The file to include.",
|
||||
"pattern": "\\.(?:css|scss|sass|less)$"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"inlineStyleLanguage": {
|
||||
"description": "The stylesheet language to use for the application's inline component styles.",
|
||||
"type": "string",
|
||||
"default": "css",
|
||||
"enum": ["css", "less", "sass", "scss"]
|
||||
},
|
||||
"stylePreprocessorOptions": {
|
||||
"description": "Options to pass to style preprocessors.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"includePaths": {
|
||||
"description": "Paths to include. Paths will be resolved to workspace root.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"sass": {
|
||||
"description": "Options to pass to the sass preprocessor.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fatalDeprecations": {
|
||||
"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.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"silenceDeprecations": {
|
||||
"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.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"futureDeprecations": {
|
||||
"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.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"externalDependencies": {
|
||||
"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.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"loader": {
|
||||
"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.",
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^\\.\\S+$": { "enum": ["text", "binary", "file", "empty"] }
|
||||
}
|
||||
},
|
||||
"define": {
|
||||
"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.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"include": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["**/*.spec.ts"],
|
||||
"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."
|
||||
},
|
||||
"exclude": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "Globs of files to exclude, relative to the project root."
|
||||
},
|
||||
"sourceMap": {
|
||||
"description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.",
|
||||
"default": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scripts": {
|
||||
"type": "boolean",
|
||||
"description": "Output source maps for all scripts.",
|
||||
"default": true
|
||||
},
|
||||
"styles": {
|
||||
"type": "boolean",
|
||||
"description": "Output source maps for all styles.",
|
||||
"default": true
|
||||
},
|
||||
"vendor": {
|
||||
"type": "boolean",
|
||||
"description": "Resolve vendor packages source maps.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
]
|
||||
},
|
||||
"progress": {
|
||||
"type": "boolean",
|
||||
"description": "Log progress to the console while building.",
|
||||
"default": true
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"description": "Run build when files change."
|
||||
},
|
||||
"poll": {
|
||||
"type": "number",
|
||||
"description": "Enable and define the file watching poll time period in milliseconds."
|
||||
},
|
||||
"preserveSymlinks": {
|
||||
"type": "boolean",
|
||||
"description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set."
|
||||
},
|
||||
"browsers": {
|
||||
"description": "Override which browsers tests are run against. Set to `false` to not use any browser.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "A comma seperate list of browsers to run tests against."
|
||||
},
|
||||
{
|
||||
"const": false,
|
||||
"type": "boolean",
|
||||
"description": "Does use run tests against a browser."
|
||||
}
|
||||
]
|
||||
},
|
||||
"codeCoverage": {
|
||||
"type": "boolean",
|
||||
"description": "Output a code coverage report.",
|
||||
"default": false
|
||||
},
|
||||
"codeCoverageExclude": {
|
||||
"type": "array",
|
||||
"description": "Globs to exclude from code coverage.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"fileReplacements": {
|
||||
"description": "Replace compilation source files with other compilation source files in the build.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/fileReplacement"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"reporters": {
|
||||
"type": "array",
|
||||
"description": "Karma reporters to use. Directly passed to the karma runner.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"webWorkerTsConfig": {
|
||||
"type": "string",
|
||||
"description": "TypeScript configuration for Web Worker modules."
|
||||
},
|
||||
"aot": {
|
||||
"type": "boolean",
|
||||
"description": "Run tests using Ahead of Time compilation.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["tsConfig"],
|
||||
"definitions": {
|
||||
"assetPattern": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "The pattern to match."
|
||||
},
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Absolute path within the output."
|
||||
},
|
||||
"ignore": {
|
||||
"description": "An array of globs to ignore.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["glob", "input"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fileReplacement": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"replace": {
|
||||
"type": "string",
|
||||
"pattern": "\\.(([cm]?[jt])sx?|json)$"
|
||||
},
|
||||
"with": {
|
||||
"type": "string",
|
||||
"pattern": "\\.(([cm]?[jt])sx?|json)$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["replace", "with"]
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { tags } from '@angular-devkit/core';
|
||||
import { last, tap } from 'rxjs';
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
// In each of the test below we'll have to call setTimeout to wait for the coverage
|
||||
// analysis to be done. This is because karma-coverage performs the analysis
|
||||
// asynchronously but the promise that it returns is not awaited by Karma.
|
||||
// Coverage analysis begins when onRunComplete() is invoked, and output files
|
||||
// are subsequently written to disk. For more information, see
|
||||
// https://github.com/karma-runner/karma-coverage/blob/32acafa90ed621abd1df730edb44ae55a4009c2c/lib/reporter.js#L221
|
||||
|
||||
const coveragePath = 'coverage/lcov.info';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Behavior: "codeCoverage"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('should generate coverage report when file was previously processed by Babel', async () => {
|
||||
// Force Babel transformation.
|
||||
await harness.appendToFile('src/app/app.component.ts', '// async');
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).toExist();
|
||||
});
|
||||
|
||||
it('should exit with non-zero code when coverage is below threshold', async () => {
|
||||
await harness.modifyFile('karma.conf.js', (content) =>
|
||||
content.replace(
|
||||
'coverageReporter: {',
|
||||
`coverageReporter: {
|
||||
check: {
|
||||
global: {
|
||||
statements: 100,
|
||||
lines: 100,
|
||||
branches: 100,
|
||||
functions: 100
|
||||
}
|
||||
},
|
||||
`,
|
||||
),
|
||||
);
|
||||
|
||||
await harness.appendToFile(
|
||||
'src/app/app.component.ts',
|
||||
`
|
||||
export function nonCovered(): boolean {
|
||||
return true;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
});
|
||||
|
||||
await harness
|
||||
.execute()
|
||||
.pipe(
|
||||
// In incremental mode, karma-coverage does not have the ability to mark a
|
||||
// run as failed if code coverage does not pass. This is because it does
|
||||
// the coverage asynchoronously and Karma does not await the promise
|
||||
// returned by the plugin.
|
||||
|
||||
// However the program must exit with non-zero exit code.
|
||||
// This is a more common use case of coverage testing and must be supported.
|
||||
last(),
|
||||
tap((buildEvent) => expect(buildEvent.result?.success).toBeFalse()),
|
||||
)
|
||||
.toPromise();
|
||||
});
|
||||
|
||||
it('should remapped instrumented code back to the original source', async () => {
|
||||
await harness.modifyFile('karma.conf.js', (content) => content.replace('lcov', 'html'));
|
||||
|
||||
await harness.modifyFile('src/app/app.component.ts', (content) => {
|
||||
return content.replace(
|
||||
`title = 'app'`,
|
||||
tags.stripIndents`
|
||||
title = 'app';
|
||||
|
||||
async foo() {
|
||||
return 'foo';
|
||||
}
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
|
||||
harness
|
||||
.expectFile('coverage/app.component.ts.html')
|
||||
.content.toContain(
|
||||
'<span class="fstat-no" title="function not covered" >async </span>foo()',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Behavior: "Errors"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('should fail when there is a TypeScript error', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
await harness.appendToFile('src/app/app.component.spec.ts', `console.lo('foo')`);
|
||||
|
||||
const { result } = await harness.executeOnce({
|
||||
outputLogsOnFailure: false,
|
||||
});
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Behavior: "fakeAsync"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('loads zone.js/testing at the right time', async () => {
|
||||
await harness.writeFiles({
|
||||
'./src/app/app.component.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: false,
|
||||
template: '<button (click)="changeMessage()" class="change">{{ message }}</button>',
|
||||
})
|
||||
export class AppComponent {
|
||||
message = 'Initial';
|
||||
|
||||
changeMessage() {
|
||||
setTimeout(() => {
|
||||
this.message = 'Changed';
|
||||
}, 1000);
|
||||
}
|
||||
}`,
|
||||
'./src/app/app.component.spec.ts': `
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
declarations: [AppComponent]
|
||||
}));
|
||||
|
||||
it('allows terrible things that break the most basic assumptions', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
|
||||
const btn = fixture.debugElement
|
||||
.query(By.css('button.change'));
|
||||
|
||||
fixture.detectChanges();
|
||||
expect(btn.nativeElement.innerText).toBe('Initial');
|
||||
|
||||
btn.triggerEventHandler('click', null);
|
||||
|
||||
// Pre-tick: Still the old value.
|
||||
fixture.detectChanges();
|
||||
expect(btn.nativeElement.innerText).toBe('Initial');
|
||||
|
||||
tick(1500);
|
||||
|
||||
fixture.detectChanges();
|
||||
expect(btn.nativeElement.innerText).toBe('Changed');
|
||||
}));
|
||||
});`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Behavior: "jasmine.clock()"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('can install and uninstall the mock clock', async () => {
|
||||
await harness.writeFiles({
|
||||
'./src/app/app.component.spec.ts': `
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('Using jasmine.clock()', () => {
|
||||
beforeEach(async () => {
|
||||
jasmine.clock().install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('runs a basic test case', () => {
|
||||
expect(!!AppComponent).toBe(true);
|
||||
});
|
||||
});`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Behavior: "module commonjs"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('should work when module is commonjs', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
await harness.modifyFile('src/tsconfig.spec.json', (content) => {
|
||||
const tsConfig = JSON.parse(content);
|
||||
tsConfig.compilerOptions.module = 'commonjs';
|
||||
|
||||
return JSON.stringify(tsConfig);
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { concatMap, count, debounceTime, distinctUntilChanged, take, timeout } from 'rxjs';
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
import { BuilderOutput } from '@angular-devkit/architect';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Behavior: "Rebuilds"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('recovers from compilation failures in watch mode', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
watch: true,
|
||||
});
|
||||
|
||||
const goodFile = await harness.readFile('src/app/app.component.spec.ts');
|
||||
|
||||
interface OutputCheck {
|
||||
(result: BuilderOutput | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
const expectedSequence: OutputCheck[] = [
|
||||
async (result) => {
|
||||
// Karma run should succeed.
|
||||
// Add a compilation error.
|
||||
expect(result?.success).withContext('Initial test run should succeed').toBeTrue();
|
||||
// Add an syntax error to a non-main file.
|
||||
await harness.appendToFile('src/app/app.component.spec.ts', `error`);
|
||||
},
|
||||
async (result) => {
|
||||
expect(result?.success)
|
||||
.withContext('Test should fail after build error was introduced')
|
||||
.toBeFalse();
|
||||
await harness.writeFile('src/app/app.component.spec.ts', goodFile);
|
||||
},
|
||||
async (result) => {
|
||||
expect(result?.success)
|
||||
.withContext('Test should succeed again after build error was fixed')
|
||||
.toBeTrue();
|
||||
},
|
||||
];
|
||||
|
||||
const buildCount = await harness
|
||||
.execute({ outputLogsOnFailure: false })
|
||||
.pipe(
|
||||
timeout(60000),
|
||||
debounceTime(500),
|
||||
// There may be a sequence of {success:true} events that should be
|
||||
// de-duplicated.
|
||||
distinctUntilChanged((prev, current) => prev.result?.success === current.result?.success),
|
||||
concatMap(async ({ result }, index) => {
|
||||
await expectedSequence[index](result);
|
||||
}),
|
||||
take(expectedSequence.length),
|
||||
count(),
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
expect(buildCount).toBe(expectedSequence.length);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApp) => {
|
||||
describe('Behavior: "Specs"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('supports multiple spec files with same basename', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
const collidingBasename = 'collision.spec.ts';
|
||||
|
||||
// src/app/app.component.spec.ts conflicts with this one:
|
||||
await harness.writeFiles({
|
||||
[`src/app/a/foo-bar/${collidingBasename}`]: `/** Success! */`,
|
||||
[`src/app/a-foo/bar/${collidingBasename}`]: `/** Success! */`,
|
||||
[`src/app/a-foo-bar/${collidingBasename}`]: `/** Success! */`,
|
||||
[`src/app/b/${collidingBasename}`]: `/** Success! */`,
|
||||
});
|
||||
|
||||
const { result, logs } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
if (isApp) {
|
||||
const bundleLog = logs.find((log) =>
|
||||
log.message.includes('Application bundle generation complete.'),
|
||||
);
|
||||
expect(bundleLog?.message).toContain('spec-app-a-foo-bar-collision.spec.js');
|
||||
expect(bundleLog?.message).toContain('spec-app-a-foo-bar-collision-2.spec.js');
|
||||
expect(bundleLog?.message).toContain('spec-app-a-foo-bar-collision-3.spec.js');
|
||||
expect(bundleLog?.message).toContain('spec-app-b-collision.spec.js');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "aot"', () => {
|
||||
it('enables aot', async () => {
|
||||
await setupTarget(harness);
|
||||
|
||||
await harness.writeFiles({
|
||||
'src/aot.spec.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
describe('Hello', () => {
|
||||
it('should *not* contain jit instructions', () => {
|
||||
@Component({
|
||||
template: 'Hello',
|
||||
})
|
||||
class Hello {}
|
||||
|
||||
expect((Hello as any).ɵcmp.template.toString()).not.toContain('jit');
|
||||
});
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
aot: true,
|
||||
/** Cf. {@link ../builder-mode_spec.ts} */
|
||||
polyfills: ['zone.js', '@angular/localize/init', 'zone.js/testing'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "assets"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('includes assets', async () => {
|
||||
await harness.writeFiles({
|
||||
'./src/string-file-asset.txt': 'string-file-asset.txt',
|
||||
'./src/string-folder-asset/file.txt': 'string-folder-asset.txt',
|
||||
'./src/glob-asset.txt': 'glob-asset.txt',
|
||||
'./src/folder/folder-asset.txt': 'folder-asset.txt',
|
||||
'./src/output-asset.txt': 'output-asset.txt',
|
||||
'./src/app/app.module.ts': `
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
HttpClientModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
`,
|
||||
'./src/app/app.component.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: false,
|
||||
template: '<p *ngFor="let asset of assets">{{ asset.content }}</p>'
|
||||
})
|
||||
export class AppComponent {
|
||||
public assets = [
|
||||
{ path: './string-file-asset.txt', content: '' },
|
||||
{ path: './string-folder-asset/file.txt', content: '' },
|
||||
{ path: './glob-asset.txt', content: '' },
|
||||
{ path: './folder/folder-asset.txt', content: '' },
|
||||
{ path: './output-folder/output-asset.txt', content: '' },
|
||||
];
|
||||
constructor(private http: HttpClient) {
|
||||
this.assets.forEach(asset => http.get(asset.path, { responseType: 'text' })
|
||||
.subscribe(res => asset.content = res));
|
||||
}
|
||||
}`,
|
||||
'./src/app/app.component.spec.ts': `
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
declarations: [AppComponent]
|
||||
}));
|
||||
|
||||
it('should create the app', async () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
await fixture.whenStable();
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
assets: [
|
||||
'src/string-file-asset.txt',
|
||||
'src/string-folder-asset',
|
||||
{ glob: 'glob-asset.txt', input: 'src/', output: '/' },
|
||||
{ glob: 'output-asset.txt', input: 'src/', output: '/output-folder' },
|
||||
{ glob: '**/*', input: 'src/folder', output: '/folder' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
// In each of the test below we'll have to call setTimeout to wait for the coverage
|
||||
// analysis to be done. This is because karma-coverage performs the analysis
|
||||
// asynchronously but the promise that it returns is not awaited by Karma.
|
||||
// Coverage analysis begins when onRunComplete() is invoked, and output files
|
||||
// are subsequently written to disk. For more information, see
|
||||
// https://github.com/karma-runner/karma-coverage/blob/32acafa90ed621abd1df730edb44ae55a4009c2c/lib/reporter.js#L221
|
||||
|
||||
const coveragePath = 'coverage/lcov.info';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "codeCoverageExclude"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('should exclude file from coverage when set', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
codeCoverageExclude: ['**/app.component.ts'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).content.not.toContain('app.component.ts');
|
||||
});
|
||||
|
||||
it('should exclude file from coverage when set when glob starts with a forward slash', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
codeCoverageExclude: ['/**/app.component.ts'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).content.not.toContain('app.component.ts');
|
||||
});
|
||||
|
||||
it('should not exclude file from coverage when set', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).content.toContain('app.component.ts');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
// In each of the test below we'll have to call setTimeout to wait for the coverage
|
||||
// analysis to be done. This is because karma-coverage performs the analysis
|
||||
// asynchronously but the promise that it returns is not awaited by Karma.
|
||||
// Coverage analysis begins when onRunComplete() is invoked, and output files
|
||||
// are subsequently written to disk. For more information, see
|
||||
// https://github.com/karma-runner/karma-coverage/blob/32acafa90ed621abd1df730edb44ae55a4009c2c/lib/reporter.js#L221
|
||||
|
||||
const coveragePath = 'coverage/lcov.info';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "codeCoverage"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it('should generate coverage report when option is set to true', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).toExist();
|
||||
});
|
||||
|
||||
it('should not generate coverage report when option is set to false', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: false,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).toNotExist();
|
||||
});
|
||||
|
||||
it('should not generate coverage report when option is unset', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).toNotExist();
|
||||
});
|
||||
|
||||
it(`should collect coverage from paths in 'sourceRoot'`, async () => {
|
||||
await harness.writeFiles({
|
||||
'./dist/my-lib/index.d.ts': `
|
||||
export declare const title = 'app';
|
||||
`,
|
||||
'./dist/my-lib/index.js': `
|
||||
export const title = 'app';
|
||||
`,
|
||||
'./src/app/app.component.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
import { title } from 'my-lib';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: false,
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
})
|
||||
export class AppComponent {
|
||||
title = title;
|
||||
}
|
||||
`,
|
||||
});
|
||||
await harness.modifyFile('tsconfig.json', (content) =>
|
||||
content.replace(
|
||||
/"baseUrl": ".\/",/,
|
||||
`
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"my-lib": [
|
||||
"./dist/my-lib"
|
||||
]
|
||||
},
|
||||
`,
|
||||
),
|
||||
);
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
codeCoverage: true,
|
||||
});
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
|
||||
await setTimeout(1000);
|
||||
harness.expectFile(coveragePath).content.not.toContain('my-lib');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "exclude"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.writeFiles({
|
||||
'src/app/error.spec.ts': `
|
||||
describe('Error spec', () => {
|
||||
it('should error', () => {
|
||||
expect(false).toBe(true);
|
||||
});
|
||||
});`,
|
||||
});
|
||||
});
|
||||
|
||||
it(`should not exclude any spec when exclude is not supplied`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeFalse();
|
||||
});
|
||||
|
||||
it(`should exclude spec that matches the 'exclude' glob pattern`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
exclude: ['**/error.spec.ts'],
|
||||
});
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
|
||||
it(`should exclude spec that matches the 'exclude' pattern with a relative project root`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
exclude: ['src/app/error.spec.ts'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
|
||||
it(`should exclude spec that matches the 'exclude' pattern prefixed with a slash`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
exclude: ['/src/app/error.spec.ts'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "include"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it(`should fail when includes doesn't match any files`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
include: ['abc.spec.ts', 'def.spec.ts'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeFalse();
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
test: 'relative path from workspace to spec',
|
||||
input: ['src/app/app.component.spec.ts'],
|
||||
},
|
||||
{
|
||||
test: 'relative path from workspace to file',
|
||||
input: ['src/app/app.component.ts'],
|
||||
},
|
||||
{
|
||||
test: 'relative path from project root to spec',
|
||||
input: ['app/services/test.service.spec.ts'],
|
||||
},
|
||||
{
|
||||
test: 'relative path from project root to file',
|
||||
input: ['app/services/test.service.ts'],
|
||||
},
|
||||
{
|
||||
test: 'relative path from workspace to directory',
|
||||
input: ['src/app/services'],
|
||||
},
|
||||
{
|
||||
test: 'relative path from project root to directory',
|
||||
input: ['app/services'],
|
||||
},
|
||||
{
|
||||
test: 'glob with spec suffix',
|
||||
input: ['**/*.pipe.spec.ts', '**/*.pipe.spec.ts', '**/*test.service.spec.ts'],
|
||||
},
|
||||
{
|
||||
test: 'glob with forward slash and spec suffix',
|
||||
input: ['/**/*test.service.spec.ts'],
|
||||
},
|
||||
].forEach((options, index) => {
|
||||
it(`should work with ${options.test} (${index})`, async () => {
|
||||
await harness.writeFiles({
|
||||
'src/app/services/test.service.spec.ts': `
|
||||
describe('TestService', () => {
|
||||
it('should succeed', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});`,
|
||||
'src/app/failing.service.spec.ts': `
|
||||
describe('FailingService', () => {
|
||||
it('should be ignored', () => {
|
||||
expect(true).toBe(false);
|
||||
});
|
||||
});`,
|
||||
'src/app/property.pipe.spec.ts': `
|
||||
describe('PropertyPipe', () => {
|
||||
it('should succeed', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
include: options.input,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "main"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.writeFiles({
|
||||
'src/magic.ts': `Object.assign(globalThis, {MAGIC_IS_REAL: true});`,
|
||||
'src/magic.spec.ts': `
|
||||
declare const MAGIC_IS_REAL: boolean;
|
||||
describe('Magic', () => {
|
||||
it('can be scientificially proven to be true', () => {
|
||||
expect(typeof MAGIC_IS_REAL).toBe('boolean');
|
||||
});
|
||||
});`,
|
||||
});
|
||||
// Remove this test, we don't expect it to pass with our setup script.
|
||||
await harness.removeFile('src/app/app.component.spec.ts');
|
||||
|
||||
// Add src/magic.ts to tsconfig.
|
||||
interface TsConfig {
|
||||
files: string[];
|
||||
}
|
||||
const tsConfig = JSON.parse(harness.readFile('src/tsconfig.spec.json')) as TsConfig;
|
||||
tsConfig.files.push('magic.ts');
|
||||
await harness.writeFile('src/tsconfig.spec.json', JSON.stringify(tsConfig));
|
||||
});
|
||||
|
||||
it('uses custom setup file', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
main: './src/magic.ts',
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
|
||||
describe('Option: "styles"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
it(`processes 'styles.css' styles`, async () => {
|
||||
await harness.writeFiles({
|
||||
'src/styles.css': 'p {display: none}',
|
||||
'src/app/app.component.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: false,
|
||||
template: '<p>Hello World</p>'
|
||||
})
|
||||
export class AppComponent {
|
||||
}
|
||||
`,
|
||||
'src/app/app.component.spec.ts': `
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
declarations: [AppComponent]
|
||||
}));
|
||||
|
||||
it('should not contain text that is hidden via css', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
expect(fixture.nativeElement.innerText).not.toContain('Hello World');
|
||||
});
|
||||
});`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
styles: ['src/styles.css'],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
|
||||
it('processes style with bundleName', async () => {
|
||||
await harness.writeFiles({
|
||||
'src/dark-theme.css': '',
|
||||
'src/app/app.module.ts': `
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { AppComponent } from './app.component';
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
HttpClientModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
`,
|
||||
'src/app/app.component.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: false,
|
||||
template: '<p *ngFor="let asset of css">{{ asset.content }}</p>'
|
||||
})
|
||||
export class AppComponent {
|
||||
public assets = [
|
||||
{ path: './dark-theme.css', content: '' },
|
||||
];
|
||||
constructor(private http: HttpClient) {
|
||||
this.assets.forEach(asset => http.get(asset.path, { responseType: 'text' })
|
||||
.subscribe(res => asset.content = res));
|
||||
}
|
||||
}`,
|
||||
'src/app/app.component.spec.ts': `
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { AppComponent } from './app.component';
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
imports: [HttpClientModule],
|
||||
declarations: [AppComponent]
|
||||
}));
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});`,
|
||||
});
|
||||
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
styles: [
|
||||
{
|
||||
inject: false,
|
||||
input: 'src/dark-theme.css',
|
||||
bundleName: 'dark-theme',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
|
||||
it('fails and shows an error if style does not exist', async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
styles: ['src/test-style-a.css'],
|
||||
});
|
||||
|
||||
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(logs).toContain(
|
||||
jasmine.objectContaining({
|
||||
level: 'error',
|
||||
message: jasmine.stringMatching(
|
||||
/(Can't|Could not) resolve ['"]src\/test-style-a.css['"]/,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { execute } from '../../index';
|
||||
import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup';
|
||||
|
||||
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
|
||||
describe('Option: "webWorkerTsConfig"', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTarget(harness);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.writeFiles({
|
||||
'src/tsconfig.worker.json': `
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/worker",
|
||||
"lib": [
|
||||
"es2018",
|
||||
"webworker"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"**/*.worker.ts",
|
||||
]
|
||||
}`,
|
||||
'src/app/app.worker.ts': `
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const prefix: string = 'Data: ';
|
||||
addEventListener('message', ({ data }) => {
|
||||
postMessage(prefix + data);
|
||||
});
|
||||
`,
|
||||
'src/app/app.component.ts': `
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: false,
|
||||
template: ''
|
||||
})
|
||||
export class AppComponent {
|
||||
worker = new Worker(new URL('./app.worker', import.meta.url));
|
||||
}
|
||||
`,
|
||||
'./src/app/app.component.spec.ts': `
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
declarations: [AppComponent]
|
||||
}));
|
||||
|
||||
it('worker should be defined', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app.worker).toBeDefined();
|
||||
});
|
||||
});`,
|
||||
});
|
||||
});
|
||||
|
||||
// Web workers work with the application builder _without_ setting webWorkerTsConfig.
|
||||
if (isApplicationBuilder) {
|
||||
it(`should parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
webWorkerTsConfig: undefined,
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
} else {
|
||||
it(`should not parse web workers when "webWorkerTsConfig" is not set or set to undefined.`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
webWorkerTsConfig: undefined,
|
||||
});
|
||||
|
||||
await harness.writeFile(
|
||||
'./src/app/app.component.spec.ts',
|
||||
`
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({
|
||||
declarations: [AppComponent]
|
||||
}));
|
||||
|
||||
it('worker should throw', () => {
|
||||
expect(() => TestBed.createComponent(AppComponent))
|
||||
.toThrowError(/Failed to construct 'Worker'/);
|
||||
});
|
||||
});`,
|
||||
);
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
it(`should parse web workers when "webWorkerTsConfig" is set.`, async () => {
|
||||
harness.useTarget('test', {
|
||||
...BASE_OPTIONS,
|
||||
webWorkerTsConfig: 'src/tsconfig.worker.json',
|
||||
});
|
||||
|
||||
const { result } = await harness.executeOnce();
|
||||
expect(result?.success).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
142
packages/angular/build/src/builders/karma/tests/setup.ts
Normal file
142
packages/angular/build/src/builders/karma/tests/setup.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { Schema } from '../schema';
|
||||
import { BuilderHandlerFn } from '@angular-devkit/architect';
|
||||
import { json } from '@angular-devkit/core';
|
||||
import { ApplicationBuilderOptions as ApplicationSchema, buildApplication } from '@angular/build';
|
||||
import * as path from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
BuilderHarness,
|
||||
host,
|
||||
JasmineBuilderHarness,
|
||||
} from '../../../../../../../modules/testing/builder/src';
|
||||
|
||||
// TODO: Consider using package.json imports field instead of relative path
|
||||
// after the switch to rules_js.
|
||||
export * from '../../../../../../../modules/testing/builder/src';
|
||||
|
||||
export const KARMA_BUILDER_INFO = Object.freeze({
|
||||
name: '@angular/build:karma',
|
||||
schemaPath: __dirname + '/../schema.json',
|
||||
});
|
||||
|
||||
/**
|
||||
* Contains all required karma builder fields.
|
||||
* Also disables progress reporting to minimize logging output.
|
||||
*/
|
||||
export const BASE_OPTIONS = Object.freeze<Schema>({
|
||||
polyfills: ['./src/polyfills', 'zone.js/testing'],
|
||||
tsConfig: 'src/tsconfig.spec.json',
|
||||
karmaConfig: 'karma.conf.js',
|
||||
browsers: 'ChromeHeadlessCI',
|
||||
progress: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
const optionSchemaCache = new Map<string, json.schema.JsonSchema>();
|
||||
|
||||
function getCachedSchema(options: { schemaPath: string }): json.schema.JsonSchema {
|
||||
let optionSchema = optionSchemaCache.get(options.schemaPath);
|
||||
if (optionSchema === undefined) {
|
||||
optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema;
|
||||
optionSchemaCache.set(options.schemaPath, optionSchema);
|
||||
}
|
||||
return optionSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains all required application builder fields.
|
||||
* Also disables progress reporting to minimize logging output.
|
||||
*/
|
||||
export const APPLICATION_BASE_OPTIONS = Object.freeze<ApplicationSchema>({
|
||||
index: 'src/index.html',
|
||||
browser: 'src/main.ts',
|
||||
outputPath: 'dist',
|
||||
tsConfig: 'src/tsconfig.app.json',
|
||||
progress: false,
|
||||
|
||||
// Disable optimizations
|
||||
optimization: false,
|
||||
|
||||
// Enable polling (if a test enables watch mode).
|
||||
// This is a workaround for bazel isolation file watch not triggering in tests.
|
||||
poll: 100,
|
||||
});
|
||||
|
||||
// TODO: Remove and use import after Vite-based dev server is moved to new package
|
||||
export const APPLICATION_BUILDER_INFO = Object.freeze({
|
||||
name: '@angular/build:application',
|
||||
schemaPath: path.join(
|
||||
path.dirname(require.resolve('@angular/build/package.json')),
|
||||
'src/builders/application/schema.json',
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a `build` target to a builder test harness for the application builder with the base options
|
||||
* used by the application builder tests.
|
||||
*
|
||||
* @param harness The builder harness to use when setting up the application builder target
|
||||
* @param extraOptions The additional options that should be used when executing the target.
|
||||
*/
|
||||
export async function setupApplicationTarget<T>(
|
||||
harness: BuilderHarness<T>,
|
||||
extraOptions?: Partial<ApplicationSchema>,
|
||||
): Promise<void> {
|
||||
const applicationSchema = getCachedSchema(APPLICATION_BUILDER_INFO);
|
||||
|
||||
harness.withBuilderTarget(
|
||||
'build',
|
||||
buildApplication,
|
||||
{
|
||||
...APPLICATION_BASE_OPTIONS,
|
||||
...extraOptions,
|
||||
},
|
||||
{
|
||||
builderName: APPLICATION_BUILDER_INFO.name,
|
||||
optionSchema: applicationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
// For application-builder based targets, the localize polyfill needs to be explicit.
|
||||
await harness.appendToFile('src/polyfills.ts', `import '@angular/localize/init';`);
|
||||
}
|
||||
|
||||
/** Runs the test against both an application- and a browser-builder context. */
|
||||
export function describeKarmaBuilder<T>(
|
||||
builderHandler: BuilderHandlerFn<T & json.JsonObject>,
|
||||
options: { name?: string; schemaPath: string },
|
||||
specDefinitions: (
|
||||
harness: JasmineBuilderHarness<T>,
|
||||
setupTarget: typeof setupApplicationTarget,
|
||||
isApplicationTarget: true,
|
||||
) => void,
|
||||
) {
|
||||
const optionSchema = getCachedSchema(options);
|
||||
const harness = new JasmineBuilderHarness<T>(builderHandler, host, {
|
||||
builderName: options.name,
|
||||
optionSchema,
|
||||
});
|
||||
|
||||
describe(options.name || builderHandler.name, () => {
|
||||
beforeEach(async () => {
|
||||
await host.initialize().toPromise();
|
||||
|
||||
await harness.modifyFile('karma.conf.js', (content) => {
|
||||
return content
|
||||
.replace(`, '@angular-devkit/build-angular'`, '')
|
||||
.replace(`require('@angular-devkit/build-angular/plugins/karma'),`, '');
|
||||
});
|
||||
});
|
||||
afterEach(() => host.restore().toPromise());
|
||||
|
||||
specDefinitions(harness, setupApplicationTarget, true);
|
||||
});
|
||||
}
|
@ -26,6 +26,7 @@ export { buildApplicationInternal } from './builders/application';
|
||||
export type { ApplicationBuilderInternalOptions } from './builders/application/options';
|
||||
export { type Result, type ResultFile, ResultKind } from './builders/application/results';
|
||||
export { serveWithVite } from './builders/dev-server/vite-server';
|
||||
export { execute as executeKarmaInternal } from './builders/karma/application_builder';
|
||||
|
||||
// Tools
|
||||
export * from './tools/babel/plugins';
|
||||
@ -82,3 +83,4 @@ export { augmentAppWithServiceWorker } from './utils/service-worker';
|
||||
export { type BundleStats, generateBuildStatsTable } from './utils/stats-table';
|
||||
export { getSupportedBrowsers } from './utils/supported-browsers';
|
||||
export { assertCompatibleAngularVersion } from './utils/version';
|
||||
export { findTests, getTestEntrypoints } from './builders/karma/find-tests';
|
||||
|
@ -405,6 +405,7 @@
|
||||
"@angular/build:application",
|
||||
"@angular/build:dev-server",
|
||||
"@angular/build:extract-i18n",
|
||||
"@angular/build:karma",
|
||||
"@angular/build:ng-packagr",
|
||||
"@angular-devkit/build-angular:application",
|
||||
"@angular-devkit/build-angular:app-shell",
|
||||
@ -638,6 +639,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"builder": {
|
||||
"const": "@angular/build:karma"
|
||||
},
|
||||
"defaultConfiguration": {
|
||||
"type": "string",
|
||||
"description": "A default named configuration to use when a target configuration is not provided."
|
||||
},
|
||||
"options": {
|
||||
"$ref": "../../../../angular/build/src/builders/karma/schema.json"
|
||||
},
|
||||
"configurations": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "../../../../angular/build/src/builders/karma/schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
@ -6,12 +6,11 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import { findTests } from '@angular/build/private';
|
||||
import { pluginName } from 'mini-css-extract-plugin';
|
||||
import assert from 'node:assert';
|
||||
import type { Compilation, Compiler } from 'webpack';
|
||||
|
||||
import { findTests } from './find-tests';
|
||||
|
||||
/**
|
||||
* The name of the plugin provided to Webpack when tapping Webpack compiler hooks.
|
||||
*/
|
||||
|
@ -20,6 +20,7 @@ import * as path from 'node:path';
|
||||
import { Observable, from, mergeMap } from 'rxjs';
|
||||
import { Configuration } from 'webpack';
|
||||
import { ExecutionTransformer } from '../../transforms';
|
||||
import { normalizeFileReplacements } from '../../utils';
|
||||
import { BuilderMode, Schema as KarmaBuilderOptions } from './schema';
|
||||
|
||||
export type KarmaConfigOptions = ConfigOptions & {
|
||||
@ -46,7 +47,18 @@ export function execute(
|
||||
mergeMap(([useEsbuild, executeWithBuilder]) => {
|
||||
const karmaOptions = getBaseKarmaOptions(options, context, useEsbuild);
|
||||
|
||||
return executeWithBuilder.execute(options, context, karmaOptions, transforms);
|
||||
if (useEsbuild && transforms.webpackConfiguration) {
|
||||
context.logger.warn(
|
||||
`This build is using the application builder but transforms.webpackConfiguration was provided. The transform will be ignored.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (useEsbuild && options.fileReplacements) {
|
||||
options.fileReplacements = normalizeFileReplacements(options.fileReplacements, './');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return executeWithBuilder(options as any, context, karmaOptions, transforms);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -155,13 +167,26 @@ export default createBuilder<Record<string, string> & KarmaBuilderOptions>(execu
|
||||
async function getExecuteWithBuilder(
|
||||
options: KarmaBuilderOptions,
|
||||
context: BuilderContext,
|
||||
): Promise<[boolean, typeof import('./application_builder') | typeof import('./browser_builder')]> {
|
||||
): Promise<
|
||||
[
|
||||
boolean,
|
||||
(
|
||||
| (typeof import('@angular/build/private'))['executeKarmaInternal']
|
||||
| (typeof import('./browser_builder'))['execute']
|
||||
),
|
||||
]
|
||||
> {
|
||||
const useEsbuild = await checkForEsbuild(options, context);
|
||||
const executeWithBuilderModule = useEsbuild
|
||||
? import('./application_builder')
|
||||
: import('./browser_builder');
|
||||
let execute;
|
||||
if (useEsbuild) {
|
||||
const { executeKarmaInternal } = await import('@angular/build/private');
|
||||
execute = executeKarmaInternal;
|
||||
} else {
|
||||
const browserBuilderModule = await import('./browser_builder');
|
||||
execute = browserBuilderModule.execute;
|
||||
}
|
||||
|
||||
return [useEsbuild, await executeWithBuilderModule];
|
||||
return [useEsbuild, execute];
|
||||
}
|
||||
|
||||
async function checkForEsbuild(
|
||||
|
Loading…
x
Reference in New Issue
Block a user