66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9- import type { BuilderContext , BuilderOutput } from '@angular-devkit/architect' ;
9+ import {
10+ type BuilderContext ,
11+ type BuilderOutput ,
12+ targetStringFromTarget ,
13+ } from '@angular-devkit/architect' ;
1014import assert from 'node:assert' ;
1115import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin' ;
1216import { assertIsError } from '../../utils/error' ;
@@ -22,6 +26,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema';
2226
2327export type { UnitTestBuilderOptions } ;
2428
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+
25124/**
26125 * @experimental Direct usage of this function is considered experimental.
27126 */
@@ -43,24 +142,8 @@ export async function* execute(
43142 ) ;
44143
45144 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 ) ;
59146
60- return ;
61- }
62-
63- // Create the stateful executor once
64147 await using executor = await runner . createExecutor ( context , normalizedOptions ) ;
65148
66149 if ( runner . isStandalone ) {
@@ -73,68 +156,42 @@ export async function* execute(
73156 }
74157
75158 // 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+ }
80175
81176 // Get runner-specific build options from the hook
82177 const { buildOptions : runnerBuildOptions , virtualFiles } = await runner . getBuildOptions (
83178 normalizedOptions ,
84179 buildTargetOptions ,
85180 ) ;
86181
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+ ) ;
107187
108188 // Prepare and run the application build
109189 const applicationBuildOptions = {
110- // Base options
111190 ...buildTargetOptions ,
112- watch,
113- tsConfig,
114- // Runner specific
115191 ...runnerBuildOptions ,
192+ watch : normalizedOptions . watch ,
193+ tsConfig : normalizedOptions . tsConfig ,
116194 } satisfies ApplicationBuilderInternalOptions ;
117195
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 ) ;
140197}
0 commit comments