import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api, ChatWebSocket, resolveWsHost } from "./client"; const mockFetch = vi.fn(); beforeEach(() => { vi.stubGlobal("fetch", mockFetch); }); afterEach(() => { vi.restoreAllMocks(); }); function okResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" }, }); } function errorResponse(status: number, text: string) { return new Response(text, { status }); } describe("api client", () => { describe("getCurrentProject", () => { it("sends GET to /project", async () => { mockFetch.mockResolvedValueOnce(okResponse("/home/user/project")); const result = await api.getCurrentProject(); expect(mockFetch).toHaveBeenCalledWith( "/api/project", expect.objectContaining({}), ); expect(result).toBe("/home/user/project"); }); it("returns null when no project open", async () => { mockFetch.mockResolvedValueOnce(okResponse(null)); const result = await api.getCurrentProject(); expect(result).toBeNull(); }); }); describe("openProject", () => { it("sends POST with path", async () => { mockFetch.mockResolvedValueOnce(okResponse("/home/user/project")); await api.openProject("/home/user/project"); expect(mockFetch).toHaveBeenCalledWith( "/api/project", expect.objectContaining({ method: "POST", body: JSON.stringify({ path: "/home/user/project" }), }), ); }); }); describe("closeProject", () => { it("sends DELETE to /project", async () => { mockFetch.mockResolvedValueOnce(okResponse(true)); await api.closeProject(); expect(mockFetch).toHaveBeenCalledWith( "/api/project", expect.objectContaining({ method: "DELETE" }), ); }); }); describe("getKnownProjects", () => { it("returns array of project paths", async () => { mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"])); const result = await api.getKnownProjects(); expect(result).toEqual(["/a", "/b"]); }); }); describe("error handling", () => { it("throws on non-ok response with body text", async () => { mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found")); await expect(api.getCurrentProject()).rejects.toThrow("Not found"); }); it("throws with status code when no body", async () => { mockFetch.mockResolvedValueOnce(errorResponse(500, "")); await expect(api.getCurrentProject()).rejects.toThrow( "Request failed (500)", ); }); }); describe("searchFiles", () => { it("sends POST with query", async () => { mockFetch.mockResolvedValueOnce( okResponse([{ path: "src/main.rs", matches: 1 }]), ); const result = await api.searchFiles("hello"); expect(mockFetch).toHaveBeenCalledWith( "/api/fs/search", expect.objectContaining({ method: "POST", body: JSON.stringify({ query: "hello" }), }), ); expect(result).toHaveLength(1); }); }); describe("execShell", () => { it("sends POST with command and args", async () => { mockFetch.mockResolvedValueOnce( okResponse({ stdout: "output", stderr: "", exit_code: 0 }), ); const result = await api.execShell("ls", ["-la"]); expect(mockFetch).toHaveBeenCalledWith( "/api/shell/exec", expect.objectContaining({ method: "POST", body: JSON.stringify({ command: "ls", args: ["-la"] }), }), ); expect(result.exit_code).toBe(0); }); }); describe("resolveWsHost", () => { it("uses env port in dev mode", () => { expect(resolveWsHost(true, "4200", "example.com")).toBe("127.0.0.1:4200"); }); it("defaults to 3001 in dev mode when no env port", () => { expect(resolveWsHost(true, undefined, "example.com")).toBe( "127.0.0.1:3001", ); }); it("uses location host in production", () => { expect(resolveWsHost(false, "4200", "myapp.com:8080")).toBe( "myapp.com:8080", ); }); }); }); // ── ChatWebSocket reconnect tests ─────────────────────────────────────────── interface MockWsInstance { onopen: (() => void) | null; onclose: (() => void) | null; onmessage: ((e: { data: string }) => void) | null; onerror: (() => void) | null; readyState: number; send: () => void; close: () => void; simulateClose: () => void; simulateMessage: (data: Record) => void; } function makeMockWebSocket() { const instances: MockWsInstance[] = []; class MockWebSocket { static readonly CONNECTING = 0; static readonly OPEN = 1; static readonly CLOSING = 2; static readonly CLOSED = 3; onopen: (() => void) | null = null; onclose: (() => void) | null = null; onmessage: ((e: { data: string }) => void) | null = null; onerror: (() => void) | null = null; readyState = 0; constructor(_url: string) { instances.push(this as unknown as MockWsInstance); } send() {} close() { this.readyState = 3; this.onclose?.(); } simulateClose() { this.readyState = 3; this.onclose?.(); } simulateMessage(data: Record) { this.onmessage?.({ data: JSON.stringify(data) }); } } return { MockWebSocket, instances }; } describe("ChatWebSocket", () => { beforeEach(() => { vi.useFakeTimers(); const { MockWebSocket } = makeMockWebSocket(); vi.stubGlobal("WebSocket", MockWebSocket); // Reset shared static state between tests (ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null; (ChatWebSocket as unknown as { refCount: number }).refCount = 0; }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("schedules reconnect after socket closes unexpectedly", () => { const { MockWebSocket, instances } = makeMockWebSocket(); vi.stubGlobal("WebSocket", MockWebSocket); const ws = new ChatWebSocket(); ws.connect({}); expect(instances).toHaveLength(1); instances[0].simulateClose(); // No new socket created yet expect(instances).toHaveLength(1); // Advance past the initial 1s reconnect delay vi.advanceTimersByTime(1001); // A new socket should now have been created expect(instances).toHaveLength(2); }); it("delivers pipeline_state after reconnect", () => { const { MockWebSocket, instances } = makeMockWebSocket(); vi.stubGlobal("WebSocket", MockWebSocket); const onPipelineState = vi.fn(); const ws = new ChatWebSocket(); ws.connect({ onPipelineState }); // Simulate server restart instances[0].simulateClose(); vi.advanceTimersByTime(1001); // Server pushes pipeline_state on fresh connection const freshState = { upcoming: [{ story_id: "1_story_test", name: "Test", error: null }], current: [], qa: [], merge: [], }; instances[1].simulateMessage({ type: "pipeline_state", ...freshState }); expect(onPipelineState).toHaveBeenCalledWith(freshState); }); it("does not reconnect after explicit close()", () => { const { MockWebSocket, instances } = makeMockWebSocket(); vi.stubGlobal("WebSocket", MockWebSocket); const ws = new ChatWebSocket(); ws.connect({}); // Explicit close disables reconnect ws.close(); // Advance through both the DEV close-defer (250ms) and reconnect window vi.advanceTimersByTime(2000); // No new socket should be created expect(instances).toHaveLength(1); }); it("uses exponential backoff on repeated failures", () => { const { MockWebSocket, instances } = makeMockWebSocket(); vi.stubGlobal("WebSocket", MockWebSocket); const ws = new ChatWebSocket(); ws.connect({}); // First close → reconnects after 1s instances[0].simulateClose(); vi.advanceTimersByTime(1001); expect(instances).toHaveLength(2); // Second close → reconnects after 2s (doubled) instances[1].simulateClose(); vi.advanceTimersByTime(1500); // Not yet (delay is now 2s) expect(instances).toHaveLength(2); vi.advanceTimersByTime(600); expect(instances).toHaveLength(3); }); it("resets reconnect delay after successful open", () => { const { MockWebSocket, instances } = makeMockWebSocket(); vi.stubGlobal("WebSocket", MockWebSocket); const ws = new ChatWebSocket(); ws.connect({}); // Disconnect and reconnect twice to raise the delay instances[0].simulateClose(); vi.advanceTimersByTime(1001); instances[1].simulateClose(); vi.advanceTimersByTime(2001); // Simulate a successful open on third socket — resets delay to 1s instances[2].onopen?.(); // Close again — should use the reset 1s delay instances[2].simulateClose(); vi.advanceTimersByTime(1001); expect(instances).toHaveLength(4); }); });