gen-dependency-catalog.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. /** Production dependency selection and offline catalog freshness regressions. */
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join, resolve } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
  7. import {
  8. collectDependencies, computeDependencyCatalog, createNpmResolutionEnvironment,
  9. deduplicateDependencies, isDependencyCatalogCurrent,
  10. } from './gen-dependency-catalog.ts'
  11. const roots: string[] = []
  12. afterEach(() => {
  13. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
  14. })
  15. function lockfile() {
  16. const lock = {
  17. lockfileVersion: 3,
  18. packages: {
  19. '': { dependencies: { '@deepseek-ai/dsh': 'latest' } },
  20. 'node_modules/@deepseek-ai/dsh': {
  21. version: '1.0.0', dependencies: { library: '^2.0.0', alias: 'npm:original@1.0.0' },
  22. optionalDependencies: { unavailable: '1.0.0' },
  23. },
  24. 'node_modules/library': { version: '1.0.0' },
  25. 'node_modules/@deepseek-ai/dsh/node_modules/library': { version: '2.0.0', dependencies: { plugin: '*' } },
  26. 'node_modules/alias': { name: 'original', version: '1.0.0' },
  27. 'node_modules/plugin': {
  28. version: '1.0.0', dependencies: { library: '^1.0.0' }, peerDependencies: { peer: '*' },
  29. optionalDependencies: { 'native-linux': '*', 'native-helper': '*' },
  30. },
  31. 'node_modules/peer': { version: '3.0.0', peer: true },
  32. 'node_modules/native-linux': { version: '1.0.0', optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'] },
  33. 'node_modules/native-helper': { version: '1.0.0', optional: true, devOptional: true },
  34. 'node_modules/test-only': { version: '1.0.0', dev: true },
  35. },
  36. }
  37. for (const [location, entry] of Object.entries(lock.packages)) {
  38. if (location !== '') Object.assign(entry, { resolved: `https://registry.npmjs.org/${location}/package.tgz` })
  39. }
  40. return lock
  41. }
  42. function fixture(): string {
  43. const root = mkdtempSync(join(tmpdir(), 'dsh-dependency-catalog-test-'))
  44. roots.push(root)
  45. mkdirSync(join(root, 'scripts/dependency-catalog'), { recursive: true })
  46. writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(lockfile()))
  47. writeFileSync(join(root, 'scripts/dependency-catalog/resolution.json'), JSON.stringify({
  48. capturedAt: '2026-09-12T00:00:00.000Z', npm: '11.17.0', node: '24.19.0',
  49. platform: 'darwin', arch: 'arm64', registry: 'https://registry.npmjs.org/', installStrategy: 'hoisted',
  50. }))
  51. return root
  52. }
  53. describe('published npm dependency catalog', () => {
  54. it('retains nested versions, aliases, peers, and optional candidates while excluding development-only packages', () => {
  55. const { version, rows } = collectDependencies(lockfile())
  56. expect(version).toBe('1.0.0')
  57. expect(rows).toHaveLength(7)
  58. expect(rows.filter(row => row.direct).map(row => [row.name, row.version])).toEqual([
  59. ['library', '2.0.0'], ['original', '1.0.0'],
  60. ])
  61. expect(rows.find(row => row.name === 'peer')).toMatchObject({ peer: true, optional: false })
  62. expect(rows.find(row => row.name === 'native-linux')).toMatchObject({
  63. optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'],
  64. })
  65. expect(rows.find(row => row.name === 'native-helper')).toMatchObject({ optional: true })
  66. expect(rows.map(row => row.name)).not.toContain('test-only')
  67. expect(rows.map(row => row.name)).not.toContain('unavailable')
  68. })
  69. it('does not silently produce an incomplete catalog from invalid or unrelated resolutions', () => {
  70. expect(() => collectDependencies({ lockfileVersion: 2 })).toThrow('lockfileVersion 3')
  71. expect(() => collectDependencies({ lockfileVersion: 3, packages: {} })).toThrow('consumer must be an object')
  72. const input = lockfile()
  73. input.packages[''].dependencies['@deepseek-ai/dsh'] = 'next'
  74. expect(() => collectDependencies(input)).toThrow('must request only @deepseek-ai/dsh@latest')
  75. const missing = lockfile()
  76. Reflect.deleteProperty(missing.packages, 'node_modules/alias')
  77. expect(() => collectDependencies(missing)).toThrow('missing direct dependency alias')
  78. const linked = lockfile()
  79. Object.assign(linked.packages['node_modules/library'], { link: true })
  80. expect(() => collectDependencies(linked)).toThrow('is a local link')
  81. })
  82. it('sorts packages independently of lockfile object order and keeps duplicate installation locations', () => {
  83. const input = lockfile()
  84. const reordered = { ...input, packages: Object.fromEntries(Object.entries(input.packages).reverse()) }
  85. expect(collectDependencies(reordered)).toEqual(collectDependencies(input))
  86. Object.assign(input.packages, {
  87. 'node_modules/plugin/node_modules/library': {
  88. version: '1.0.0', resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
  89. },
  90. })
  91. expect(collectDependencies(input).rows.filter(row => row.name === 'library' && row.version === '1.0.0')).toHaveLength(2)
  92. })
  93. it('deduplicates identical versions without losing installation locations or optional flags', () => {
  94. const input = lockfile()
  95. Object.assign(input.packages, {
  96. 'node_modules/plugin/node_modules/library': {
  97. version: '1.0.0', optional: true, resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
  98. },
  99. })
  100. const rows = collectDependencies(input).rows
  101. const packages = deduplicateDependencies(rows)
  102. const repeated = packages.filter(entry => entry.name === 'library' && entry.version === '1.0.0')
  103. expect(repeated).toHaveLength(1)
  104. expect(repeated[0]?.installations).toEqual([
  105. { location: 'node_modules/library', direct: false, optional: false, peer: false },
  106. { location: 'node_modules/plugin/node_modules/library', direct: false, optional: true, peer: false },
  107. ])
  108. expect(packages.filter(entry => entry.name === 'library').map(entry => entry.version)).toEqual(['1.0.0', '2.0.0'])
  109. expect(deduplicateDependencies([...rows].reverse())).toEqual(packages)
  110. expect(packages.flatMap(entry => entry.installations)).toHaveLength(rows.length)
  111. })
  112. it('rejects missing, edited, or stale JSON without rewriting it', () => {
  113. const root = fixture()
  114. const output = computeDependencyCatalog(root)
  115. const path = join(root, 'docs/dependency-catalog.json')
  116. expect(isDependencyCatalogCurrent(root)).toBe(false)
  117. mkdirSync(join(root, 'docs'))
  118. writeFileSync(path, output)
  119. expect(isDependencyCatalogCurrent(root)).toBe(true)
  120. writeFileSync(path, `${output}stale\n`)
  121. expect(isDependencyCatalogCurrent(root)).toBe(false)
  122. expect(readFileSync(path, 'utf8')).toBe(`${output}stale\n`)
  123. writeFileSync(path, output)
  124. const updated = lockfile()
  125. updated.packages['node_modules/peer'].version = '3.1.0'
  126. writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(updated))
  127. expect(isDependencyCatalogCurrent(root)).toBe(false)
  128. })
  129. it('rejects non-public package sources and incorrect registry metadata', () => {
  130. const input = lockfile()
  131. Object.assign(input.packages['node_modules/library'], { resolved: 'https://private.invalid/library.tgz' })
  132. expect(() => collectDependencies(input)).toThrow('not resolved from the public npm registry')
  133. const root = fixture()
  134. const metadataPath = join(root, 'scripts/dependency-catalog/resolution.json')
  135. writeFileSync(metadataPath, JSON.stringify({ registry: 'https://private.invalid/', installStrategy: 'hoisted' }))
  136. expect(() => computeDependencyCatalog(root)).toThrow('expected the public npm registry and hoisted install strategy')
  137. })
  138. it('isolates scoped registries, resolver settings, and caches from user, global, and environment configuration', async () => {
  139. const root = fixture()
  140. const userConfig = join(root, 'user.npmrc')
  141. const globalConfig = join(root, 'global.npmrc')
  142. writeFileSync(userConfig, '@deepseek-ai:registry=https://user-override.invalid/\nstrict-peer-deps=true\n')
  143. writeFileSync(globalConfig, '@other:registry=https://global-override.invalid/\nprefer-dedupe=true\n')
  144. const inherited: NodeJS.ProcessEnv = {
  145. ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
  146. npm_config_userconfig: userConfig,
  147. NPM_CONFIG_GLOBALCONFIG: globalConfig,
  148. NPM_CONFIG_INSTALL_STRATEGY: 'nested',
  149. NPM_CONFIG_OFFLINE: 'true',
  150. NPM_CONFIG_CACHE: join(root, 'inherited-cache'),
  151. NPM_CONFIG_USER_AGENT: 'inherited-agent',
  152. }
  153. const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
  154. const args = ['config', 'list', '--json', '--registry=https://registry.npmjs.org/', '--loglevel=error']
  155. const before = await runCommandWithTimeout(npm, args, { cwd: root, env: inherited, timeoutMs: 30_000 })
  156. expect(before).toMatchObject({ status: 0, signal: null, timedOut: false })
  157. expect(JSON.parse(before.output)).toMatchObject({
  158. '@deepseek-ai:registry': 'https://user-override.invalid/', 'install-strategy': 'nested',
  159. })
  160. const isolated = createNpmResolutionEnvironment(root, inherited)
  161. const after = await runCommandWithTimeout(npm, args, { cwd: root, env: isolated, timeoutMs: 30_000 })
  162. expect(after).toMatchObject({ status: 0, signal: null, timedOut: false })
  163. const settings = JSON.parse(after.output) as Record<string, unknown>
  164. expect(settings).toMatchObject({
  165. registry: 'https://registry.npmjs.org/', '@deepseek-ai:registry': 'https://registry.npmjs.org/',
  166. 'install-strategy': 'hoisted', 'strict-peer-deps': false, 'prefer-dedupe': false, offline: false,
  167. cache: join(root, '.npm-cache'), userconfig: join(root, '.npmrc-user'), globalconfig: join(root, '.npmrc-global'),
  168. })
  169. expect(settings['@other:registry']).toBeUndefined()
  170. expect(isolated['NPM_CONFIG_USER_AGENT']).toBeUndefined()
  171. expect(inherited['npm_config_userconfig']).toBe(userConfig)
  172. })
  173. it('keeps the checked-in JSON synchronized with its recorded npm resolution', () => {
  174. expect(isDependencyCatalogCurrent(resolve(import.meta.dirname, '..'))).toBe(true)
  175. })
  176. })