@@ -20,7 +20,7 @@ import { randomUUID } from 'crypto';
2020import glob from 'fast-glob' ;
2121import * as fs from 'fs/promises' ;
2222import { IncomingMessage , ServerResponse } from 'http' ;
23- import type { Config , ConfigOptions , InlinePluginDef } from 'karma' ;
23+ import type { Config , ConfigOptions , FilePattern , InlinePluginDef } from 'karma' ;
2424import * as path from 'path' ;
2525import { Observable , Subscriber , catchError , defaultIfEmpty , from , of , switchMap } from 'rxjs' ;
2626import { Configuration } from 'webpack' ;
@@ -106,6 +106,66 @@ class AngularAssetsMiddleware {
106106 }
107107}
108108
109+ class AngularPolyfillsPlugin {
110+ static readonly $inject = [ 'config.files' ] ;
111+
112+ static readonly NAME = 'angular-polyfills' ;
113+
114+ static createPlugin (
115+ polyfillsFile : FilePattern ,
116+ jasmineCleanupFiles : FilePattern ,
117+ ) : InlinePluginDef {
118+ return {
119+ // This has to be a "reporter" because reporters run _after_ frameworks
120+ // and karma-jasmine-html-reporter injects additional scripts that may
121+ // depend on Jasmine but aren't modules - which means that they would run
122+ // _before_ all module code (including jasmine).
123+ [ `reporter:${ AngularPolyfillsPlugin . NAME } ` ] : [
124+ 'factory' ,
125+ Object . assign ( ( files : ( string | FilePattern ) [ ] ) => {
126+ // The correct order is zone.js -> jasmine -> zone.js/testing.
127+ // Jasmine has to see the patched version of the global `setTimeout`
128+ // function so it doesn't cache the unpatched version. And /testing
129+ // needs to see the global `jasmine` object so it can patch it.
130+ const polyfillsIndex = 0 ;
131+ files . splice ( polyfillsIndex , 0 , polyfillsFile ) ;
132+
133+ // Insert just before test_main.js.
134+ const zoneTestingIndex = files . findIndex ( ( f ) => {
135+ if ( typeof f === 'string' ) {
136+ return false ;
137+ }
138+
139+ return f . pattern . endsWith ( '/test_main.js' ) ;
140+ } ) ;
141+ if ( zoneTestingIndex === - 1 ) {
142+ throw new Error ( 'Could not find test entrypoint file.' ) ;
143+ }
144+ files . splice ( zoneTestingIndex , 0 , jasmineCleanupFiles ) ;
145+
146+ // We need to ensure that all files are served as modules, otherwise
147+ // the order in the files list gets really confusing: Karma doesn't
148+ // set defer on scripts, so all scripts with type=js will run first,
149+ // even if type=module files appeared earlier in `files`.
150+ for ( const f of files ) {
151+ if ( typeof f === 'string' ) {
152+ throw new Error ( `Unexpected string-based file: "${ f } "` ) ;
153+ }
154+ if ( f . included === false ) {
155+ // Don't worry about files that aren't included on the initial
156+ // page load. `type` won't affect them.
157+ continue ;
158+ }
159+ if ( 'js' === ( f . type ?? 'js' ) ) {
160+ f . type = 'module' ;
161+ }
162+ }
163+ } , AngularPolyfillsPlugin ) ,
164+ ] ,
165+ } ;
166+ }
167+ }
168+
109169function injectKarmaReporter (
110170 buildOptions : BuildOptions ,
111171 buildIterator : AsyncIterator < Result > ,
@@ -247,12 +307,27 @@ async function getProjectSourceRoot(context: BuilderContext): Promise<string> {
247307 return path . join ( context . workspaceRoot , sourceRoot ) ;
248308}
249309
250- function normalizePolyfills ( polyfills : string | string [ ] | undefined ) : string [ ] {
310+ function normalizePolyfills ( polyfills : string | string [ ] | undefined ) : [ string [ ] , string [ ] ] {
251311 if ( typeof polyfills === 'string' ) {
252- return [ polyfills ] ;
312+ polyfills = [ polyfills ] ;
313+ } else if ( ! polyfills ) {
314+ polyfills = [ ] ;
253315 }
254316
255- return polyfills ?? [ ] ;
317+ const jasmineGlobalEntryPoint =
318+ '@angular-devkit/build-angular/src/builders/karma/jasmine_global.js' ;
319+ const jasmineGlobalCleanupEntrypoint =
320+ '@angular-devkit/build-angular/src/builders/karma/jasmine_global_cleanup.js' ;
321+
322+ const zoneTestingEntryPoint = 'zone.js/testing' ;
323+ const polyfillsExludingZoneTesting = polyfills . filter ( ( p ) => p !== zoneTestingEntryPoint ) ;
324+
325+ return [
326+ polyfillsExludingZoneTesting . concat ( [ jasmineGlobalEntryPoint ] ) ,
327+ polyfillsExludingZoneTesting . length === polyfills . length
328+ ? [ jasmineGlobalCleanupEntrypoint ]
329+ : [ jasmineGlobalCleanupEntrypoint , zoneTestingEntryPoint ] ,
330+ ] ;
256331}
257332
258333async function collectEntrypoints (
@@ -311,6 +386,11 @@ async function initializeApplication(
311386 )
312387 : undefined ;
313388
389+ const [ polyfills , jasmineCleanup ] = normalizePolyfills ( options . polyfills ) ;
390+ for ( let idx = 0 ; idx < jasmineCleanup . length ; ++ idx ) {
391+ entryPoints . set ( `jasmine-cleanup-${ idx } ` , jasmineCleanup [ idx ] ) ;
392+ }
393+
314394 const buildOptions : BuildOptions = {
315395 assets : options . assets ,
316396 entryPoints,
@@ -327,7 +407,7 @@ async function initializeApplication(
327407 } ,
328408 instrumentForCoverage,
329409 styles : options . styles ,
330- polyfills : normalizePolyfills ( options . polyfills ) ,
410+ polyfills,
331411 webWorkerTsConfig : options . webWorkerTsConfig ,
332412 watch : options . watch ?? ! karmaOptions . singleRun ,
333413 stylePreprocessorOptions : options . stylePreprocessorOptions ,
@@ -349,10 +429,25 @@ async function initializeApplication(
349429 // Write test files
350430 await writeTestFiles ( buildOutput . files , buildOptions . outputPath ) ;
351431
432+ // We need to add this to the beginning *after* the testing framework has
433+ // prepended its files.
434+ const polyfillsFile : FilePattern = {
435+ pattern : `${ outputPath } /polyfills.js` ,
436+ included : true ,
437+ served : true ,
438+ type : 'module' ,
439+ watched : false ,
440+ } ;
441+ const jasmineCleanupFiles : FilePattern = {
442+ pattern : `${ outputPath } /jasmine-cleanup-*.js` ,
443+ included : true ,
444+ served : true ,
445+ type : 'module' ,
446+ watched : false ,
447+ } ;
448+
352449 karmaOptions . files ??= [ ] ;
353450 karmaOptions . files . push (
354- // Serve polyfills first.
355- { pattern : `${ outputPath } /polyfills.js` , type : 'module' , watched : false } ,
356451 // Serve global setup script.
357452 { pattern : `${ outputPath } /${ mainName } .js` , type : 'module' , watched : false } ,
358453 // Serve all source maps.
@@ -413,6 +508,12 @@ async function initializeApplication(
413508 parsedKarmaConfig . middleware ??= [ ] ;
414509 parsedKarmaConfig . middleware . push ( AngularAssetsMiddleware . NAME ) ;
415510
511+ parsedKarmaConfig . plugins . push (
512+ AngularPolyfillsPlugin . createPlugin ( polyfillsFile , jasmineCleanupFiles ) ,
513+ ) ;
514+ parsedKarmaConfig . reporters ??= [ ] ;
515+ parsedKarmaConfig . reporters . push ( AngularPolyfillsPlugin . NAME ) ;
516+
416517 // When using code-coverage, auto-add karma-coverage.
417518 // This was done as part of the karma plugin for webpack.
418519 if (
0 commit comments