11import { randomUUID } from 'node:crypto' ;
2- import fs , { readFileSync } from 'node:fs' ;
2+ import fs from 'node:fs' ;
33import fsPromises from 'node:fs/promises' ;
4- import { open } from 'node:fs/promises' ;
54import { cpus , homedir , tmpdir } from 'node:os' ;
65import path from 'node:path' ;
76import { setTimeout } from 'node:timers/promises' ;
@@ -92,6 +91,167 @@ function selectShard<T>(items: T[], index: number, total: number): T[] {
9291
9392const NPM_GLOBAL_PREFIX_DIR = 'npm-global-lib-for-snap-tests' ;
9493
94+ function resolveGlobalCliScriptsDir ( casesDir : string ) : string {
95+ const candidates = [
96+ // `packages/cli/snap-tests-global` -> `packages/cli/dist`
97+ path . join ( path . dirname ( casesDir ) , 'dist' ) ,
98+ // Fallback for the common `pnpm -F vite-plus snap-test-global` cwd.
99+ path . resolve ( 'dist' ) ,
100+ ] ;
101+
102+ const scriptsDir = candidates . find ( ( dir ) => fs . existsSync ( path . join ( dir , 'bin.js' ) ) ) ;
103+ if ( ! scriptsDir ) {
104+ throw new Error (
105+ `Unable to find built Vite+ CLI scripts for global snap tests. Tried:\n${ candidates
106+ . map ( ( dir ) => `- ${ dir } ` )
107+ . join ( '\n' ) } `,
108+ ) ;
109+ }
110+
111+ return scriptsDir ;
112+ }
113+
114+ function resolveRepoRoot ( casesDir : string ) : string {
115+ return path . resolve ( path . dirname ( casesDir ) , '..' , '..' ) ;
116+ }
117+
118+ function resolveGlobalCliBinary ( binDir : string ) : string {
119+ const binaryName = process . platform === 'win32' ? 'vp.exe' : 'vp' ;
120+ const binaryPath = path . join ( path . resolve ( expandHome ( binDir ) ) , binaryName ) ;
121+ if ( ! fs . existsSync ( binaryPath ) ) {
122+ throw new Error ( `Unable to find global snap test vp binary at ${ binaryPath } ` ) ;
123+ }
124+
125+ return fs . realpathSync ( binaryPath ) ;
126+ }
127+
128+ function resolveBuiltGlobalCliBinary ( casesDir : string ) : string {
129+ const binaryName = process . platform === 'win32' ? 'vp.exe' : 'vp' ;
130+ const repoRoot = resolveRepoRoot ( casesDir ) ;
131+ const targetDirs = [
132+ ...( process . env . CARGO_TARGET_DIR ? [ process . env . CARGO_TARGET_DIR ] : [ ] ) ,
133+ path . join ( repoRoot , 'target' ) ,
134+ ] ;
135+ const candidates = targetDirs . flatMap ( ( targetDir ) => {
136+ const directCandidates = [
137+ path . join ( targetDir , 'release' , binaryName ) ,
138+ path . join ( targetDir , 'debug' , binaryName ) ,
139+ ] ;
140+ if ( ! fs . existsSync ( targetDir ) ) {
141+ return directCandidates ;
142+ }
143+
144+ return [
145+ ...directCandidates ,
146+ ...fs
147+ . readdirSync ( targetDir , { withFileTypes : true } )
148+ . filter ( ( entry ) => entry . isDirectory ( ) )
149+ . flatMap ( ( entry ) => [
150+ path . join ( targetDir , entry . name , 'release' , binaryName ) ,
151+ path . join ( targetDir , entry . name , 'debug' , binaryName ) ,
152+ ] ) ,
153+ ] ;
154+ } ) ;
155+ const binaryPath = candidates . find ( ( candidate ) => fs . existsSync ( candidate ) ) ;
156+ if ( ! binaryPath ) {
157+ throw new Error (
158+ `Unable to find built Vite+ global CLI binary for global snap tests. Tried:\n${ candidates
159+ . map ( ( candidate ) => `- ${ candidate } ` )
160+ . join ( '\n' ) } \nRun \`cargo build -p vite_global_cli --release\` before snap-test-global.`,
161+ ) ;
162+ }
163+
164+ return fs . realpathSync ( binaryPath ) ;
165+ }
166+
167+ function newestMtimeMs ( filePath : string ) : number {
168+ const stats = fs . statSync ( filePath ) ;
169+ if ( ! stats . isDirectory ( ) ) {
170+ return stats . mtimeMs ;
171+ }
172+
173+ return fs
174+ . readdirSync ( filePath )
175+ . reduce (
176+ ( newest , entry ) => Math . max ( newest , newestMtimeMs ( path . join ( filePath , entry ) ) ) ,
177+ stats . mtimeMs ,
178+ ) ;
179+ }
180+
181+ function fileContentsEqual ( a : string , b : string ) : boolean {
182+ return fs . readFileSync ( a ) . equals ( fs . readFileSync ( b ) ) ;
183+ }
184+
185+ function assertGlobalCliBinaryMatchesCheckout ( binDir : string , casesDir : string ) : void {
186+ const repoRoot = resolveRepoRoot ( casesDir ) ;
187+ const builtBinary = resolveBuiltGlobalCliBinary ( casesDir ) ;
188+ const sourcePaths = [
189+ path . join ( repoRoot , 'Cargo.toml' ) ,
190+ path . join ( repoRoot , 'Cargo.lock' ) ,
191+ path . join ( repoRoot , 'crates' , 'vite_global_cli' , 'src' ) ,
192+ path . join ( repoRoot , 'crates' , 'vite_shared' , 'src' ) ,
193+ ] ;
194+ const shouldCheckMtime = process . env . GITHUB_ACTIONS !== 'true' ;
195+ const newestSourceMtime = shouldCheckMtime ? Math . max ( ...sourcePaths . map ( newestMtimeMs ) ) : 0 ;
196+ if ( shouldCheckMtime && fs . statSync ( builtBinary ) . mtimeMs + 1000 < newestSourceMtime ) {
197+ throw new Error (
198+ `Built Vite+ global CLI binary is older than the current checkout: ${ builtBinary } \n` +
199+ 'Run `cargo build -p vite_global_cli --release` before snap-test-global.' ,
200+ ) ;
201+ }
202+
203+ const globalBinary = resolveGlobalCliBinary ( binDir ) ;
204+ if ( fileContentsEqual ( globalBinary , builtBinary ) ) {
205+ return ;
206+ }
207+
208+ throw new Error (
209+ `Global snap tests would use a stale vp binary from ${ globalBinary } .\n` +
210+ `Expected it to match the current checkout build at ${ builtBinary } .\n` +
211+ 'Run `pnpm bootstrap-cli` or `pnpm bootstrap-cli:ci` before snap-test-global.' ,
212+ ) ;
213+ }
214+
215+ function replaceInstalledCheckoutPackages ( rootDir : string , repoRoot : string ) : void {
216+ const stack = [ rootDir ] ;
217+ const symlinkType = process . platform === 'win32' ? 'junction' : 'dir' ;
218+ const replacements = new Map ( [
219+ [ 'node_modules/vite-plus' , path . join ( repoRoot , 'packages' , 'cli' ) ] ,
220+ [ 'node_modules/vite' , path . join ( repoRoot , 'packages' , 'core' ) ] ,
221+ [ 'node_modules/vitest' , path . join ( repoRoot , 'packages' , 'test' ) ] ,
222+ [ 'node_modules/@voidzero-dev/vite-plus-core' , path . join ( repoRoot , 'packages' , 'core' ) ] ,
223+ [ 'node_modules/@voidzero-dev/vite-plus-test' , path . join ( repoRoot , 'packages' , 'test' ) ] ,
224+ ] ) ;
225+
226+ while ( stack . length > 0 ) {
227+ const dir = stack . pop ( ) ! ;
228+ for ( const [ relativePackagePath , checkoutPackageDir ] of replacements ) {
229+ const candidate = path . join ( dir , relativePackagePath ) ;
230+ if ( fs . existsSync ( candidate ) && fs . realpathSync ( candidate ) !== checkoutPackageDir ) {
231+ fs . rmSync ( candidate , { recursive : true , force : true } ) ;
232+ fs . symlinkSync ( checkoutPackageDir , candidate , symlinkType ) ;
233+ }
234+ }
235+
236+ const isNodeModulesPath = dir . split ( path . sep ) . includes ( 'node_modules' ) ;
237+ const isPnpmStorePath = dir . split ( path . sep ) . includes ( '.pnpm' ) ;
238+ for ( const entry of fs . readdirSync ( dir , { withFileTypes : true } ) ) {
239+ if ( ! entry . isDirectory ( ) || entry . name === '.git' || entry . name === '.bin' ) {
240+ continue ;
241+ }
242+ if (
243+ isNodeModulesPath &&
244+ ! isPnpmStorePath &&
245+ entry . name !== '.pnpm' &&
246+ entry . name !== '@voidzero-dev'
247+ ) {
248+ continue ;
249+ }
250+ stack . push ( path . join ( dir , entry . name ) ) ;
251+ }
252+ }
253+ }
254+
95255export async function snapTest ( ) {
96256 const { positionals, values } = parseArgs ( {
97257 allowPositionals : true ,
@@ -203,13 +363,18 @@ export async function snapTest() {
203363 const selectedCases = shard
204364 ? selectShard ( validCaseNames , shard . index , shard . total )
205365 : validCaseNames ;
366+ const globalCliScriptsDir = values [ 'bin-dir' ] ? resolveGlobalCliScriptsDir ( casesDir ) : undefined ;
367+ if ( values [ 'bin-dir' ] ) {
368+ assertGlobalCliBinaryMatchesCheckout ( values [ 'bin-dir' ] , casesDir ) ;
369+ }
206370
207371 const serialTasks : ( ( ) => Promise < void > ) [ ] = [ ] ;
208372 const parallelTasks : ( ( ) => Promise < void > ) [ ] = [ ] ;
209373 for ( const caseName of selectedCases ) {
210374 const stepsPath = path . join ( casesDir , caseName , 'steps.json' ) ;
211- const steps : Steps = JSON . parse ( readFileSync ( stepsPath , 'utf-8' ) ) ;
212- const task = ( ) => runTestCase ( caseName , tempTmpDir , casesDir , values [ 'bin-dir' ] ) ;
375+ const steps : Steps = JSON . parse ( fs . readFileSync ( stepsPath , 'utf-8' ) ) ;
376+ const task = ( ) =>
377+ runTestCase ( caseName , tempTmpDir , casesDir , values [ 'bin-dir' ] , globalCliScriptsDir ) ;
213378 if ( steps . serial ) {
214379 serialTasks . push ( task ) ;
215380 } else {
@@ -258,6 +423,11 @@ interface Steps {
258423 ignoredPlatforms ?: ( string | PlatformFilter ) [ ] ;
259424 env : Record < string , string > ;
260425 commands : ( string | Command ) [ ] ;
426+ /**
427+ * If true, installed Vite+ packages in the test project are relinked to the
428+ * current checkout after each successful command.
429+ */
430+ linkCheckoutPackages ?: boolean ;
261431 /**
262432 * Commands to run after the test completes, regardless of success or failure.
263433 * Useful for cleanup tasks like killing background processes.
@@ -322,7 +492,13 @@ function shouldSkipPlatform(ignoredPlatforms: (string | PlatformFilter)[]): bool
322492 return false ;
323493}
324494
325- async function runTestCase ( name : string , tempTmpDir : string , casesDir : string , binDir ?: string ) {
495+ async function runTestCase (
496+ name : string ,
497+ tempTmpDir : string ,
498+ casesDir : string ,
499+ binDir ?: string ,
500+ globalCliScriptsDir ?: string ,
501+ ) {
326502 const steps : Steps = JSON . parse (
327503 await fsPromises . readFile ( `${ casesDir } /${ name } /steps.json` , 'utf-8' ) ,
328504 ) ;
@@ -362,6 +538,9 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string, b
362538 VP_SKIP_INSTALL : '1' ,
363539 // make sure npm install global packages to the temporary directory
364540 NPM_CONFIG_PREFIX : path . join ( tempTmpDir , NPM_GLOBAL_PREFIX_DIR ) ,
541+ // Global CLI snap tests execute the Rust binary from --bin-dir, but the JS
542+ // entry should come from this checkout instead of a stale ~/.vite-plus install.
543+ ...( globalCliScriptsDir ? { VITE_GLOBAL_CLI_JS_SCRIPTS_DIR : globalCliScriptsDir } : { } ) ,
365544
366545 // A test case can override/unset environment variables above.
367546 // For example, VP_CLI_TEST/CI can be unset to test the real-world outputs.
@@ -409,7 +588,7 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string, b
409588 // it seems not to have stable ordering of stdout/stderr chunks.
410589 // To ensure stable ordering, we redirect outputs to a file instead.
411590 const outputStreamPath = path . join ( caseTmpDir , 'output.log' ) ;
412- const outputStream = await open ( outputStreamPath , 'w' ) ;
591+ const outputStream = await fsPromises . open ( outputStreamPath , 'w' ) ;
413592
414593 const exitCode = await Promise . race ( [
415594 execute ( stripComments ( cmd . command ) , [ ] , {
@@ -431,8 +610,11 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string, b
431610 ] ) ;
432611
433612 await outputStream . close ( ) ;
613+ if ( exitCode === 0 && globalCliScriptsDir && steps . linkCheckoutPackages ) {
614+ replaceInstalledCheckoutPackages ( caseTmpDir , resolveRepoRoot ( casesDir ) ) ;
615+ }
434616
435- let output = readFileSync ( outputStreamPath , 'utf-8' ) ;
617+ let output = fs . readFileSync ( outputStreamPath , 'utf-8' ) ;
436618
437619 let commandLine = `> ${ cmd . command } ` ;
438620 if ( exitCode !== 0 ) {
0 commit comments