Files
storkit/frontend/src/api/client.test.ts

333 lines
8.5 KiB
TypeScript
Raw Normal View History

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<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;
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<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 = {
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);
});
});