@@ -22,6 +22,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema';
2222
2323export type { UnitTestBuilderOptions } ;
2424
25+ async function loadTestRunner ( runnerName : string ) : Promise < TestRunner > {
26+ // Harden against directory traversal
27+ if ( ! / ^ [ a - z A - Z 0 - 9 - ] + $ / . test ( runnerName ) ) {
28+ throw new Error (
29+ `Invalid runner name "${ runnerName } ". Runner names can only contain alphanumeric characters and hyphens.` ,
30+ ) ;
31+ }
32+
33+ let runnerModule ;
34+ try {
35+ runnerModule = await import ( `./runners/${ runnerName } /index` ) ;
36+ } catch ( e ) {
37+ assertIsError ( e ) ;
38+ if ( e . code === 'ERR_MODULE_NOT_FOUND' ) {
39+ throw new Error ( `Unknown test runner "${ runnerName } ".` ) ;
40+ }
41+ throw new Error (
42+ `Failed to load the '${ runnerName } ' test runner. The package may be corrupted or improperly installed.\n` +
43+ `Error: ${ e . message } ` ,
44+ ) ;
45+ }
46+
47+ const runner = runnerModule . default ;
48+ if (
49+ ! runner ||
50+ typeof runner . getBuildOptions !== 'function' ||
51+ typeof runner . createExecutor !== 'function'
52+ ) {
53+ throw new Error (
54+ `The loaded test runner '${ runnerName } ' does not appear to be a valid TestRunner implementation.` ,
55+ ) ;
56+ }
57+
58+ return runner ;
59+ }
60+
61+ function prepareBuildExtensions (
62+ virtualFiles : Record < string , string > | undefined ,
63+ projectSourceRoot : string ,
64+ extensions ?: ApplicationBuilderExtensions ,
65+ ) : ApplicationBuilderExtensions | undefined {
66+ if ( ! virtualFiles ) {
67+ return extensions ;
68+ }
69+
70+ extensions ??= { } ;
71+ extensions . codePlugins ??= [ ] ;
72+ for ( const [ namespace , contents ] of Object . entries ( virtualFiles ) ) {
73+ extensions . codePlugins . push (
74+ createVirtualModulePlugin ( {
75+ namespace,
76+ loadContent : ( ) => {
77+ return {
78+ contents,
79+ loader : 'js' ,
80+ resolveDir : projectSourceRoot ,
81+ } ;
82+ } ,
83+ } ) ,
84+ ) ;
85+ }
86+
87+ return extensions ;
88+ }
89+
90+ async function * runBuildAndTest (
91+ executor : import ( './runners/api' ) . TestExecutor ,
92+ applicationBuildOptions : ApplicationBuilderInternalOptions ,
93+ context : BuilderContext ,
94+ extensions : ApplicationBuilderExtensions | undefined ,
95+ ) : AsyncIterable < BuilderOutput > {
96+ for await ( const buildResult of buildApplicationInternal (
97+ applicationBuildOptions ,
98+ context ,
99+ extensions ,
100+ ) ) {
101+ if ( buildResult . kind === ResultKind . Failure ) {
102+ yield { success : false } ;
103+ continue ;
104+ } else if (
105+ buildResult . kind !== ResultKind . Full &&
106+ buildResult . kind !== ResultKind . Incremental
107+ ) {
108+ assert . fail (
109+ 'A full and/or incremental build result is required from the application builder.' ,
110+ ) ;
111+ }
112+
113+ assert ( buildResult . files , 'Builder did not provide result files.' ) ;
114+
115+ // Pass the build artifacts to the executor
116+ yield * executor . execute ( buildResult ) ;
117+ }
118+ }
119+
25120/**
26121 * @experimental Direct usage of this function is considered experimental.
27122 */
@@ -43,24 +138,8 @@ export async function* execute(
43138 ) ;
44139
45140 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 } ".` ) ;
141+ const runner = await loadTestRunner ( normalizedOptions . runnerName ) ;
59142
60- return ;
61- }
62-
63- // Create the stateful executor once
64143 await using executor = await runner . createExecutor ( context , normalizedOptions ) ;
65144
66145 if ( runner . isStandalone ) {
@@ -73,68 +152,42 @@ export async function* execute(
73152 }
74153
75154 // 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 ;
155+ let buildTargetOptions : ApplicationBuilderInternalOptions ;
156+ try {
157+ buildTargetOptions = ( await context . validateOptions (
158+ await context . getTargetOptions ( normalizedOptions . buildTarget ) ,
159+ await context . getBuilderNameForTarget ( normalizedOptions . buildTarget ) ,
160+ ) ) as unknown as ApplicationBuilderInternalOptions ;
161+ } catch ( e ) {
162+ assertIsError ( e ) ;
163+ context . logger . error (
164+ `Could not load build target options for "${ normalizedOptions . buildTarget . project } :${ normalizedOptions . buildTarget . target } ".\n` +
165+ `Please check your 'angular.json' configuration.\n` +
166+ `Error: ${ e . message } ` ,
167+ ) ;
168+
169+ return ;
170+ }
80171
81172 // Get runner-specific build options from the hook
82173 const { buildOptions : runnerBuildOptions , virtualFiles } = await runner . getBuildOptions (
83174 normalizedOptions ,
84175 buildTargetOptions ,
85176 ) ;
86177
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 ;
178+ const finalExtensions = prepareBuildExtensions (
179+ virtualFiles ,
180+ normalizedOptions . projectSourceRoot ,
181+ extensions ,
182+ ) ;
107183
108184 // Prepare and run the application build
109185 const applicationBuildOptions = {
110- // Base options
111186 ...buildTargetOptions ,
112- watch,
113- tsConfig,
114- // Runner specific
115187 ...runnerBuildOptions ,
188+ watch : normalizedOptions . watch ,
189+ tsConfig : normalizedOptions . tsConfig ,
116190 } satisfies ApplicationBuilderInternalOptions ;
117191
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- }
192+ yield * runBuildAndTest ( executor , applicationBuildOptions , context , finalExtensions ) ;
140193}
0 commit comments