diff --git a/frontend/src/api/agents.test.ts b/frontend/src/api/agents.test.ts new file mode 100644 index 0000000..ce9a7c1 --- /dev/null +++ b/frontend/src/api/agents.test.ts @@ -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; + 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); + }); +});