story-kit: merge 111_story_add_test_coverage_for_api_agents_ts
This commit is contained in:
386
frontend/src/api/agents.test.ts
Normal file
386
frontend/src/api/agents.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
|
||||
import { agentsApi, subscribeAgentStream } from "./agents";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const sampleAgent: AgentInfo = {
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
status: "running",
|
||||
session_id: null,
|
||||
worktree_path: null,
|
||||
base_branch: null,
|
||||
log_session_id: null,
|
||||
};
|
||||
|
||||
const sampleConfig: AgentConfigInfo = {
|
||||
name: "coder",
|
||||
role: "engineer",
|
||||
model: "claude-sonnet-4-6",
|
||||
allowed_tools: null,
|
||||
max_turns: null,
|
||||
max_budget_usd: null,
|
||||
};
|
||||
|
||||
// ── agentsApi ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("agentsApi", () => {
|
||||
describe("startAgent", () => {
|
||||
it("sends POST to /agents/start with story_id", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
const result = await agentsApi.startAgent("42_story_test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/start",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(sampleAgent);
|
||||
});
|
||||
|
||||
it("sends POST with optional agent_name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
await agentsApi.startAgent("42_story_test", "coder");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/start",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
|
||||
|
||||
await agentsApi.startAgent(
|
||||
"42_story_test",
|
||||
undefined,
|
||||
"http://localhost:3002/api",
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/start",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopAgent", () => {
|
||||
it("sends POST to /agents/stop with story_id and agent_name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
|
||||
const result = await agentsApi.stopAgent("42_story_test", "coder");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/stop",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
story_id: "42_story_test",
|
||||
agent_name: "coder",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(false));
|
||||
|
||||
await agentsApi.stopAgent(
|
||||
"42_story_test",
|
||||
"coder",
|
||||
"http://localhost:3002/api",
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/stop",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAgents", () => {
|
||||
it("sends GET to /agents and returns agent list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleAgent]));
|
||||
|
||||
const result = await agentsApi.listAgents();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual([sampleAgent]);
|
||||
});
|
||||
|
||||
it("returns empty array when no agents running", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
const result = await agentsApi.listAgents();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
await agentsApi.listAgents("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentConfig", () => {
|
||||
it("sends GET to /agents/config and returns config list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
const result = await agentsApi.getAgentConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
await agentsApi.getAgentConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadConfig", () => {
|
||||
it("sends POST to /agents/config/reload", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
|
||||
const result = await agentsApi.reloadConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config/reload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([]));
|
||||
|
||||
await agentsApi.reloadConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config/reload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found"));
|
||||
|
||||
await expect(agentsApi.listAgents()).rejects.toThrow("agent not found");
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(agentsApi.listAgents()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── subscribeAgentStream ─────────────────────────────────────────────────────
|
||||
|
||||
interface MockESInstance {
|
||||
url: string;
|
||||
onmessage: ((e: { data: string }) => void) | null;
|
||||
onerror: ((e: Event) => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
simulateMessage: (data: unknown) => void;
|
||||
simulateError: (e: Event) => void;
|
||||
}
|
||||
|
||||
function makeMockEventSource() {
|
||||
const instances: MockESInstance[] = [];
|
||||
|
||||
class MockEventSource {
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: ((e: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public url: string) {
|
||||
instances.push(this as unknown as MockESInstance);
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
simulateError(e: Event) {
|
||||
this.onerror?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { MockEventSource, instances };
|
||||
}
|
||||
|
||||
describe("subscribeAgentStream", () => {
|
||||
let instances: MockESInstance[];
|
||||
|
||||
beforeEach(() => {
|
||||
const { MockEventSource, instances: inst } = makeMockEventSource();
|
||||
instances = inst;
|
||||
vi.stubGlobal("EventSource", MockEventSource);
|
||||
});
|
||||
|
||||
it("creates an EventSource with encoded story and agent in URL", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].url).toContain(
|
||||
`/agents/${encodeURIComponent("42_story_test")}/${encodeURIComponent("coder")}/stream`,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onEvent when a message is received", () => {
|
||||
const onEvent = vi.fn();
|
||||
subscribeAgentStream("42_story_test", "coder", onEvent);
|
||||
|
||||
const event: AgentEvent = { type: "output", text: "hello" };
|
||||
instances[0].simulateMessage(event);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("closes EventSource on 'done' type event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "done" });
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource on 'error' type event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({
|
||||
type: "error",
|
||||
message: "something failed",
|
||||
});
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource on status=stopped event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "status", status: "stopped" });
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close on status=running event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "status", status: "running" });
|
||||
|
||||
expect(instances[0].close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close on 'output' event", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
instances[0].simulateMessage({ type: "output", text: "building..." });
|
||||
|
||||
expect(instances[0].close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onError and closes on EventSource onerror", () => {
|
||||
const onError = vi.fn();
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn(), onError);
|
||||
|
||||
const err = new Event("error");
|
||||
instances[0].simulateError(err);
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(err);
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource when onError is not provided", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
const err = new Event("error");
|
||||
instances[0].simulateError(err);
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes EventSource when cleanup function is called", () => {
|
||||
const cleanup = subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(instances[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles malformed JSON without throwing", () => {
|
||||
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
||||
|
||||
expect(() => {
|
||||
instances[0].onmessage?.({ data: "{ not valid json" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("delivers multiple events before a terminal event", () => {
|
||||
const onEvent = vi.fn();
|
||||
subscribeAgentStream("42_story_test", "coder", onEvent);
|
||||
|
||||
instances[0].simulateMessage({ type: "output", text: "line 1" });
|
||||
instances[0].simulateMessage({ type: "output", text: "line 2" });
|
||||
instances[0].simulateMessage({ type: "done" });
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(3);
|
||||
expect(instances[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user