serviceCli.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { spawnSync } from 'node:child_process'
  2. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  3. import { homedir, tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  6. import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
  7. describe('npm-installed CLI commands', () => {
  8. const label = 'com.samien.hermes-yaoyao'
  9. const domain = `gui/${process.getuid?.() ?? 501}`
  10. const entry = join(process.cwd(), 'bin', 'hermes-yaoyao.mjs')
  11. const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
  12. let root: string
  13. let plist: string
  14. beforeEach(() => {
  15. root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-cli-'))
  16. const bin = join(root, 'bin')
  17. mkdirSync(bin)
  18. const launchAgents = join(root, 'Library', 'LaunchAgents')
  19. mkdirSync(launchAgents, { recursive: true })
  20. plist = join(launchAgents, `${label}.plist`)
  21. for (const name of ['yaoyao', 'hermes-yaoyao']) symlinkSync(entry, join(bin, name))
  22. writeFileSync(join(bin, 'launchctl'), `#!${process.execPath}
  23. import { appendFileSync } from 'node:fs'
  24. const args = process.argv.slice(2)
  25. appendFileSync(process.env.YAOYAO_CLI_TEST_LOG, JSON.stringify(args) + '\\n')
  26. if (args[0] === process.env.YAOYAO_CLI_TEST_FAIL) {
  27. process.stderr.write('fixture launchctl failure\\n')
  28. process.exit(1)
  29. }
  30. if (args[0] === 'print' && process.env.YAOYAO_CLI_TEST_LOADED !== '1') process.exit(1)
  31. `, { mode: 0o755 })
  32. })
  33. afterEach(() => { rmSync(root, { recursive: true, force: true }) })
  34. function runCli(args: string[], env: NodeJS.ProcessEnv = {}, name = 'yaoyao') {
  35. return spawnSync(process.execPath, [join(root, 'bin', name), ...args], {
  36. cwd: root,
  37. encoding: 'utf8',
  38. timeout: 5_000,
  39. env: {
  40. ...process.env,
  41. HOME: root,
  42. PATH: `${join(root, 'bin')}:${process.env.PATH}`,
  43. YAOYAO_HOME: join(root, 'data'),
  44. HERMES_YAOYAO_SERVICE_ROOT: join(root, 'other-runtime'),
  45. YAOYAO_CLI_TEST_LOG: join(root, 'launchctl.log'),
  46. ...env,
  47. },
  48. })
  49. }
  50. function calls(): string[][] {
  51. const log = join(root, 'launchctl.log')
  52. return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : []
  53. }
  54. it.each(['version', '--version', '-v'])('queries the package version through both npm bin aliases: %s', command => {
  55. for (const name of ['yaoyao', 'hermes-yaoyao']) {
  56. const result = runCli([command], {}, name)
  57. expect(result.status).toBe(0)
  58. expect(result.stdout).toBe(`${version}\n`)
  59. expect(result.stderr).toBe('')
  60. }
  61. expect(calls()).toEqual([])
  62. expect(existsSync(join(root, 'data'))).toBe(false)
  63. })
  64. it.each(['yaoyao', 'hermes-yaoyao'])('restarts the installed service through %s while preserving its configuration and data', name => {
  65. writeFileSync(plist, 'saved service configuration')
  66. mkdirSync(join(root, 'data'))
  67. const data = join(root, 'data', 'keep.txt')
  68. writeFileSync(data, 'saved user data')
  69. const result = runCli(['service', 'restart'], { YAOYAO_CLI_TEST_LOADED: '1' }, name)
  70. expect(result.status).toBe(0)
  71. expect(result.stdout).toBe(`已重启 ${label}\n`)
  72. expect(calls()).toEqual([
  73. ['print', `${domain}/${label}`],
  74. ['bootout', `${domain}/${label}`],
  75. ['bootstrap', domain, plist],
  76. ])
  77. expect(readFileSync(plist, 'utf8')).toBe('saved service configuration')
  78. expect(readFileSync(data, 'utf8')).toBe('saved user data')
  79. })
  80. it('starts an installed service that was stopped', () => {
  81. writeFileSync(plist, 'saved service configuration')
  82. const result = runCli(['service', 'restart'])
  83. expect(result.status).toBe(0)
  84. expect(calls()).toEqual([['print', `${domain}/${label}`], ['bootstrap', domain, plist]])
  85. })
  86. it('explains how to install a missing service without invoking launchctl', () => {
  87. const result = runCli(['service', 'restart'])
  88. expect(result.status).toBe(1)
  89. expect(result.stderr).toContain('请先运行 yaoyao service install')
  90. expect(result.stdout).toBe('')
  91. expect(calls()).toEqual([])
  92. })
  93. it.each(['bootout', 'bootstrap'])('reports a failed %s without reporting restart success', command => {
  94. writeFileSync(plist, 'saved service configuration')
  95. const result = runCli(['service', 'restart'], {
  96. YAOYAO_CLI_TEST_LOADED: '1',
  97. YAOYAO_CLI_TEST_FAIL: command,
  98. })
  99. expect(result.status).toBe(1)
  100. expect(result.stdout).toBe('')
  101. expect(result.stderr).toContain('fixture launchctl failure')
  102. expect(calls().at(-1)?.[0]).toBe(command)
  103. })
  104. })
  105. describe('hermes-yaoyao LaunchAgent plist', () => {
  106. it('includes the local Hermes CLI directory in PATH', () => {
  107. const plist = launchAgentPlist()
  108. expect(plist).toContain(`${join(homedir(), '.local', 'bin')}:`)
  109. })
  110. it('enables Dashboard supervision by default', () => {
  111. expect(launchAgentPlist()).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>')
  112. })
  113. it('uses 15300 without injecting credentials into 9119 startup', () => {
  114. const plist = launchAgentPlist({ environment: {} })
  115. expect(plist).toContain('<key>HERMES_YAOYAO_PORT</key>\n <string>15300</string>')
  116. expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_USERNAME')
  117. expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_PASSWORD')
  118. })
  119. it('binds managed Web to the trusted LAN and enables local Dashboard supervision by default', () => {
  120. const plist = launchAgentPlist({ environment: {} })
  121. expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n <string>0.0.0.0</string>')
  122. expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n <string>1</string>')
  123. expect(plist).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>\n <string>1</string>')
  124. })
  125. it('keeps the managed installation on LAN even with stale loopback environment', () => {
  126. const plist = launchAgentPlist({
  127. environment: {
  128. HERMES_YAOYAO_HOST: '127.0.0.1',
  129. HERMES_YAOYAO_ALLOW_INSECURE_LAN: '0',
  130. },
  131. })
  132. expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n <string>0.0.0.0</string>')
  133. expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n <string>1</string>')
  134. })
  135. it('preserves APNs provider settings in the LaunchAgent', () => {
  136. const plist = launchAgentPlist({
  137. environment: {
  138. HERMES_YAOYAO_APNS_KEY_FILE: '/Users/test/AuthKey_ABC123.p8',
  139. HERMES_YAOYAO_APNS_KEY_ID: 'ABC123',
  140. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123',
  141. HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes',
  142. },
  143. })
  144. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_KEY_FILE</key>')
  145. expect(plist).toContain('/Users/test/AuthKey_ABC123.p8')
  146. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_KEY_ID</key>')
  147. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_TEAM_ID</key>')
  148. expect(plist).toContain('<key>HERMES_YAOYAO_APNS_TOPIC</key>')
  149. })
  150. it('preserves FCM provider settings in the LaunchAgent', () => {
  151. const plist = launchAgentPlist({
  152. environment: {
  153. HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE: '/Users/test/firebase-service-account.json',
  154. HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project',
  155. HERMES_YAOYAO_FCM_PACKAGE_NAME: 'cn.samien.yaoyao.hermes',
  156. },
  157. })
  158. expect(plist).toContain('<key>HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE</key>')
  159. expect(plist).toContain('/Users/test/firebase-service-account.json')
  160. expect(plist).toContain('<key>HERMES_YAOYAO_FCM_PROJECT_ID</key>')
  161. expect(plist).toContain('yaoyao-test-project')
  162. expect(plist).toContain('<key>HERMES_YAOYAO_FCM_PACKAGE_NAME</key>')
  163. expect(plist).toContain('cn.samien.yaoyao.hermes')
  164. })
  165. it('can point the managed service at the stable current release link', () => {
  166. const root = join(homedir(), '.local', 'share', 'hermes-yaoyao', 'current')
  167. const plist = launchAgentPlist({ serviceRoot: root })
  168. expect(plist).toContain(`<string>${join(root, 'dist-server', 'server', 'index.js')}</string>`)
  169. expect(plist).toContain(`<key>WorkingDirectory</key><string>${root}</string>`)
  170. })
  171. it('runs the CLI when its entry path uses the stable current symlink', () => {
  172. const root = mkdtempSync(join(tmpdir(), 'hermes-service-cli-'))
  173. const entry = join(root, 'hermes-yaoyao.mjs')
  174. symlinkSync(join(process.cwd(), 'bin', 'hermes-yaoyao.mjs'), entry)
  175. try {
  176. expect(isMainModule(entry)).toBe(true)
  177. } finally {
  178. rmSync(root, { recursive: true, force: true })
  179. }
  180. })
  181. it('retries the transient launchctl bootstrap race after bootout', async () => {
  182. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  183. stderr: 'Bootstrap failed: 5: Input/output error',
  184. })
  185. const run = vi.fn()
  186. .mockImplementationOnce(() => { throw error })
  187. .mockReturnValue('')
  188. const wait = vi.fn(async () => {})
  189. await expect(bootstrapLaunchAgent(run, wait)).resolves.toBe('')
  190. expect(run).toHaveBeenCalledTimes(2)
  191. expect(wait).toHaveBeenCalledWith(250)
  192. })
  193. it('keeps retrying until a slow launchd removal finishes', async () => {
  194. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  195. stderr: 'Bootstrap failed: 5: Input/output error',
  196. })
  197. let elapsedMs = 0
  198. const run = vi.fn(() => {
  199. if (elapsedMs < 5_000) throw error
  200. return ''
  201. })
  202. const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
  203. await expect(bootstrapLaunchAgent(run, wait, {
  204. timeoutMs: 20_000,
  205. now: () => elapsedMs,
  206. })).resolves.toBe('')
  207. expect(elapsedMs).toBeGreaterThanOrEqual(5_000)
  208. expect(run).toHaveBeenCalledTimes(6)
  209. expect(wait.mock.calls.map(([waitMs]) => waitMs)).toEqual([250, 500, 1_000, 2_000, 2_000])
  210. })
  211. it('stops retrying a transient launchctl error at the deadline', async () => {
  212. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  213. stderr: 'Bootstrap failed: 5: Input/output error',
  214. })
  215. let elapsedMs = 0
  216. const run = vi.fn(() => { throw error })
  217. const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
  218. await expect(bootstrapLaunchAgent(run, wait, {
  219. timeoutMs: 2_000,
  220. now: () => elapsedMs,
  221. })).rejects.toThrow(error)
  222. expect(elapsedMs).toBe(2_000)
  223. expect(run).toHaveBeenCalledTimes(5)
  224. })
  225. it('does not retry permanent launchctl bootstrap failures', async () => {
  226. const error = new Error('Bootstrap failed: 125: Domain does not support specified action')
  227. const run = vi.fn(() => { throw error })
  228. const wait = vi.fn(async () => {})
  229. await expect(bootstrapLaunchAgent(run, wait)).rejects.toThrow(error)
  230. expect(run).toHaveBeenCalledTimes(1)
  231. expect(wait).not.toHaveBeenCalled()
  232. })
  233. })