|
1 | | -import { describe, expect, it, vi } from 'vitest' |
2 | | -import type { PackumentVersion } from '../../../../shared/types' |
| 1 | +import { describe, expect, it, vi, beforeEach } from 'vitest' |
| 2 | +import type { Packument, PackumentVersion } from '../../../../shared/types' |
3 | 3 |
|
4 | 4 | // Mock Nitro globals before importing the module |
5 | 5 | vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) |
6 | 6 | vi.stubGlobal('$fetch', vi.fn()) |
7 | 7 |
|
8 | | -const { TARGET_PLATFORM, matchesPlatform, resolveVersion } = |
| 8 | +const mockFetchNpmPackage = vi.fn<(name: string) => Promise<Packument | null>>() |
| 9 | +vi.stubGlobal('fetchNpmPackage', mockFetchNpmPackage) |
| 10 | + |
| 11 | +const { TARGET_PLATFORM, matchesPlatform, resolveVersion, resolveDependencyTree } = |
9 | 12 | await import('../../../../server/utils/dependency-resolver') |
10 | 13 |
|
| 14 | +/** |
| 15 | + * Helper to build a minimal Packument for mocking. |
| 16 | + */ |
| 17 | +function makePackument( |
| 18 | + name: string, |
| 19 | + versions: Array<{ |
| 20 | + version: string |
| 21 | + deps?: Record<string, string> |
| 22 | + optionalDeps?: Record<string, string> |
| 23 | + os?: string[] |
| 24 | + cpu?: string[] |
| 25 | + libc?: string[] |
| 26 | + unpackedSize?: number |
| 27 | + deprecated?: string |
| 28 | + }>, |
| 29 | +): Packument { |
| 30 | + const versionsMap: Record<string, PackumentVersion> = {} |
| 31 | + for (const v of versions) { |
| 32 | + versionsMap[v.version] = { |
| 33 | + version: v.version, |
| 34 | + dependencies: v.deps, |
| 35 | + optionalDependencies: v.optionalDeps, |
| 36 | + os: v.os, |
| 37 | + cpu: v.cpu, |
| 38 | + ...(v.libc ? { libc: v.libc } : {}), |
| 39 | + dist: { unpackedSize: v.unpackedSize }, |
| 40 | + ...(v.deprecated ? { deprecated: v.deprecated } : {}), |
| 41 | + } as unknown as PackumentVersion |
| 42 | + } |
| 43 | + return { name, versions: versionsMap } as Packument |
| 44 | +} |
| 45 | + |
11 | 46 | describe('dependency-resolver', () => { |
12 | 47 | describe('TARGET_PLATFORM', () => { |
13 | 48 | it('is configured for linux-x64-glibc', () => { |
@@ -149,4 +184,230 @@ describe('dependency-resolver', () => { |
149 | 184 | expect(resolveVersion('^2.0.0-beta.0', versions)).toBe('2.0.0') |
150 | 185 | }) |
151 | 186 | }) |
| 187 | + |
| 188 | + describe('resolveDependencyTree', () => { |
| 189 | + beforeEach(() => { |
| 190 | + mockFetchNpmPackage.mockReset() |
| 191 | + }) |
| 192 | + |
| 193 | + it('resolves a single package with no dependencies', async () => { |
| 194 | + mockFetchNpmPackage.mockResolvedValue( |
| 195 | + makePackument('root', [{ version: '1.0.0', unpackedSize: 5000 }]), |
| 196 | + ) |
| 197 | + |
| 198 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 199 | + |
| 200 | + expect(result.size).toBe(1) |
| 201 | + const pkg = result.get('root@1.0.0') |
| 202 | + expect(pkg).toEqual({ name: 'root', version: '1.0.0', size: 5000, optional: false }) |
| 203 | + }) |
| 204 | + |
| 205 | + it('resolves direct dependencies', async () => { |
| 206 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 207 | + if (name === 'root') |
| 208 | + return makePackument('root', [ |
| 209 | + { |
| 210 | + version: '1.0.0', |
| 211 | + deps: { 'dep-a': '^1.0.0', 'dep-b': '^2.0.0' }, |
| 212 | + unpackedSize: 1000, |
| 213 | + }, |
| 214 | + ]) |
| 215 | + if (name === 'dep-a') |
| 216 | + return makePackument('dep-a', [{ version: '1.2.0', unpackedSize: 2000 }]) |
| 217 | + if (name === 'dep-b') |
| 218 | + return makePackument('dep-b', [{ version: '2.1.0', unpackedSize: 3000 }]) |
| 219 | + return null |
| 220 | + }) |
| 221 | + |
| 222 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 223 | + |
| 224 | + expect(result.size).toBe(3) |
| 225 | + expect(result.get('root@1.0.0')).toMatchObject({ name: 'root', version: '1.0.0' }) |
| 226 | + expect(result.get('dep-a@1.2.0')).toMatchObject({ |
| 227 | + name: 'dep-a', |
| 228 | + version: '1.2.0', |
| 229 | + size: 2000, |
| 230 | + optional: false, |
| 231 | + }) |
| 232 | + expect(result.get('dep-b@2.1.0')).toMatchObject({ |
| 233 | + name: 'dep-b', |
| 234 | + version: '2.1.0', |
| 235 | + size: 3000, |
| 236 | + optional: false, |
| 237 | + }) |
| 238 | + }) |
| 239 | + |
| 240 | + it('resolves transitive dependencies (A → B → C)', async () => { |
| 241 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 242 | + if (name === 'a') return makePackument('a', [{ version: '1.0.0', deps: { b: '^1.0.0' } }]) |
| 243 | + if (name === 'b') return makePackument('b', [{ version: '1.0.0', deps: { c: '^1.0.0' } }]) |
| 244 | + if (name === 'c') return makePackument('c', [{ version: '1.0.0' }]) |
| 245 | + return null |
| 246 | + }) |
| 247 | + |
| 248 | + const result = await resolveDependencyTree('a', '1.0.0') |
| 249 | + |
| 250 | + expect(result.size).toBe(3) |
| 251 | + expect(result.has('a@1.0.0')).toBe(true) |
| 252 | + expect(result.has('b@1.0.0')).toBe(true) |
| 253 | + expect(result.has('c@1.0.0')).toBe(true) |
| 254 | + }) |
| 255 | + |
| 256 | + it('handles circular dependencies without infinite loop', async () => { |
| 257 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 258 | + if (name === 'a') return makePackument('a', [{ version: '1.0.0', deps: { b: '^1.0.0' } }]) |
| 259 | + if (name === 'b') return makePackument('b', [{ version: '1.0.0', deps: { a: '^1.0.0' } }]) |
| 260 | + return null |
| 261 | + }) |
| 262 | + |
| 263 | + const result = await resolveDependencyTree('a', '1.0.0') |
| 264 | + |
| 265 | + expect(result.size).toBe(2) |
| 266 | + expect(result.has('a@1.0.0')).toBe(true) |
| 267 | + expect(result.has('b@1.0.0')).toBe(true) |
| 268 | + }) |
| 269 | + |
| 270 | + it('marks optional dependencies with optional: true', async () => { |
| 271 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 272 | + if (name === 'root') |
| 273 | + return makePackument('root', [ |
| 274 | + { version: '1.0.0', optionalDeps: { 'opt-dep': '^1.0.0' } }, |
| 275 | + ]) |
| 276 | + if (name === 'opt-dep') |
| 277 | + return makePackument('opt-dep', [{ version: '1.0.0', unpackedSize: 500 }]) |
| 278 | + return null |
| 279 | + }) |
| 280 | + |
| 281 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 282 | + |
| 283 | + expect(result.size).toBe(2) |
| 284 | + expect(result.get('opt-dep@1.0.0')).toMatchObject({ optional: true }) |
| 285 | + }) |
| 286 | + |
| 287 | + it('skips dependencies that do not match the target platform', async () => { |
| 288 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 289 | + if (name === 'root') |
| 290 | + return makePackument('root', [ |
| 291 | + { version: '1.0.0', deps: { 'darwin-only': '^1.0.0', 'linux-ok': '^1.0.0' } }, |
| 292 | + ]) |
| 293 | + if (name === 'darwin-only') |
| 294 | + return makePackument('darwin-only', [{ version: '1.0.0', os: ['darwin'] }]) |
| 295 | + if (name === 'linux-ok') |
| 296 | + return makePackument('linux-ok', [{ version: '1.0.0', os: ['linux'] }]) |
| 297 | + return null |
| 298 | + }) |
| 299 | + |
| 300 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 301 | + |
| 302 | + expect(result.has('darwin-only@1.0.0')).toBe(false) |
| 303 | + expect(result.has('linux-ok@1.0.0')).toBe(true) |
| 304 | + }) |
| 305 | + |
| 306 | + it('skips dependencies with unresolvable version ranges', async () => { |
| 307 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 308 | + if (name === 'root') |
| 309 | + return makePackument('root', [{ version: '1.0.0', deps: { missing: '^99.0.0' } }]) |
| 310 | + if (name === 'missing') return makePackument('missing', [{ version: '1.0.0' }]) |
| 311 | + return null |
| 312 | + }) |
| 313 | + |
| 314 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 315 | + |
| 316 | + expect(result.size).toBe(1) |
| 317 | + expect(result.has('root@1.0.0')).toBe(true) |
| 318 | + }) |
| 319 | + |
| 320 | + it('continues resolving when fetchPackument fails for a dependency', async () => { |
| 321 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 322 | + if (name === 'root') |
| 323 | + return makePackument('root', [ |
| 324 | + { version: '1.0.0', deps: { broken: '^1.0.0', healthy: '^1.0.0' } }, |
| 325 | + ]) |
| 326 | + if (name === 'broken') return null |
| 327 | + if (name === 'healthy') return makePackument('healthy', [{ version: '1.0.0' }]) |
| 328 | + return null |
| 329 | + }) |
| 330 | + |
| 331 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 332 | + |
| 333 | + expect(result.size).toBe(2) |
| 334 | + expect(result.has('root@1.0.0')).toBe(true) |
| 335 | + expect(result.has('healthy@1.0.0')).toBe(true) |
| 336 | + expect(result.has('broken@1.0.0')).toBe(false) |
| 337 | + }) |
| 338 | + |
| 339 | + it('assigns depth and path when trackDepth is enabled', async () => { |
| 340 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 341 | + if (name === 'root') |
| 342 | + return makePackument('root', [{ version: '1.0.0', deps: { mid: '^1.0.0' } }]) |
| 343 | + if (name === 'mid') |
| 344 | + return makePackument('mid', [{ version: '1.0.0', deps: { leaf: '^1.0.0' } }]) |
| 345 | + if (name === 'leaf') return makePackument('leaf', [{ version: '1.0.0' }]) |
| 346 | + return null |
| 347 | + }) |
| 348 | + |
| 349 | + const result = await resolveDependencyTree('root', '1.0.0', { trackDepth: true }) |
| 350 | + |
| 351 | + expect(result.get('root@1.0.0')).toMatchObject({ |
| 352 | + depth: 'root', |
| 353 | + path: ['root@1.0.0'], |
| 354 | + }) |
| 355 | + expect(result.get('mid@1.0.0')).toMatchObject({ |
| 356 | + depth: 'direct', |
| 357 | + path: ['root@1.0.0', 'mid@1.0.0'], |
| 358 | + }) |
| 359 | + expect(result.get('leaf@1.0.0')).toMatchObject({ |
| 360 | + depth: 'transitive', |
| 361 | + path: ['root@1.0.0', 'mid@1.0.0', 'leaf@1.0.0'], |
| 362 | + }) |
| 363 | + }) |
| 364 | + |
| 365 | + it('does not include depth/path when trackDepth is not enabled', async () => { |
| 366 | + mockFetchNpmPackage.mockResolvedValue(makePackument('root', [{ version: '1.0.0' }])) |
| 367 | + |
| 368 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 369 | + |
| 370 | + const pkg = result.get('root@1.0.0')! |
| 371 | + expect(pkg.depth).toBeUndefined() |
| 372 | + expect(pkg.path).toBeUndefined() |
| 373 | + }) |
| 374 | + |
| 375 | + it('includes deprecated field on deprecated versions', async () => { |
| 376 | + mockFetchNpmPackage.mockResolvedValue( |
| 377 | + makePackument('root', [{ version: '1.0.0', deprecated: 'Use v2 instead' }]), |
| 378 | + ) |
| 379 | + |
| 380 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 381 | + |
| 382 | + expect(result.get('root@1.0.0')).toMatchObject({ deprecated: 'Use v2 instead' }) |
| 383 | + }) |
| 384 | + |
| 385 | + it('defaults size to 0 when unpackedSize is missing', async () => { |
| 386 | + mockFetchNpmPackage.mockResolvedValue(makePackument('root', [{ version: '1.0.0' }])) |
| 387 | + |
| 388 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 389 | + |
| 390 | + expect(result.get('root@1.0.0')!.size).toBe(0) |
| 391 | + }) |
| 392 | + |
| 393 | + it('deduplicates the same name@version appearing via multiple paths', async () => { |
| 394 | + // root → a, root → b, both a and b depend on shared@1.0.0 |
| 395 | + mockFetchNpmPackage.mockImplementation(async (name: string) => { |
| 396 | + if (name === 'root') |
| 397 | + return makePackument('root', [{ version: '1.0.0', deps: { a: '^1.0.0', b: '^1.0.0' } }]) |
| 398 | + if (name === 'a') |
| 399 | + return makePackument('a', [{ version: '1.0.0', deps: { shared: '^1.0.0' } }]) |
| 400 | + if (name === 'b') |
| 401 | + return makePackument('b', [{ version: '1.0.0', deps: { shared: '^1.0.0' } }]) |
| 402 | + if (name === 'shared') return makePackument('shared', [{ version: '1.0.0' }]) |
| 403 | + return null |
| 404 | + }) |
| 405 | + |
| 406 | + const result = await resolveDependencyTree('root', '1.0.0') |
| 407 | + |
| 408 | + // root + a + b + shared (only once) |
| 409 | + expect(result.size).toBe(4) |
| 410 | + expect(result.has('shared@1.0.0')).toBe(true) |
| 411 | + }) |
| 412 | + }) |
152 | 413 | }) |
0 commit comments