8
8
9
9
import type { BuilderOutput } from '@angular-devkit/architect' ;
10
10
import 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' ;
14
12
import path from 'node:path' ;
15
13
import type { InlineConfig , Vitest } from 'vitest/node' ;
16
14
import { assertIsError } from '../../../../utils/error' ;
17
15
import { loadEsmModule } from '../../../../utils/load-esm' ;
18
16
import { 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' ;
21
23
import { NormalizedUnitTestBuilderOptions } from '../../options' ;
24
+ import { findTests , getTestEntrypoints } from '../../test-discovery' ;
22
25
import type { TestExecutor } from '../api' ;
23
26
import { setupBrowserConfiguration } from './browser-provider' ;
24
27
@@ -28,73 +31,94 @@ export class VitestExecutor implements TestExecutor {
28
31
private vitest : Vitest | undefined ;
29
32
private readonly projectName : string ;
30
33
private readonly options : NormalizedUnitTestBuilderOptions ;
31
- private readonly outputPath : string ;
32
- private latestBuildResult : FullResult | IncrementalResult | undefined ;
34
+ private buildResultFiles = new Map < string , ResultFile > ( ) ;
33
35
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 > ( ) ;
39
42
40
43
constructor ( projectName : string , options : NormalizedUnitTestBuilderOptions ) {
41
44
this . projectName = projectName ;
42
45
this . options = options ;
43
- this . outputPath = toPosixPath ( path . join ( options . workspaceRoot , generateOutputPath ( ) ) ) ;
44
- process . on ( 'SIGINT' , this . sigintListener ) ;
45
46
}
46
47
47
48
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
+ }
49
62
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
+ }
51
77
52
78
// Initialize Vitest if not already present.
53
79
this . vitest ??= await this . initializeVitest ( ) ;
54
80
const vitest = this . vitest ;
55
81
56
82
let testResults ;
57
83
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
+ }
65
93
}
66
94
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 ) ;
78
101
}
79
102
}
103
+
104
+ if ( specsToRerun . length > 0 ) {
105
+ testResults = await vitest . rerunTestSpecifications ( specsToRerun ) ;
106
+ }
80
107
}
81
108
82
109
// Check if all the tests pass to calculate the result
83
- const testModules = testResults ?. testModules ;
110
+ const testModules = testResults ?. testModules ?? this . vitest . state . getTestModules ( ) ;
84
111
85
- yield { success : testModules ? .every ( ( testModule ) => testModule . ok ( ) ) ?? true } ;
112
+ yield { success : testModules . every ( ( testModule ) => testModule . ok ( ) ) } ;
86
113
}
87
114
88
115
async [ Symbol . asyncDispose ] ( ) : Promise < void > {
89
- process . off ( 'SIGINT' , this . sigintListener ) ;
90
116
await this . vitest ?. close ( ) ;
91
- await rm ( this . outputPath , { recursive : true , force : true } ) ;
92
117
}
93
118
94
119
private async initializeVitest ( ) : Promise < Vitest > {
95
120
const { codeCoverage, reporters, workspaceRoot, setupFiles, browsers, debug, watch } =
96
121
this . options ;
97
- const { outputPath, projectName, latestBuildResult } = this ;
98
122
99
123
let vitestNodeModule ;
100
124
try {
@@ -120,14 +144,16 @@ export class VitestExecutor implements TestExecutor {
120
144
throw new Error ( browserOptions . errors . join ( '\n' ) ) ;
121
145
}
122
146
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
+ ) ;
124
151
// Add setup file entries for TestBed initialization and project polyfills
125
152
const testSetupFiles = [ 'init-testbed.js' , ...setupFiles ] ;
126
153
127
154
// 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' ) ;
131
157
}
132
158
133
159
const debugOptions = debug
@@ -145,12 +171,12 @@ export class VitestExecutor implements TestExecutor {
145
171
// Disable configuration file resolution/loading
146
172
config : false ,
147
173
root : workspaceRoot ,
148
- project : [ 'base' , projectName ] ,
174
+ project : [ 'base' , this . projectName ] ,
149
175
name : 'base' ,
150
176
include : [ ] ,
151
177
reporters : reporters ?? [ 'default' ] ,
152
178
watch,
153
- coverage : generateCoverageOption ( codeCoverage , workspaceRoot , this . outputPath ) ,
179
+ coverage : generateCoverageOption ( codeCoverage ) ,
154
180
...debugOptions ,
155
181
} ,
156
182
{
@@ -162,39 +188,111 @@ export class VitestExecutor implements TestExecutor {
162
188
plugins : [
163
189
{
164
190
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 ) => {
166
194
// Create a subproject that can be configured with plugins for browser mode.
167
195
// Plugins defined directly in the vite overrides will not be present in the
168
196
// browser specific Vite instance.
169
197
const [ project ] = await context . injectTestProjects ( {
170
198
test : {
171
- name : projectName ,
172
- root : outputPath ,
199
+ name : this . projectName ,
200
+ root : workspaceRoot ,
173
201
globals : true ,
174
202
setupFiles : testSetupFiles ,
175
203
// Use `jsdom` if no browsers are explicitly configured.
176
204
// `node` is effectively no "environment" and the default.
177
205
environment : browserOptions . browser ? 'node' : 'jsdom' ,
178
206
browser : browserOptions . browser ,
207
+ include : this . options . include ,
208
+ ...( this . options . exclude ? { exclude : this . options . exclude } : { } ) ,
179
209
} ,
180
210
plugins : [
181
211
{
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
+
184
234
assert (
185
- latestBuildResult ,
186
- 'buildResult must be available for HTML index transformation .' ,
235
+ this . buildResultFiles . size > 0 ,
236
+ 'buildResult must be available for resolving .' ,
187
237
) ;
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.' ,
191
247
) ;
192
248
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 [ ] ;
198
296
} ,
199
297
} ,
200
298
] ,
@@ -216,17 +314,8 @@ export class VitestExecutor implements TestExecutor {
216
314
}
217
315
}
218
316
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
-
226
317
function generateCoverageOption (
227
318
codeCoverage : NormalizedUnitTestBuilderOptions [ 'codeCoverage' ] ,
228
- workspaceRoot : string ,
229
- outputPath : string ,
230
319
) : VitestCoverageOption {
231
320
if ( ! codeCoverage ) {
232
321
return {
@@ -237,7 +326,6 @@ function generateCoverageOption(
237
326
return {
238
327
enabled : true ,
239
328
excludeAfterRemap : true ,
240
- include : [ `${ toPosixPath ( path . relative ( workspaceRoot , outputPath ) ) } /**` ] ,
241
329
// Special handling for `reporter` due to an undefined value causing upstream failures
242
330
...( codeCoverage . reporters
243
331
? ( { reporter : codeCoverage . reporters } satisfies VitestCoverageOption )
0 commit comments