1+ import { resolve , sep } from 'node:path'
12import { beforeEach , describe , expect , it , vi } from 'vitest'
2- import { createResolver , getRoots } from './resolver'
3+ import {
4+ computeInvocationSearchPaths ,
5+ createResolver ,
6+ findInvocationNodeModulesPath ,
7+ getRoots ,
8+ } from './resolver'
39
410const vitefuMocks = vi . hoisted ( ( ) => {
511 const FAKE_ROOT = '/user/project'
@@ -21,6 +27,58 @@ const vitefuMocks = vi.hoisted(() => {
2127
2228vi . mock ( 'vitefu' , ( ) => vitefuMocks )
2329
30+ // Virtual filesystem the `node:fs` mock reads from. Tests populate it with
31+ // the paths they care about before exercising the resolver helpers.
32+ type FsNodeKind = 'dir' | 'file' | 'symlink'
33+ interface FsNode { kind : FsNodeKind , target ?: string }
34+ const fsMocks = vi . hoisted ( ( ) => {
35+ const tree = new Map < string , { kind : 'dir' | 'file' | 'symlink' , target ?: string } > ( )
36+ const ENOENT = ( path : string ) => {
37+ const err = Object . assign ( new Error ( `ENOENT: no such file or directory '${ path } '` ) , { code : 'ENOENT' } )
38+ return err
39+ }
40+ return {
41+ tree,
42+ existsSync : vi . fn ( ( path : string ) => tree . has ( path ) ) ,
43+ lstatSync : vi . fn ( ( path : string ) => {
44+ const node = tree . get ( path )
45+ if ( ! node )
46+ throw ENOENT ( path )
47+ return {
48+ isSymbolicLink : ( ) => node . kind === 'symlink' ,
49+ isDirectory : ( ) => node . kind === 'dir' ,
50+ isFile : ( ) => node . kind === 'file' ,
51+ }
52+ } ) ,
53+ readlinkSync : vi . fn ( ( path : string ) => {
54+ const node = tree . get ( path )
55+ if ( ! node || node . kind !== 'symlink' )
56+ throw Object . assign ( new Error ( `EINVAL: readlink '${ path } '` ) , { code : 'EINVAL' } )
57+ return node . target !
58+ } ) ,
59+ readdirSync : vi . fn ( ( path : string ) => {
60+ const node = tree . get ( path )
61+ if ( ! node || node . kind !== 'dir' )
62+ throw Object . assign ( new Error ( `ENOTDIR: scandir '${ path } '` ) , { code : 'ENOTDIR' } )
63+ const prefix = path . endsWith ( sep ) ? path : `${ path } ${ sep } `
64+ const children = new Set < string > ( )
65+ for ( const key of tree . keys ( ) ) {
66+ if ( key === path )
67+ continue
68+ if ( key . startsWith ( prefix ) ) {
69+ const rest = key . slice ( prefix . length )
70+ const first = rest . split ( sep ) [ 0 ]
71+ if ( first )
72+ children . add ( first )
73+ }
74+ }
75+ return Array . from ( children ) . sort ( )
76+ } ) ,
77+ }
78+ } )
79+
80+ vi . mock ( 'node:fs' , ( ) => fsMocks )
81+
2482describe ( 'createResolver' , ( ) => {
2583 beforeEach ( async ( ) => {
2684 await getRoots ( '/user/project' )
@@ -137,3 +195,173 @@ describe('createResolver', () => {
137195 )
138196 } )
139197} )
198+
199+ function setNode ( path : string , node : FsNode ) {
200+ fsMocks . tree . set ( resolve ( path ) , node )
201+ }
202+ function dir ( path : string ) {
203+ setNode ( path , { kind : 'dir' } )
204+ }
205+ function symlink ( path : string , target : string ) {
206+ // `readlinkSync` returns the target verbatim — relative or absolute — so we
207+ // store it as-given rather than resolving eagerly, so relative-target paths
208+ // exercise the dirname-relative branch in `findInvocationNodeModulesPath`.
209+ setNode ( path , { kind : 'symlink' , target } )
210+ }
211+
212+ describe ( 'findInvocationNodeModulesPath' , ( ) => {
213+ beforeEach ( ( ) => {
214+ fsMocks . tree . clear ( )
215+ } )
216+
217+ it ( 'returns undefined when argv1 is undefined' , ( ) => {
218+ expect ( findInvocationNodeModulesPath ( undefined ) ) . toBeUndefined ( )
219+ } )
220+
221+ it ( 'returns undefined when argv1 is empty' , ( ) => {
222+ expect ( findInvocationNodeModulesPath ( '' ) ) . toBeUndefined ( )
223+ } )
224+
225+ it ( 'extracts the trailing node_modules from a pnpm v11 shell-shim dispatched path' , ( ) => {
226+ // `{PNPM_HOME}/bin/slidev` shell shim exec's `node <path-to-cli>.mjs`.
227+ const argv1 = resolve ( '/pnpm-home/v11/abc123/node_modules/@slidev/cli/bin/slidev.mjs' )
228+ expect ( findInvocationNodeModulesPath ( argv1 ) ) . toBe (
229+ resolve ( '/pnpm-home/v11/abc123/node_modules' ) ,
230+ )
231+ } )
232+
233+ it ( 'extracts node_modules from an npm/yarn globals path' , ( ) => {
234+ const argv1 = resolve ( '/usr/local/lib/node_modules/@slidev/cli/bin/slidev.mjs' )
235+ expect ( findInvocationNodeModulesPath ( argv1 ) ) . toBe (
236+ resolve ( '/usr/local/lib/node_modules' ) ,
237+ )
238+ } )
239+
240+ it ( 'extracts node_modules from a local project install path' , ( ) => {
241+ const argv1 = resolve ( '/my/project/node_modules/@slidev/cli/bin/slidev.mjs' )
242+ expect ( findInvocationNodeModulesPath ( argv1 ) ) . toBe (
243+ resolve ( '/my/project/node_modules' ) ,
244+ )
245+ } )
246+
247+ it ( 'uses the last node_modules segment when paths nest' , ( ) => {
248+ // Some monorepos have stacked node_modules (e.g. project-level + pnpm's
249+ // own `.pnpm` virtual store). The closest one to the script wins.
250+ const argv1 = resolve ( '/project/node_modules/.pnpm/@slidev+cli@x/node_modules/@slidev/cli/bin/slidev.mjs' )
251+ expect ( findInvocationNodeModulesPath ( argv1 ) ) . toBe (
252+ resolve ( '/project/node_modules/.pnpm/@slidev+cli@x/node_modules' ) ,
253+ )
254+ } )
255+
256+ it ( 'follows a single fs symlink when argv1 itself has no node_modules segment' , ( ) => {
257+ // Legacy `.bin` symlink layout: `{prefix}/bin/slidev` -> install group's
258+ // `node_modules/@slidev/cli/bin/slidev.mjs`.
259+ symlink ( '/usr/local/bin/slidev' , '/usr/local/lib/node_modules/@slidev/cli/bin/slidev.mjs' )
260+ expect ( findInvocationNodeModulesPath ( '/usr/local/bin/slidev' ) ) . toBe (
261+ resolve ( '/usr/local/lib/node_modules' ) ,
262+ )
263+ } )
264+
265+ it ( 'returns undefined when argv1 is neither a symlink nor under a node_modules' , ( ) => {
266+ setNode ( '/some/random/binary' , { kind : 'file' } )
267+ expect ( findInvocationNodeModulesPath ( '/some/random/binary' ) ) . toBeUndefined ( )
268+ } )
269+
270+ it ( 'returns undefined when argv1 does not exist and has no node_modules in its literal path' , ( ) => {
271+ expect ( findInvocationNodeModulesPath ( '/does/not/exist' ) ) . toBeUndefined ( )
272+ } )
273+
274+ it ( 'handles relative symlink targets' , ( ) => {
275+ // pnpm typically writes relative targets like `../../@slidev/cli/...`.
276+ symlink ( '/usr/local/bin/slidev' , '../lib/node_modules/@slidev/cli/bin/slidev.mjs' )
277+ expect ( findInvocationNodeModulesPath ( '/usr/local/bin/slidev' ) ) . toBe (
278+ resolve ( '/usr/local/lib/node_modules' ) ,
279+ )
280+ } )
281+
282+ it ( 'follows multi-hop symlink chains and stops at the first node_modules' , ( ) => {
283+ // `bin/slidev` -> `bin/slidev.real` (still under `bin/`) -> the actual
284+ // `.mjs` file under a `node_modules/`.
285+ symlink ( '/prefix/bin/slidev' , '/prefix/bin/slidev.real' )
286+ symlink ( '/prefix/bin/slidev.real' , '/prefix/lib/node_modules/@slidev/cli/bin/slidev.mjs' )
287+ expect ( findInvocationNodeModulesPath ( '/prefix/bin/slidev' ) ) . toBe (
288+ resolve ( '/prefix/lib/node_modules' ) ,
289+ )
290+ } )
291+
292+ it ( 'gives up after a long symlink chain that never enters a node_modules' , ( ) => {
293+ // Build a 20-hop chain of symlinks that all point at each other,
294+ // none of which contain `node_modules`.
295+ for ( let i = 0 ; i < 20 ; i ++ )
296+ symlink ( `/chain/${ i } ` , `/chain/${ i + 1 } ` )
297+ setNode ( resolve ( '/chain/20' ) , { kind : 'file' } )
298+ expect ( findInvocationNodeModulesPath ( '/chain/0' ) ) . toBeUndefined ( )
299+ } )
300+ } )
301+
302+ describe ( 'computeInvocationSearchPaths' , ( ) => {
303+ beforeEach ( ( ) => {
304+ fsMocks . tree . clear ( )
305+ } )
306+
307+ it ( 'returns an empty array when input is undefined' , ( ) => {
308+ expect ( computeInvocationSearchPaths ( undefined ) ) . toEqual ( [ ] )
309+ } )
310+
311+ it ( 'returns just the input when there are no siblings to enumerate' , ( ) => {
312+ // The grandparent of `ownNodeModules` doesn't contain any other install
313+ // groups (e.g. npm globals layout: only `/usr/local/lib/node_modules`).
314+ dir ( '/usr/local' )
315+ dir ( '/usr/local/lib' )
316+ dir ( '/usr/local/lib/node_modules' )
317+ expect ( computeInvocationSearchPaths ( resolve ( '/usr/local/lib/node_modules' ) ) ) . toEqual ( [
318+ resolve ( '/usr/local/lib/node_modules' ) ,
319+ ] )
320+ } )
321+
322+ it ( 'enumerates every sibling install group in pnpm v11\'s isolated globals layout' , ( ) => {
323+ // pnpm v11 creates one `{hash}/` directory per top-level `pnpm add -g`
324+ // target. Each has its own `node_modules/`.
325+ dir ( '/pnpm-home/v11' )
326+ dir ( '/pnpm-home/v11/abc' )
327+ dir ( '/pnpm-home/v11/abc/node_modules' )
328+ dir ( '/pnpm-home/v11/def' )
329+ dir ( '/pnpm-home/v11/def/node_modules' )
330+ dir ( '/pnpm-home/v11/ghi' )
331+ dir ( '/pnpm-home/v11/ghi/node_modules' )
332+ const result = computeInvocationSearchPaths ( resolve ( '/pnpm-home/v11/abc/node_modules' ) )
333+ expect ( result ) . toEqual ( expect . arrayContaining ( [
334+ resolve ( '/pnpm-home/v11/abc/node_modules' ) ,
335+ resolve ( '/pnpm-home/v11/def/node_modules' ) ,
336+ resolve ( '/pnpm-home/v11/ghi/node_modules' ) ,
337+ ] ) )
338+ expect ( result [ 0 ] ) . toBe ( resolve ( '/pnpm-home/v11/abc/node_modules' ) )
339+ expect ( result ) . toHaveLength ( 3 )
340+ } )
341+
342+ it ( 'skips sibling install groups that have no node_modules subdirectory' , ( ) => {
343+ dir ( '/pnpm-home/v11' )
344+ dir ( '/pnpm-home/v11/abc' )
345+ dir ( '/pnpm-home/v11/abc/node_modules' )
346+ dir ( '/pnpm-home/v11/empty' ) // no node_modules child
347+ expect ( computeInvocationSearchPaths ( resolve ( '/pnpm-home/v11/abc/node_modules' ) ) ) . toEqual ( [
348+ resolve ( '/pnpm-home/v11/abc/node_modules' ) ,
349+ ] )
350+ } )
351+
352+ it ( 'returns just the input when the global root is unreadable' , ( ) => {
353+ // Grandparent isn't listed in the virtual fs at all -> readdirSync throws.
354+ expect ( computeInvocationSearchPaths ( resolve ( '/missing-root/abc/node_modules' ) ) ) . toEqual ( [
355+ resolve ( '/missing-root/abc/node_modules' ) ,
356+ ] )
357+ } )
358+
359+ it ( 'does not include the input directory as its own sibling' , ( ) => {
360+ dir ( '/pnpm-home/v11' )
361+ dir ( '/pnpm-home/v11/abc' )
362+ dir ( '/pnpm-home/v11/abc/node_modules' )
363+ const result = computeInvocationSearchPaths ( resolve ( '/pnpm-home/v11/abc/node_modules' ) )
364+ // Exactly one occurrence of the input path.
365+ expect ( result . filter ( p => p === resolve ( '/pnpm-home/v11/abc/node_modules' ) ) ) . toHaveLength ( 1 )
366+ } )
367+ } )
0 commit comments