| 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 |