返回 slidev
resolver.test.ts
根目录 / packages / slidev / node / resolver.test.ts
1 import { resolve, sep } from 'pathe'
2 import { beforeEach, describe, expect, it, vi } from 'vitest'
3 import {
4 computeInvocationSearchPaths,
5 createResolver,
6 findInvocationNodeModulesPath,
7 getRoots,
8 } from './resolver'
9
10 const vitefuMocks = vi.hoisted(() => {
11 const FAKE_ROOT = '/user/project'
12 return {
13 findDepPkgJsonPath: vi.fn((name: string) => {
14 if (name === '@slidev/client')
15 return `${FAKE_ROOT}/node_modules/@slidev/client/package.json`
16 if (name === '@slidev/theme-official')
17 return `${FAKE_ROOT}/node_modules/@slidev/theme-official/package.json`
18 if (name === 'slidev-theme-xyz')
19 return `${FAKE_ROOT}/node_modules/slidev-theme-xyz/package.json`
20 if (name === 'slidev-theme-full')
21 return `${FAKE_ROOT}/node_modules/slidev-theme-full/package.json`
22 return null
23 }),
24 findClosestPkgJsonPath: vi.fn().mockResolvedValue(`${FAKE_ROOT}/package.json`),
25 }
26 })
27
28 vi.mock('vitefu', () => vitefuMocks)
29
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
82 describe('createResolver', () => {
83 beforeEach(async () => {
84 await getRoots('/user/project')
85 })
86
87 it('resolves @slidev/ official prefixed theme', async () => {
88 const resolver = createResolver('theme', {})
89
90 const res = await resolver('official', '/')
91
92 expect(res).toEqual([
93 '@slidev/theme-official',
94 '/user/project/node_modules/@slidev/theme-official',
95 ],
96 )
97 })
98
99 it('resolves slidev-theme- third party prefixed theme', async () => {
100 const resolver = createResolver('theme', {})
101
102 const res = await resolver('xyz', '/')
103
104 expect(res).toEqual([
105 'slidev-theme-xyz',
106 '/user/project/node_modules/slidev-theme-xyz',
107 ],
108 )
109 })
110
111 it('resolves fully-specified package', async () => {
112 const resolver = createResolver('theme', {})
113
114 const res = await resolver('slidev-theme-full', '/')
115
116 expect(res).toEqual([
117 'slidev-theme-full',
118 '/user/project/node_modules/slidev-theme-full',
119 ],
120 )
121 })
122
123 it('resolves valid scoped name through validation', async () => {
124 const resolver = createResolver('theme', {})
125
126 const res = await resolver('@slidev/theme-official', '/')
127
128 expect(res).toEqual([
129 '@slidev/theme-official',
130 '/user/project/node_modules/@slidev/theme-official',
131 ])
132 })
133
134 it('passes through "none" without validation', async () => {
135 const resolver = createResolver('theme', {})
136
137 const res = await resolver('none', '/')
138
139 expect(res).toEqual(['', null])
140 })
141
142 it('rejects names with shell metacharacters', async () => {
143 const resolver = createResolver('theme', {})
144
145 await expect(resolver('evil$(rm -rf)', '/')).rejects.toThrowError(
146 'Invalid theme name "evil$(rm -rf)". Only valid npm package names are allowed.',
147 )
148 })
149
150 it('rejects names with backslashes', async () => {
151 const resolver = createResolver('theme', {})
152
153 await expect(resolver('evil\\package', '/')).rejects.toThrowError(
154 /Invalid theme name/,
155 )
156 })
157
158 it('rejects names with uppercase letters', async () => {
159 const resolver = createResolver('theme', {})
160
161 await expect(resolver('EvilTheme', '/')).rejects.toThrowError(
162 /Invalid theme name/,
163 )
164 })
165
166 it('rejects scoped names with extra slash segments', async () => {
167 const resolver = createResolver('theme', {})
168
169 await expect(resolver('@scope/foo/bar', '/')).rejects.toThrowError(
170 /Invalid theme name/,
171 )
172 })
173
174 it('rejects scoped names with parent traversal segment', async () => {
175 const resolver = createResolver('theme', {})
176
177 await expect(resolver('@evil/../escape', '/')).rejects.toThrowError(
178 /Invalid theme name/,
179 )
180 })
181
182 it('rejects empty string', async () => {
183 const resolver = createResolver('theme', {})
184
185 await expect(resolver('', '/')).rejects.toThrowError(
186 /Invalid theme name/,
187 )
188 })
189
190 it('reflects the resolver type in the error message', async () => {
191 const resolver = createResolver('addon', {})
192
193 await expect(resolver('evil$(rm -rf)', '/')).rejects.toThrowError(
194 'Invalid addon name "evil$(rm -rf)". Only valid npm package names are allowed.',
195 )
196 })
197 })
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 })
368
368 lines TYPESCRIPT