npmUpdates.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. // @vitest-environment node
  2. import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  3. import { createHash } from 'node:crypto'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { afterEach, expect, it, vi } from 'vitest'
  7. import { SystemUpdateManager } from '../../src/server/updateManager.js'
  8. import { loadServerConfig } from '../../src/server/config.js'
  9. import { NPM_RELEASE_SOURCE, npmInstallationLocation, parseNpmRelease } from '../../bin/lib/npm-release.mjs'
  10. import { writeNpmFixture } from '../fixtures/npm-release.mjs'
  11. const roots: string[] = []
  12. afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
  13. const archive = Buffer.from('fixture npm archive')
  14. const npm = { version: '0.3.0', integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}`, tarball: 'https://registry.npmjs.org/fixture.tgz' }
  15. const target = { schemaVersion: 1 as const, releaseVersion: npm.version, webVersion: npm.version, gitTag: `v${npm.version}` }
  16. function fixture() {
  17. const root = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-npm-manager-'))); roots.push(root)
  18. const projectRoot = join(root, 'global/lib/node_modules/@lsamien/yaoyao')
  19. writeNpmFixture(projectRoot, '0.2.0')
  20. const config = loadServerConfig({ HERMES_YAOYAO_HOME: join(root, 'data'), HERMES_YAOYAO_RELEASE_ROOT: join(root, 'programs') })
  21. const launchUpdater = vi.fn(), inspectRemote = vi.fn(async () => ({ npm, manifest: target }))
  22. const options = { projectRoot, platform: 'darwin' as const, launchUpdater, inspectRemote }
  23. const manager = new SystemUpdateManager(config, options)
  24. return { root, manager, config, options, launchUpdater, inspectRemote }
  25. }
  26. it('detects npm installs and queues an integrity-pinned download without activating', async () => {
  27. const f = fixture()
  28. expect(await f.manager.check()).toMatchObject({ installationMode: 'npm', updateMethod: 'npm', releaseSource: NPM_RELEASE_SOURCE, updateAvailable: true })
  29. const job = await f.manager.startUpdate(npm.version)
  30. const stored = JSON.parse(readFileSync(f.launchUpdater.mock.calls[0][0], 'utf8'))
  31. expect(stored.plan.npm).toEqual(npm)
  32. expect(stored.plan.prepared).toBeUndefined()
  33. expect(f.manager.job(job.id)).not.toHaveProperty('plan')
  34. expect(() => f.manager.startActivation(job.id)).toThrow('没有可生效')
  35. expect(f.launchUpdater).toHaveBeenCalledTimes(1)
  36. })
  37. it('restores a prepared update after manager recreation and activates offline only after an explicit request', async () => {
  38. const f = fixture(), job = await f.manager.startUpdate(npm.version)
  39. const path = f.launchUpdater.mock.calls[0][0], stored = JSON.parse(readFileSync(path, 'utf8'))
  40. const prepared = { archive: join(f.config.home, 'updates/npm', job.id, 'release.tgz') }
  41. mkdirSync(join(prepared.archive, '..'), { recursive: true }); writeFileSync(prepared.archive, archive)
  42. writeFileSync(path, JSON.stringify({ ...stored, state: 'prepared', plan: { ...stored.plan, prepared } }))
  43. rmSync(join(f.config.home, 'updates/active.lock'))
  44. f.inspectRemote.mockRejectedValue(new Error('npm offline'))
  45. const resumed = new SystemUpdateManager(f.config, f.options)
  46. expect(resumed.status().job?.state).toBe('prepared')
  47. expect(() => resumed.startActivation('wrong-job')).toThrow('没有可生效')
  48. const activate = resumed.startActivation(job.id)
  49. expect(activate.state).toBe('queued')
  50. const activationPlan = JSON.parse(readFileSync(f.launchUpdater.mock.calls[1][0], 'utf8')).plan
  51. expect(activationPlan.prepared).toEqual(prepared)
  52. expect(f.inspectRemote).toHaveBeenCalledTimes(1)
  53. expect(() => resumed.startActivation(job.id)).toThrow('没有可生效')
  54. })
  55. it('rejects a changed downloaded archive and retains explicit Git sources for npm installs', async () => {
  56. const f = fixture(), job = await f.manager.startUpdate(npm.version)
  57. const path = f.launchUpdater.mock.calls[0][0], stored = JSON.parse(readFileSync(path, 'utf8'))
  58. const prepared = { archive: join(f.config.home, 'updates/npm', job.id, 'release.tgz') }
  59. mkdirSync(join(prepared.archive, '..'), { recursive: true }); writeFileSync(prepared.archive, archive)
  60. writeFileSync(path, JSON.stringify({ ...stored, state: 'prepared', plan: { ...stored.plan, prepared } }))
  61. rmSync(join(f.config.home, 'updates/active.lock'))
  62. writeFileSync(prepared.archive, 'changed after verification')
  63. expect(() => f.manager.startActivation(job.id)).toThrow('文件已改变')
  64. expect(f.manager.status().job?.state).toBe('failed')
  65. expect(f.launchUpdater).toHaveBeenCalledTimes(1)
  66. const git = new SystemUpdateManager({ ...f.config, releaseSource: 'https://example.test/private.git' }, f.options)
  67. expect(git.status()).toMatchObject({ installationMode: 'source', updateMethod: 'git', releaseSource: 'https://example.test/private.git' })
  68. })
  69. it('disables npm rollback even with a legacy record and limits overwrites to npm installation layouts', () => {
  70. const f = fixture()
  71. mkdirSync(join(f.config.home, 'updates'), { recursive: true })
  72. writeFileSync(join(f.config.home, 'updates/last-success.json'), '{}')
  73. expect(f.manager.status().canRollback).toBe(false)
  74. expect(() => f.manager.startRollback()).toThrow('不支持回滚')
  75. expect(npmInstallationLocation(f.options.projectRoot)).toEqual({ prefix: join(f.root, 'global'), global: true })
  76. const local = join(f.root, 'local/node_modules/@lsamien/yaoyao')
  77. writeNpmFixture(local, '0.2.0')
  78. expect(npmInstallationLocation(local)).toEqual({ prefix: join(f.root, 'local'), global: false })
  79. const source = join(f.root, 'source'); writeNpmFixture(source, '0.2.0')
  80. expect(() => npmInstallationLocation(source)).toThrow('npm 安装目录')
  81. })
  82. it('validates npm metadata and restricts configured npm sources to the official package', () => {
  83. const value = { name: '@lsamien/yaoyao', version: npm.version, dist: npm }
  84. expect(parseNpmRelease(value)).toEqual(npm)
  85. expect(() => parseNpmRelease({ ...value, name: 'other-package' })).toThrow('名称或版本')
  86. expect(() => parseNpmRelease({ ...value, dist: { ...npm, integrity: 'sha1-weak' } })).toThrow('SHA-512')
  87. expect(loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: NPM_RELEASE_SOURCE }).releaseSource).toBe(NPM_RELEASE_SOURCE)
  88. expect(() => loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: 'npm:other-package' })).toThrow()
  89. })