From 11fab9c7dde950e46b2a23d239bb9e29b20f5eff Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:09:22 -0500 Subject: [PATCH] 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 --- .../npm_translate_lock_MzA5NzUwNzMx | 2 +- packages/angular/build/BUILD.bazel | 54 +++ packages/angular/build/builders.json | 5 + packages/angular/build/package.json | 4 + .../src/builders/karma/application_builder.ts | 112 +++--- .../build}/src/builders/karma/find-tests.ts | 0 .../src/builders/karma/find-tests_spec.ts | 0 .../angular/build/src/builders/karma/index.ts | 139 +++++++ .../karma/polyfills}/init_test_bed.js | 0 .../karma/polyfills}/jasmine_global.js | 0 .../polyfills}/jasmine_global_cleanup.js | 0 .../build/src/builders/karma/schema.json | 347 ++++++++++++++++++ .../tests/behavior/code-coverage_spec.ts | 126 +++++++ .../karma/tests/behavior/errors_spec.ts | 32 ++ .../karma/tests/behavior/fake-async_spec.ts | 78 ++++ .../tests/behavior/jasmine-clock_spec.ts | 46 +++ .../karma/tests/behavior/module-cjs_spec.ts | 35 ++ .../karma/tests/behavior/rebuilds_spec.ts | 72 ++++ .../karma/tests/behavior/specs_spec.ts | 48 +++ .../builders/karma/tests/options/aot_spec.ts | 45 +++ .../karma/tests/options/assets_spec.ts | 101 +++++ .../options/code-coverage-exclude_spec.ts | 71 ++++ .../karma/tests/options/code-coverage_spec.ts | 116 ++++++ .../karma/tests/options/exclude_spec.ts | 67 ++++ .../karma/tests/options/include_spec.ts | 94 +++++ .../builders/karma/tests/options/main_spec.ts | 51 +++ .../karma/tests/options/styles_spec.ts | 146 ++++++++ .../tests/options/web-worker-tsconfig_spec.ts | 124 +++++++ .../build/src/builders/karma/tests/setup.ts | 142 +++++++ packages/angular/build/src/private.ts | 2 + .../cli/lib/config/workspace-schema.json | 23 ++ .../src/builders/karma/find-tests-plugin.ts | 3 +- .../build_angular/src/builders/karma/index.ts | 37 +- 33 files changed, 2055 insertions(+), 67 deletions(-) rename packages/{angular_devkit/build_angular => angular/build}/src/builders/karma/application_builder.ts (86%) rename packages/{angular_devkit/build_angular => angular/build}/src/builders/karma/find-tests.ts (100%) rename packages/{angular_devkit/build_angular => angular/build}/src/builders/karma/find-tests_spec.ts (100%) create mode 100644 packages/angular/build/src/builders/karma/index.ts rename packages/{angular_devkit/build_angular/src/builders/karma => angular/build/src/builders/karma/polyfills}/init_test_bed.js (100%) rename packages/{angular_devkit/build_angular/src/builders/karma => angular/build/src/builders/karma/polyfills}/jasmine_global.js (100%) rename packages/{angular_devkit/build_angular/src/builders/karma => angular/build/src/builders/karma/polyfills}/jasmine_global_cleanup.js (100%) create mode 100644 packages/angular/build/src/builders/karma/schema.json create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/code-coverage_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/fake-async_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/jasmine-clock_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/module-cjs_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/rebuilds_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/behavior/specs_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/aot_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/assets_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/code-coverage-exclude_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/code-coverage_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/exclude_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/include_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/main_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/styles_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/options/web-worker-tsconfig_spec.ts create mode 100644 packages/angular/build/src/builders/karma/tests/setup.ts diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx b/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx index 4e73264b74..abc07b4e47 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx @@ -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 diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index b9c75e97af..2cc52e6ba2 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -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"], diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json index ef98f535c1..ab3dbb2370 100644 --- a/packages/angular/build/builders.json +++ b/packages/angular/build/builders.json @@ -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", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index de53cfa330..3c89d39c8a 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.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 }, diff --git a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts b/packages/angular/build/src/builders/karma/application_builder.ts similarity index 86% rename from packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts rename to packages/angular/build/src/builders/karma/application_builder.ts index cc90564d9c..0db9497b0c 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts +++ b/packages/angular/build/src/builders/karma/application_builder.ts @@ -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, karmaConfig: Config & ConfigOptions, - subscriber: Subscriber, + controller: ReadableStreamController, ) { 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; // The karma options transform cannot be async without a refactor of the builder implementation karmaOptions?: (options: ConfigOptions) => ConfigOptions; } = {}, -): Observable { - return from(initializeApplication(options, context, karmaOptions, transforms)).pipe( - switchMap( - ([karma, karmaConfig, buildOptions, buildIterator]) => - new Observable((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 { + 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 { @@ -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; karmaOptions?: (options: ConfigOptions) => ConfigOptions; } = {}, ): Promise< [typeof import('karma'), Config & ConfigOptions, BuildOptions, AsyncIterator | 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. diff --git a/packages/angular_devkit/build_angular/src/builders/karma/find-tests.ts b/packages/angular/build/src/builders/karma/find-tests.ts similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/karma/find-tests.ts rename to packages/angular/build/src/builders/karma/find-tests.ts diff --git a/packages/angular_devkit/build_angular/src/builders/karma/find-tests_spec.ts b/packages/angular/build/src/builders/karma/find-tests_spec.ts similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/karma/find-tests_spec.ts rename to packages/angular/build/src/builders/karma/find-tests_spec.ts diff --git a/packages/angular/build/src/builders/karma/index.ts b/packages/angular/build/src/builders/karma/index.ts new file mode 100644 index 0000000000..ca7ee3ed82 --- /dev/null +++ b/packages/angular/build/src/builders/karma/index.ts @@ -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 { + 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((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 { + 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 = createBuilder(execute); + +export default builder; diff --git a/packages/angular_devkit/build_angular/src/builders/karma/init_test_bed.js b/packages/angular/build/src/builders/karma/polyfills/init_test_bed.js similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/karma/init_test_bed.js rename to packages/angular/build/src/builders/karma/polyfills/init_test_bed.js diff --git a/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global.js b/packages/angular/build/src/builders/karma/polyfills/jasmine_global.js similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/karma/jasmine_global.js rename to packages/angular/build/src/builders/karma/polyfills/jasmine_global.js diff --git a/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global_cleanup.js b/packages/angular/build/src/builders/karma/polyfills/jasmine_global_cleanup.js similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/karma/jasmine_global_cleanup.js rename to packages/angular/build/src/builders/karma/polyfills/jasmine_global_cleanup.js diff --git a/packages/angular/build/src/builders/karma/schema.json b/packages/angular/build/src/builders/karma/schema.json new file mode 100644 index 0000000000..cf71252178 --- /dev/null +++ b/packages/angular/build/src/builders/karma/schema.json @@ -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"] + } + } +} diff --git a/packages/angular/build/src/builders/karma/tests/behavior/code-coverage_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/code-coverage_spec.ts new file mode 100644 index 0000000000..835f48724d --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/code-coverage_spec.ts @@ -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( + 'async foo()', + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts new file mode 100644 index 0000000000..366cc2aa20 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/errors_spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/behavior/fake-async_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/fake-async_spec.ts new file mode 100644 index 0000000000..355ddda8ed --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/fake-async_spec.ts @@ -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: '', + }) + 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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/behavior/jasmine-clock_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/jasmine-clock_spec.ts new file mode 100644 index 0000000000..302b549b5d --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/jasmine-clock_spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/behavior/module-cjs_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/module-cjs_spec.ts new file mode 100644 index 0000000000..d5c1c7b3d1 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/module-cjs_spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/behavior/rebuilds_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/rebuilds_spec.ts new file mode 100644 index 0000000000..e740b7adfc --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/rebuilds_spec.ts @@ -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; + } + + 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); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/behavior/specs_spec.ts b/packages/angular/build/src/builders/karma/tests/behavior/specs_spec.ts new file mode 100644 index 0000000000..5cb56abe9b --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/behavior/specs_spec.ts @@ -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'); + } + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/aot_spec.ts b/packages/angular/build/src/builders/karma/tests/options/aot_spec.ts new file mode 100644 index 0000000000..422e66e200 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/aot_spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/assets_spec.ts b/packages/angular/build/src/builders/karma/tests/options/assets_spec.ts new file mode 100644 index 0000000000..b26e30d2da --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/assets_spec.ts @@ -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: '

{{ asset.content }}

' + }) + 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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/code-coverage-exclude_spec.ts b/packages/angular/build/src/builders/karma/tests/options/code-coverage-exclude_spec.ts new file mode 100644 index 0000000000..082275dfd0 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/code-coverage-exclude_spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/code-coverage_spec.ts b/packages/angular/build/src/builders/karma/tests/options/code-coverage_spec.ts new file mode 100644 index 0000000000..a8849ba643 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/code-coverage_spec.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'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/exclude_spec.ts b/packages/angular/build/src/builders/karma/tests/options/exclude_spec.ts new file mode 100644 index 0000000000..08623b689a --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/exclude_spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/include_spec.ts b/packages/angular/build/src/builders/karma/tests/options/include_spec.ts new file mode 100644 index 0000000000..89ad4e3072 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/include_spec.ts @@ -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(); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/main_spec.ts b/packages/angular/build/src/builders/karma/tests/options/main_spec.ts new file mode 100644 index 0000000000..104b15cec3 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/main_spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/styles_spec.ts b/packages/angular/build/src/builders/karma/tests/options/styles_spec.ts new file mode 100644 index 0000000000..6ede50e694 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/styles_spec.ts @@ -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: '

Hello World

' + }) + 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: '

{{ asset.content }}

' + }) + 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['"]/, + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/options/web-worker-tsconfig_spec.ts b/packages/angular/build/src/builders/karma/tests/options/web-worker-tsconfig_spec.ts new file mode 100644 index 0000000000..9c1d6af166 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/options/web-worker-tsconfig_spec.ts @@ -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': ` + /// + + 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(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/karma/tests/setup.ts b/packages/angular/build/src/builders/karma/tests/setup.ts new file mode 100644 index 0000000000..dd0ad34f31 --- /dev/null +++ b/packages/angular/build/src/builders/karma/tests/setup.ts @@ -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({ + 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(); + +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({ + 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( + harness: BuilderHarness, + extraOptions?: Partial, +): Promise { + 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( + builderHandler: BuilderHandlerFn, + options: { name?: string; schemaPath: string }, + specDefinitions: ( + harness: JasmineBuilderHarness, + setupTarget: typeof setupApplicationTarget, + isApplicationTarget: true, + ) => void, +) { + const optionSchema = getCachedSchema(options); + const harness = new JasmineBuilderHarness(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); + }); +} diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts index 1975efaa36..25eb48f22a 100644 --- a/packages/angular/build/src/private.ts +++ b/packages/angular/build/src/private.ts @@ -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'; diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index 5fe427d4f4..0fdf0b19c4 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -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, diff --git a/packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts b/packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts index d6089f9d7b..2c45812611 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts @@ -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. */ diff --git a/packages/angular_devkit/build_angular/src/builders/karma/index.ts b/packages/angular_devkit/build_angular/src/builders/karma/index.ts index 16d308711c..24b8084858 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/index.ts @@ -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 & 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(