serviceCli.test.ts 10 KB

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