serviceCli.test.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  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('can point the managed service at the stable current release link', () => {
  15. const root = join(homedir(), '.local', 'share', 'hermes-yaoyao', 'current')
  16. const plist = launchAgentPlist({ serviceRoot: root })
  17. expect(plist).toContain(`<string>${join(root, 'dist-server', 'server', 'index.js')}</string>`)
  18. expect(plist).toContain(`<key>WorkingDirectory</key><string>${root}</string>`)
  19. })
  20. it('runs the CLI when its entry path uses the stable current symlink', () => {
  21. const root = mkdtempSync(join(tmpdir(), 'hermes-service-cli-'))
  22. const entry = join(root, 'hermes-yaoyao.mjs')
  23. symlinkSync(join(process.cwd(), 'bin', 'hermes-yaoyao.mjs'), entry)
  24. try {
  25. expect(isMainModule(entry)).toBe(true)
  26. } finally {
  27. rmSync(root, { recursive: true, force: true })
  28. }
  29. })
  30. it('retries the transient launchctl bootstrap race after bootout', async () => {
  31. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  32. stderr: 'Bootstrap failed: 5: Input/output error',
  33. })
  34. const run = vi.fn()
  35. .mockImplementationOnce(() => { throw error })
  36. .mockReturnValue('')
  37. const wait = vi.fn(async () => {})
  38. await expect(bootstrapLaunchAgent(run, wait)).resolves.toBe('')
  39. expect(run).toHaveBeenCalledTimes(2)
  40. expect(wait).toHaveBeenCalledWith(250)
  41. })
  42. it('keeps retrying until a slow launchd removal finishes', async () => {
  43. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  44. stderr: 'Bootstrap failed: 5: Input/output error',
  45. })
  46. let elapsedMs = 0
  47. const run = vi.fn(() => {
  48. if (elapsedMs < 5_000) throw error
  49. return ''
  50. })
  51. const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
  52. await expect(bootstrapLaunchAgent(run, wait, {
  53. timeoutMs: 20_000,
  54. now: () => elapsedMs,
  55. })).resolves.toBe('')
  56. expect(elapsedMs).toBeGreaterThanOrEqual(5_000)
  57. expect(run).toHaveBeenCalledTimes(6)
  58. })
  59. it('stops retrying a transient launchctl error at the deadline', async () => {
  60. const error = Object.assign(new Error('launchctl bootstrap failed'), {
  61. stderr: 'Bootstrap failed: 5: Input/output error',
  62. })
  63. let elapsedMs = 0
  64. const run = vi.fn(() => { throw error })
  65. const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
  66. await expect(bootstrapLaunchAgent(run, wait, {
  67. timeoutMs: 2_000,
  68. now: () => elapsedMs,
  69. })).rejects.toThrow(error)
  70. expect(elapsedMs).toBe(2_000)
  71. expect(run).toHaveBeenCalledTimes(5)
  72. })
  73. it('does not retry permanent launchctl bootstrap failures', async () => {
  74. const error = new Error('Bootstrap failed: 125: Domain does not support specified action')
  75. const run = vi.fn(() => { throw error })
  76. const wait = vi.fn(async () => {})
  77. await expect(bootstrapLaunchAgent(run, wait)).rejects.toThrow(error)
  78. expect(run).toHaveBeenCalledTimes(1)
  79. expect(wait).not.toHaveBeenCalled()
  80. })
  81. })