6
6
* found in the LICENSE file at https://angular.dev/license
7
7
*/
8
8
9
- import type { BuilderContext , BuilderOutput } from '@angular-devkit/architect' ;
9
+ import {
10
+ type BuilderContext ,
11
+ type BuilderOutput ,
12
+ targetStringFromTarget ,
13
+ } from '@angular-devkit/architect' ;
10
14
import assert from 'node:assert' ;
11
15
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin' ;
12
16
import { assertIsError } from '../../utils/error' ;
@@ -22,6 +26,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema';
22
26
23
27
export type { UnitTestBuilderOptions } ;
24
28
29
+ async function loadTestRunner ( runnerName : string ) : Promise < TestRunner > {
30
+ // Harden against directory traversal
31
+ if ( ! / ^ [ a - z A - Z 0 - 9 - ] + $ / . test ( runnerName ) ) {
32
+ throw new Error (
33
+ `Invalid runner name "${ runnerName } ". Runner names can only contain alphanumeric characters and hyphens.` ,
34
+ ) ;
35
+ }
36
+
37
+ let runnerModule ;
38
+ try {
39
+ runnerModule = await import ( `./runners/${ runnerName } /index` ) ;
40
+ } catch ( e ) {
41
+ assertIsError ( e ) ;
42
+ if ( e . code === 'ERR_MODULE_NOT_FOUND' ) {
43
+ throw new Error ( `Unknown test runner "${ runnerName } ".` ) ;
44
+ }
45
+ throw new Error (
46
+ `Failed to load the '${ runnerName } ' test runner. The package may be corrupted or improperly installed.\n` +
47
+ `Error: ${ e . message } ` ,
48
+ ) ;
49
+ }
50
+
51
+ const runner = runnerModule . default ;
52
+ if (
53
+ ! runner ||
54
+ typeof runner . getBuildOptions !== 'function' ||
55
+ typeof runner . createExecutor !== 'function'
56
+ ) {
57
+ throw new Error (
58
+ `The loaded test runner '${ runnerName } ' does not appear to be a valid TestRunner implementation.` ,
59
+ ) ;
60
+ }
61
+
62
+ return runner ;
63
+ }
64
+
65
+ function prepareBuildExtensions (
66
+ virtualFiles : Record < string , string > | undefined ,
67
+ projectSourceRoot : string ,
68
+ extensions ?: ApplicationBuilderExtensions ,
69
+ ) : ApplicationBuilderExtensions | undefined {
70
+ if ( ! virtualFiles ) {
71
+ return extensions ;
72
+ }
73
+
74
+ extensions ??= { } ;
75
+ extensions . codePlugins ??= [ ] ;
76
+ for ( const [ namespace , contents ] of Object . entries ( virtualFiles ) ) {
77
+ extensions . codePlugins . push (
78
+ createVirtualModulePlugin ( {
79
+ namespace,
80
+ loadContent : ( ) => {
81
+ return {
82
+ contents,
83
+ loader : 'js' ,
84
+ resolveDir : projectSourceRoot ,
85
+ } ;
86
+ } ,
87
+ } ) ,
88
+ ) ;
89
+ }
90
+
91
+ return extensions ;
92
+ }
93
+
94
+ async function * runBuildAndTest (
95
+ executor : import ( './runners/api' ) . TestExecutor ,
96
+ applicationBuildOptions : ApplicationBuilderInternalOptions ,
97
+ context : BuilderContext ,
98
+ extensions : ApplicationBuilderExtensions | undefined ,
99
+ ) : AsyncIterable < BuilderOutput > {
100
+ for await ( const buildResult of buildApplicationInternal (
101
+ applicationBuildOptions ,
102
+ context ,
103
+ extensions ,
104
+ ) ) {
105
+ if ( buildResult . kind === ResultKind . Failure ) {
106
+ yield { success : false } ;
107
+ continue ;
108
+ } else if (
109
+ buildResult . kind !== ResultKind . Full &&
110
+ buildResult . kind !== ResultKind . Incremental
111
+ ) {
112
+ assert . fail (
113
+ 'A full and/or incremental build result is required from the application builder.' ,
114
+ ) ;
115
+ }
116
+
117
+ assert ( buildResult . files , 'Builder did not provide result files.' ) ;
118
+
119
+ // Pass the build artifacts to the executor
120
+ yield * executor . execute ( buildResult ) ;
121
+ }
122
+ }
123
+
25
124
/**
26
125
* @experimental Direct usage of this function is considered experimental.
27
126
*/
@@ -43,24 +142,8 @@ export async function* execute(
43
142
) ;
44
143
45
144
const normalizedOptions = await normalizeOptions ( context , projectName , options ) ;
46
- const { runnerName, projectSourceRoot } = normalizedOptions ;
47
-
48
- // Dynamically load the requested runner
49
- let runner : TestRunner ;
50
- try {
51
- const { default : runnerModule } = await import ( `./runners/${ runnerName } /index` ) ;
52
- runner = runnerModule ;
53
- } catch ( e ) {
54
- assertIsError ( e ) ;
55
- if ( e . code !== 'ERR_MODULE_NOT_FOUND' ) {
56
- throw e ;
57
- }
58
- context . logger . error ( `Unknown test runner "${ runnerName } ".` ) ;
145
+ const runner = await loadTestRunner ( normalizedOptions . runnerName ) ;
59
146
60
- return ;
61
- }
62
-
63
- // Create the stateful executor once
64
147
await using executor = await runner . createExecutor ( context , normalizedOptions ) ;
65
148
66
149
if ( runner . isStandalone ) {
@@ -73,68 +156,42 @@ export async function* execute(
73
156
}
74
157
75
158
// Get base build options from the buildTarget
76
- const buildTargetOptions = ( await context . validateOptions (
77
- await context . getTargetOptions ( normalizedOptions . buildTarget ) ,
78
- await context . getBuilderNameForTarget ( normalizedOptions . buildTarget ) ,
79
- ) ) as unknown as ApplicationBuilderInternalOptions ;
159
+ let buildTargetOptions : ApplicationBuilderInternalOptions ;
160
+ try {
161
+ buildTargetOptions = ( await context . validateOptions (
162
+ await context . getTargetOptions ( normalizedOptions . buildTarget ) ,
163
+ await context . getBuilderNameForTarget ( normalizedOptions . buildTarget ) ,
164
+ ) ) as unknown as ApplicationBuilderInternalOptions ;
165
+ } catch ( e ) {
166
+ assertIsError ( e ) ;
167
+ context . logger . error (
168
+ `Could not load build target options for "${ targetStringFromTarget ( normalizedOptions . buildTarget ) } ".\n` +
169
+ `Please check your 'angular.json' configuration.\n` +
170
+ `Error: ${ e . message } ` ,
171
+ ) ;
172
+
173
+ return ;
174
+ }
80
175
81
176
// Get runner-specific build options from the hook
82
177
const { buildOptions : runnerBuildOptions , virtualFiles } = await runner . getBuildOptions (
83
178
normalizedOptions ,
84
179
buildTargetOptions ,
85
180
) ;
86
181
87
- if ( virtualFiles ) {
88
- extensions ??= { } ;
89
- extensions . codePlugins ??= [ ] ;
90
- for ( const [ namespace , contents ] of Object . entries ( virtualFiles ) ) {
91
- extensions . codePlugins . push (
92
- createVirtualModulePlugin ( {
93
- namespace,
94
- loadContent : ( ) => {
95
- return {
96
- contents,
97
- loader : 'js' ,
98
- resolveDir : projectSourceRoot ,
99
- } ;
100
- } ,
101
- } ) ,
102
- ) ;
103
- }
104
- }
105
-
106
- const { watch, tsConfig } = normalizedOptions ;
182
+ const finalExtensions = prepareBuildExtensions (
183
+ virtualFiles ,
184
+ normalizedOptions . projectSourceRoot ,
185
+ extensions ,
186
+ ) ;
107
187
108
188
// Prepare and run the application build
109
189
const applicationBuildOptions = {
110
- // Base options
111
190
...buildTargetOptions ,
112
- watch,
113
- tsConfig,
114
- // Runner specific
115
191
...runnerBuildOptions ,
192
+ watch : normalizedOptions . watch ,
193
+ tsConfig : normalizedOptions . tsConfig ,
116
194
} satisfies ApplicationBuilderInternalOptions ;
117
195
118
- for await ( const buildResult of buildApplicationInternal (
119
- applicationBuildOptions ,
120
- context ,
121
- extensions ,
122
- ) ) {
123
- if ( buildResult . kind === ResultKind . Failure ) {
124
- yield { success : false } ;
125
- continue ;
126
- } else if (
127
- buildResult . kind !== ResultKind . Full &&
128
- buildResult . kind !== ResultKind . Incremental
129
- ) {
130
- assert . fail (
131
- 'A full and/or incremental build result is required from the application builder.' ,
132
- ) ;
133
- }
134
-
135
- assert ( buildResult . files , 'Builder did not provide result files.' ) ;
136
-
137
- // Pass the build artifacts to the executor
138
- yield * executor . execute ( buildResult ) ;
139
- }
196
+ yield * runBuildAndTest ( executor , applicationBuildOptions , context , finalExtensions ) ;
140
197
}
0 commit comments