daemon-client.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 3 import { 4 fetchDaemonStatus, 5 getDaemonHealth, 6 requestDaemonShutdown, 7 sendCommand, 8 } from './daemon-client.js'; 9 10 describe('daemon-client', () => { 11 beforeEach(() => { 12 vi.stubGlobal('fetch', vi.fn()); 13 }); 14 15 afterEach(() => { 16 vi.restoreAllMocks(); 17 }); 18 19 it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => { 20 const status = { 21 ok: true, 22 pid: 123, 23 uptime: 10, 24 extensionConnected: true, 25 extensionVersion: '1.2.3', 26 pending: 0, 27 memoryMB: 32, 28 port: 19825, 29 }; 30 const fetchMock = vi.mocked(fetch); 31 fetchMock.mockResolvedValue({ 32 ok: true, 33 json: () => Promise.resolve(status), 34 } as Response); 35 36 await expect(fetchDaemonStatus()).resolves.toEqual(status); 37 expect(fetchMock).toHaveBeenCalledWith( 38 expect.stringMatching(/\/status$/), 39 expect.objectContaining({ 40 headers: expect.objectContaining({ 'X-OpenCLI': '1' }), 41 }), 42 ); 43 }); 44 45 it('fetchDaemonStatus returns null on network failure', async () => { 46 vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); 47 48 await expect(fetchDaemonStatus()).resolves.toBeNull(); 49 }); 50 51 it('requestDaemonShutdown POSTs to the shared shutdown endpoint', async () => { 52 const fetchMock = vi.mocked(fetch); 53 fetchMock.mockResolvedValue({ ok: true } as Response); 54 55 await expect(requestDaemonShutdown()).resolves.toBe(true); 56 expect(fetchMock).toHaveBeenCalledWith( 57 expect.stringMatching(/\/shutdown$/), 58 expect.objectContaining({ 59 method: 'POST', 60 headers: expect.objectContaining({ 'X-OpenCLI': '1' }), 61 }), 62 ); 63 }); 64 65 it('getDaemonHealth returns stopped when daemon is not reachable', async () => { 66 vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); 67 68 await expect(getDaemonHealth()).resolves.toEqual({ state: 'stopped', status: null }); 69 }); 70 71 it('getDaemonHealth returns no-extension when daemon is running but extension disconnected', async () => { 72 const status = { 73 ok: true, 74 pid: 123, 75 uptime: 10, 76 extensionConnected: false, 77 pending: 0, 78 memoryMB: 16, 79 port: 19825, 80 }; 81 vi.mocked(fetch).mockResolvedValue({ 82 ok: true, 83 json: () => Promise.resolve(status), 84 } as Response); 85 86 await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status }); 87 }); 88 89 it('getDaemonHealth returns ready when daemon and extension are both connected', async () => { 90 const status = { 91 ok: true, 92 pid: 123, 93 uptime: 10, 94 extensionConnected: true, 95 extensionVersion: '1.2.3', 96 pending: 0, 97 memoryMB: 32, 98 port: 19825, 99 }; 100 vi.mocked(fetch).mockResolvedValue({ 101 ok: true, 102 json: () => Promise.resolve(status), 103 } as Response); 104 105 await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status }); 106 }); 107 108 it('sendCommand includes the current pid in generated command ids', async () => { 109 vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); 110 vi.mocked(fetch).mockResolvedValue({ 111 status: 200, 112 json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), 113 } as Response); 114 115 await expect(sendCommand('exec', { code: '1 + 1' })).resolves.toBe('ok'); 116 await expect(sendCommand('exec', { code: '2 + 2' })).resolves.toBe('ok'); 117 118 const ids = vi.mocked(fetch).mock.calls.map(([, init]) => { 119 const body = JSON.parse(String(init?.body)) as { id: string }; 120 return body.id; 121 }); 122 123 expect(ids).toHaveLength(2); 124 expect(ids[0]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`)); 125 expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`)); 126 expect(ids[0]).not.toBe(ids[1]); 127 }); 128 129 it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => { 130 vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123); 131 const fetchMock = vi.mocked(fetch); 132 fetchMock 133 .mockResolvedValueOnce({ 134 ok: false, 135 status: 409, 136 json: () => Promise.resolve({ ok: false, error: 'Duplicate command id already pending; retry' }), 137 } as Response) 138 .mockResolvedValueOnce({ 139 ok: true, 140 status: 200, 141 json: () => Promise.resolve({ id: 'server', ok: true, data: 42 }), 142 } as Response); 143 144 await expect(sendCommand('exec', { code: '6 * 7' })).resolves.toBe(42); 145 expect(fetchMock).toHaveBeenCalledTimes(2); 146 147 const ids = fetchMock.mock.calls.map(([, init]) => { 148 const body = JSON.parse(String(init?.body)) as { id: string }; 149 return body.id; 150 }); 151 expect(ids[0]).not.toBe(ids[1]); 152 }); 153 });