6
6
* found in the LICENSE file at https://angular.dev/license
7
7
*/
8
8
9
- import { assertCompatibleAngularVersion , purgeStaleBuildCache } from '@angular/build/private' ;
10
- import { BuilderContext , BuilderOutput , createBuilder } from '@angular-devkit/architect' ;
9
+ import {
10
+ ResultKind ,
11
+ assertCompatibleAngularVersion ,
12
+ buildApplicationInternal ,
13
+ purgeStaleBuildCache ,
14
+ } from '@angular/build/private' ;
15
+ import {
16
+ BuilderContext ,
17
+ BuilderOutput ,
18
+ createBuilder ,
19
+ targetFromTargetString ,
20
+ } from '@angular-devkit/architect' ;
11
21
import { strings } from '@angular-devkit/core' ;
12
- import type { Config , ConfigOptions } from 'karma' ;
22
+ import { randomUUID } from 'crypto' ;
23
+ import * as fs from 'fs/promises' ;
24
+ import type { Config , ConfigOptions , InlinePluginDef } from 'karma' ;
13
25
import { createRequire } from 'module' ;
14
26
import * as path from 'path' ;
15
27
import { Observable , defaultIfEmpty , from , switchMap } from 'rxjs' ;
16
28
import { Configuration } from 'webpack' ;
17
29
import { getCommonConfig , getStylesConfig } from '../../tools/webpack/configs' ;
18
30
import { ExecutionTransformer } from '../../transforms' ;
31
+ import { findTestFiles } from '../../utils/test-files' ;
19
32
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config' ;
20
33
import { Schema as BrowserBuilderOptions , OutputHashing } from '../browser/schema' ;
34
+ import { writeTestFiles } from '../web-test-runner/write-test-files' ;
21
35
import { FindTestsPlugin } from './find-tests-plugin' ;
22
- import { Schema as KarmaBuilderOptions } from './schema' ;
36
+ import { BuilderMode , Schema as KarmaBuilderOptions } from './schema' ;
37
+ import { readFileSync } from 'fs' ;
23
38
24
39
export type KarmaConfigOptions = ConfigOptions & {
25
40
buildWebpack ?: unknown ;
@@ -30,10 +45,17 @@ async function initialize(
30
45
options : KarmaBuilderOptions ,
31
46
context : BuilderContext ,
32
47
webpackConfigurationTransformer ?: ExecutionTransformer < Configuration > ,
33
- ) : Promise < [ typeof import ( 'karma' ) , Configuration ] > {
48
+ ) : Promise < [ typeof import ( 'karma' ) , Configuration | null ] > {
34
49
// Purge old build disk cache.
35
50
await purgeStaleBuildCache ( context ) ;
36
51
52
+ const useEsbuild = await checkForEsbuild ( options , context ) ;
53
+ if ( useEsbuild ) {
54
+ const karma = await import ( 'karma' ) ;
55
+
56
+ return [ karma , null ] ;
57
+ }
58
+
37
59
const { config } = await generateBrowserWebpackConfigFromContext (
38
60
// only two properties are missing:
39
61
// * `outputPath` which is fixed for tests
@@ -64,6 +86,199 @@ async function initialize(
64
86
return [ karma , ( await webpackConfigurationTransformer ?.( config ) ) ?? config ] ;
65
87
}
66
88
89
+ async function createEsbuildConfig (
90
+ options : KarmaBuilderOptions ,
91
+ context : BuilderContext ,
92
+ karma : typeof import ( 'karma' ) ,
93
+ karmaOptions : KarmaConfigOptions ,
94
+ transforms : {
95
+ karmaOptions ?: ( options : KarmaConfigOptions ) => KarmaConfigOptions ;
96
+ } = { } ,
97
+ ) {
98
+ const testDir = path . join ( context . workspaceRoot , 'dist/test-out' , randomUUID ( ) ) ;
99
+
100
+ // Parallelize startup work.
101
+ const [ testFiles ] = await Promise . all ( [
102
+ // Glob for files to test.
103
+ findTestFiles ( options . include ?? [ ] , options . exclude ?? [ ] , context . workspaceRoot ) ,
104
+ // Clean build output path.
105
+ fs . rm ( testDir , { recursive : true , force : true } ) ,
106
+ ] ) ;
107
+
108
+ const entryPoints = new Set ( [
109
+ ...testFiles ,
110
+ // 'jasmine-core/lib/jasmine-core/jasmine.js',
111
+ '@angular-devkit/build-angular/src/builders/karma/init_test_bed.js' ,
112
+ ] ) ;
113
+ const outputPath = testDir ;
114
+ // Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine.
115
+ const [ polyfills , hasZoneTesting ] = extractZoneTesting ( options . polyfills ) ;
116
+ if ( hasZoneTesting ) {
117
+ entryPoints . add ( 'zone.js/testing' ) ;
118
+ }
119
+ // see: packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts
120
+ // TODO: Make this relative to tsConfig.
121
+ // const localizePackageInitEntryPoint = '@angular/localize/init';
122
+ // const hasLocalizeType = tsConfig.options.types?.some(
123
+ // (t) => t === '@angular/localize' || t === localizePackageInitEntryPoint,
124
+ // );
125
+
126
+ // if (hasLocalizeType) {
127
+ // entryPoints['main'] = [localizePackageInitEntryPoint];
128
+ // }
129
+ polyfills . push ( '@angular/localize/init' ) ;
130
+
131
+ // Build tests with `application` builder, using test files as entry points.
132
+ // Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies.
133
+ const buildOutput = await first (
134
+ buildApplicationInternal (
135
+ {
136
+ entryPoints,
137
+ tsConfig : options . tsConfig ,
138
+ outputPath,
139
+ aot : false ,
140
+ index : false ,
141
+ outputHashing : OutputHashing . None ,
142
+ optimization : false ,
143
+ externalDependencies : [
144
+ // Resolved by `@web/test-runner` at runtime with dynamically generated code.
145
+ // '@web/test-runner-core',
146
+ ] ,
147
+ sourceMap : {
148
+ scripts : true ,
149
+ styles : true ,
150
+ vendor : true ,
151
+ } ,
152
+ polyfills,
153
+ } ,
154
+ context ,
155
+ ) ,
156
+ ) ;
157
+ if ( buildOutput . kind === ResultKind . Failure ) {
158
+ // TODO: Forward {success: false}
159
+ throw new Error ( 'Build failed' ) ;
160
+ } else if ( buildOutput . kind !== ResultKind . Full ) {
161
+ // TODO: Forward {success: false}
162
+ // return {
163
+ // success: false,
164
+ // error: 'A full build result is required from the application builder.',
165
+ // };
166
+ throw new Error ( 'A full build result is required from the application builder.' ) ;
167
+ }
168
+
169
+ // Write test files
170
+ await writeTestFiles ( buildOutput . files , testDir ) ;
171
+
172
+ // TODO: Base this on the buildOutput.files to make it less fragile to exclude patterns?
173
+ karmaOptions . files ??= [ ] ;
174
+ karmaOptions . files = karmaOptions . files . concat (
175
+ [ `${ testDir } /polyfills.js` ] . map ( ( pattern ) => ( { pattern, type : 'module' } ) ) ,
176
+ ) ;
177
+ karmaOptions . files = karmaOptions . files . concat (
178
+ [
179
+ `${ testDir } /chunk-*.js` ,
180
+ `${ testDir } /testing.js` , // `zone.js/testing`
181
+ ] . map ( ( pattern ) => ( { pattern, type : 'module' , included : false } ) ) ,
182
+ ) ;
183
+ karmaOptions . files = karmaOptions . files . concat (
184
+ [ `${ testDir } /*.js` ] . map ( ( pattern ) => ( { pattern, type : 'module' } ) ) ,
185
+ ) ;
186
+
187
+ const parsedKarmaConfig : Config & KarmaConfigOptions = await karma . config . parseConfig (
188
+ options . karmaConfig && path . resolve ( context . workspaceRoot , options . karmaConfig ) ,
189
+ transforms . karmaOptions ? transforms . karmaOptions ( karmaOptions ) : karmaOptions ,
190
+ { promiseConfig : true , throwErrors : true } ,
191
+ ) ;
192
+
193
+ // Remove the webpack plugin/framework:
194
+ // Alternative would be to make the Karma plugin "smart" but that's a tall order
195
+ // with managing unneeded imports etc..
196
+ parsedKarmaConfig . plugins = ( parsedKarmaConfig . plugins ?? [ ] )
197
+ . filter ( ( plugin : string | InlinePluginDef ) => {
198
+ if ( typeof plugin === 'string' ) {
199
+ return plugin !== 'framework:@angular-devkit/build-angular' ;
200
+ }
201
+
202
+ return ! plugin [ 'framework:@angular-devkit/build-angular' ] ;
203
+ } )
204
+ . concat ( [
205
+ {
206
+ [ 'preprocessor:attach-source-maps' ] : [
207
+ 'factory' ,
208
+ Object . assign (
209
+ ( ) => {
210
+ interface BasicSourceMap {
211
+ file ?: string ;
212
+ sourcesContent ?: unknown ;
213
+ }
214
+ interface KarmaFile {
215
+ path : string ;
216
+ readonly originalPath : string ;
217
+ encodings : { [ key : string ] : Buffer } ;
218
+ type : unknown ;
219
+ sourceMap ?: BasicSourceMap ;
220
+ }
221
+
222
+ return (
223
+ content : Buffer ,
224
+ file : KarmaFile ,
225
+ done : ( err : Error | null , content : Buffer ) => void ,
226
+ ) => {
227
+ if ( file . path . startsWith ( `${ testDir } /` ) && file . path . endsWith ( '.js' ) ) {
228
+ // Attempt to load source map.
229
+ const sourceMapPath = `${ file . path } .map` ;
230
+ const mapBytes = readFileSync ( sourceMapPath , 'utf8' ) ;
231
+ file . sourceMap = JSON . parse ( mapBytes ) as BasicSourceMap ;
232
+ delete file . sourceMap . sourcesContent ;
233
+ file . sourceMap . file = file . path ; // basename?
234
+ }
235
+
236
+ done ( null , content ) ;
237
+ } ;
238
+ } ,
239
+ { '$inject' : [ ] } ,
240
+ ) ,
241
+ ] ,
242
+ } ,
243
+ ] ) ;
244
+ parsedKarmaConfig . frameworks = parsedKarmaConfig . frameworks ?. filter (
245
+ ( framework : string ) => framework !== '@angular-devkit/build-angular' ,
246
+ ) ;
247
+
248
+ // When using code-coverage, auto-add karma-coverage.
249
+ // This was done as part of the karma plugin for webpack.
250
+ if (
251
+ options . codeCoverage &&
252
+ ! parsedKarmaConfig . reporters ?. some ( ( r : string ) => r === 'coverage' || r === 'coverage-istanbul' )
253
+ ) {
254
+ parsedKarmaConfig . reporters = ( parsedKarmaConfig . reporters ?? [ ] ) . concat ( [ 'coverage' ] ) ;
255
+ }
256
+
257
+ /*
258
+ const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? '');
259
+ const sourceRoot = projectMetadata.sourceRoot as string | undefined;
260
+ const projectSourceRoot = sourceRoot ? path.join(workspaceRoot, sourceRoot) : undefined;
261
+ */
262
+
263
+ // Add preprocessor to instrument code for coverage gathering.
264
+ if ( options . codeCoverage ) {
265
+ parsedKarmaConfig . preprocessors ??= { } ;
266
+ // sourceRoot ?? projectRoot
267
+ /*
268
+ {
269
+ includedBasePath: sourceRoot ?? projectRoot,
270
+ excludedPaths: getInstrumentationExcludedPaths(root, codeCoverageExclude),
271
+ }
272
+ */
273
+ parsedKarmaConfig . preprocessors [ `${ testDir } /!(polyfills|testing).js` ] = [
274
+ 'attach-source-maps' ,
275
+ 'coverage' ,
276
+ ] ;
277
+ }
278
+
279
+ return [ karma , parsedKarmaConfig ] as [ typeof karma , KarmaConfigOptions ] ;
280
+ }
281
+
67
282
/**
68
283
* @experimental Direct usage of this function is considered experimental.
69
284
*/
@@ -92,9 +307,11 @@ export function execute(
92
307
throw new Error ( `The 'karma' builder requires a target to be specified.` ) ;
93
308
}
94
309
310
+ const useEsbuild = ! webpackConfig ;
311
+
95
312
const karmaOptions : KarmaConfigOptions = options . karmaConfig
96
313
? { }
97
- : getBuiltInKarmaConfig ( context . workspaceRoot , projectName ) ;
314
+ : getBuiltInKarmaConfig ( context . workspaceRoot , projectName , useEsbuild ) ;
98
315
99
316
karmaOptions . singleRun = singleRun ;
100
317
@@ -122,6 +339,10 @@ export function execute(
122
339
}
123
340
}
124
341
342
+ if ( useEsbuild ) {
343
+ return createEsbuildConfig ( options , context , karma , karmaOptions , transforms ) ;
344
+ }
345
+
125
346
if ( ! options . main ) {
126
347
webpackConfig . entry ??= { } ;
127
348
if ( typeof webpackConfig . entry === 'object' && ! Array . isArray ( webpackConfig . entry ) ) {
@@ -195,6 +416,7 @@ export function execute(
195
416
function getBuiltInKarmaConfig (
196
417
workspaceRoot : string ,
197
418
projectName : string ,
419
+ useEsbuild : boolean ,
198
420
) : ConfigOptions & Record < string , unknown > {
199
421
let coverageFolderName = projectName . charAt ( 0 ) === '@' ? projectName . slice ( 1 ) : projectName ;
200
422
if ( / [ A - Z ] / . test ( coverageFolderName ) ) {
@@ -206,13 +428,13 @@ function getBuiltInKarmaConfig(
206
428
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
207
429
return {
208
430
basePath : '' ,
209
- frameworks : [ 'jasmine' , '@angular-devkit/build-angular' ] ,
431
+ frameworks : [ 'jasmine' , ... ( useEsbuild ? [ ] : [ '@angular-devkit/build-angular' ] ) ] ,
210
432
plugins : [
211
433
'karma-jasmine' ,
212
434
'karma-chrome-launcher' ,
213
435
'karma-jasmine-html-reporter' ,
214
436
'karma-coverage' ,
215
- '@angular-devkit/build-angular/plugins/karma' ,
437
+ ... ( useEsbuild ? [ ] : [ '@angular-devkit/build-angular/plugins/karma' ] ) ,
216
438
] . map ( ( p ) => workspaceRootRequire ( p ) ) ,
217
439
jasmineHtmlReporter : {
218
440
suppressAll : true , // removes the duplicated traces
@@ -262,3 +484,76 @@ function getBuiltInMainFile(): string {
262
484
263
485
return `ng-virtual-main.js!=!data:text/javascript;base64,${ content } ` ;
264
486
}
487
+
488
+ async function checkForEsbuild (
489
+ options : KarmaBuilderOptions ,
490
+ context : BuilderContext ,
491
+ ) : Promise < boolean > {
492
+ if ( options . builderMode !== BuilderMode . Detect ) {
493
+ return options . builderMode === BuilderMode . Application ;
494
+ }
495
+
496
+ // Look up the current project's build target using a development configuration.
497
+ const buildTargetSpecifier = `::development` ;
498
+ const buildTarget = targetFromTargetString (
499
+ buildTargetSpecifier ,
500
+ context . target ?. project ,
501
+ 'build' ,
502
+ ) ;
503
+
504
+ try {
505
+ const developmentBuilderName = await context . getBuilderNameForTarget ( buildTarget ) ;
506
+
507
+ return isEsbuildBased ( developmentBuilderName ) ;
508
+ } catch ( e ) {
509
+ if ( ! ( e instanceof Error ) || e . message !== 'Project target does not exist.' ) {
510
+ throw e ;
511
+ }
512
+ // If we can't find a development builder, we can't use 'detect'.
513
+ throw new Error (
514
+ 'Failed to detect the detect the builder used by the application. Please set builderMode explicitly.' ,
515
+ ) ;
516
+ }
517
+ }
518
+
519
+ function isEsbuildBased (
520
+ builderName : string ,
521
+ ) : builderName is
522
+ | '@angular/build:application'
523
+ | '@angular-devkit/build-angular:application'
524
+ | '@angular-devkit/build-angular:browser-esbuild' {
525
+ if (
526
+ builderName === '@angular/build:application' ||
527
+ builderName === '@angular-devkit/build-angular:application' ||
528
+ builderName === '@angular-devkit/build-angular:browser-esbuild'
529
+ ) {
530
+ return true ;
531
+ }
532
+
533
+ return false ;
534
+ }
535
+
536
+ function extractZoneTesting (
537
+ polyfills : readonly string [ ] | string | undefined ,
538
+ ) : [ polyfills : string [ ] , hasZoneTesting : boolean ] {
539
+ if ( typeof polyfills === 'string' ) {
540
+ polyfills = [ polyfills ] ;
541
+ }
542
+ polyfills ??= [ ] ;
543
+
544
+ const polyfillsWithoutZoneTesting = polyfills . filter (
545
+ ( polyfill ) => polyfill !== 'zone.js/testing' ,
546
+ ) ;
547
+ const hasZoneTesting = polyfills . length !== polyfillsWithoutZoneTesting . length ;
548
+
549
+ return [ polyfillsWithoutZoneTesting , hasZoneTesting ] ;
550
+ }
551
+
552
+ /** Returns the first item yielded by the given generator and cancels the execution. */
553
+ async function first < T > ( generator : AsyncIterable < T > ) : Promise < T > {
554
+ for await ( const value of generator ) {
555
+ return value ;
556
+ }
557
+
558
+ throw new Error ( 'Expected generator to emit at least once.' ) ;
559
+ }
0 commit comments