updater.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  2. import { createHash, randomUUID } from 'node:crypto'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
  7. import {
  8. currentTarget,
  9. formatCommandFailure,
  10. overwriteNpmRelease,
  11. restorePreviousService,
  12. serviceInstallInvocation,
  13. switchCurrent,
  14. validateManifest,
  15. verifyRuntime,
  16. } from '../bin/hermes-yaoyao-updater.mjs'
  17. import { writeNpmFixture } from './fixtures/npm-release.mjs'
  18. const roots: string[] = []
  19. afterEach(() => {
  20. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  21. })
  22. describe('standalone updater primitives', () => {
  23. it.each([true, false])('protects data inside the npm installation and never restarts an old version after an overwrite failure (nested data: %s)', async (nestedData) => {
  24. const root = realpathSync(mkdtempSync(join(tmpdir(), 'npm-overwrite-'))); roots.push(root)
  25. const packageRoot = join(root, 'node_modules/@lsamien/yaoyao')
  26. writeNpmFixture(packageRoot, '0.2.0')
  27. const home = nestedData ? join(packageRoot, 'data') : join(root, 'data'), id = randomUUID()
  28. const archive = join(home, 'updates/npm', id, 'release.tgz'), contents = Buffer.from('verified archive')
  29. mkdirSync(join(archive, '..'), { recursive: true }); writeFileSync(archive, contents)
  30. writeFileSync(join(home, 'user.txt'), 'user data')
  31. const target = { schemaVersion: 1, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' }
  32. const job = { id, target, plan: { home, releaseRoot: join(root, 'releases'), previousServiceRoot: packageRoot, prepared: { archive },
  33. npm: { version: '0.3.0', integrity: `sha512-${createHash('sha512').update(contents).digest('base64')}`, tarball: 'https://example.test/release.tgz' } } }
  34. const jobPath = join(home, 'updates', `${id}.json`); writeFileSync(jobPath, JSON.stringify(job))
  35. const driver = { snapshot: () => ({ root: packageRoot, wasRunning: false }), quiesce: vi.fn(), stop: vi.fn(), resume: vi.fn(), start: vi.fn(), restore: vi.fn() }
  36. const install = vi.fn(async () => { writeFileSync(join(packageRoot, 'dist/index.html'), 'partly overwritten'); throw new Error('installation failed') })
  37. await expect(overwriteNpmRelease(job, jobPath, { driver, install })).rejects.toThrow(nestedData ? '用户数据目录' : '未保留旧版本')
  38. expect(readFileSync(join(home, 'user.txt'), 'utf8')).toBe('user data')
  39. expect(driver.start).not.toHaveBeenCalled(); expect(driver.restore).not.toHaveBeenCalled(); expect(driver.resume).not.toHaveBeenCalled()
  40. expect(install).toHaveBeenCalledTimes(nestedData ? 0 : 1)
  41. expect(driver.stop).toHaveBeenCalledTimes(nestedData ? 0 : 1)
  42. expect(existsSync(archive)).toBe(false)
  43. })
  44. it('accepts a healthy Web without probing 9119 or upstream-dependent readiness', async () => {
  45. const fetchImpl = vi.fn(async (url: string) => {
  46. if (url !== 'http://127.0.0.1:15300/healthz') throw new Error('9119 offline')
  47. return Response.json({ ok: true })
  48. })
  49. await verifyRuntime(1_000, fetchImpl)
  50. expect(fetchImpl).toHaveBeenCalledTimes(1)
  51. expect(fetchImpl.mock.calls[0]?.[0]).toBe('http://127.0.0.1:15300/healthz')
  52. })
  53. it('still fails an unhealthy Web and retries transient startup failures', async () => {
  54. vi.useFakeTimers()
  55. try {
  56. const unhealthy = vi.fn(async () => Response.json({ ok: false }))
  57. const check = expect(verifyRuntime(1_000, unhealthy)).rejects.toThrow('升级后健康检查失败')
  58. await vi.advanceTimersByTimeAsync(1_000)
  59. await check
  60. const recovering = vi.fn().mockRejectedValueOnce(new Error('starting'))
  61. .mockResolvedValueOnce(Response.json({ ok: true }))
  62. const recovered = verifyRuntime(1_000, recovering)
  63. await vi.advanceTimersByTimeAsync(500)
  64. await recovered
  65. expect(recovering).toHaveBeenCalledTimes(2)
  66. } finally { vi.useRealTimers() }
  67. })
  68. it('atomically switches the stable current symlink', () => {
  69. const root = mkdtempSync(join(tmpdir(), 'hermes-updater-'))
  70. roots.push(root)
  71. const first = join(root, 'releases', '0.2.0-a')
  72. const second = join(root, 'releases', '0.3.0-b')
  73. switchCurrent(root, first, 'first')
  74. expect(currentTarget(root)).toBe(first)
  75. switchCurrent(root, second, 'second')
  76. expect(currentTarget(root)).toBe(second)
  77. expect(lstatSync(join(root, 'current')).isSymbolicLink()).toBe(true)
  78. })
  79. it('rejects a mismatched release manifest before any service transition', () => {
  80. expect(() => validateManifest({
  81. schemaVersion: 1,
  82. releaseVersion: '0.3.0',
  83. webVersion: '0.3.1',
  84. gitTag: 'v0.3.0',
  85. })).toThrow('版本组合不一致')
  86. })
  87. it('keeps useful command output while redacting credentials', () => {
  88. const error = Object.assign(new Error('Command failed'), {
  89. status: 127,
  90. stdout: '\u001B[31m发布版本校验通过\u001B[0m',
  91. stderr: 'https://secret@example.test/release.git\nsh: vue-tsc: command not found',
  92. })
  93. const message = formatCommandFailure('npm', ['run', 'build'], error)
  94. expect(message).toContain('npm run build 失败,退出码 127')
  95. expect(message).toContain('发布版本校验通过')
  96. expect(message).toContain('https://***@example.test/release.git')
  97. expect(message).toContain('vue-tsc: command not found')
  98. expect(message).not.toContain('secret')
  99. expect(message).not.toContain('\u001B')
  100. })
  101. it('uses the new lifecycle code while starting a rolled-back release', () => {
  102. const releaseRoot = '/Users/test/.local/share/hermes-yaoyao'
  103. const oldRelease = join(releaseRoot, 'releases', '0.2.11-old')
  104. const newRelease = join(releaseRoot, 'releases', '0.2.12-new')
  105. const invocation = serviceInstallInvocation(
  106. oldRelease,
  107. { releaseRoot, source: 'https://example.test/hermes-yaoyao.git' },
  108. true,
  109. newRelease,
  110. )
  111. expect(invocation.cli).toBe(join(newRelease, 'bin', 'hermes-yaoyao.mjs'))
  112. expect(invocation.env.HERMES_YAOYAO_SERVICE_ROOT).toBe(join(releaseRoot, 'current'))
  113. expect(invocation.env.HERMES_YAOYAO_RELEASE_SOURCE).toBe('https://example.test/hermes-yaoyao.git')
  114. expect(invocation.env.HERMES_YAOYAO_HOST).toBe('0.0.0.0')
  115. expect(invocation.env.HERMES_YAOYAO_ALLOW_INSECURE_LAN).toBe('1')
  116. const sourceRollback = serviceInstallInvocation(
  117. oldRelease,
  118. { releaseRoot, source: 'https://example.test/hermes-yaoyao.git' },
  119. false,
  120. newRelease,
  121. )
  122. expect(sourceRollback.cli).toBe(join(newRelease, 'bin', 'hermes-yaoyao.mjs'))
  123. expect(sourceRollback.env.HERMES_YAOYAO_SERVICE_ROOT).toBe(oldRelease)
  124. const rollbackPlist = launchAgentPlist({ serviceRoot: sourceRollback.env.HERMES_YAOYAO_SERVICE_ROOT })
  125. expect(rollbackPlist).toContain(`<string>${join(oldRelease, 'dist-server', 'server', 'index.js')}</string>`)
  126. expect(rollbackPlist).toContain(`<key>WorkingDirectory</key><string>${oldRelease}</string>`)
  127. })
  128. it('routes release and source rollbacks through the new lifecycle root', () => {
  129. const plan = {
  130. releaseRoot: '/Users/test/.local/share/hermes-yaoyao',
  131. source: 'https://example.test/hermes-yaoyao.git',
  132. }
  133. const lifecycleRoot = join(plan.releaseRoot, 'releases', '0.2.12-new')
  134. const previousRelease = join(plan.releaseRoot, 'releases', '0.2.11-old')
  135. const previousSource = '/Users/test/git/hermes-yaoyao'
  136. const switchCurrentCommand = vi.fn()
  137. const removeCurrentCommand = vi.fn()
  138. const startServiceCommand = vi.fn()
  139. restorePreviousService({
  140. plan,
  141. previousCurrentTarget: previousRelease,
  142. previousServiceRoot: previousSource,
  143. lifecycleRoot,
  144. token: 'release-rollback',
  145. switchCurrentCommand,
  146. removeCurrentCommand,
  147. startServiceCommand,
  148. })
  149. expect(switchCurrentCommand).toHaveBeenCalledWith(plan.releaseRoot, previousRelease, 'release-rollback')
  150. expect(removeCurrentCommand).not.toHaveBeenCalled()
  151. expect(startServiceCommand).toHaveBeenCalledWith(
  152. join(plan.releaseRoot, 'current'), plan, true, lifecycleRoot,
  153. )
  154. switchCurrentCommand.mockClear()
  155. startServiceCommand.mockClear()
  156. restorePreviousService({
  157. plan,
  158. previousCurrentTarget: undefined,
  159. previousServiceRoot: previousSource,
  160. lifecycleRoot,
  161. token: 'source-rollback',
  162. switchCurrentCommand,
  163. removeCurrentCommand,
  164. startServiceCommand,
  165. })
  166. expect(switchCurrentCommand).not.toHaveBeenCalled()
  167. expect(removeCurrentCommand).toHaveBeenCalledWith(plan.releaseRoot)
  168. expect(startServiceCommand).toHaveBeenCalledWith(previousSource, plan, false, lifecycleRoot)
  169. })
  170. it('refuses rollback without a usable lifecycle root', () => {
  171. expect(() => restorePreviousService({
  172. plan: { releaseRoot: '/tmp/releases', source: 'https://example.test/repo.git' },
  173. previousCurrentTarget: '/tmp/releases/old',
  174. previousServiceRoot: '/tmp/source',
  175. lifecycleRoot: undefined,
  176. token: 'missing-lifecycle',
  177. })).toThrow('当前发布版本不可用')
  178. })
  179. })