From a9a3470147aaf66ff4784a5b5c26c56d1051a5b3 Mon Sep 17 00:00:00 2001 From: Jan Krems Date: Tue, 10 Dec 2024 14:59:05 -0800 Subject: [PATCH] fix(@angular-devkit/build-angular): jasmine.clock with app builder --- .../src/builders/karma/application_builder.ts | 115 ++++++++++++++++-- .../src/builders/karma/jasmine_global.js | 18 +++ .../builders/karma/jasmine_global_cleanup.js | 14 +++ .../tests/behavior/jasmine-clock_spec.ts | 46 +++++++ 4 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/karma/jasmine_global.js create mode 100644 packages/angular_devkit/build_angular/src/builders/karma/jasmine_global_cleanup.js create mode 100644 packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/jasmine-clock_spec.ts diff --git a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts index ff4604c7c91e..c30bbbd5539a 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts @@ -20,7 +20,7 @@ import { randomUUID } from 'crypto'; import glob from 'fast-glob'; import * as fs from 'fs/promises'; import { IncomingMessage, ServerResponse } from 'http'; -import type { Config, ConfigOptions, InlinePluginDef } from 'karma'; +import type { Config, ConfigOptions, FilePattern, InlinePluginDef } from 'karma'; import * as path from 'path'; import { Observable, Subscriber, catchError, defaultIfEmpty, from, of, switchMap } from 'rxjs'; import { Configuration } from 'webpack'; @@ -106,6 +106,66 @@ class AngularAssetsMiddleware { } } +class AngularPolyfillsPlugin { + static readonly $inject = ['config.files']; + + static readonly NAME = 'angular-polyfills'; + + static createPlugin( + polyfillsFile: FilePattern, + jasmineCleanupFiles: FilePattern, + ): InlinePluginDef { + return { + // This has to be a "reporter" because reporters run _after_ frameworks + // and karma-jasmine-html-reporter injects additional scripts that may + // depend on Jasmine but aren't modules - which means that they would run + // _before_ all module code (including jasmine). + [`reporter:${AngularPolyfillsPlugin.NAME}`]: [ + 'factory', + Object.assign((files: (string | FilePattern)[]) => { + // The correct order is zone.js -> jasmine -> zone.js/testing. + // Jasmine has to see the patched version of the global `setTimeout` + // function so it doesn't cache the unpatched version. And /testing + // needs to see the global `jasmine` object so it can patch it. + const polyfillsIndex = 0; + files.splice(polyfillsIndex, 0, polyfillsFile); + + // Insert just before test_main.js. + const zoneTestingIndex = files.findIndex((f) => { + if (typeof f === 'string') { + return false; + } + + return f.pattern.endsWith('/test_main.js'); + }); + if (zoneTestingIndex === -1) { + throw new Error('Could not find test entrypoint file.'); + } + files.splice(zoneTestingIndex, 0, jasmineCleanupFiles); + + // We need to ensure that all files are served as modules, otherwise + // the order in the files list gets really confusing: Karma doesn't + // set defer on scripts, so all scripts with type=js will run first, + // even if type=module files appeared earlier in `files`. + for (const f of files) { + if (typeof f === 'string') { + throw new Error(`Unexpected string-based file: "${f}"`); + } + if (f.included === false) { + // Don't worry about files that aren't included on the initial + // page load. `type` won't affect them. + continue; + } + if ('js' === (f.type ?? 'js')) { + f.type = 'module'; + } + } + }, AngularPolyfillsPlugin), + ], + }; + } +} + function injectKarmaReporter( buildOptions: BuildOptions, buildIterator: AsyncIterator, @@ -247,12 +307,27 @@ async function getProjectSourceRoot(context: BuilderContext): Promise { return path.join(context.workspaceRoot, sourceRoot); } -function normalizePolyfills(polyfills: string | string[] | undefined): string[] { +function normalizePolyfills(polyfills: string | string[] | undefined): [string[], string[]] { if (typeof polyfills === 'string') { - return [polyfills]; + polyfills = [polyfills]; + } else if (!polyfills) { + polyfills = []; } - return 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 zoneTestingEntryPoint = 'zone.js/testing'; + const polyfillsExludingZoneTesting = polyfills.filter((p) => p !== zoneTestingEntryPoint); + + return [ + polyfillsExludingZoneTesting.concat([jasmineGlobalEntryPoint]), + polyfillsExludingZoneTesting.length === polyfills.length + ? [jasmineGlobalCleanupEntrypoint] + : [jasmineGlobalCleanupEntrypoint, zoneTestingEntryPoint], + ]; } async function collectEntrypoints( @@ -311,6 +386,11 @@ async function initializeApplication( ) : undefined; + const [polyfills, jasmineCleanup] = normalizePolyfills(options.polyfills); + for (let idx = 0; idx < jasmineCleanup.length; ++idx) { + entryPoints.set(`jasmine-cleanup-${idx}`, jasmineCleanup[idx]); + } + const buildOptions: BuildOptions = { assets: options.assets, entryPoints, @@ -327,7 +407,7 @@ async function initializeApplication( }, instrumentForCoverage, styles: options.styles, - polyfills: normalizePolyfills(options.polyfills), + polyfills, webWorkerTsConfig: options.webWorkerTsConfig, watch: options.watch ?? !karmaOptions.singleRun, stylePreprocessorOptions: options.stylePreprocessorOptions, @@ -349,10 +429,25 @@ async function initializeApplication( // Write test files await writeTestFiles(buildOutput.files, buildOptions.outputPath); + // We need to add this to the beginning *after* the testing framework has + // prepended its files. + const polyfillsFile: FilePattern = { + pattern: `${outputPath}/polyfills.js`, + included: true, + served: true, + type: 'module', + watched: false, + }; + const jasmineCleanupFiles: FilePattern = { + pattern: `${outputPath}/jasmine-cleanup-*.js`, + included: true, + served: true, + type: 'module', + watched: false, + }; + karmaOptions.files ??= []; karmaOptions.files.push( - // Serve polyfills first. - { pattern: `${outputPath}/polyfills.js`, type: 'module', watched: false }, // Serve global setup script. { pattern: `${outputPath}/${mainName}.js`, type: 'module', watched: false }, // Serve all source maps. @@ -413,6 +508,12 @@ async function initializeApplication( parsedKarmaConfig.middleware ??= []; parsedKarmaConfig.middleware.push(AngularAssetsMiddleware.NAME); + parsedKarmaConfig.plugins.push( + AngularPolyfillsPlugin.createPlugin(polyfillsFile, jasmineCleanupFiles), + ); + parsedKarmaConfig.reporters ??= []; + parsedKarmaConfig.reporters.push(AngularPolyfillsPlugin.NAME); + // When using code-coverage, auto-add karma-coverage. // This was done as part of the karma plugin for webpack. if ( diff --git a/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global.js b/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global.js new file mode 100644 index 000000000000..7f45cf531b41 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global.js @@ -0,0 +1,18 @@ +/** + * @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 + */ + +// See: https://github.com/jasmine/jasmine/issues/2015 +(function () { + 'use strict'; + + // jasmine will ignore `window` unless it returns this specific (but uncommon) + // value from toString(). + window.toString = function () { + return '[object GjsGlobal]'; + }; +})(); diff --git a/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global_cleanup.js b/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global_cleanup.js new file mode 100644 index 000000000000..d703f8eaf5a9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/karma/jasmine_global_cleanup.js @@ -0,0 +1,14 @@ +/** + * @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 + */ + +// See: https://github.com/jasmine/jasmine/issues/2015 +(function () { + 'use strict'; + + delete window.toString; +})(); diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/jasmine-clock_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/jasmine-clock_spec.ts new file mode 100644 index 000000000000..302b549b5d2c --- /dev/null +++ b/packages/angular_devkit/build_angular/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(); + }); + }); +});