refactor: engineering improvements -- API versioning, structured logging, Alembic, error standardization, test coverage

- API versioning: all REST endpoints prefixed with /api/v1/
- Structured logging: replaced stdlib logging with structlog (console/JSON modes)
- Alembic migrations: versioned DB schema with initial migration
- Error standardization: global exception handlers for consistent envelope format
- Interrupt cleanup: asyncio background task for expired interrupt removal
- Integration tests: +30 tests (analytics, replay, openapi, error, session APIs)
- Frontend tests: +57 tests (all components, pages, useWebSocket hook)
- Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
This commit is contained in:
Yaojia Wang
2026-04-06 23:19:29 +02:00
parent af53111928
commit f0699436c5
59 changed files with 2846 additions and 149 deletions

View File

@@ -31,14 +31,14 @@ describe("fetchConversations", () => {
const result = await fetchConversations();
expect(result.conversations).toHaveLength(1);
expect(result.total).toBe(1);
expect(mockFetch).toHaveBeenCalledWith("/api/conversations?page=1&per_page=20");
expect(mockFetch).toHaveBeenCalledWith("/api/v1/conversations?page=1&per_page=20");
});
it("passes custom page and perPage", async () => {
mockFetch.mockResolvedValue(jsonResponse({ success: true, data: { conversations: [], total: 0, page: 2, per_page: 10 }, error: null }));
await fetchConversations(2, 10);
expect(mockFetch).toHaveBeenCalledWith("/api/conversations?page=2&per_page=10");
expect(mockFetch).toHaveBeenCalledWith("/api/v1/conversations?page=2&per_page=10");
});
it("throws on HTTP error", async () => {
@@ -80,7 +80,7 @@ describe("fetchReplay", () => {
mockFetch.mockResolvedValue(jsonResponse({ success: true, data: { thread_id: "a/b", total_steps: 0, page: 1, per_page: 20, steps: [] }, error: null }));
await fetchReplay("a/b");
expect(mockFetch).toHaveBeenCalledWith("/api/replay/a%2Fb?page=1&per_page=20");
expect(mockFetch).toHaveBeenCalledWith("/api/v1/replay/a%2Fb?page=1&per_page=20");
});
it("throws on HTTP error", async () => {
@@ -112,6 +112,6 @@ describe("fetchAnalytics", () => {
mockFetch.mockResolvedValue(jsonResponse({ success: true, data: { range: "7d" }, error: null }));
await fetchAnalytics();
expect(mockFetch).toHaveBeenCalledWith("/api/analytics?range=7d");
expect(mockFetch).toHaveBeenCalledWith("/api/v1/analytics?range=7d");
});
});

View File

@@ -84,7 +84,7 @@ export async function fetchConversations(
perPage = 20
): Promise<ConversationsPage> {
return apiFetch<ConversationsPage>(
`/api/conversations?page=${page}&per_page=${perPage}`
`/api/v1/conversations?page=${page}&per_page=${perPage}`
);
}
@@ -94,12 +94,12 @@ export async function fetchReplay(
perPage = 20
): Promise<ReplayPage> {
return apiFetch<ReplayPage>(
`/api/replay/${encodeURIComponent(threadId)}?page=${page}&per_page=${perPage}`
`/api/v1/replay/${encodeURIComponent(threadId)}?page=${page}&per_page=${perPage}`
);
}
export async function fetchAnalytics(range = "7d"): Promise<AnalyticsData> {
return apiFetch<AnalyticsData>(`/api/analytics?range=${range}`);
return apiFetch<AnalyticsData>(`/api/v1/analytics?range=${range}`);
}
// -- OpenAPI import --
@@ -143,11 +143,11 @@ async function apiPost<T>(path: string, body: unknown): Promise<T> {
}
export async function startImport(url: string): Promise<ImportJobResponse> {
return apiPost<ImportJobResponse>("/api/openapi/import", { url });
return apiPost<ImportJobResponse>("/api/v1/openapi/import", { url });
}
export async function fetchImportJob(jobId: string): Promise<ImportJobResponse> {
const res = await fetch(`${API_BASE}/api/openapi/jobs/${encodeURIComponent(jobId)}`);
const res = await fetch(`${API_BASE}/api/v1/openapi/jobs/${encodeURIComponent(jobId)}`);
if (!res.ok) {
throw new Error(`API error ${res.status}: ${res.statusText}`);
}
@@ -158,7 +158,7 @@ export async function fetchClassifications(
jobId: string
): Promise<EndpointClassification[]> {
const res = await fetch(
`${API_BASE}/api/openapi/jobs/${encodeURIComponent(jobId)}/classifications`
`${API_BASE}/api/v1/openapi/jobs/${encodeURIComponent(jobId)}/classifications`
);
if (!res.ok) {
throw new Error(`API error ${res.status}: ${res.statusText}`);
@@ -168,7 +168,7 @@ export async function fetchClassifications(
export async function approveJob(jobId: string): Promise<ImportJobResponse> {
return apiPost<ImportJobResponse>(
`/api/openapi/jobs/${encodeURIComponent(jobId)}/approve`,
`/api/v1/openapi/jobs/${encodeURIComponent(jobId)}/approve`,
{}
);
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { AgentAction } from "./AgentAction";
import type { ToolAction } from "../types";
function makeAction(overrides: Partial<ToolAction> = {}): ToolAction {
return {
id: "action-1",
agent: "OrderAgent",
tool: "get_order",
args: { order_id: "ORD-100" },
timestamp: Date.now(),
...overrides,
};
}
describe("AgentAction", () => {
it("renders agent name and tool name", () => {
render(<AgentAction action={makeAction()} />);
expect(screen.getByText("OrderAgent")).toBeInTheDocument();
expect(screen.getByText("get_order")).toBeInTheDocument();
});
it("shows args and result when expanded", () => {
const action = makeAction({ result: { status: "shipped" } });
render(<AgentAction action={action} />);
// Click header to expand
fireEvent.click(screen.getByText("OrderAgent"));
expect(screen.getByText("Args:")).toBeInTheDocument();
expect(screen.getByText("Result:")).toBeInTheDocument();
expect(screen.getByText(/"order_id": "ORD-100"/)).toBeInTheDocument();
expect(screen.getByText(/"status": "shipped"/)).toBeInTheDocument();
});
it("does not show result section when result is undefined", () => {
render(<AgentAction action={makeAction()} />);
// Expand
fireEvent.click(screen.getByText("OrderAgent"));
expect(screen.getByText("Args:")).toBeInTheDocument();
expect(screen.queryByText("Result:")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ChatInput } from "./ChatInput";
describe("ChatInput", () => {
it("renders input field and send button", () => {
render(<ChatInput onSend={vi.fn()} disabled={false} />);
expect(screen.getByPlaceholderText("Message Smart Support...")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send Message" })).toBeInTheDocument();
});
it("calls onSend with trimmed content when form is submitted via Enter", () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} disabled={false} />);
const input = screen.getByPlaceholderText("Message Smart Support...");
fireEvent.change(input, { target: { value: " Hello world " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("Hello world");
});
it("clears input after successful send", () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} disabled={false} />);
const input = screen.getByPlaceholderText("Message Smart Support...") as HTMLInputElement;
fireEvent.change(input, { target: { value: "Test message" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(input.value).toBe("");
});
it("shows disabled placeholder and disables input when disabled", () => {
render(<ChatInput onSend={vi.fn()} disabled={true} />);
const input = screen.getByPlaceholderText("Agent is working...") as HTMLInputElement;
expect(input.disabled).toBe(true);
expect(screen.getByRole("button", { name: "Send Message" })).toBeDisabled();
});
it("does not call onSend when input is empty or whitespace", () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} disabled={false} />);
const input = screen.getByPlaceholderText("Message Smart Support...");
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ChatMessages } from "./ChatMessages";
import type { ChatMessage } from "../types";
// Mock react-markdown to avoid complex rendering
vi.mock("react-markdown", () => ({
default: ({ children }: { children: string }) => <span>{children}</span>,
}));
describe("ChatMessages", () => {
it("renders welcome message when messages array is empty", () => {
render(<ChatMessages messages={[]} />);
expect(screen.getByText("Hello! How can I help you today?")).toBeInTheDocument();
expect(screen.getByText("Smart Support")).toBeInTheDocument();
});
it("renders user messages with correct sender label", () => {
const messages: ChatMessage[] = [
{ id: "1", sender: "user", content: "I need help", timestamp: Date.now() },
];
render(<ChatMessages messages={messages} />);
expect(screen.getByText("You")).toBeInTheDocument();
expect(screen.getByText("I need help")).toBeInTheDocument();
expect(screen.getByText("Me")).toBeInTheDocument();
});
it("renders agent messages with agent name", () => {
const messages: ChatMessage[] = [
{ id: "2", sender: "agent", agent: "OrderBot", content: "Sure, let me check.", timestamp: Date.now() },
];
render(<ChatMessages messages={messages} />);
expect(screen.getByText("OrderBot")).toBeInTheDocument();
expect(screen.getByText("Sure, let me check.")).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
});
it("shows streaming cursor for messages being streamed", () => {
const messages: ChatMessage[] = [
{ id: "3", sender: "agent", agent: "Bot", content: "Processing", timestamp: Date.now(), isStreaming: true },
];
render(<ChatMessages messages={messages} />);
expect(screen.getByText("|")).toBeInTheDocument();
expect(document.querySelector(".cursor-blink")).toBeTruthy();
});
it("shows fallback agent label when agent field is missing", () => {
const messages: ChatMessage[] = [
{ id: "4", sender: "agent", content: "Generic response", timestamp: Date.now() },
];
render(<ChatMessages messages={messages} />);
expect(screen.getByText("Agent")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ErrorBanner } from "./ErrorBanner";
describe("ErrorBanner", () => {
it("returns null when status is connected", () => {
const { container } = render(<ErrorBanner status="connected" />);
expect(container.innerHTML).toBe("");
});
it("shows disconnection message when status is disconnected", () => {
render(<ErrorBanner status="disconnected" onReconnect={vi.fn()} />);
expect(screen.getByText("Disconnected from server. Retrying...")).toBeInTheDocument();
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("shows connecting message when status is connecting", () => {
render(<ErrorBanner status="connecting" />);
expect(screen.getByText("Connecting to server...")).toBeInTheDocument();
// No reconnect button while connecting
expect(screen.queryByText("Reconnect")).not.toBeInTheDocument();
});
it("calls onReconnect when reconnect button is clicked", () => {
const onReconnect = vi.fn();
render(<ErrorBanner status="disconnected" onReconnect={onReconnect} />);
fireEvent.click(screen.getByText("Reconnect"));
expect(onReconnect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { InterruptPrompt } from "./InterruptPrompt";
import type { InterruptMessage } from "../types";
describe("InterruptPrompt", () => {
const baseInterrupt: InterruptMessage = {
type: "interrupt",
thread_id: "t1",
action: "cancel_order",
params: {},
};
it("renders action name and approval title", () => {
render(<InterruptPrompt interrupt={baseInterrupt} onRespond={vi.fn()} />);
expect(screen.getByText("Action Requires Approval")).toBeInTheDocument();
expect(screen.getByText("cancel_order")).toBeInTheDocument();
});
it("calls onRespond with true when Approve button is clicked", () => {
const onRespond = vi.fn();
render(<InterruptPrompt interrupt={baseInterrupt} onRespond={onRespond} />);
fireEvent.click(screen.getByText("Approve Action"));
expect(onRespond).toHaveBeenCalledWith(true);
});
it("calls onRespond with false when Reject button is clicked", () => {
const onRespond = vi.fn();
render(<InterruptPrompt interrupt={baseInterrupt} onRespond={onRespond} />);
fireEvent.click(screen.getByText("Reject & Escalate"));
expect(onRespond).toHaveBeenCalledWith(false);
});
it("displays order_id parameter when present", () => {
const interrupt: InterruptMessage = {
...baseInterrupt,
params: { order_id: "ORD-12345" },
};
render(<InterruptPrompt interrupt={interrupt} onRespond={vi.fn()} />);
expect(screen.getByText("Target Order ID")).toBeInTheDocument();
expect(screen.getByText("ORD-12345")).toBeInTheDocument();
});
it("displays message parameter when present", () => {
const interrupt: InterruptMessage = {
...baseInterrupt,
params: { message: "This will refund $50" },
};
render(<InterruptPrompt interrupt={interrupt} onRespond={vi.fn()} />);
expect(screen.getByText("Detail Message")).toBeInTheDocument();
expect(screen.getByText("This will refund $50")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { Layout } from "./Layout";
// Mock NavBar to simplify layout tests
vi.mock("./NavBar", () => ({
NavBar: () => <nav data-testid="navbar">NavBar</nav>,
}));
function renderLayout(path = "/") {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<div>Home Content</div>} />
<Route path="/dashboard" element={<div>Dashboard Content</div>} />
</Route>
</Routes>
</MemoryRouter>
);
}
describe("Layout", () => {
it("renders NavBar component", () => {
renderLayout();
expect(screen.getByTestId("navbar")).toBeInTheDocument();
});
it("renders child route content via Outlet", () => {
renderLayout("/");
expect(screen.getByText("Home Content")).toBeInTheDocument();
});
it("renders correct content for different routes", () => {
renderLayout("/dashboard");
expect(screen.getByText("Dashboard Content")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricCard } from "./MetricCard";
describe("MetricCard", () => {
it("renders label and value", () => {
render(<MetricCard label="Total Users" value={42} />);
expect(screen.getByText("Total Users")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
});
it("renders with unit prefix and suffix", () => {
render(<MetricCard label="Cost" value="3.50" unit="$" suffix="/mo" />);
expect(screen.getByText("Cost")).toBeInTheDocument();
expect(screen.getByText("$")).toBeInTheDocument();
expect(screen.getByText("3.50")).toBeInTheDocument();
expect(screen.getByText("/mo")).toBeInTheDocument();
});
it("handles zero value correctly", () => {
render(<MetricCard label="Errors" value={0} />);
expect(screen.getByText("Errors")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { NavBar } from "./NavBar";
function renderNavBar(initialPath = "/") {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<NavBar />
</MemoryRouter>
);
}
describe("NavBar", () => {
it("renders all navigation links", () => {
renderNavBar();
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Inbox")).toBeInTheDocument();
expect(screen.getByText("Conversation Replay")).toBeInTheDocument();
expect(screen.getByText("Agents & Tools")).toBeInTheDocument();
});
it("navigation links point to correct routes", () => {
renderNavBar();
const dashboardLink = screen.getByText("Dashboard").closest("a");
expect(dashboardLink).toHaveAttribute("href", "/dashboard");
const inboxLink = screen.getByText("Inbox").closest("a");
expect(inboxLink).toHaveAttribute("href", "/");
const replayLink = screen.getByText("Conversation Replay").closest("a");
expect(replayLink).toHaveAttribute("href", "/replay");
const reviewLink = screen.getByText("Agents & Tools").closest("a");
expect(reviewLink).toHaveAttribute("href", "/review");
});
it("active link has active class when on matching route", () => {
renderNavBar("/dashboard");
const dashboardLink = screen.getByText("Dashboard").closest("a");
expect(dashboardLink?.className).toContain("active");
const inboxLink = screen.getByText("Inbox").closest("a");
expect(inboxLink?.className).not.toContain("active");
});
it("renders brand name", () => {
renderNavBar();
expect(screen.getByText("Nexus AI")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ReplayTimeline } from "./ReplayTimeline";
import type { ReplayStep } from "../api";
function makeStep(overrides: Partial<ReplayStep> = {}): ReplayStep {
return {
step: 1,
type: "message",
content: "Hello",
agent: null,
tool: null,
params: null,
result: null,
timestamp: "2026-04-01T12:00:00Z",
...overrides,
};
}
describe("ReplayTimeline", () => {
it("returns null when steps array is empty", () => {
const { container } = render(<ReplayTimeline steps={[]} />);
expect(container.innerHTML).toBe("");
});
it("renders a list of steps with type badges", () => {
const steps = [
makeStep({ step: 1, type: "message", content: "User said hi" }),
makeStep({ step: 2, type: "tool_call", content: "Calling API", agent: "OrderBot", tool: "get_order" }),
];
render(<ReplayTimeline steps={steps} />);
expect(screen.getByText("message")).toBeInTheDocument();
expect(screen.getByText("tool call")).toBeInTheDocument();
expect(screen.getByText("User said hi")).toBeInTheDocument();
expect(screen.getByText("OrderBot")).toBeInTheDocument();
expect(screen.getByText("get_order()")).toBeInTheDocument();
});
it("expands step details when View JSON Payload button is clicked", () => {
const steps = [
makeStep({
step: 1,
type: "tool_call",
params: { order_id: "123" },
result: { status: "ok" },
}),
];
render(<ReplayTimeline steps={steps} />);
const expandButton = screen.getByText("View JSON Payload", { exact: false });
expect(expandButton).toBeInTheDocument();
fireEvent.click(expandButton);
// After expanding, the JSON payload should be visible
expect(screen.getByText("Hide JSON Payload", { exact: false })).toBeInTheDocument();
expect(screen.getByText(/"order_id": "123"/)).toBeInTheDocument();
});
it("does not show expand button when step has no params or result", () => {
const steps = [
makeStep({ step: 1, type: "message", params: null, result: null }),
];
render(<ReplayTimeline steps={steps} />);
expect(screen.queryByText("View JSON Payload", { exact: false })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useWebSocket } from "./useWebSocket";
// Mock sessionStorage
const mockSessionStorage: Record<string, string> = {};
vi.stubGlobal("sessionStorage", {
getItem: (key: string) => mockSessionStorage[key] ?? null,
setItem: (key: string, value: string) => {
mockSessionStorage[key] = value;
},
});
// Mock crypto.randomUUID
vi.stubGlobal("crypto", { randomUUID: () => "test-uuid-1234" });
// Mock WebSocket
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
url: string;
readyState = 0;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
send = vi.fn();
close = vi.fn().mockImplementation(() => {
this.readyState = MockWebSocket.CLOSED;
// Trigger onclose asynchronously like real WebSocket
setTimeout(() => this.onclose?.(), 0);
});
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
simulateOpen() {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}
simulateMessage(data: unknown) {
this.onmessage?.({ data: JSON.stringify(data) });
}
simulateClose() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
simulateError() {
this.onerror?.();
}
}
vi.stubGlobal("WebSocket", MockWebSocket);
beforeEach(() => {
MockWebSocket.instances = [];
delete mockSessionStorage["smart_support_thread_id"];
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("useWebSocket", () => {
it("establishes connection with correct URL on mount", () => {
const onMessage = vi.fn();
renderHook(() => useWebSocket(onMessage));
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0].url).toContain("/ws");
});
it("sets status to connected when WebSocket opens", () => {
const onMessage = vi.fn();
const { result } = renderHook(() => useWebSocket(onMessage));
expect(result.current.status).toBe("connecting");
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
expect(result.current.status).toBe("connected");
});
it("parses incoming JSON messages and dispatches to callback", () => {
const onMessage = vi.fn();
renderHook(() => useWebSocket(onMessage));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
const serverMsg = { type: "token", agent: "bot", content: "Hello" };
act(() => {
MockWebSocket.instances[0].simulateMessage(serverMsg);
});
expect(onMessage).toHaveBeenCalledWith(serverMsg);
});
it("sends JSON through WebSocket via sendMessage", () => {
const onMessage = vi.fn();
const { result } = renderHook(() => useWebSocket(onMessage));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendMessage("Hi there");
});
expect(MockWebSocket.instances[0].send).toHaveBeenCalledTimes(1);
const sent = JSON.parse(MockWebSocket.instances[0].send.mock.calls[0][0]);
expect(sent.type).toBe("message");
expect(sent.content).toBe("Hi there");
expect(sent.thread_id).toBeDefined();
});
it("calls onDisconnect when WebSocket closes", () => {
const onMessage = vi.fn();
const onDisconnect = vi.fn();
renderHook(() => useWebSocket(onMessage, { onDisconnect }));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
MockWebSocket.instances[0].simulateClose();
});
expect(onDisconnect).toHaveBeenCalledTimes(1);
});
it("sets status to disconnected on close and attempts reconnect", () => {
const onMessage = vi.fn();
const { result } = renderHook(() => useWebSocket(onMessage));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
MockWebSocket.instances[0].simulateClose();
});
expect(result.current.status).toBe("disconnected");
// After timeout, a new WebSocket should be created (reconnect attempt)
act(() => {
vi.advanceTimersByTime(1500);
});
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
});
it("closes WebSocket on error event", () => {
const onMessage = vi.fn();
renderHook(() => useWebSocket(onMessage));
const ws = MockWebSocket.instances[0];
act(() => {
ws.simulateError();
});
expect(ws.close).toHaveBeenCalledTimes(1);
});
it("reconnect resets retries and creates a new connection", () => {
const onMessage = vi.fn();
const { result } = renderHook(() => useWebSocket(onMessage));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
const wsBeforeReconnect = MockWebSocket.instances[0];
act(() => {
result.current.reconnect();
});
// The old socket should have been closed
expect(wsBeforeReconnect.close).toHaveBeenCalled();
// Let the close callback fire and reconnect timer run
act(() => {
vi.advanceTimersByTime(100);
});
// A new WebSocket should have been created
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
});
it("sends interrupt response with approved flag", () => {
const onMessage = vi.fn();
const { result } = renderHook(() => useWebSocket(onMessage));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendInterruptResponse(true);
});
const sent = JSON.parse(MockWebSocket.instances[0].send.mock.calls[0][0]);
expect(sent.type).toBe("interrupt_response");
expect(sent.approved).toBe(true);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { ChatPage } from "./ChatPage";
// Mock react-markdown
vi.mock("react-markdown", () => ({
default: ({ children }: { children: string }) => <span>{children}</span>,
}));
// Mock crypto.randomUUID for stable IDs
vi.stubGlobal("crypto", { randomUUID: () => `uuid-${Date.now()}-${Math.random()}` });
// Capture the onMessage callback from the hook
let capturedOnMessage: ((msg: unknown) => void) | null = null;
const mockSendMessage = vi.fn();
const mockSendInterruptResponse = vi.fn();
const mockReconnect = vi.fn();
let mockStatus = "connected";
vi.mock("../hooks/useWebSocket", () => ({
useWebSocket: (onMessage: (msg: unknown) => void) => {
capturedOnMessage = onMessage;
return {
status: mockStatus,
threadId: "test-thread",
sendMessage: mockSendMessage,
sendInterruptResponse: mockSendInterruptResponse,
reconnect: mockReconnect,
};
},
}));
beforeEach(() => {
capturedOnMessage = null;
mockSendMessage.mockReset();
mockSendInterruptResponse.mockReset();
mockReconnect.mockReset();
mockStatus = "connected";
});
describe("ChatPage", () => {
it("renders chat interface with input field and header", () => {
render(<ChatPage />);
expect(screen.getByText("Inbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Message Smart Support...")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send Message" })).toBeInTheDocument();
});
it("user can type and submit a message", () => {
render(<ChatPage />);
const input = screen.getByPlaceholderText("Message Smart Support...");
fireEvent.change(input, { target: { value: "Hello bot" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(mockSendMessage).toHaveBeenCalledWith("Hello bot");
expect(screen.getByText("Hello bot")).toBeInTheDocument();
expect(screen.getByText("You")).toBeInTheDocument();
});
it("displays streaming tokens as they arrive", () => {
render(<ChatPage />);
act(() => {
capturedOnMessage?.({ type: "token", agent: "Bot", content: "Hello " });
});
act(() => {
capturedOnMessage?.({ type: "token", agent: "Bot", content: "world" });
});
expect(screen.getByText("Hello world")).toBeInTheDocument();
});
it("shows interrupt prompt when interrupt message received", () => {
render(<ChatPage />);
act(() => {
capturedOnMessage?.({
type: "interrupt",
thread_id: "t1",
action: "cancel_order",
params: { order_id: "ORD-999" },
});
});
expect(screen.getByText("Action Requires Approval")).toBeInTheDocument();
expect(screen.getByText("cancel_order")).toBeInTheDocument();
});
it("shows error message when server sends error", () => {
render(<ChatPage />);
act(() => {
capturedOnMessage?.({ type: "error", message: "Something went wrong" });
});
expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
});
it("renders welcome message in empty state", () => {
render(<ChatPage />);
expect(screen.getByText("Hello! How can I help you today?")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ReviewPage } from "./ReviewPage";
vi.mock("../api", () => ({
startImport: vi.fn(),
fetchImportJob: vi.fn(),
fetchClassifications: vi.fn(),
approveJob: vi.fn(),
}));
import { startImport, fetchImportJob, fetchClassifications, approveJob } from "../api";
const mockStartImport = vi.mocked(startImport);
const mockFetchImportJob = vi.mocked(fetchImportJob);
const mockFetchClassifications = vi.mocked(fetchClassifications);
const mockApproveJob = vi.mocked(approveJob);
beforeEach(() => {
mockStartImport.mockReset();
mockFetchImportJob.mockReset();
mockFetchClassifications.mockReset();
mockApproveJob.mockReset();
});
describe("ReviewPage", () => {
it("renders the OpenAPI URL input form", () => {
render(<ReviewPage />);
expect(screen.getByText("Agents & Tools Registry")).toBeInTheDocument();
expect(screen.getByPlaceholderText("https://example.com/openapi.yaml")).toBeInTheDocument();
expect(screen.getByText("Scan Tools")).toBeInTheDocument();
});
it("submit form triggers API call with entered URL", async () => {
mockStartImport.mockResolvedValue({
job_id: "job-1",
status: "processing",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 5,
classified_count: 0,
error_message: null,
});
mockFetchImportJob.mockResolvedValue({
job_id: "job-1",
status: "processing",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 5,
classified_count: 0,
error_message: null,
});
render(<ReviewPage />);
const input = screen.getByPlaceholderText("https://example.com/openapi.yaml");
fireEvent.change(input, { target: { value: "https://example.com/openapi.yaml" } });
fireEvent.click(screen.getByText("Scan Tools"));
await waitFor(() => {
expect(mockStartImport).toHaveBeenCalledWith("https://example.com/openapi.yaml");
});
});
it("shows loading state during import", async () => {
mockStartImport.mockReturnValue(new Promise(() => {})); // never resolves
render(<ReviewPage />);
const input = screen.getByPlaceholderText("https://example.com/openapi.yaml");
fireEvent.change(input, { target: { value: "https://api.test.com/spec.json" } });
fireEvent.click(screen.getByText("Scan Tools"));
await waitFor(() => {
expect(screen.getByText("Importing...")).toBeInTheDocument();
});
});
it("displays classification results after job completes", async () => {
mockStartImport.mockResolvedValue({
job_id: "job-1",
status: "done",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 2,
classified_count: 2,
error_message: null,
});
mockFetchImportJob.mockResolvedValue({
job_id: "job-1",
status: "done",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 2,
classified_count: 2,
error_message: null,
});
mockFetchClassifications.mockResolvedValue([
{
index: 0,
access_type: "read",
needs_interrupt: false,
agent_group: "OrderAgent",
confidence: 0.95,
customer_params: [],
endpoint: { path: "/orders", method: "get", operation_id: "getOrders", summary: "List orders", description: "" },
},
{
index: 1,
access_type: "write",
needs_interrupt: true,
agent_group: "OrderAgent",
confidence: 0.9,
customer_params: ["order_id"],
endpoint: { path: "/orders/{id}/cancel", method: "post", operation_id: "cancelOrder", summary: "Cancel an order", description: "" },
},
]);
render(<ReviewPage />);
const input = screen.getByPlaceholderText("https://example.com/openapi.yaml");
fireEvent.change(input, { target: { value: "https://example.com/openapi.yaml" } });
fireEvent.click(screen.getByText("Scan Tools"));
await waitFor(() => {
expect(screen.getByText("Assigned Capabilities (2)")).toBeInTheDocument();
});
expect(screen.getByText("OrderAgent")).toBeInTheDocument();
expect(screen.getByText("/orders")).toBeInTheDocument();
expect(screen.getByText("List orders")).toBeInTheDocument();
});
it("shows error on API failure", async () => {
mockStartImport.mockRejectedValue(new Error("Network timeout"));
render(<ReviewPage />);
const input = screen.getByPlaceholderText("https://example.com/openapi.yaml");
fireEvent.change(input, { target: { value: "https://example.com/openapi.yaml" } });
fireEvent.click(screen.getByText("Scan Tools"));
await waitFor(() => {
expect(screen.getByText("Error: Network timeout")).toBeInTheDocument();
});
});
it("shows success message after approval", async () => {
// Set up initial state with classifications
mockStartImport.mockResolvedValue({
job_id: "job-1",
status: "done",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 1,
classified_count: 1,
error_message: null,
});
mockFetchImportJob.mockResolvedValue({
job_id: "job-1",
status: "done",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 1,
classified_count: 1,
error_message: null,
});
mockFetchClassifications.mockResolvedValue([
{
index: 0,
access_type: "read",
needs_interrupt: false,
agent_group: "TestAgent",
confidence: 0.9,
customer_params: [],
endpoint: { path: "/test", method: "get", operation_id: "test", summary: "Test endpoint", description: "" },
},
]);
mockApproveJob.mockResolvedValue({
job_id: "job-1",
status: "approved",
spec_url: "https://example.com/openapi.yaml",
total_endpoints: 1,
classified_count: 1,
error_message: null,
generated_tools_count: 3,
});
render(<ReviewPage />);
// Import first
const input = screen.getByPlaceholderText("https://example.com/openapi.yaml");
fireEvent.change(input, { target: { value: "https://example.com/openapi.yaml" } });
fireEvent.click(screen.getByText("Scan Tools"));
await waitFor(() => {
expect(screen.getByText("Save Configuration")).toBeInTheDocument();
});
// Approve
fireEvent.click(screen.getByText("Save Configuration"));
await waitFor(() => {
expect(screen.getByText("Configuration saved. 3 tools generated.")).toBeInTheDocument();
});
});
});