serviceCli.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'
  2. import { homedir, tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
  6. describe('hermes-yaoyao LaunchAgent plist', () => {
  7. it('includes the local Hermes CLI directory in PATH', () => {
  8. const plist = launchAgentPlist()
  9. expect(plist).toContain(`${join(homedir(), '.local', 'bin')}:`)
  10. })
  11. it('enables Dashboard supervision by default', () => {
  12. expect(launchAgentPlist()).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>')
  13. })
  14. it('uses 15300 without injecting credentials into 9119 startup', () => {
  15. const plist = launchAgentPlist({ environment: {} })
  16. expect(plist).toContain('<key>HERMES_YAOYAO_PORT</key>\n <string>15300</string>')
  17. expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_USERNAME')
  18. expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_PASSWORD')
  19. })
  20. it('binds managed Web to the trusted LAN and enables local Dashboard supervision by default', () => {
  21. const plist = launchAgentPlist({ environment: {} })
  22. expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n <string>0.0.0.0</string>')
  23. expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n <string>1</string>')
  24. expect(plist).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>\n <string>1</string>')
  25. })
  26. it('keeps the managed installation on LAN even with stale loopback environment', () => {
  27. const plist = launchAgentPlist({
  28. environment: {
  29. HERMES_YAOYAO_HOST: '127.0.0.1',
  30. HERMES_YAOYAO_ALLOW_INSECURE_LAN: '0',
  31. },
  32. })
  33. expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n <string>0.0.0.0</string>')
  34. expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n <string>1</string>')
  35. })
  36. it('preserves APNs provider settings in the LaunchAgent', () => {
  37. const plist = launchAgentPlist({
  38. environment: {
  39. HERMES_YAOYAO_APNS_KEY_FILE: '/Users/test/AuthKey_ABC123.p8',
  40. HERMES_YAOYAO_APNS_KEY_ID: 'ABC123',
  41. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123',
  42. HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes',
  43. },
  44. })
  45. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_KEY_FILE</key>')
  46. expect(plist).toContain('/Users/test/AuthKey_ABC123.p8')
  47. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_KEY_ID</key>')
  48. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_TEAM_ID</key>')
  49. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_TOPIC</key>')
  50. })
  51. it('preserves FCM provider settings in the LaunchAgent', () => {
  52. const plist = launchAgentPlist({
  53. environment: {
  54. HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE: '/Users/test/firebase-service-account.json',
  55. HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project',
  56. HERMES_YAOYAO_FCM_PACKAGE_NAME: 'cn.samien.yaoyao.hermes',
  57. },
  58. })
  59. expect(plist).toContain('<key>HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE</key>')
  60. expect(plist).toContain('/Users/test/firebase-service-account.json')
  61. expect(plist).toContain('<key>HERMES_YAOYAO_FCM_PROJECT_ID</key>')
  62. expect(plist).toContain('yaoyao-test-project')
  63. expect(plist).toContain('<key>HERMES_YAOYAO_FCM_PACKAGE_NAME</key>')
  64. expect(plist).toContain('cn.samien.yaoyao.hermes')
  65. })
  66. it('can point the managed service at the stable current release link', () => {
  67. const root = join(homedir(), '.local', 'share', 'hermes-yaoyao', 'current')
  68. const plist = launchAgentPlist({ serviceRoot: root })
  69. expect(plist).toContain(`<string>${join(root, 'dist-server', 'server', 'index.js')}</string>`)
  70. expect(plist).toContain(`<key>WorkingDirectory</key><string>${root}</string>`)
  71. })
  72. it('runs the CLI when its entry path uses the stable current symlink', () => {
  73. const root = mkdtempSync(join(tmpdir(), 'hermes-service-cli-'))
  74. const entry = join(root, 'hermes-yaoyao.mjs')
  75. symlinkSync(join(process.cwd(), 'bin', 'hermes-yaoyao.mjs'), entry)
  76. try {
  77. expect(isMainModule(entry)).toBe(true)
  78. } finally {
  79. rmSync(root, { recursive: true, force: true })
  80. }
  81. })
  82. it('retries the transient launchctl bootstrap race after bootout', async () => {
  83. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  84. stderr: 'Bootstrap failed: 5: Input/output error',
  85. })
  86. const run = vi.fn()
  87. .mockImplementationOnce(() => { throw error })
  88. .mockReturnValue('')
  89. const wait = vi.fn(async () => {})
  90. await expect(bootstrapLaunchAgent(run, wait)).resolves.toBe('')
  91. expect(run).toHaveBeenCalledTimes(2)
  92. expect(wait).toHaveBeenCalledWith(250)
  93. })
  94. it('keeps retrying until a slow launchd removal finishes', async () => {
  95. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  96. stderr: 'Bootstrap failed: 5: Input/output error',
  97. })
  98. let elapsedMs = 0
  99. const run = vi.fn(() => {
  100. if (elapsedMs < 5_000) throw error
  101. return ''
  102. })
  103. const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
  104. await expect(bootstrapLaunchAgent(run, wait, {
  105. timeoutMs: 20_000,
  106. now: () => elapsedMs,
  107. })).resolves.toBe('')
  108. expect(elapsedMs).toBeGreaterThanOrEqual(5_000)
  109. expect(run).toHaveBeenCalledTimes(6)
  110. expect(wait.mock.calls.map(([waitMs]) => waitMs)).toEqual([250, 500, 1_000, 2_000, 2_000])
  111. })
  112. it('stops retrying a transient launchctl error at the deadline', async () => {
  113. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  114. stderr: 'Bootstrap failed: 5: Input/output error',
  115. })
  116. let elapsedMs = 0
  117. const run = vi.fn(() => { throw error })
  118. const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
  119. await expect(bootstrapLaunchAgent(run, wait, {
  120. timeoutMs: 2_000,
  121. now: () => elapsedMs,
  122. })).rejects.toThrow(error)
  123. expect(elapsedMs).toBe(2_000)
  124. expect(run).toHaveBeenCalledTimes(5)
  125. })
  126. it('does not retry permanent launchctl bootstrap failures', async () => {
  127. const error = new Error('Bootstrap failed: 125: Domain does not support specified action')
  128. const run = vi.fn(() => { throw error })
  129. const wait = vi.fn(async () => {})
  130. await expect(bootstrapLaunchAgent(run, wait)).rejects.toThrow(error)
  131. expect(run).toHaveBeenCalledTimes(1)
  132. expect(wait).not.toHaveBeenCalled()
  133. })
  134. })