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' ;
22
+ import { randomUUID } from 'crypto' ;
23
+ import * as fs from 'fs/promises' ;
12
24
import type { Config , ConfigOptions } 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' ;
23
37
24
38
export type KarmaConfigOptions = ConfigOptions & {
25
39
buildWebpack ?: unknown ;
@@ -30,10 +44,17 @@ async function initialize(
30
44
options : KarmaBuilderOptions ,
31
45
context : BuilderContext ,
32
46
webpackConfigurationTransformer ?: ExecutionTransformer < Configuration > ,
33
- ) : Promise < [ typeof import ( 'karma' ) , Configuration ] > {
47
+ ) : Promise < [ typeof import ( 'karma' ) , Configuration | null ] > {
34
48
// Purge old build disk cache.
35
49
await purgeStaleBuildCache ( context ) ;
36
50
51
+ const useEsbuild = await checkForEsbuild ( options , context ) ;
52
+ if ( useEsbuild ) {
53
+ const karma = await import ( 'karma' ) ;
54
+
55
+ return [ karma , null ] ;
56
+ }
57
+
37
58
const { config } = await generateBrowserWebpackConfigFromContext (
38
59
// only two properties are missing:
39
60
// * `outputPath` which is fixed for tests
@@ -92,9 +113,11 @@ export function execute(
92
113
throw new Error ( `The 'karma' builder requires a target to be specified.` ) ;
93
114
}
94
115
116
+ const useEsbuild = ! webpackConfig ;
117
+
95
118
const karmaOptions : KarmaConfigOptions = options . karmaConfig
96
119
? { }
97
- : getBuiltInKarmaConfig ( context . workspaceRoot , projectName ) ;
120
+ : getBuiltInKarmaConfig ( context . workspaceRoot , projectName , useEsbuild ) ;
98
121
99
122
karmaOptions . singleRun = singleRun ;
100
123
@@ -122,6 +145,116 @@ export function execute(
122
145
}
123
146
}
124
147
148
+ if ( useEsbuild ) {
149
+ const testDir = path . join ( context . workspaceRoot , 'dist/test-out' , randomUUID ( ) ) ;
150
+
151
+ // Parallelize startup work.
152
+ const [ testFiles ] = await Promise . all ( [
153
+ // Glob for files to test.
154
+ findTestFiles ( options . include ?? [ ] , options . exclude ?? [ ] , context . workspaceRoot ) ,
155
+ // Clean build output path.
156
+ fs . rm ( testDir , { recursive : true , force : true } ) ,
157
+ ] ) ;
158
+
159
+ const entryPoints = new Set ( [
160
+ ...testFiles ,
161
+ // 'jasmine-core/lib/jasmine-core/jasmine.js',
162
+ '@angular-devkit/build-angular/src/builders/karma/init_test_bed.js' ,
163
+ ] ) ;
164
+ const outputPath = testDir ;
165
+ // Extract `zone.js/testing` to a separate entry point because it needs to be loaded after Jasmine.
166
+ const [ polyfills , hasZoneTesting ] = extractZoneTesting ( options . polyfills ) ;
167
+ if ( hasZoneTesting ) {
168
+ entryPoints . add ( 'zone.js/testing' ) ;
169
+ }
170
+ // see: packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts
171
+ polyfills . push ( '@angular/localize/init' ) ;
172
+
173
+ // Build tests with `application` builder, using test files as entry points.
174
+ // Also bundle in Jasmine and the Jasmine runner script, which need to share chunked dependencies.
175
+ const buildOutput = await first (
176
+ buildApplicationInternal (
177
+ {
178
+ entryPoints,
179
+ tsConfig : options . tsConfig ,
180
+ outputPath,
181
+ aot : false ,
182
+ index : false ,
183
+ outputHashing : OutputHashing . None ,
184
+ optimization : false ,
185
+ externalDependencies : [
186
+ // Resolved by `@web/test-runner` at runtime with dynamically generated code.
187
+ // '@web/test-runner-core',
188
+ ] ,
189
+ sourceMap : {
190
+ scripts : true ,
191
+ styles : true ,
192
+ vendor : true ,
193
+ } ,
194
+ polyfills,
195
+ } ,
196
+ context ,
197
+ ) ,
198
+ ) ;
199
+ if ( buildOutput . kind === ResultKind . Failure ) {
200
+ // TODO: Forward {success: false}
201
+ throw new Error ( 'Build failed' ) ;
202
+ } else if ( buildOutput . kind !== ResultKind . Full ) {
203
+ // TODO: Forward {success: false}
204
+ // return {
205
+ // success: false,
206
+ // error: 'A full build result is required from the application builder.',
207
+ // };
208
+ throw new Error ( 'A full build result is required from the application builder.' ) ;
209
+ }
210
+
211
+ // Write test files
212
+ await writeTestFiles ( buildOutput . files , testDir ) ;
213
+
214
+ // TODO: Base this on the buildOutput.files to make it less fragile to exclude patterns?
215
+ karmaOptions . files ??= [ ] ;
216
+ karmaOptions . files = karmaOptions . files . concat (
217
+ [ `**/*.js` ] . map ( ( pattern ) => ( { pattern, type : 'module' } ) ) ,
218
+ ) ;
219
+ karmaOptions . exclude = ( karmaOptions . exclude ?? [ ] ) . concat ( [
220
+ `polyfills.js` ,
221
+ `chunk-*.js` ,
222
+ // `!jasmine.js`,
223
+ // `!jasmine_runner.js`,
224
+ `testing.js` , // `zone.js/testing`
225
+ '*.js' ,
226
+ ] ) ;
227
+
228
+ const parsedKarmaConfig = await karma . config . parseConfig (
229
+ options . karmaConfig && path . resolve ( context . workspaceRoot , options . karmaConfig ) ,
230
+ transforms . karmaOptions ? transforms . karmaOptions ( karmaOptions ) : karmaOptions ,
231
+ { promiseConfig : true , throwErrors : true } ,
232
+ ) ;
233
+
234
+ // Remove the webpack plugin/framework:
235
+ // Alternative would be to make the Karma plugin "smart" but that's a tall order
236
+ // with managing unneeded imports etc..
237
+ ( parsedKarmaConfig as any ) . plugins = ( parsedKarmaConfig as any ) . plugins . filter (
238
+ ( plugin : { [ key : string ] : unknown } ) => {
239
+ // Remove the webpack Karma plugin.
240
+ return ! plugin [ 'framework:@angular-devkit/build-angular' ] ;
241
+ } ,
242
+ ) ;
243
+ ( parsedKarmaConfig as any ) . frameworks = ( parsedKarmaConfig as any ) . frameworks . filter (
244
+ ( framework : string ) => {
245
+ // Remove the webpack "framework".
246
+ return framework !== '@angular-devkit/build-angular' ;
247
+ } ,
248
+ ) ;
249
+
250
+ // Add the plugin/framework for the application builder:
251
+ // The options here are, to a certain extend:
252
+ // - Run the dev server as a Karma plugin.
253
+ // - Run the build separately and let Karma serve the files.
254
+
255
+ return [ karma , parsedKarmaConfig ] as [ typeof karma , KarmaConfigOptions ] ;
256
+ }
257
+
125
258
if ( ! options . main ) {
126
259
webpackConfig . entry ??= { } ;
127
260
if ( typeof webpackConfig . entry === 'object' && ! Array . isArray ( webpackConfig . entry ) ) {
@@ -195,6 +328,7 @@ export function execute(
195
328
function getBuiltInKarmaConfig (
196
329
workspaceRoot : string ,
197
330
projectName : string ,
331
+ useEsbuild : boolean ,
198
332
) : ConfigOptions & Record < string , unknown > {
199
333
let coverageFolderName = projectName . charAt ( 0 ) === '@' ? projectName . slice ( 1 ) : projectName ;
200
334
if ( / [ A - Z ] / . test ( coverageFolderName ) ) {
@@ -206,13 +340,13 @@ function getBuiltInKarmaConfig(
206
340
// Any changes to the config here need to be synced to: packages/schematics/angular/config/files/karma.conf.js.template
207
341
return {
208
342
basePath : '' ,
209
- frameworks : [ 'jasmine' , '@angular-devkit/build-angular' ] ,
343
+ frameworks : [ 'jasmine' , ... ( useEsbuild ? [ ] : [ '@angular-devkit/build-angular' ] ) ] ,
210
344
plugins : [
211
345
'karma-jasmine' ,
212
346
'karma-chrome-launcher' ,
213
347
'karma-jasmine-html-reporter' ,
214
348
'karma-coverage' ,
215
- '@angular-devkit/build-angular/plugins/karma' ,
349
+ ... ( useEsbuild ? [ ] : [ '@angular-devkit/build-angular/plugins/karma' ] ) ,
216
350
] . map ( ( p ) => workspaceRootRequire ( p ) ) ,
217
351
jasmineHtmlReporter : {
218
352
suppressAll : true , // removes the duplicated traces
@@ -262,3 +396,76 @@ function getBuiltInMainFile(): string {
262
396
263
397
return `ng-virtual-main.js!=!data:text/javascript;base64,${ content } ` ;
264
398
}
399
+
400
+ async function checkForEsbuild (
401
+ options : KarmaBuilderOptions ,
402
+ context : BuilderContext ,
403
+ ) : Promise < boolean > {
404
+ if ( options . builderMode !== BuilderMode . Detect ) {
405
+ return options . builderMode === BuilderMode . Application ;
406
+ }
407
+
408
+ // Look up the current project's build target using a development configuration.
409
+ const buildTargetSpecifier = `::development` ;
410
+ const buildTarget = targetFromTargetString (
411
+ buildTargetSpecifier ,
412
+ context . target ?. project ,
413
+ 'build' ,
414
+ ) ;
415
+
416
+ try {
417
+ const developmentBuilderName = await context . getBuilderNameForTarget ( buildTarget ) ;
418
+
419
+ return isEsbuildBased ( developmentBuilderName ) ;
420
+ } catch ( e ) {
421
+ if ( ! ( e instanceof Error ) || e . message !== 'Project target does not exist.' ) {
422
+ throw e ;
423
+ }
424
+ // If we can't find a development builder, we can't use 'detect'.
425
+ throw new Error (
426
+ 'Failed to detect the detect the builder used by the application. Please set builderMode explicitly.' ,
427
+ ) ;
428
+ }
429
+ }
430
+
431
+ function isEsbuildBased (
432
+ builderName : string ,
433
+ ) : builderName is
434
+ | '@angular/build:application'
435
+ | '@angular-devkit/build-angular:application'
436
+ | '@angular-devkit/build-angular:browser-esbuild' {
437
+ if (
438
+ builderName === '@angular/build:application' ||
439
+ builderName === '@angular-devkit/build-angular:application' ||
440
+ builderName === '@angular-devkit/build-angular:browser-esbuild'
441
+ ) {
442
+ return true ;
443
+ }
444
+
445
+ return false ;
446
+ }
447
+
448
+ function extractZoneTesting (
449
+ polyfills : readonly string [ ] | string | undefined ,
450
+ ) : [ polyfills : string [ ] , hasZoneTesting : boolean ] {
451
+ if ( typeof polyfills === 'string' ) {
452
+ polyfills = [ polyfills ] ;
453
+ }
454
+ polyfills ??= [ ] ;
455
+
456
+ const polyfillsWithoutZoneTesting = polyfills . filter (
457
+ ( polyfill ) => polyfill !== 'zone.js/testing' ,
458
+ ) ;
459
+ const hasZoneTesting = polyfills . length !== polyfillsWithoutZoneTesting . length ;
460
+
461
+ return [ polyfillsWithoutZoneTesting , hasZoneTesting ] ;
462
+ }
463
+
464
+ /** Returns the first item yielded by the given generator and cancels the execution. */
465
+ async function first < T > ( generator : AsyncIterable < T > ) : Promise < T > {
466
+ for await ( const value of generator ) {
467
+ return value ;
468
+ }
469
+
470
+ throw new Error ( 'Expected generator to emit at least once.' ) ;
471
+ }
0 commit comments