updater.test.ts 6.8 KB

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