/ src / browser / daemon-client.test.ts
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  });