Skip to content

Commit 94f95fa

Browse files
authored
test: add coverage for critical resolveDependencyTree (#1911)
1 parent f2045e6 commit 94f95fa

File tree

1 file changed

+264
-3
lines changed

1 file changed

+264
-3
lines changed

test/unit/server/utils/dependency-resolver.spec.ts

Lines changed: 264 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
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'
33

44
// Mock Nitro globals before importing the module
55
vi.stubGlobal('defineCachedFunction', (fn: Function) => fn)
66
vi.stubGlobal('$fetch', vi.fn())
77

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 } =
912
await import('../../../../server/utils/dependency-resolver')
1013

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+
1146
describe('dependency-resolver', () => {
1247
describe('TARGET_PLATFORM', () => {
1348
it('is configured for linux-x64-glibc', () => {
@@ -149,4 +184,230 @@ describe('dependency-resolver', () => {
149184
expect(resolveVersion('^2.0.0-beta.0', versions)).toBe('2.0.0')
150185
})
151186
})
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+
})
152413
})

0 commit comments

Comments
 (0)