import { beforeEach, describe, expect, it, vi } from 'vitest'; const invokeIpcMock = vi.fn(); vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), })); describe('host-api', () => { beforeEach(() => { vi.resetModules(); vi.resetAllMocks(); vi.unstubAllGlobals(); window.localStorage.removeItem('niancode:allow-localhost-fallback'); }); it('uses IPC proxy and returns unified envelope json', async () => { invokeIpcMock.mockResolvedValueOnce({ ok: true, data: { status: 200, ok: true, json: { success: true }, }, }); const { hostApiFetch } = await import('@/lib/host-api'); const result = await hostApiFetch<{ success: boolean }>('/api/settings'); expect(result.success).toBe(true); expect(invokeIpcMock).toHaveBeenCalledWith( 'hostapi:fetch', expect.objectContaining({ path: '/api/settings', method: 'GET' }), ); }); it('throws the Host API error from a unified non-ok HTTP response', async () => { invokeIpcMock.mockResolvedValueOnce({ ok: true, data: { status: 403, ok: false, json: { success: false, error: 'Game asset review is unavailable for an uninitialized project' }, }, }); const { hostApiFetch } = await import('@/lib/host-api'); const promise = hostApiFetch('/api/files/game-asset-review?invocationId=legacy-review'); await expect(promise).rejects.toMatchObject({ message: 'Game asset review is unavailable for an uninitialized project', details: expect.objectContaining({ status: 403 }), }); }); it('returns undefined for a unified 204 response', async () => { invokeIpcMock.mockResolvedValueOnce({ ok: true, data: { status: 204, ok: true }, }); const { hostApiFetch } = await import('@/lib/host-api'); await expect(hostApiFetch('/api/test')).resolves.toBeUndefined(); }); it('supports legacy proxy envelope response', async () => { invokeIpcMock.mockResolvedValueOnce({ success: true, status: 200, ok: true, json: { ok: 1 }, }); const { hostApiFetch } = await import('@/lib/host-api'); const result = await hostApiFetch<{ ok: number }>('/api/settings'); expect(result.ok).toBe(1); }); it('returns undefined for a legacy 204 response', async () => { invokeIpcMock.mockResolvedValueOnce({ success: true, status: 204, ok: true, }); const { hostApiFetch } = await import('@/lib/host-api'); await expect(hostApiFetch('/api/test')).resolves.toBeUndefined(); }); it('falls back to browser fetch when hostapi handler is not registered', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ fallback: true }), }); vi.stubGlobal('fetch', fetchMock); window.localStorage.setItem('niancode:allow-localhost-fallback', '1'); invokeIpcMock.mockResolvedValueOnce({ ok: false, error: { message: 'No handler registered for hostapi:fetch' }, }); const { hostApiFetch } = await import('@/lib/host-api'); const result = await hostApiFetch<{ fallback: boolean }>('/api/test'); expect(result.fallback).toBe(true); expect(fetchMock).toHaveBeenCalledWith( 'http://127.0.0.1:13210/api/test', expect.objectContaining({ headers: expect.any(Object) }), ); }); it('throws message from legacy non-ok envelope', async () => { invokeIpcMock.mockResolvedValueOnce({ success: true, ok: false, status: 401, json: { error: 'Invalid Authentication' }, }); const { hostApiFetch } = await import('@/lib/host-api'); await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid Authentication'); }); it('preserves HTTP status from a legacy non-ok envelope', async () => { invokeIpcMock.mockResolvedValueOnce({ success: true, ok: false, status: 503, json: { error: 'Service unavailable' }, }); const { hostApiFetch } = await import('@/lib/host-api'); await expect(hostApiFetch('/api/test')).rejects.toMatchObject({ message: 'Service unavailable', details: expect.objectContaining({ status: 503 }), }); }); it('falls back to browser fetch only when IPC channel is unavailable', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ fallback: true }), }); vi.stubGlobal('fetch', fetchMock); window.localStorage.setItem('niancode:allow-localhost-fallback', '1'); invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch')); const { hostApiFetch } = await import('@/lib/host-api'); const result = await hostApiFetch<{ fallback: boolean }>('/api/test'); expect(result.fallback).toBe(true); expect(fetchMock).toHaveBeenCalledWith( 'http://127.0.0.1:13210/api/test', expect.objectContaining({ headers: expect.any(Object) }), ); }); it('does not use localhost fallback when policy flag is disabled', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ fallback: true }), }); vi.stubGlobal('fetch', fetchMock); invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch')); const { hostApiFetch } = await import('@/lib/host-api'); await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid IPC channel: hostapi:fetch'); expect(fetchMock).not.toHaveBeenCalled(); }); it('primes the host api token and base url before creating an event stream url', async () => { const eventSourceMock = vi.fn(); vi.stubGlobal('EventSource', eventSourceMock); invokeIpcMock.mockImplementation(async (channel: string) => { if (channel === 'hostapi:token') return 'secret-token'; if (channel === 'hostapi:base-url') return 'http://127.0.0.1:4567'; return null; }); const { createHostEventSource, ensureHostApiToken, getHostApiBase } = await import('@/lib/host-api'); await ensureHostApiToken(); createHostEventSource('/api/opencode/events?sessionId=ses_1'); expect(invokeIpcMock).toHaveBeenCalledWith('hostapi:token'); expect(invokeIpcMock).toHaveBeenCalledWith('hostapi:base-url'); expect(getHostApiBase()).toBe('http://127.0.0.1:4567'); expect(eventSourceMock).toHaveBeenCalledWith( 'http://127.0.0.1:4567/api/opencode/events?sessionId=ses_1&token=secret-token', ); }); });