504 lines
13 KiB
TypeScript
504 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { api, ChatWebSocket, resolveWsHost } from "./client";
|
|
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
|
|
|
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("dispatches project.current RPC and returns the path", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("project.current", "/home/user/project");
|
|
|
|
const result = await api.getCurrentProject();
|
|
|
|
expect(rpc.calls).toEqual([{ method: "project.current", params: {} }]);
|
|
expect(result).toBe("/home/user/project");
|
|
});
|
|
|
|
it("returns null when no project open", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("project.current", null);
|
|
|
|
const result = await api.getCurrentProject();
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("openProject", () => {
|
|
it("dispatches project.open RPC with path and returns the canonical path", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("project.open", { path: "/home/user/project" });
|
|
|
|
const result = await api.openProject("/home/user/project");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{
|
|
method: "project.open",
|
|
params: { path: "/home/user/project" },
|
|
},
|
|
]);
|
|
expect(result).toBe("/home/user/project");
|
|
});
|
|
});
|
|
|
|
describe("closeProject", () => {
|
|
it("dispatches project.close RPC and returns ok", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("project.close", { ok: true });
|
|
|
|
const result = await api.closeProject();
|
|
|
|
expect(rpc.calls).toEqual([{ method: "project.close", params: {} }]);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("forgetKnownProject", () => {
|
|
it("dispatches project.forget RPC with path", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("project.forget", { ok: true });
|
|
|
|
const result = await api.forgetKnownProject("/some/path");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{ method: "project.forget", params: { path: "/some/path" } },
|
|
]);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("setModelPreference", () => {
|
|
it("dispatches model.set_preference RPC", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("model.set_preference", { ok: true });
|
|
|
|
await api.setModelPreference("claude-sonnet-4-6");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{
|
|
method: "model.set_preference",
|
|
params: { model: "claude-sonnet-4-6" },
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("setAnthropicApiKey", () => {
|
|
it("dispatches anthropic.set_api_key RPC", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("anthropic.set_api_key", { ok: true });
|
|
|
|
await api.setAnthropicApiKey("sk-ant-xxx");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{
|
|
method: "anthropic.set_api_key",
|
|
params: { api_key: "sk-ant-xxx" },
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("cancelChat", () => {
|
|
it("dispatches chat.cancel RPC", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("chat.cancel", { ok: true });
|
|
|
|
await api.cancelChat();
|
|
|
|
expect(rpc.calls).toEqual([{ method: "chat.cancel", params: {} }]);
|
|
});
|
|
});
|
|
|
|
describe("getKnownProjects", () => {
|
|
it("dispatches project.known RPC and returns the path list", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("project.known", ["/a", "/b"]);
|
|
|
|
const result = await api.getKnownProjects();
|
|
expect(rpc.calls).toEqual([{ method: "project.known", params: {} }]);
|
|
expect(result).toEqual(["/a", "/b"]);
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("surfaces RPC errors visibly", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respondError("project.current", "store offline", "INTERNAL");
|
|
|
|
await expect(api.getCurrentProject()).rejects.toThrow("store offline");
|
|
});
|
|
|
|
it("surfaces RPC errors visibly for write methods", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respondError("project.open", "No such directory", "INTERNAL");
|
|
|
|
await expect(api.openProject("/some/path")).rejects.toThrow(
|
|
"No such directory",
|
|
);
|
|
});
|
|
|
|
it("throws on non-ok HTTP response for legacy POST endpoints", async () => {
|
|
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
|
|
|
await expect(api.searchFiles("query")).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;
|
|
sentMessages: string[];
|
|
send: (data: string) => void;
|
|
close: () => void;
|
|
simulateClose: () => void;
|
|
simulateMessage: (data: Record<string, unknown>) => 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;
|
|
sentMessages: string[] = [];
|
|
|
|
constructor(_url: string) {
|
|
instances.push(this as unknown as MockWsInstance);
|
|
}
|
|
|
|
send(data: string) {
|
|
this.sentMessages.push(data);
|
|
}
|
|
|
|
close() {
|
|
this.readyState = 3;
|
|
this.onclose?.();
|
|
}
|
|
|
|
simulateClose() {
|
|
this.readyState = 3;
|
|
this.onclose?.();
|
|
}
|
|
|
|
simulateMessage(data: Record<string, unknown>) {
|
|
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 = {
|
|
backlog: [{ story_id: "1_story_test", name: "Test", error: null }],
|
|
current: [],
|
|
qa: [],
|
|
merge: [],
|
|
done: [],
|
|
deterministic_merges_in_flight: [],
|
|
};
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("ChatWebSocket heartbeat", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
const { MockWebSocket } = makeMockWebSocket();
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
|
|
(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("sends ping after heartbeat interval", () => {
|
|
const { MockWebSocket, instances } = makeMockWebSocket();
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
const ws = new ChatWebSocket();
|
|
ws.connect({});
|
|
instances[0].readyState = 1; // OPEN
|
|
instances[0].onopen?.(); // starts heartbeat
|
|
|
|
vi.advanceTimersByTime(29_999);
|
|
expect(instances[0].sentMessages).toHaveLength(0);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(instances[0].sentMessages).toHaveLength(1);
|
|
expect(JSON.parse(instances[0].sentMessages[0])).toEqual({ type: "ping" });
|
|
|
|
ws.close();
|
|
});
|
|
|
|
it("closes stale connection when pong is not received", () => {
|
|
const { MockWebSocket, instances } = makeMockWebSocket();
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
const ws = new ChatWebSocket();
|
|
ws.connect({});
|
|
instances[0].readyState = 1; // OPEN
|
|
instances[0].onopen?.(); // starts heartbeat
|
|
|
|
// Fire heartbeat — sends ping and starts pong timeout
|
|
vi.advanceTimersByTime(30_000);
|
|
|
|
// No pong received; advance past pong timeout → socket closed → reconnect scheduled
|
|
vi.advanceTimersByTime(5_000);
|
|
|
|
// Advance past reconnect delay
|
|
vi.advanceTimersByTime(1_001);
|
|
|
|
expect(instances).toHaveLength(2);
|
|
ws.close();
|
|
});
|
|
|
|
it("does not close when pong is received before timeout", () => {
|
|
const { MockWebSocket, instances } = makeMockWebSocket();
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
const ws = new ChatWebSocket();
|
|
ws.connect({});
|
|
instances[0].readyState = 1; // OPEN
|
|
instances[0].onopen?.(); // starts heartbeat
|
|
|
|
// Fire heartbeat
|
|
vi.advanceTimersByTime(30_000);
|
|
|
|
// Server responds with pong — clears the pong timeout
|
|
instances[0].simulateMessage({ type: "pong" });
|
|
|
|
// Advance past where pong timeout would have fired
|
|
vi.advanceTimersByTime(5_001);
|
|
|
|
// No reconnect triggered
|
|
expect(instances).toHaveLength(1);
|
|
ws.close();
|
|
});
|
|
|
|
it("stops sending pings after explicit close", () => {
|
|
const { MockWebSocket, instances } = makeMockWebSocket();
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
|
|
const ws = new ChatWebSocket();
|
|
ws.connect({});
|
|
instances[0].readyState = 1; // OPEN
|
|
instances[0].onopen?.(); // starts heartbeat
|
|
|
|
ws.close();
|
|
|
|
// Advance well past multiple heartbeat intervals
|
|
vi.advanceTimersByTime(90_000);
|
|
|
|
expect(instances[0].sentMessages).toHaveLength(0);
|
|
});
|
|
});
|