agent-orchestrator/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx

261 lines
7.6 KiB
TypeScript

import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
const { getMock, hasTrustedApiBaseUrlMock } = vi.hoisted(() => ({
getMock: vi.fn(),
hasTrustedApiBaseUrlMock: vi.fn(() => true),
}));
vi.mock("../lib/api-client", () => ({
apiClient: { GET: getMock },
hasTrustedApiBaseUrl: hasTrustedApiBaseUrlMock,
}));
import { useWorkspaceQuery } from "./useWorkspaceQuery";
function wrapper({ children }: { children: ReactNode }) {
// The hook pins its own retry policy; retryDelay 0 keeps the error tests fast.
const queryClient = new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } });
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function respondWith(payload: {
projects?: { data?: unknown; error?: unknown };
sessions?: { data?: unknown; error?: unknown };
}) {
getMock.mockImplementation(async (url: string) => {
if (url === "/api/v1/projects") return payload.projects ?? { data: { projects: [] }, error: undefined };
if (url === "/api/v1/sessions") return payload.sessions ?? { data: { sessions: [] }, error: undefined };
throw new Error(`unexpected GET ${url}`);
});
}
beforeEach(() => {
getMock.mockReset();
hasTrustedApiBaseUrlMock.mockReset().mockReturnValue(true);
});
describe("useWorkspaceQuery", () => {
it("returns an empty workspace list while the daemon base URL is untrusted", async () => {
hasTrustedApiBaseUrlMock.mockReturnValue(false);
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
expect(getMock).not.toHaveBeenCalled();
});
it("maps projects and their sessions, applying provider/status/title fallbacks", async () => {
respondWith({
projects: {
data: {
projects: [
{
id: "proj-1",
name: "my-app",
path: "/home/me/my-app",
orchestratorAgent: "codex",
},
],
},
error: undefined,
},
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
terminalHandleId: "term-1",
displayName: "fix-bug",
issueId: "github:acme/project-one#42",
harness: "claude-code",
branch: "qa/modal-worker",
status: "mergeable",
isTerminated: false,
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
updatedAt: "2026-06-10T16:15:04Z",
},
{
// Unknown harness/status and no displayName/issueId: falls back
// to codex / unknown / the session id.
id: "sess-2",
projectId: "proj-1",
harness: "mystery-agent",
status: "bogus",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
// Belongs to another project; must not leak into proj-1.
{ id: "sess-3", projectId: "proj-2", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z" },
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? [];
expect(workspace).toMatchObject({
id: "proj-1",
name: "my-app",
path: "/home/me/my-app",
orchestratorAgent: "codex",
});
expect(workspace.sessions).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({
id: "sess-1",
terminalHandleId: "term-1",
title: "fix-bug",
issueId: "github:acme/project-one#42",
provider: "claude-code",
branch: "qa/modal-worker",
status: "mergeable",
activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" },
});
expect(workspace.sessions[1]).toMatchObject({
id: "sess-2",
title: "sess-2",
provider: "codex",
status: "unknown",
branch: "session/sess-2",
});
});
it("maps each session's prs straight from the session list", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "pr_open",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
prs: [
{
number: 278,
state: "open",
url: "u",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
{
id: "sess-2",
projectId: "proj-1",
status: "working",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const sessions = result.current.data?.[0].sessions ?? [];
expect(sessions[0].prs).toEqual([
{
number: 278,
state: "open",
url: "u",
ci: "passing",
review: "approved",
mergeability: "clean",
reviewComments: false,
updatedAt: "2026-06-10T16:15:04Z",
},
]);
// A session with no PRs maps to an empty stack, so the empty states render.
expect(sessions[1].prs).toEqual([]);
});
it("preserves backend merged status for terminated merged sessions", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "merged",
isTerminated: true,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].sessions[0].status).toBe("merged");
});
it("falls back to terminated for terminated sessions without a known backend status", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "bogus",
isTerminated: true,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].sessions[0].status).toBe("terminated");
});
it("surfaces a projects fetch error", async () => {
const failure = new TypeError("Failed to fetch");
respondWith({ projects: { data: undefined, error: failure } });
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 3_000 });
expect(result.current.error).toBe(failure);
});
it("surfaces a sessions fetch error even when projects load", async () => {
const failure = new Error("sessions backend down");
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: { data: undefined, error: failure },
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 3_000 });
expect(result.current.error).toBe(failure);
});
});