88
99import type { BuilderOutput } from '@angular-devkit/architect' ;
1010import assert from 'node:assert' ;
11- import { randomUUID } from 'node:crypto' ;
12- import { rmSync } from 'node:fs' ;
13- import { rm } from 'node:fs/promises' ;
11+ import { readFile } from 'node:fs/promises' ;
1412import path from 'node:path' ;
1513import type { InlineConfig , Vitest } from 'vitest/node' ;
1614import { assertIsError } from '../../../../utils/error' ;
1715import { loadEsmModule } from '../../../../utils/load-esm' ;
1816import { toPosixPath } from '../../../../utils/path' ;
19- import { type FullResult , type IncrementalResult , ResultKind } from '../../../application/results' ;
20- import { writeTestFiles } from '../../../karma/application_builder' ;
17+ import {
18+ type FullResult ,
19+ type IncrementalResult ,
20+ type ResultFile ,
21+ ResultKind ,
22+ } from '../../../application/results' ;
2123import { NormalizedUnitTestBuilderOptions } from '../../options' ;
24+ import { findTests , getTestEntrypoints } from '../../test-discovery' ;
2225import type { TestExecutor } from '../api' ;
2326import { setupBrowserConfiguration } from './browser-provider' ;
2427
@@ -28,73 +31,94 @@ export class VitestExecutor implements TestExecutor {
2831 private vitest : Vitest | undefined ;
2932 private readonly projectName : string ;
3033 private readonly options : NormalizedUnitTestBuilderOptions ;
31- private readonly outputPath : string ;
32- private latestBuildResult : FullResult | IncrementalResult | undefined ;
34+ private buildResultFiles = new Map < string , ResultFile > ( ) ;
3335
34- // Graceful shutdown signal handler
35- // This is needed to remove the temporary output directory on Ctrl+C
36- private readonly sigintListener = ( ) => {
37- rmSync ( this . outputPath , { recursive : true , force : true } ) ;
38- } ;
36+ // This is a reverse map of the entry points created in `build-options.ts`.
37+ // It is used by the in-memory provider plugin to map the requested test file
38+ // path back to its bundled output path.
39+ // Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>`
40+ private testFileToEntryPoint = new Map < string , string > ( ) ;
41+ private entryPointToTestFile = new Map < string , string > ( ) ;
3942
4043 constructor ( projectName : string , options : NormalizedUnitTestBuilderOptions ) {
4144 this . projectName = projectName ;
4245 this . options = options ;
43- this . outputPath = toPosixPath ( path . join ( options . workspaceRoot , generateOutputPath ( ) ) ) ;
44- process . on ( 'SIGINT' , this . sigintListener ) ;
4546 }
4647
4748 async * execute ( buildResult : FullResult | IncrementalResult ) : AsyncIterable < BuilderOutput > {
48- await writeTestFiles ( buildResult . files , this . outputPath ) ;
49+ if ( buildResult . kind === ResultKind . Full ) {
50+ this . buildResultFiles . clear ( ) ;
51+ for ( const [ path , file ] of Object . entries ( buildResult . files ) ) {
52+ this . buildResultFiles . set ( path , file ) ;
53+ }
54+ } else {
55+ for ( const file of buildResult . removed ) {
56+ this . buildResultFiles . delete ( file . path ) ;
57+ }
58+ for ( const [ path , file ] of Object . entries ( buildResult . files ) ) {
59+ this . buildResultFiles . set ( path , file ) ;
60+ }
61+ }
4962
50- this . latestBuildResult = buildResult ;
63+ // The `getTestEntrypoints` function is used here to create the same mapping
64+ // that was used in `build-options.ts` to generate the build entry points.
65+ // This is a deliberate duplication to avoid a larger refactoring of the
66+ // builder's core interfaces to pass the entry points from the build setup
67+ // phase to the execution phase.
68+ if ( this . testFileToEntryPoint . size === 0 ) {
69+ const { include, exclude = [ ] , workspaceRoot, projectSourceRoot } = this . options ;
70+ const testFiles = await findTests ( include , exclude , workspaceRoot , projectSourceRoot ) ;
71+ const entryPoints = getTestEntrypoints ( testFiles , { projectSourceRoot, workspaceRoot } ) ;
72+ for ( const [ entryPoint , testFile ] of entryPoints ) {
73+ this . testFileToEntryPoint . set ( testFile , entryPoint ) ;
74+ this . entryPointToTestFile . set ( entryPoint + '.js' , testFile ) ;
75+ }
76+ }
5177
5278 // Initialize Vitest if not already present.
5379 this . vitest ??= await this . initializeVitest ( ) ;
5480 const vitest = this . vitest ;
5581
5682 let testResults ;
5783 if ( buildResult . kind === ResultKind . Incremental ) {
58- const addedFiles = buildResult . added . map ( ( file ) => path . join ( this . outputPath , file ) ) ;
59- const modifiedFiles = buildResult . modified . map ( ( file ) => path . join ( this . outputPath , file ) ) ;
60-
61- if ( addedFiles . length === 0 && modifiedFiles . length === 0 ) {
62- yield { success : true } ;
63-
64- return ;
84+ // To rerun tests, Vitest needs the original test file paths, not the output paths.
85+ const modifiedSourceFiles = new Set < string > ( ) ;
86+ for ( const modifiedFile of buildResult . modified ) {
87+ // The `modified` files in the build result are the output paths.
88+ // We need to find the original source file path to pass to Vitest.
89+ const source = this . entryPointToTestFile . get ( modifiedFile ) ;
90+ if ( source ) {
91+ modifiedSourceFiles . add ( source ) ;
92+ }
6593 }
6694
67- // If new files are added, use `start` to trigger test discovery.
68- // Also pass modified files to `start` to ensure they are re-run.
69- if ( addedFiles . length > 0 ) {
70- await vitest . start ( [ ...addedFiles , ...modifiedFiles ] ) ;
71- } else {
72- // For modified files only, use the more efficient `rerunTestSpecifications`
73- const specsToRerun = modifiedFiles . flatMap ( ( file ) => vitest . getModuleSpecifications ( file ) ) ;
74-
75- if ( specsToRerun . length > 0 ) {
76- modifiedFiles . forEach ( ( file ) => vitest . invalidateFile ( file ) ) ;
77- testResults = await vitest . rerunTestSpecifications ( specsToRerun ) ;
95+ const specsToRerun = [ ] ;
96+ for ( const file of modifiedSourceFiles ) {
97+ vitest . invalidateFile ( file ) ;
98+ const specs = vitest . getModuleSpecifications ( file ) ;
99+ if ( specs ) {
100+ specsToRerun . push ( ...specs ) ;
78101 }
79102 }
103+
104+ if ( specsToRerun . length > 0 ) {
105+ testResults = await vitest . rerunTestSpecifications ( specsToRerun ) ;
106+ }
80107 }
81108
82109 // Check if all the tests pass to calculate the result
83- const testModules = testResults ?. testModules ;
110+ const testModules = testResults ?. testModules ?? this . vitest . state . getTestModules ( ) ;
84111
85- yield { success : testModules ? .every ( ( testModule ) => testModule . ok ( ) ) ?? true } ;
112+ yield { success : testModules . every ( ( testModule ) => testModule . ok ( ) ) } ;
86113 }
87114
88115 async [ Symbol . asyncDispose ] ( ) : Promise < void > {
89- process . off ( 'SIGINT' , this . sigintListener ) ;
90116 await this . vitest ?. close ( ) ;
91- await rm ( this . outputPath , { recursive : true , force : true } ) ;
92117 }
93118
94119 private async initializeVitest ( ) : Promise < Vitest > {
95120 const { codeCoverage, reporters, workspaceRoot, setupFiles, browsers, debug, watch } =
96121 this . options ;
97- const { outputPath, projectName, latestBuildResult } = this ;
98122
99123 let vitestNodeModule ;
100124 try {
@@ -120,14 +144,16 @@ export class VitestExecutor implements TestExecutor {
120144 throw new Error ( browserOptions . errors . join ( '\n' ) ) ;
121145 }
122146
123- assert ( latestBuildResult , 'buildResult must be available before initializing vitest' ) ;
147+ assert (
148+ this . buildResultFiles . size > 0 ,
149+ 'buildResult must be available before initializing vitest' ,
150+ ) ;
124151 // Add setup file entries for TestBed initialization and project polyfills
125152 const testSetupFiles = [ 'init-testbed.js' , ...setupFiles ] ;
126153
127154 // TODO: Provide additional result metadata to avoid needing to extract based on filename
128- const polyfillsFile = Object . keys ( latestBuildResult . files ) . find ( ( f ) => f === 'polyfills.js' ) ;
129- if ( polyfillsFile ) {
130- testSetupFiles . unshift ( polyfillsFile ) ;
155+ if ( this . buildResultFiles . has ( 'polyfills.js' ) ) {
156+ testSetupFiles . unshift ( 'polyfills.js' ) ;
131157 }
132158
133159 const debugOptions = debug
@@ -145,12 +171,12 @@ export class VitestExecutor implements TestExecutor {
145171 // Disable configuration file resolution/loading
146172 config : false ,
147173 root : workspaceRoot ,
148- project : [ 'base' , projectName ] ,
174+ project : [ 'base' , this . projectName ] ,
149175 name : 'base' ,
150176 include : [ ] ,
151177 reporters : reporters ?? [ 'default' ] ,
152178 watch,
153- coverage : generateCoverageOption ( codeCoverage , workspaceRoot , this . outputPath ) ,
179+ coverage : generateCoverageOption ( codeCoverage ) ,
154180 ...debugOptions ,
155181 } ,
156182 {
@@ -162,39 +188,111 @@ export class VitestExecutor implements TestExecutor {
162188 plugins : [
163189 {
164190 name : 'angular:project-init' ,
165- async configureVitest ( context ) {
191+ // Type is incorrect. This allows a Promise<void>.
192+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
193+ configureVitest : async ( context ) => {
166194 // Create a subproject that can be configured with plugins for browser mode.
167195 // Plugins defined directly in the vite overrides will not be present in the
168196 // browser specific Vite instance.
169197 const [ project ] = await context . injectTestProjects ( {
170198 test : {
171- name : projectName ,
172- root : outputPath ,
199+ name : this . projectName ,
200+ root : workspaceRoot ,
173201 globals : true ,
174202 setupFiles : testSetupFiles ,
175203 // Use `jsdom` if no browsers are explicitly configured.
176204 // `node` is effectively no "environment" and the default.
177205 environment : browserOptions . browser ? 'node' : 'jsdom' ,
178206 browser : browserOptions . browser ,
207+ include : this . options . include ,
208+ ...( this . options . exclude ? { exclude : this . options . exclude } : { } ) ,
179209 } ,
180210 plugins : [
181211 {
182- name : 'angular:html-index' ,
183- transformIndexHtml : ( ) => {
212+ name : 'angular:test-in-memory-provider' ,
213+ enforce : 'pre' ,
214+ resolveId : ( id , importer ) => {
215+ if ( importer && id . startsWith ( '.' ) ) {
216+ let fullPath ;
217+ let relativePath ;
218+ if ( this . testFileToEntryPoint . has ( importer ) ) {
219+ fullPath = toPosixPath ( path . join ( this . options . workspaceRoot , id ) ) ;
220+ relativePath = path . normalize ( id ) ;
221+ } else {
222+ fullPath = toPosixPath ( path . join ( path . dirname ( importer ) , id ) ) ;
223+ relativePath = path . relative ( this . options . workspaceRoot , fullPath ) ;
224+ }
225+ if ( this . buildResultFiles . has ( toPosixPath ( relativePath ) ) ) {
226+ return fullPath ;
227+ }
228+ }
229+
230+ if ( this . testFileToEntryPoint . has ( id ) ) {
231+ return id ;
232+ }
233+
184234 assert (
185- latestBuildResult ,
186- 'buildResult must be available for HTML index transformation .' ,
235+ this . buildResultFiles . size > 0 ,
236+ 'buildResult must be available for resolving .' ,
187237 ) ;
188- // Add all global stylesheets
189- const styleFiles = Object . entries ( latestBuildResult . files ) . filter (
190- ( [ file ] ) => file === 'styles.css' ,
238+ const relativePath = path . relative ( this . options . workspaceRoot , id ) ;
239+ if ( this . buildResultFiles . has ( toPosixPath ( relativePath ) ) ) {
240+ return id ;
241+ }
242+ } ,
243+ load : async ( id ) => {
244+ assert (
245+ this . buildResultFiles . size > 0 ,
246+ 'buildResult must be available for in-memory loading.' ,
191247 ) ;
192248
193- return styleFiles . map ( ( [ href ] ) => ( {
194- tag : 'link' ,
195- attrs : { href, rel : 'stylesheet' } ,
196- injectTo : 'head' ,
197- } ) ) ;
249+ // Attempt to load as a source test file.
250+ const entryPoint = this . testFileToEntryPoint . get ( id ) ;
251+ let outputPath ;
252+ if ( entryPoint ) {
253+ outputPath = entryPoint + '.js' ;
254+ } else {
255+ // Attempt to load as a built artifact.
256+ const relativePath = path . relative ( this . options . workspaceRoot , id ) ;
257+ outputPath = toPosixPath ( relativePath ) ;
258+ }
259+
260+ const outputFile = this . buildResultFiles . get ( outputPath ) ;
261+ if ( outputFile ) {
262+ const sourceMapPath = outputPath + '.map' ;
263+ const sourceMapFile = this . buildResultFiles . get ( sourceMapPath ) ;
264+ const code =
265+ outputFile . origin === 'memory'
266+ ? Buffer . from ( outputFile . contents ) . toString ( 'utf-8' )
267+ : await readFile ( outputFile . inputPath , 'utf-8' ) ;
268+ const map = sourceMapFile
269+ ? sourceMapFile . origin === 'memory'
270+ ? Buffer . from ( sourceMapFile . contents ) . toString ( 'utf-8' )
271+ : await readFile ( sourceMapFile . inputPath , 'utf-8' )
272+ : undefined ;
273+
274+ return {
275+ code,
276+ map : map ? JSON . parse ( map ) : undefined ,
277+ } ;
278+ }
279+ } ,
280+ } ,
281+ {
282+ name : 'angular:html-index' ,
283+ transformIndexHtml : ( ) => {
284+ // Add all global stylesheets
285+ if ( this . buildResultFiles . has ( 'styles.css' ) ) {
286+ return [
287+ {
288+ tag : 'link' ,
289+ attrs : { href : 'styles.css' , rel : 'stylesheet' } ,
290+ injectTo : 'head' ,
291+ } ,
292+ ] ;
293+ }
294+
295+ return [ ] ;
198296 } ,
199297 } ,
200298 ] ,
@@ -216,17 +314,8 @@ export class VitestExecutor implements TestExecutor {
216314 }
217315}
218316
219- function generateOutputPath ( ) : string {
220- const datePrefix = new Date ( ) . toISOString ( ) . replaceAll ( / [ - : . ] / g, '' ) ;
221- const uuidSuffix = randomUUID ( ) . slice ( 0 , 8 ) ;
222-
223- return path . join ( 'dist' , 'test-out' , `${ datePrefix } -${ uuidSuffix } ` ) ;
224- }
225-
226317function generateCoverageOption (
227318 codeCoverage : NormalizedUnitTestBuilderOptions [ 'codeCoverage' ] ,
228- workspaceRoot : string ,
229- outputPath : string ,
230319) : VitestCoverageOption {
231320 if ( ! codeCoverage ) {
232321 return {
@@ -237,7 +326,6 @@ function generateCoverageOption(
237326 return {
238327 enabled : true ,
239328 excludeAfterRemap : true ,
240- include : [ `${ toPosixPath ( path . relative ( workspaceRoot , outputPath ) ) } /**` ] ,
241329 // Special handling for `reporter` due to an undefined value causing upstream failures
242330 ...( codeCoverage . reporters
243331 ? ( { reporter : codeCoverage . reporters } satisfies VitestCoverageOption )
0 commit comments