@@ -2,6 +2,7 @@ import type { Vitest } from './core'
22import type { TestProject } from './project'
33import type { TestSpecification } from './test-specification'
44import { existsSync } from 'node:fs'
5+ import os from 'node:os'
56import { join , relative , resolve } from 'pathe'
67import pm from 'picomatch'
78import { isWindows } from '../utils/env'
@@ -145,34 +146,81 @@ export class VitestSpecifications {
145146 return [ ]
146147 }
147148
148- const testGraphs = await Promise . all (
149- specs . map ( async ( spec ) => {
150- const deps = await this . getTestDependencies ( spec )
151- return [ spec , deps ] as const
152- } ) ,
153- )
149+ // The module graph, and so the dependency edges, are per project.
150+ const specsByProject = new Map < TestProject , TestSpecification [ ] > ( )
151+ for ( const spec of specs ) {
152+ let projectSpecs = specsByProject . get ( spec . project )
153+ if ( ! projectSpecs ) {
154+ specsByProject . set ( spec . project , projectSpecs = [ ] )
155+ }
156+ projectSpecs . push ( spec )
157+ }
158+
159+ const affectedByProject = new Map < TestProject , Set < string > > ( )
160+ for ( const [ project , projectSpecs ] of specsByProject ) {
161+ affectedByProject . set (
162+ project ,
163+ await this . getAffectedModules ( project , projectSpecs , related ) ,
164+ )
165+ }
166+
167+ return specs . filter ( spec => affectedByProject . get ( spec . project ) ! . has ( spec . moduleId ) )
168+ }
154169
155- const runningTests : TestSpecification [ ] = [ ]
170+ /**
171+ * Returns every module in `project` that transitively imports one of `related`.
172+ *
173+ * Expands each module's imports at most once into a shared reverse-edge map,
174+ * then walks that map backwards from the changed files.
175+ */
176+ private async getAffectedModules (
177+ project : TestProject ,
178+ specs : TestSpecification [ ] ,
179+ related : string [ ] ,
180+ ) : Promise < Set < string > > {
181+ const importers = new Map < string , Set < string > > ( )
182+ const visited = new Set < string > ( )
183+ const existsCache = new Map < string , boolean > ( )
156184
157- for ( const [ specification , deps ] of testGraphs ) {
158- // if deps or the test itself were changed
159- if ( related . some ( path => path === specification . moduleId || deps . has ( path ) ) ) {
160- runningTests . push ( specification )
185+ // limit concurrency to lower peak memory usage on large graphs
186+ const TRANSFORM_CONCURRENCY = os . availableParallelism ?.( ) ?? os . cpus ( ) . length
187+ let active = 0
188+ const waiters : Array < ( ) => void > = [ ]
189+ const withLimit = async < T > ( fn : ( ) => Promise < T > ) : Promise < T > => {
190+ if ( active >= TRANSFORM_CONCURRENCY ) {
191+ await new Promise < void > ( resolve => waiters . push ( resolve ) )
192+ }
193+ active ++
194+ try {
195+ return await fn ( )
196+ }
197+ finally {
198+ active --
199+ waiters . shift ( ) ?.( )
161200 }
162201 }
163202
164- return runningTests
165- }
203+ const cachedExists = ( filepath : string ) : boolean => {
204+ const cached = existsCache . get ( filepath )
205+ if ( cached !== undefined ) {
206+ return cached
207+ }
208+ const result = existsSync ( filepath )
209+ existsCache . set ( filepath , result )
210+ return result
211+ }
166212
167- private async getTestDependencies ( spec : TestSpecification , deps = new Set < string > ( ) ) : Promise < Set < string > > {
168- const addImports = async ( project : TestProject , filepath : string ) => {
169- if ( deps . has ( filepath ) ) {
213+ const addImports = async ( filepath : string ) => {
214+ // `visited` is shared by every spec in the project, so a module is
215+ // expanded once per run instead of once per test file that reaches it.
216+ if ( visited . has ( filepath ) ) {
170217 return
171218 }
172- deps . add ( filepath )
219+ visited . add ( filepath )
173220
174- const mod = project . vite . environments . ssr . moduleGraph . getModuleById ( filepath )
175- const transformed = mod ?. transformResult || await project . vite . environments . ssr . transformRequest ( filepath )
221+ const environment = project . vite . environments . ssr
222+ const mod = environment . moduleGraph . getModuleById ( filepath )
223+ const transformed = mod ?. transformResult || await withLimit ( ( ) => environment . transformRequest ( filepath ) )
176224 if ( ! transformed ) {
177225 return
178226 }
@@ -181,15 +229,35 @@ export class VitestSpecifications {
181229 const fsPath = dep . startsWith ( '/@fs/' )
182230 ? dep . slice ( isWindows ? 5 : 4 )
183231 : join ( project . config . root , dep )
184- if ( ! fsPath . includes ( 'node_modules' ) && ! deps . has ( fsPath ) && existsSync ( fsPath ) ) {
185- await addImports ( project , fsPath )
232+ if ( fsPath . includes ( 'node_modules' ) || ! cachedExists ( fsPath ) ) {
233+ return
186234 }
235+ let importedBy = importers . get ( fsPath )
236+ if ( ! importedBy ) {
237+ importers . set ( fsPath , importedBy = new Set ( ) )
238+ }
239+ importedBy . add ( filepath )
240+ await addImports ( fsPath )
187241 } ) )
188242 }
189243
190- await addImports ( spec . project , spec . moduleId )
191- deps . delete ( spec . moduleId )
244+ await Promise . all ( specs . map ( spec => addImports ( spec . moduleId ) ) )
245+
246+ const affected = new Set < string > ( related )
247+ const queue = [ ...related ]
248+ while ( queue . length ) {
249+ const importedBy = importers . get ( queue . pop ( ) ! )
250+ if ( ! importedBy ) {
251+ continue
252+ }
253+ for ( const importer of importedBy ) {
254+ if ( ! affected . has ( importer ) ) {
255+ affected . add ( importer )
256+ queue . push ( importer )
257+ }
258+ }
259+ }
192260
193- return deps
261+ return affected
194262 }
195263}
0 commit comments