= {}) {
}
describe("SecurityIdentitySection — Flag UX", () => {
- it("renders the section header", () => {
+ it("renders the section header", async () => {
renderSection();
- expect(screen.getByText(/Security & Identity/i)).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText(/Security & Identity/i)).toBeInTheDocument();
+ });
});
- it("renders all three security flag labels when expanded", () => {
+ it("renders all three security flag labels when expanded", async () => {
+ const user = userEvent.setup();
renderSection();
- fireEvent.click(screen.getByText(/Security & Identity/i));
+ await user.click(screen.getByText(/Security & Identity/i));
- expect(screen.getByText(/Sign inter-agent/i)).toBeInTheDocument();
- expect(screen.getByText(/Sign MCP invocations/i)).toBeInTheDocument();
- expect(screen.getByText(/Require peer verification/i)).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText(/Sign inter-agent/i)).toBeInTheDocument();
+ expect(screen.getByText(/Sign MCP invocations/i)).toBeInTheDocument();
+ expect(screen.getByText(/Require peer verification/i)).toBeInTheDocument();
+ });
});
- it("does NOT show warning banner when no flags are enabled", () => {
+ it("does NOT show warning banner when no flags are enabled", async () => {
+ const user = userEvent.setup();
renderSection();
- fireEvent.click(screen.getByText(/Security & Identity/i));
+ await user.click(screen.getByText(/Security & Identity/i));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Sign inter-agent/i)).toBeInTheDocument();
+ });
expect(screen.queryByTestId("security-flag-warning")).not.toBeInTheDocument();
});
- it("shows warning banner when a flag is enabled", () => {
+ it("shows warning banner when a flag is enabled", async () => {
renderSection({
security: {
signInterAgentMessages: true,
@@ -64,22 +77,23 @@ describe("SecurityIdentitySection — Flag UX", () => {
},
});
- expect(screen.getByTestId("security-flag-warning")).toBeInTheDocument();
- expect(screen.getByText(/Cryptographic signing is not yet available/i)).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId("security-flag-warning")).toBeInTheDocument();
+ expect(screen.getByText(/Cryptographic signing is not yet available/i)).toBeInTheDocument();
+ });
});
it("shows confirmation dialog when toggling a flag ON", async () => {
+ const user = userEvent.setup();
renderSection();
- fireEvent.click(screen.getByText(/Security & Identity/i));
+ await user.click(screen.getByText(/Security & Identity/i));
await waitFor(() => {
- expect(screen.getByText(/Sign inter-agent/i)).toBeInTheDocument();
+ expect(screen.getByTestId("security-flag-signInterAgentMessages")).toBeInTheDocument();
});
- // The checkboxes are rendered as
- const checkboxes = screen.getAllByRole("checkbox");
- // First checkbox is signInterAgentMessages
- fireEvent.click(checkboxes[0]!);
+ // Use the specific data-testid instead of index-based selector
+ await user.click(screen.getByTestId("security-flag-signInterAgentMessages"));
await waitFor(() => {
expect(screen.getByText(/Enable security flag\?/i)).toBeInTheDocument();
@@ -87,40 +101,103 @@ describe("SecurityIdentitySection — Flag UX", () => {
});
});
- it("closes confirmation dialog when cancel is clicked", async () => {
+ it("closes confirmation dialog when cancel is clicked and checkbox remains unchecked", async () => {
+ const user = userEvent.setup();
renderSection();
- fireEvent.click(screen.getByText(/Security & Identity/i));
+ await user.click(screen.getByText(/Security & Identity/i));
await waitFor(() => {
- expect(screen.getByText(/Sign inter-agent/i)).toBeInTheDocument();
+ expect(screen.getByTestId("security-flag-signInterAgentMessages")).toBeInTheDocument();
});
- const checkboxes = screen.getAllByRole("checkbox");
- fireEvent.click(checkboxes[0]!);
+ const checkbox = screen.getByTestId("security-flag-signInterAgentMessages") as HTMLInputElement;
+
+ // Verify checkbox starts unchecked
+ expect(checkbox.checked).toBe(false);
+
+ await user.click(checkbox);
await waitFor(() => {
expect(screen.getByText(/Enable security flag\?/i)).toBeInTheDocument();
});
- fireEvent.click(screen.getByText("Cancel"));
+ await user.click(screen.getByText("Cancel"));
+
+ await waitFor(() => {
+ expect(screen.queryByText(/Enable security flag\?/i)).not.toBeInTheDocument();
+ });
+
+ // After cancel, the checkbox should still be unchecked
+ expect(checkbox.checked).toBe(false);
+ });
+
+ it("clicking 'Enable anyway' confirms the flag and calls update API", async () => {
+ let updateCalled = false;
+ server.use(
+ http.put("*/agentstore/agents/:id", () => {
+ updateCalled = true;
+ return new HttpResponse(null, {
+ status: 200,
+ headers: { Location: "/agentstore/agents/agent-test?version=2" },
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderSection();
+ await user.click(screen.getByText(/Security & Identity/i));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("security-flag-signInterAgentMessages")).toBeInTheDocument();
+ });
+
+ // Click the checkbox to open the confirmation dialog
+ await user.click(screen.getByTestId("security-flag-signInterAgentMessages"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Enable anyway/i)).toBeInTheDocument();
+ });
+
+ // Click "Enable anyway" to confirm
+ await user.click(screen.getByText(/Enable anyway/i));
+ // The dialog should close and the API should be called
await waitFor(() => {
expect(screen.queryByText(/Enable security flag\?/i)).not.toBeInTheDocument();
+ expect(updateCalled).toBe(true);
});
});
- it("renders identity fields when expanded", () => {
+ it("renders identity fields when expanded", async () => {
+ const user = userEvent.setup();
renderSection();
- fireEvent.click(screen.getByText(/Security & Identity/i));
+ await user.click(screen.getByText(/Security & Identity/i));
- expect(screen.getByTestId("identity-section")).toBeInTheDocument();
- expect(screen.getByText("Cryptographic Identity")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId("identity-section")).toBeInTheDocument();
+ expect(screen.getByText("Cryptographic Identity")).toBeInTheDocument();
+ });
+ });
+
+ it("shows security toggles section", async () => {
+ const user = userEvent.setup();
+ renderSection();
+ await user.click(screen.getByText(/Security & Identity/i));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("security-toggles")).toBeInTheDocument();
+ });
});
- it("shows security toggles section", () => {
+ it("renders all three checkboxes with correct data-testids", async () => {
+ const user = userEvent.setup();
renderSection();
- fireEvent.click(screen.getByText(/Security & Identity/i));
+ await user.click(screen.getByText(/Security & Identity/i));
- expect(screen.getByTestId("security-toggles")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId("security-flag-signInterAgentMessages")).toBeInTheDocument();
+ expect(screen.getByTestId("security-flag-signMcpInvocations")).toBeInTheDocument();
+ expect(screen.getByTestId("security-flag-requirePeerVerification")).toBeInTheDocument();
+ });
});
});
diff --git a/src/pages/__tests__/agent-detail.test.tsx b/src/pages/__tests__/agent-detail.test.tsx
index 925e1dc3..b356c31c 100644
--- a/src/pages/__tests__/agent-detail.test.tsx
+++ b/src/pages/__tests__/agent-detail.test.tsx
@@ -1,10 +1,12 @@
-import { describe, it, expect } from "vitest";
+import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { ThemeProvider } from "@/components/layout/theme-provider";
import { AgentDetailPage } from "@/pages/agent-detail";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
function renderAgentDetail(id = "agent1") {
const queryClient = new QueryClient({
@@ -37,6 +39,25 @@ async function expandA2ASection() {
}
describe("AgentDetailPage", () => {
+ // ─── Loading state ──────────────────────────────────────────────────────
+ it("shows loading spinner while fetching agent data", async () => {
+ // Delay the agent response so we can observe the loading state
+ server.use(
+ http.get("*/agentstore/agents/:id", async () => {
+ await new Promise((r) => setTimeout(r, 200));
+ return HttpResponse.json({ workflows: [] });
+ }),
+ // Delay versions as well so loading persists
+ http.get("*/:store/:plural/:id/currentversion", async () => {
+ await new Promise((r) => setTimeout(r, 200));
+ return HttpResponse.json(1);
+ })
+ );
+
+ renderAgentDetail();
+ expect(screen.getByTestId("agent-detail-loading")).toBeInTheDocument();
+ });
+
it("renders agent detail title", async () => {
renderAgentDetail();
await waitFor(() => {
@@ -59,11 +80,34 @@ describe("AgentDetailPage", () => {
});
});
- it("renders duplicate button", async () => {
+ // ─── Duplicate — fires mutation immediately (no dialog) ─────────────────
+ it("clicking duplicate button triggers duplicate mutation directly", async () => {
+ let duplicateCalled = false;
+ server.use(
+ http.post("*/agentstore/agents/:id", ({ request }) => {
+ const url = new URL(request.url);
+ if (url.searchParams.has("version")) {
+ duplicateCalled = true;
+ }
+ return new HttpResponse(null, {
+ status: 201,
+ headers: { Location: "/agentstore/agents/newid?version=1" },
+ });
+ })
+ );
+
renderAgentDetail();
await waitFor(() => {
expect(screen.getByTestId("duplicate-agent-btn")).toBeInTheDocument();
});
+
+ // handleDuplicate() fires the mutation immediately — no confirmation dialog
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("duplicate-agent-btn"));
+
+ await waitFor(() => {
+ expect(duplicateCalled).toBe(true);
+ });
});
it("renders export button", async () => {
@@ -84,7 +128,7 @@ describe("AgentDetailPage", () => {
renderAgentDetail();
await waitFor(() => {
expect(screen.getByTestId("env-badges")).toBeInTheDocument();
- }, { timeout: 5000 });
+ });
});
it("renders add package button", async () => {
@@ -101,6 +145,16 @@ describe("AgentDetailPage", () => {
});
});
+ // ─── Workflow list rendering ────────────────────────────────────────────
+ it("renders workflow cards for agent1 with wf1", async () => {
+ renderAgentDetail();
+ await waitFor(() => {
+ // agent1 has workflow wf1
+ expect(screen.getByText("wf1")).toBeInTheDocument();
+ });
+ });
+
+ // ─── A2A section ────────────────────────────────────────────────────────
it("renders A2A protocol section", async () => {
renderAgentDetail();
await waitFor(() => {
@@ -145,6 +199,7 @@ describe("AgentDetailPage", () => {
});
});
+ // ─── Channels ───────────────────────────────────────────────────────────
it("renders channels section with add button", async () => {
renderAgentDetail();
// Channel Connectors section is collapsed by default (no channels) — expand it
@@ -163,4 +218,472 @@ describe("AgentDetailPage", () => {
expect(screen.getByTestId("channel-id-0")).toHaveValue("C0123ABCDEF");
});
});
+
+ // ─── Back link ──────────────────────────────────────────────────────────
+ it("renders back to agents link", async () => {
+ renderAgentDetail();
+ await waitFor(() => {
+ expect(screen.getByText("Back to Agents")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Version badge / picker ─────────────────────────────────────────────
+ it("shows version info", async () => {
+ renderAgentDetail();
+ await waitFor(() => {
+ // Should show either a version badge or version picker
+ const picker = screen.queryByTestId("version-picker");
+ const badge = screen.queryByTestId("version-badge");
+ expect(picker || badge).toBeTruthy();
+ });
+ });
+
+ // ─── Deploy button — calls deploy API directly (no dropdown) ────────────
+ it("deploy button calls deploy API when clicked", async () => {
+ let deployCalled = false;
+ server.use(
+ http.get("*/administration/:env/deploymentstatus/:agentId", () => {
+ return HttpResponse.json({ status: "NOT_FOUND" });
+ }),
+ http.post("*/administration/:env/deploy/:id", () => {
+ deployCalled = true;
+ return new HttpResponse(null, { status: 200 });
+ })
+ );
+
+ renderAgentDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ const btn = screen.getByTestId("deploy-btn");
+ expect(btn).toHaveTextContent(/Deploy/i);
+ });
+
+ // Deploy button calls handleDeploy() directly — no dropdown
+ await user.click(screen.getByTestId("deploy-btn"));
+
+ await waitFor(() => {
+ expect(deployCalled).toBe(true);
+ });
+ });
+
+ // ─── Undeploy flow ──────────────────────────────────────────────────────
+ it("undeploy button calls undeploy API when agent is deployed", async () => {
+ let undeployCalled = false;
+ server.use(
+ http.get("*/administration/:env/deploymentstatus/:agentId", () => {
+ return HttpResponse.json({ status: "READY" });
+ }),
+ http.post("*/administration/:env/undeploy/:id", () => {
+ undeployCalled = true;
+ return new HttpResponse(null, { status: 200 });
+ })
+ );
+
+ renderAgentDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ const btn = screen.getByTestId("deploy-btn");
+ // When deployed, button shows "Undeploy"
+ expect(btn).toHaveTextContent(/Undeploy/i);
+ });
+
+ await user.click(screen.getByTestId("deploy-btn"));
+
+ await waitFor(() => {
+ expect(undeployCalled).toBe(true);
+ });
+ });
+
+ // ─── Chat button ────────────────────────────────────────────────────────
+ it("renders chat button", async () => {
+ renderAgentDetail();
+ await waitFor(() => {
+ expect(screen.getByTestId("chat-btn")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Open in Studio ─────────────────────────────────────────────────────
+ it("renders Open in Studio link", async () => {
+ renderAgentDetail();
+ await waitFor(() => {
+ expect(screen.getByTestId("open-studio-btn")).toBeInTheDocument();
+ expect(screen.getByText("Open in Studio")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Error state ────────────────────────────────────────────────────────
+ it("shows error state when agent API returns 500", async () => {
+ server.use(
+ http.get("*/agentstore/agents/:id", () => {
+ return HttpResponse.json({ error: "fail" }, { status: 500 });
+ })
+ );
+ renderAgentDetail("fail-agent");
+ await waitFor(() => {
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ });
+ });
+
+ it("shows retry button in error state", async () => {
+ server.use(
+ http.get("*/agentstore/agents/:id", () => {
+ return HttpResponse.json({ error: "fail" }, { status: 500 });
+ })
+ );
+ renderAgentDetail("fail-agent");
+ await waitFor(() => {
+ expect(screen.getByText("Retry")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Delete dialog ──────────────────────────────────────────────────────
+ it("opens delete confirmation dialog and executes deletion", async () => {
+ let deleteCalled = false;
+ server.use(
+ http.delete("*/agentstore/agents/:id", () => {
+ deleteCalled = true;
+ return new HttpResponse(null, { status: 200 });
+ })
+ );
+
+ renderAgentDetail();
+ const user = userEvent.setup();
+ await waitFor(() => {
+ expect(screen.getByTestId("delete-agent-btn")).toBeInTheDocument();
+ });
+
+ // Open delete dialog
+ await user.click(screen.getByTestId("delete-agent-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument();
+ });
+
+ // Click confirm delete
+ const confirmBtn = screen.getByRole("button", { name: /Delete/i });
+ await user.click(confirmBtn);
+
+ await waitFor(() => {
+ expect(deleteCalled).toBe(true);
+ });
+ });
+
+ // ─── Export dialog ──────────────────────────────────────────────────────
+ it("opens export dialog", async () => {
+ renderAgentDetail();
+ const user = userEvent.setup();
+ await waitFor(() => {
+ expect(screen.getByTestId("export-agent-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("export-agent-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("export-agent-dialog")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Deploy & Chat button ──────────────────────────────────────────────
+ it("shows Deploy & Chat button for undeployed agent", async () => {
+ server.use(
+ http.get("*/administration/:env/deploymentstatus/:agentId", () => {
+ return HttpResponse.json({ status: "NOT_FOUND" });
+ })
+ );
+ renderAgentDetail();
+ await waitFor(() => {
+ expect(screen.getByTestId("deploy-chat-btn")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Agent ID display ──────────────────────────────────────────────────
+ it("shows agent ID in page header", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByText("agent1")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Version picker with multiple versions ───────────────────────────
+ it("shows version picker when multiple versions exist", async () => {
+ // The default MSW handler for agent1 descriptors returns 2 versions
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ const picker = screen.queryByTestId("version-picker");
+ const badge = screen.queryByTestId("version-badge");
+ // Should show either picker (multiple versions) or badge (single version)
+ expect(picker || badge).toBeTruthy();
+ });
+ });
+
+ // ─── Workflow card shows version badge ────────────────────────────────
+ it("shows workflow version badge in workflow card", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ // agent1 has workflow wf1?version=1 → workflow card should be rendered
+ expect(screen.getByText("wf1")).toBeInTheDocument();
+ });
+ // The workflow card should exist — version is shown nearby
+ // The version picker already confirms versions are loaded
+ });
+
+ // ─── Workflow Open button ─────────────────────────────────────────────
+ it("shows Open button for workflow card", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByText("Open")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Add workflow panel toggle ────────────────────────────────────────
+ it("opens add workflow panel when add workflow button is clicked", async () => {
+ renderAgentDetail("agent1");
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("add-workflow-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("add-workflow-btn"));
+
+ // After clicking, the "Add Workflow" panel should appear with workflow options
+ await waitFor(() => {
+ expect(screen.getByText(/Cancel/)).toBeInTheDocument();
+ });
+ });
+
+ // ─── Remove workflow mutation ─────────────────────────────────────────
+ it("clicking remove workflow button triggers mutation", async () => {
+ let updateCalled = false;
+ server.use(
+ http.put("*/agentstore/agents/:id", () => {
+ updateCalled = true;
+ return new HttpResponse(null, {
+ status: 200,
+ headers: { Location: "/agentstore/agents/agent1?version=2" },
+ });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ const user = userEvent.setup();
+
+ // Wait for the workflow card with remove button to appear
+ await waitFor(() => {
+ expect(screen.getByText("wf1")).toBeInTheDocument();
+ });
+
+ // Find the delete button within the workflow row
+ // Each workflow card has a trash button
+ const trashButtons = screen.getAllByTitle("Delete");
+ expect(trashButtons.length).toBeGreaterThan(0);
+ await user.click(trashButtons[0]!);
+
+ await waitFor(() => {
+ expect(updateCalled).toBe(true);
+ });
+ });
+
+ // ─── Security & Identity section ──────────────────────────────────────
+ it("renders Security & Identity section", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByText("Security & Identity")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Capabilities section ──────────────────────────────────────────────
+ it("renders Capabilities section", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByText("Capabilities")).toBeInTheDocument();
+ });
+ });
+
+ // ─── User Memory section ──────────────────────────────────────────────
+ it("renders User Memory section", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByText("User Memory")).toBeInTheDocument();
+ });
+ });
+
+ // ─── No save feedback initially ───────────────────────────────────────
+ it("does not show save feedback initially", async () => {
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByText("Support Agent")).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId("save-feedback")).not.toBeInTheDocument();
+ });
+
+ // ─── Deployed agent shows external chat link ──────────────────────────
+ it("shows external chat link when agent is deployed", async () => {
+ server.use(
+ http.get("*/administration/:env/deploymentstatus/:agentId", () => {
+ return HttpResponse.json({ status: "READY" });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByTestId("external-chat-btn")).toBeInTheDocument();
+ });
+ });
+
+ // ─── IN_PROGRESS deployment status ─────────────────────────────────────
+ it("shows Deploying status when agent is IN_PROGRESS", async () => {
+ server.use(
+ http.get("*/administration/:env/deploymentstatus/:agentId", () => {
+ return HttpResponse.json({ status: "IN_PROGRESS" });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ expect(screen.getByTestId("deployment-status")).toHaveTextContent(/Deploying/i);
+ });
+ });
+
+ // ─── Deploy button disabled when IN_PROGRESS ──────────────────────────
+ it("deploy button is disabled when agent is IN_PROGRESS", async () => {
+ server.use(
+ http.get("*/administration/:env/deploymentstatus/:agentId", () => {
+ return HttpResponse.json({ status: "IN_PROGRESS" });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ await waitFor(() => {
+ const btn = screen.getByTestId("deploy-btn");
+ expect(btn).toBeDisabled();
+ });
+ });
+
+ // ─── Raw Configuration section ──────────────────────────────────────────
+ it("expands raw config section and shows JSON content", async () => {
+ renderAgentDetail("agent1");
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByText("Support Agent")).toBeInTheDocument();
+ });
+
+ // Raw Configuration section toggle
+ const rawConfigToggle = screen.getByText("Raw Configuration");
+ expect(rawConfigToggle).toBeInTheDocument();
+
+ // Click to expand
+ await user.click(rawConfigToggle);
+
+ // After expanding, a element with JSON content should appear
+ await waitFor(() => {
+ const rawContent = document.getElementById("raw-config-content");
+ expect(rawContent).toBeInTheDocument();
+ });
+ });
+
+ // ─── A2A description save mutation ────────────────────────────────────
+ it("saving A2A description on blur calls update mutation", async () => {
+ let updateCalled = false;
+ server.use(
+ http.put("*/agentstore/agents/:id", () => {
+ updateCalled = true;
+ return new HttpResponse(null, {
+ status: 200,
+ headers: { Location: "/agentstore/agents/agent1?version=2" },
+ });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ const user = userEvent.setup();
+
+ // Expand A2A section first
+ await expandA2ASection();
+
+ await waitFor(() => {
+ // The A2A toggle button when already enabled is a link/text or there's a "Disable" button
+ // Since A2A is enabled for agent1, and there's no explicit toggle, let's verify A2A section content instead
+ expect(screen.getByTestId("a2a-description")).toBeInTheDocument();
+ });
+
+ // Modify the description to trigger a mutation
+ const descInput = screen.getByTestId("a2a-description");
+ await user.clear(descInput);
+ await user.type(descInput, "Updated description");
+ // Blur triggers handleDescriptionSave
+ await user.tab();
+
+ await waitFor(() => {
+ expect(updateCalled).toBe(true);
+ });
+ });
+
+ // ─── A2A add skill ────────────────────────────────────────────────────
+ it("adding a skill calls update mutation with new skill", async () => {
+ let updatePayload: Record | null = null;
+ server.use(
+ http.put("*/agentstore/agents/:id", async ({ request }) => {
+ updatePayload = (await request.json()) as Record;
+ return new HttpResponse(null, {
+ status: 200,
+ headers: { Location: "/agentstore/agents/agent1?version=2" },
+ });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ const user = userEvent.setup();
+
+ await expandA2ASection();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("a2a-skill-input")).toBeInTheDocument();
+ });
+
+ // Type a new skill and press Enter
+ const skillInput = screen.getByTestId("a2a-skill-input");
+ await user.type(skillInput, "new-skill{Enter}");
+
+ await waitFor(() => {
+ expect(updatePayload).toBeTruthy();
+ const skills = (updatePayload as Record)?.a2aSkills;
+ expect(skills).toContain("new-skill");
+ });
+ });
+
+ // ─── A2A remove skill ────────────────────────────────────────────────
+ it("removing a skill calls update mutation", async () => {
+ let updateCalled = false;
+ server.use(
+ http.put("*/agentstore/agents/:id", () => {
+ updateCalled = true;
+ return new HttpResponse(null, {
+ status: 200,
+ headers: { Location: "/agentstore/agents/agent1?version=2" },
+ });
+ })
+ );
+
+ renderAgentDetail("agent1");
+ const user = userEvent.setup();
+
+ await expandA2ASection();
+
+ await waitFor(() => {
+ expect(screen.getByText("order-tracking")).toBeInTheDocument();
+ });
+
+ // Click the × button on a skill badge using aria-label
+ const removeBtn = screen.getByRole("button", { name: /Remove order-tracking/ });
+ await user.click(removeBtn);
+
+ await waitFor(() => {
+ expect(updateCalled).toBe(true);
+ });
+ });
});
+
diff --git a/src/pages/__tests__/agent-studio.test.tsx b/src/pages/__tests__/agent-studio.test.tsx
index c1a69c2f..a0122476 100644
--- a/src/pages/__tests__/agent-studio.test.tsx
+++ b/src/pages/__tests__/agent-studio.test.tsx
@@ -1,32 +1,13 @@
import { describe, it, expect } from "vitest";
import { screen, waitFor } from "@testing-library/react";
-import { render } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MemoryRouter, Route, Routes } from "react-router-dom";
-import { ThemeProvider } from "@/components/layout/theme-provider";
+import { renderPage, userEvent } from "@/test/test-utils";
import { AgentStudioPage } from "@/pages/agent-studio";
function renderStudio(agentId = "agent1") {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
-
- return render(
-
-
-
-
- }
- />
-
-
-
-
+ return renderPage(
+ `/manage/studio/${agentId}`,
+ ,
+ "/manage/studio/:agentId"
);
}
@@ -38,19 +19,14 @@ describe("Agent Studio Page", () => {
});
});
- it("shows agent name or ID in the header", async () => {
+ it("shows actual agent name from mock data in the header", async () => {
renderStudio();
+ // The agent descriptor resolves agent1 to "Support Agent"
await waitFor(() => {
- // Either the resolved agent name or the raw agentId
- const header = screen.getByTestId("agent-studio");
- expect(header).toBeInTheDocument();
- });
- // The agent descriptor should resolve to the name
- await waitFor(() => {
- expect(
- screen.getByText("Agent Studio")
- ).toBeInTheDocument();
+ expect(screen.getByText("Support Agent")).toBeInTheDocument();
});
+ // The subtitle should say "Agent Studio"
+ expect(screen.getByText("Agent Studio")).toBeInTheDocument();
});
it("renders the back link to agent detail", async () => {
@@ -106,25 +82,56 @@ describe("Agent Studio Page", () => {
});
});
- it("loads pipeline stages from the workflow", async () => {
+ it("loads pipeline stages from the workflow and shows stage content", async () => {
renderStudio();
- // The mock workflow has 5 steps; pipeline-railroad renders them
- // Wait for the pipeline to load (the workflow fetch needs to complete)
+ // The mock workflow for agent1 has 5 steps: parser, rules, property, llm, output
+ // The pipeline railroad renders them with data-testid="stage-{idx}"
+ // Note: PipelineRailroad renders twice (desktop + mobile), so use getAllByTestId
await waitFor(
() => {
- // The pipeline railroad should render the pipeline steps
- // At minimum, the studio container should be present
- expect(screen.getByTestId("agent-studio")).toBeInTheDocument();
+ const railroads = screen.getAllByTestId("pipeline-railroad");
+ expect(railroads.length).toBeGreaterThanOrEqual(1);
+ // Verify actual pipeline stage labels from mock data
+ const stages = screen.getAllByTestId("stage-0");
+ expect(stages.length).toBeGreaterThanOrEqual(1);
+ const lastStages = screen.getAllByTestId("stage-4");
+ expect(lastStages.length).toBeGreaterThanOrEqual(1);
},
{ timeout: 3000 }
);
+
+ // Verify actual extension type labels are rendered
+ await waitFor(() => {
+ expect(screen.getAllByText("Rules").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("LLM").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("Output").length).toBeGreaterThanOrEqual(1);
+ });
});
- it("navigates back correctly with back link", async () => {
+ it("can select a pipeline stage by clicking", async () => {
renderStudio();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ // Multiple renders of stage-1 exist (desktop + mobile)
+ const stages = screen.getAllByTestId("stage-1");
+ expect(stages.length).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 3000 }
+ );
+
+ // Click on the first "Rules" stage (index 1) - pick first one (desktop)
+ const stageButtons = screen.getAllByTestId("stage-1");
+ await user.click(stageButtons[0]!);
+
+ // After selecting, the button should have aria-current="true"
await waitFor(() => {
- const backLink = screen.getByTestId("studio-back");
- expect(backLink.getAttribute("href")).toBe("/manage/agentview/agent1");
+ const updatedStages = screen.getAllByTestId("stage-1");
+ const hasAriaCurrent = updatedStages.some(
+ (el) => el.getAttribute("aria-current") === "true"
+ );
+ expect(hasAriaCurrent).toBe(true);
});
});
});
diff --git a/src/pages/__tests__/agent-wizard.test.tsx b/src/pages/__tests__/agent-wizard.test.tsx
index becef5fd..c01ac04d 100644
--- a/src/pages/__tests__/agent-wizard.test.tsx
+++ b/src/pages/__tests__/agent-wizard.test.tsx
@@ -1,8 +1,10 @@
-import { describe, it, expect } from "vitest";
-import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/test/test-utils";
import { AgentWizardPage } from "@/pages/agent-wizard";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
describe("AgentWizardPage", () => {
it("renders wizard heading", () => {
@@ -177,4 +179,551 @@ describe("AgentWizardPage", () => {
expect(screen.getByTestId("wizard-create-only")).toBeInTheDocument();
expect(screen.getByTestId("wizard-create-deploy")).toBeInTheDocument();
});
+
+ // ── Create Only mutation ───────────────────────────────────────────
+
+ it("calls setup API when Create Only is clicked", async () => {
+ let setupCalled = false;
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ setupCalled = true;
+ return HttpResponse.json({
+ agentId: "new-agent-1",
+ agentName: "My Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: false,
+ deploymentStatus: null,
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ // Navigate through all steps
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "My Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-test-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Review — click Create Only
+ await user.click(screen.getByTestId("wizard-create-only"));
+
+ await waitFor(() => {
+ expect(setupCalled).toBe(true);
+ });
+ });
+
+ // ── Create & Deploy mutation ───────────────────────────────────────
+
+ it("calls setup API with deploy=true when Create & Deploy is clicked", async () => {
+ let deployFlag = false;
+ server.use(
+ http.post("*/administration/agents/setup", async ({ request }) => {
+ const body = (await request.json()) as { deploy?: boolean };
+ deployFlag = body.deploy === true;
+ return HttpResponse.json({
+ agentId: "new-agent-2",
+ agentName: "My Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: true,
+ deploymentStatus: "deployed",
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ // Navigate through all steps
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "My Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-test-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Review — click Create & Deploy
+ await user.click(screen.getByTestId("wizard-create-deploy"));
+
+ await waitFor(() => {
+ expect(deployFlag).toBe(true);
+ });
+ });
+
+ // ── Success state ──────────────────────────────────────────────────
+
+ it("shows success state with View Agent link after creation", async () => {
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ return HttpResponse.json({
+ agentId: "created-agent-1",
+ agentName: "My Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: false,
+ deploymentStatus: null,
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ // Navigate through all steps
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "My Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-test-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+
+ await user.click(screen.getByTestId("wizard-create-only"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Agent Created!")).toBeInTheDocument();
+ expect(screen.getByText("View Agent")).toBeInTheDocument();
+ expect(screen.getByText("Create Another")).toBeInTheDocument();
+ });
+ });
+
+ // ── Error state ───────────────────────────────────────────────────
+
+ it("shows error message when setup fails", async () => {
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ return HttpResponse.json(
+ { error: "Invalid API key" },
+ { status: 400 }
+ );
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ // Navigate through all steps
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "My Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "bad-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+
+ await user.click(screen.getByTestId("wizard-create-only"));
+
+ // Should stay on review step with error
+ await waitFor(() => {
+ expect(screen.getByTestId("wizard-review")).toBeInTheDocument();
+ });
+ });
+
+ // ── Provider switching ────────────────────────────────────────────
+
+ it("switches provider and shows model suggestions", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Test Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Provider select should be present
+ const providerSelect = screen.getByTestId("wizard-provider");
+ expect(providerSelect).toBeInTheDocument();
+
+ // Model input should be present
+ expect(screen.getByTestId("wizard-model")).toBeInTheDocument();
+ });
+
+ // ── LLM step: next disabled without model ─────────────────────────
+
+ it("LLM step next is disabled without model and API key", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Test Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // No model or API key → next disabled
+ expect(screen.getByTestId("wizard-next")).toBeDisabled();
+ });
+
+ // ── Features step interactions ────────────────────────────────────
+
+ /** Helper: navigate through type + identity + LLM to reach Features step (Standard) */
+ async function navigateToFeaturesStep(user: ReturnType) {
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "My Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ // Now on Features step
+ }
+
+ it("Features step shows Intro Message toggle for standard agent", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ expect(screen.getByText("Intro Message")).toBeInTheDocument();
+ expect(screen.getByTestId("wizard-toggle-intro")).toBeInTheDocument();
+ });
+
+ it("enables intro message and shows textarea", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ await user.click(screen.getByTestId("wizard-toggle-intro"));
+ expect(screen.getByTestId("wizard-intro-text")).toBeInTheDocument();
+ // Pre-filled default text
+ expect(screen.getByTestId("wizard-intro-text")).toHaveValue(
+ "Hello! How can I help you today?"
+ );
+ });
+
+ it("toggles structured output and shows sub-options", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ await user.click(screen.getByTestId("wizard-toggle-structured"));
+ // Sub-options appear
+ expect(screen.getByTestId("structured-output-info")).toBeInTheDocument();
+ expect(screen.getByTestId("wizard-toggle-qr")).toBeInTheDocument();
+ expect(screen.getByTestId("wizard-toggle-sentiment")).toBeInTheDocument();
+ });
+
+ it("toggles sentiment analysis independently", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ // Enable structured output
+ await user.click(screen.getByTestId("wizard-toggle-structured"));
+ // Enable sentiment
+ await user.click(screen.getByTestId("wizard-toggle-sentiment"));
+ expect(screen.getByTestId("wizard-toggle-sentiment")).toBeChecked();
+ });
+
+ it("shows Built-in Tools toggle for standard agent", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ expect(screen.getByText("Built-in Tools")).toBeInTheDocument();
+ expect(screen.getByTestId("wizard-toggle-tools")).toBeInTheDocument();
+ });
+
+ it("enables built-in tools and shows All Tools mode by default", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ await user.click(screen.getByTestId("wizard-toggle-tools"));
+ expect(screen.getByTestId("wizard-tool-selection-mode")).toBeInTheDocument();
+ expect(screen.getByTestId("wizard-all-tools-info")).toBeInTheDocument();
+ });
+
+ it("switches to Select Specific mode and shows tool chips", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ await user.click(screen.getByTestId("wizard-toggle-tools"));
+ await user.click(screen.getByTestId("wizard-tool-mode-specific"));
+ expect(screen.getByTestId("wizard-tools-whitelist")).toBeInTheDocument();
+ expect(screen.getByText("Available Tools")).toBeInTheDocument();
+ });
+
+ it("shows deploy toggle and environment selector", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ expect(screen.getByTestId("wizard-toggle-deploy")).toBeInTheDocument();
+ // Deploy is enabled by default → env selector should be visible
+ expect(screen.getByTestId("wizard-environment")).toBeInTheDocument();
+ expect(screen.getByTestId("wizard-environment")).toHaveValue("production");
+ });
+
+ it("changes environment to test", async () => {
+ const user = userEvent.setup();
+ await navigateToFeaturesStep(user);
+ await user.selectOptions(screen.getByTestId("wizard-environment"), "test");
+ expect(screen.getByTestId("wizard-environment")).toHaveValue("test");
+ });
+
+ // ── API spec paste mode ───────────────────────────────────────────
+
+ it("switches to paste mode and shows textarea", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-api"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "API Bot");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "API manager");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Click paste tab
+ await user.click(screen.getByText("Paste"));
+ expect(screen.getByTestId("wizard-spec-paste")).toBeInTheDocument();
+ });
+
+ it("switches to file mode and shows dropzone", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-api"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "API Bot");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "API manager");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Click file tab
+ await user.click(screen.getByText("File"));
+ expect(screen.getByTestId("wizard-spec-dropzone")).toBeInTheDocument();
+ expect(screen.getByText("Browse Files")).toBeInTheDocument();
+ });
+
+ // ── Review step content ───────────────────────────────────────────
+
+ it("review step shows agent name and model in review table", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Review Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be smart");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "gpt-5");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Review
+ expect(screen.getByText("Review Agent")).toBeInTheDocument();
+ expect(screen.getByText("gpt-5")).toBeInTheDocument();
+ expect(screen.getByText("Standard Agent")).toBeInTheDocument();
+ });
+
+ // ── Success state: deployed badge ─────────────────────────────────
+
+ it("shows deployed badge in success state when agent is deployed", async () => {
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ return HttpResponse.json({
+ action: "created",
+ agentId: "deployed-agent",
+ agentName: "Deployed Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: true,
+ deploymentStatus: "ready",
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Deployed Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Deploy me");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-create-deploy"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Agent Created!")).toBeInTheDocument();
+ // Check for provider/model summary
+ expect(screen.getByText(/Deployed Agent/)).toBeInTheDocument();
+ expect(screen.getByText(/anthropic/)).toBeInTheDocument();
+ });
+ });
+
+ // ── Create Another resets wizard ──────────────────────────────────
+
+ it("Create Another resets to step 1", async () => {
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ return HttpResponse.json({
+ agentId: "agent-1",
+ agentName: "Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: false,
+ deploymentStatus: null,
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Help");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-key");
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.click(screen.getByTestId("wizard-create-only"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Agent Created!")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Create Another"));
+
+ // Should reset back to type selection
+ await waitFor(() => {
+ expect(screen.getByTestId("type-grid")).toBeInTheDocument();
+ });
+ });
+
+ // ── Ollama provider (no API key, base URL required) ───────────────
+
+ it("Ollama provider hides API key and shows required base URL", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Local Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Local help");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Switch to Ollama
+ await user.selectOptions(screen.getByTestId("wizard-provider"), "ollama");
+
+ // API key should NOT be required (Ollama needs no key)
+ expect(screen.queryByTestId("wizard-apikey-input")).not.toBeInTheDocument();
+
+ // Base URL should show required hint
+ expect(
+ screen.getByText(/Required — the URL where your local model server is running/i)
+ ).toBeInTheDocument();
+ });
+
+ // ── LLM step: base URL input ──────────────────────────────────────
+
+ it("shows base URL input on LLM step", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-standard"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "Test Agent");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "Be helpful");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ expect(screen.getByTestId("wizard-baseurl")).toBeInTheDocument();
+ });
+
+ // ── API agent full flow ───────────────────────────────────────────
+
+ it("calls create API agent endpoint for API mode", async () => {
+ let apiAgentCalled = false;
+ server.use(
+ http.post("*/administration/agents/setup-api", () => {
+ apiAgentCalled = true;
+ return HttpResponse.json({
+ action: "created",
+ agentId: "api-agent-1",
+ agentName: "API Bot",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: false,
+ deploymentStatus: null,
+ endpointCount: 5,
+ groups: ["users", "orders"],
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/agents/wizard",
+ });
+
+ await user.click(screen.getByTestId("type-api"));
+ await user.click(screen.getByTestId("wizard-next"));
+ await user.type(screen.getByTestId("wizard-agent-name"), "API Bot");
+ await user.type(screen.getByTestId("wizard-system-prompt"), "API helper");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // API spec step - paste spec (use fireEvent.change because userEvent.type treats { as keyboard modifier)
+ await user.click(screen.getByText("Paste"));
+ const specInput = screen.getByTestId("wizard-spec-paste");
+ fireEvent.change(specInput, { target: { value: '{"openapi":"3.0.0"}' } });
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // LLM step
+ await user.type(screen.getByTestId("wizard-model"), "claude-sonnet-4-6");
+ await user.type(screen.getByTestId("wizard-apikey-input"), "sk-key");
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Features step
+ await user.click(screen.getByTestId("wizard-next"));
+
+ // Review - create
+ await user.click(screen.getByTestId("wizard-create-only"));
+
+ await waitFor(() => {
+ expect(apiAgentCalled).toBe(true);
+ });
+
+ // Should show endpoint count
+ await waitFor(() => {
+ expect(screen.getByText(/5 API endpoints parsed/)).toBeInTheDocument();
+ });
+ });
});
+
diff --git a/src/pages/__tests__/agents.test.tsx b/src/pages/__tests__/agents.test.tsx
index 9e5c2631..319dff8c 100644
--- a/src/pages/__tests__/agents.test.tsx
+++ b/src/pages/__tests__/agents.test.tsx
@@ -1,27 +1,458 @@
-import { describe, it, expect } from "vitest";
-import { screen } from "@testing-library/react";
-import { renderWithProviders } from "@/test/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import { renderWithProviders, userEvent } from "@/test/test-utils";
import { AgentsPage } from "@/pages/agents";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
+
+// Mock sonner toast
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+function renderAgents() {
+ return renderWithProviders();
+}
describe("AgentsPage", () => {
it("renders page heading", () => {
- renderWithProviders();
+ renderAgents();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("renders search input", () => {
- renderWithProviders();
+ renderAgents();
expect(screen.getByTestId("agent-search")).toBeInTheDocument();
});
it("renders create agent button", () => {
- renderWithProviders();
+ renderAgents();
expect(screen.getByTestId("create-agent-btn")).toBeInTheDocument();
});
- it("shows loading state initially", () => {
- renderWithProviders();
- // TanStack Query will immediately show loading
+ it("shows loading skeletons before data loads", () => {
+ // Delay the API response so loading state is visible
+ server.use(
+ http.get("*/agentstore/agents/descriptors", async () => {
+ await new Promise((r) => setTimeout(r, 5000));
+ return HttpResponse.json([]);
+ })
+ );
+ renderAgents();
+ // The page heading should be visible, but the data grid should NOT be visible yet
expect(screen.getByText("Agents")).toBeInTheDocument();
+ expect(screen.queryByTestId("agent-grid")).not.toBeInTheDocument();
+ // Verify skeleton loading container is rendering
+ expect(screen.getByTestId("agents-loading")).toBeInTheDocument();
+ });
+
+ it("renders subtitle text", () => {
+ renderAgents();
+ expect(
+ screen.getByText("Manage your conversational AI agents")
+ ).toBeInTheDocument();
+ });
+
+ it("renders import agent button", () => {
+ renderAgents();
+ expect(screen.getByTestId("import-agent-btn")).toBeInTheDocument();
+ });
+
+ it("renders the view toggle component", () => {
+ renderAgents();
+ expect(screen.getByTestId("view-toggle")).toBeInTheDocument();
+ });
+
+ // --- Data loading ---
+
+ it("renders agent cards after loading", async () => {
+ renderAgents();
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+ });
+
+ it("shows agent count text", async () => {
+ renderAgents();
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+ await waitFor(() => {
+ const countTexts = screen.getAllByText(/agent/i);
+ expect(countTexts.length).toBeGreaterThan(0);
+ });
+ });
+
+ it("renders multiple agent cards", async () => {
+ renderAgents();
+ await waitFor(() => {
+ const grid = screen.getByTestId("agent-grid");
+ expect(grid.children.length).toBeGreaterThan(0);
+ });
+ });
+
+ // --- View toggle ---
+
+ it("can switch to list view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+ });
+
+ it("renders table headers in list view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ expect(screen.getByText("Version")).toBeInTheDocument();
+ expect(screen.getByText("Modified")).toBeInTheDocument();
+ });
+
+ it("can switch back to card view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-card"));
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+ });
+
+ // --- Search ---
+
+ it("allows typing in the search input", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ const searchInput = screen.getByTestId("agent-search");
+ await user.type(searchInput, "support");
+ expect(searchInput).toHaveValue("support");
+ });
+
+ // --- Sorting in list view ---
+
+ it("sorts by name when clicking Name header and verifies order", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ // Default sort is by modified desc. Click name to sort asc by name.
+ const nameButton = screen.getByLabelText("Sort by name");
+ await user.click(nameButton);
+
+ // Verify ascending order: first agent should come before last alphabetically
+ const listContainer = screen.getByTestId("agent-list");
+ const rows = within(listContainer).getAllByRole("row");
+ // rows[0] is the header, data rows start at 1
+ const firstDataRow = rows[1]!;
+ const lastDataRow = rows[rows.length - 1]!;
+ const firstName = firstDataRow.querySelector("td")?.textContent ?? "";
+ const lastName = lastDataRow.querySelector("td")?.textContent ?? "";
+ expect(firstName.localeCompare(lastName)).toBeLessThanOrEqual(0);
+
+ // Click again to reverse (desc)
+ await user.click(nameButton);
+
+ const rowsDesc = within(screen.getByTestId("agent-list")).getAllByRole("row");
+ const firstDesc = rowsDesc[1]!.querySelector("td")?.textContent ?? "";
+ const lastDesc = rowsDesc[rowsDesc.length - 1]!.querySelector("td")?.textContent ?? "";
+ expect(firstDesc.localeCompare(lastDesc)).toBeGreaterThanOrEqual(0);
+ });
+
+ it("sorts by version", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ const versionButton = screen.getByLabelText("Sort by version");
+ await user.click(versionButton);
+
+ // Just verify the list is still rendered after sort
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ it("sorts by modified", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ const modifiedButton = screen.getByLabelText("Sort by last modified");
+ await user.click(modifiedButton);
+
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ // --- Delete flow ---
+
+ it("shows delete buttons in list view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByTitle("Delete");
+ expect(deleteButtons.length).toBeGreaterThan(0);
+ });
+
+ it("opens delete confirmation dialog from list view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByTitle("Delete");
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Are you sure you want to delete this agent?")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("can cancel the delete dialog", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByTitle("Delete");
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Cancel"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Are you sure you want to delete this agent?")
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ // --- Duplicate and Export in list view ---
+
+ it("shows duplicate and export buttons in list view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ const duplicateButtons = screen.getAllByTitle("Duplicate");
+ expect(duplicateButtons.length).toBeGreaterThan(0);
+
+ const exportButtons = screen.getAllByTitle("Export");
+ expect(exportButtons.length).toBeGreaterThan(0);
+ });
+
+ // --- Create agent dialog ---
+
+ it("opens create dialog when create button is clicked", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("create-agent-btn"));
+
+ // Should show the CreateOrWizardDialog with New Agent text
+ await waitFor(() => {
+ // The dialog shows options like "Quick Create" and "Wizard"
+ expect(screen.getByText(/quick create/i)).toBeInTheDocument();
+ });
+ });
+
+ // --- Import dialog ---
+
+ it("opens import dialog when import button is clicked", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("import-agent-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("import-agent-dialog")).toBeInTheDocument();
+ });
+ });
+
+ // --- Error state ---
+
+ it("shows error state when API fails", async () => {
+ server.use(
+ http.get("*/agentstore/agents/descriptors", () => {
+ return HttpResponse.json(
+ { error: "Server error" },
+ { status: 500 }
+ );
+ })
+ );
+
+ renderAgents();
+
+ await waitFor(() => {
+ expect(screen.getByText("Retry")).toBeInTheDocument();
+ });
+ });
+
+ // --- Empty state ---
+
+ it("shows empty state when no agents exist", async () => {
+ server.use(
+ http.get("*/agentstore/agents/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderAgents();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/no agents/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows 'no results' when search returns empty", async () => {
+ server.use(
+ http.get("*/agentstore/agents/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderAgents();
+ const user = userEvent.setup();
+
+ await user.type(screen.getByTestId("agent-search"), "nonexistent");
+
+ await waitFor(() => {
+ expect(screen.getByText(/no results/i)).toBeInTheDocument();
+ });
+ });
+
+ // --- Agent names on cards ---
+
+ it("shows actual agent names on cards", async () => {
+ renderAgents();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ // Assert actual agent names from mock data
+ await waitFor(() => {
+ expect(screen.getByText("Support Agent")).toBeInTheDocument();
+ expect(screen.getByText("FAQ Agent")).toBeInTheDocument();
+ expect(screen.getByText("Appointment Scheduler")).toBeInTheDocument();
+ });
+ });
+
+ it("shows version badges in list view", async () => {
+ renderAgents();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-grid")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("agent-list")).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ const versionBadges = screen.getAllByText(/^v\d+$/);
+ expect(versionBadges.length).toBeGreaterThan(0);
+ });
});
});
diff --git a/src/pages/__tests__/audit.test.tsx b/src/pages/__tests__/audit.test.tsx
index ce488b5a..c78573bd 100644
--- a/src/pages/__tests__/audit.test.tsx
+++ b/src/pages/__tests__/audit.test.tsx
@@ -1,29 +1,12 @@
import { describe, it, expect } from "vitest";
import { screen, waitFor } from "@testing-library/react";
-import { render } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MemoryRouter } from "react-router-dom";
-import { ThemeProvider } from "@/components/layout/theme-provider";
+import { renderWithProviders, userEvent } from "@/test/test-utils";
import { AuditPage } from "@/pages/audit";
-import userEvent from "@testing-library/user-event";
function renderAudit() {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
+ return renderWithProviders(, {
+ initialRoute: "/manage/audit",
});
-
- return render(
-
-
-
-
-
-
-
- );
}
describe("AuditPage", () => {
@@ -192,7 +175,7 @@ describe("AuditPage", () => {
// Find and expand LLM Detail
const llmButtons = screen.getAllByTestId("expand-LLM Detail");
expect(llmButtons.length).toBe(1); // Only langchain entry has LLM detail
- await userEvent.click(llmButtons[0]!);
+ await user.click(llmButtons[0]!);
await waitFor(() => {
expect(screen.getByText(/"compiled_prompt"/)).toBeInTheDocument();
diff --git a/src/pages/__tests__/backup.test.tsx b/src/pages/__tests__/backup.test.tsx
index c0b1944a..290f15e2 100644
--- a/src/pages/__tests__/backup.test.tsx
+++ b/src/pages/__tests__/backup.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { screen } from "@testing-library/react";
+import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders, userEvent } from "@/test/test-utils";
import { AgentsPage } from "@/pages/agents";
@@ -26,8 +26,28 @@ describe("AgentsPage — Import/Export", () => {
expect(screen.getByTestId("import-agent-dialog")).toBeInTheDocument();
});
- it("still renders create agent button", () => {
+ it("import dialog shows drop zone for file upload", async () => {
renderWithProviders();
- expect(screen.getByTestId("create-agent-btn")).toBeInTheDocument();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("import-agent-btn"));
+
+ expect(screen.getByTestId("import-agent-dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("import-drop-zone")).toBeInTheDocument();
+ });
+
+ it("import dialog shows strategy options after file selection", async () => {
+ renderWithProviders();
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("import-agent-btn"));
+
+ // Upload a file
+ const fileInput = screen.getByTestId("import-file-input");
+ const file = new File(["fake-zip"], "agent.zip", { type: "application/zip" });
+ await user.upload(fileInput, file);
+
+ // Strategy options should appear
+ await waitFor(() => {
+ expect(screen.getByTestId("strategy-create")).toBeInTheDocument();
+ });
});
});
diff --git a/src/pages/__tests__/capabilities.test.tsx b/src/pages/__tests__/capabilities.test.tsx
index e28f75ad..394ececc 100644
--- a/src/pages/__tests__/capabilities.test.tsx
+++ b/src/pages/__tests__/capabilities.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect } from "vitest";
+import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
diff --git a/src/pages/__tests__/channel-detail.test.tsx b/src/pages/__tests__/channel-detail.test.tsx
index 03661afc..322616d0 100644
--- a/src/pages/__tests__/channel-detail.test.tsx
+++ b/src/pages/__tests__/channel-detail.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect } from "vitest";
-import { screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
import { renderPage, userEvent } from "@/test/test-utils";
import { ChannelDetailPage } from "@/pages/channel-detail";
@@ -14,115 +14,89 @@ function renderChannelDetail(id = "ch1", version = 1) {
describe("ChannelDetailPage", () => {
it("renders loading skeleton initially", () => {
renderChannelDetail();
- expect(document.querySelector(".animate-pulse")).toBeTruthy();
+ expect(screen.getByTestId("channel-detail-loading")).toBeInTheDocument();
});
it("displays channel name after loading", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByText("Engineering Slack")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByText("Engineering Slack")).toBeInTheDocument();
+ });
});
it("shows save and delete buttons", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByTestId("save-channel-btn")).toBeInTheDocument();
- expect(screen.getByTestId("delete-channel-btn")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByTestId("save-channel-btn")).toBeInTheDocument();
+ expect(screen.getByTestId("delete-channel-btn")).toBeInTheDocument();
+ });
});
it("renders General section with name and type inputs", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByTestId("channel-name-input")).toBeInTheDocument();
- expect(screen.getByTestId("channel-type-select")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByTestId("channel-name-input")).toBeInTheDocument();
+ expect(screen.getByTestId("channel-type-select")).toBeInTheDocument();
+ });
});
it("renders Platform Configuration section with channel ID", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- const channelIdInput = screen.getByTestId("channel-id-input");
- expect(channelIdInput).toBeInTheDocument();
- expect(channelIdInput).toHaveValue("C0123ABCDEF");
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ const channelIdInput = screen.getByTestId("channel-id-input");
+ expect(channelIdInput).toBeInTheDocument();
+ expect(channelIdInput).toHaveValue("C0123ABCDEF");
+ });
});
it("renders target cards for each target", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByTestId("target-card-0")).toBeInTheDocument();
- expect(screen.getByTestId("target-card-1")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByTestId("target-card-0")).toBeInTheDocument();
+ expect(screen.getByTestId("target-card-1")).toBeInTheDocument();
+ });
});
it("shows default badge on the default target", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- const defaultBadges = screen.getAllByText("default");
- expect(defaultBadges.length).toBeGreaterThanOrEqual(1);
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ const defaultBadges = screen.getAllByText("default");
+ expect(defaultBadges.length).toBeGreaterThanOrEqual(1);
+ });
});
it("renders trigger keyword chips on the second target", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- const allTexts = document.body.textContent ?? "";
- expect(allTexts).toContain("faq");
- expect(allTexts).toContain("help-me");
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ // Trigger keywords render inside Badge as "{keyword}:" — use aria-label on remove buttons
+ const targetCard = screen.getByTestId("target-card-1");
+ expect(within(targetCard).getByLabelText("Remove faq")).toBeInTheDocument();
+ expect(within(targetCard).getByLabelText("Remove help-me")).toBeInTheDocument();
+ });
});
it("shows add target button", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByTestId("add-target-btn")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByTestId("add-target-btn")).toBeInTheDocument();
+ });
});
it("adds a new target card when add target is clicked", async () => {
renderChannelDetail();
const user = userEvent.setup();
- await waitFor(
- () => {
- expect(screen.getByTestId("add-target-btn")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByTestId("add-target-btn")).toBeInTheDocument();
+ });
await user.click(screen.getByTestId("add-target-btn"));
@@ -133,12 +107,9 @@ describe("ChannelDetailPage", () => {
renderChannelDetail();
const user = userEvent.setup();
- await waitFor(
- () => {
- expect(screen.getByTestId("channel-name-input")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByTestId("channel-name-input")).toBeInTheDocument();
+ });
const nameInput = screen.getByTestId("channel-name-input");
await user.clear(nameInput);
@@ -149,77 +120,186 @@ describe("ChannelDetailPage", () => {
it("renders the raw JSON toggle", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByText("Raw Configuration")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByText("Raw Configuration")).toBeInTheDocument();
+ });
});
it("shows webhook URL section", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(
- screen.getByText(/\/integrations\/slack\/events/),
- ).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(
+ screen.getByText(/\/integrations\/slack\/events/),
+ ).toBeInTheDocument();
+ });
});
it("shows required Bot Token Scopes", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByText("chat:write")).toBeInTheDocument();
- expect(screen.getByText("app_mentions:read")).toBeInTheDocument();
- expect(screen.getByText("im:history")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByText("chat:write")).toBeInTheDocument();
+ expect(screen.getByText("app_mentions:read")).toBeInTheDocument();
+ expect(screen.getByText("im:history")).toBeInTheDocument();
+ });
});
it("shows required Event Subscriptions", async () => {
renderChannelDetail();
- await waitFor(
- () => {
- expect(screen.getByText("app_mention")).toBeInTheDocument();
- expect(screen.getByText("message.im")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(screen.getByText("app_mention")).toBeInTheDocument();
+ expect(screen.getByText("message.im")).toBeInTheDocument();
+ });
});
it("renders all three target cards for ch2", async () => {
renderChannelDetail("ch2", 2);
- await waitFor(
- () => {
- // ch2 has 3 targets (support, review-panel, observer)
- expect(screen.getByTestId("target-card-0")).toBeInTheDocument();
- expect(screen.getByTestId("target-card-1")).toBeInTheDocument();
- expect(screen.getByTestId("target-card-2")).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ // ch2 has 3 targets (support, review-panel, observer)
+ expect(screen.getByTestId("target-card-0")).toBeInTheDocument();
+ expect(screen.getByTestId("target-card-1")).toBeInTheDocument();
+ expect(screen.getByTestId("target-card-2")).toBeInTheDocument();
+ });
});
- it("shows target type (AGENT/GROUP) on targets", async () => {
+ it("shows target name inputs for ch2", async () => {
renderChannelDetail("ch2", 2);
- await waitFor(
- () => {
- // ch2 has both AGENT and GROUP targets
- const allTexts = document.body.textContent ?? "";
- expect(allTexts).toContain("review");
- expect(allTexts).toContain("panel");
- },
- { timeout: 10000 },
+ await waitFor(() => {
+ // ch2 target names should be populated in the name inputs
+ const nameInput1 = screen.getByTestId("target-name-1") as HTMLInputElement;
+ expect(nameInput1.value).toBe("review-panel");
+ });
+ });
+
+ // ─── Interaction tests ──────────────────────────────────────────────────
+
+ it("removes a target card when remove button is clicked", async () => {
+ renderChannelDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("target-card-1")).toBeInTheDocument();
+ });
+
+ // Click the Remove button on the second target
+ const targetCard = screen.getByTestId("target-card-1");
+ const removeBtn = within(targetCard).getByText("Remove");
+ await user.click(removeBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("target-card-1")).not.toBeInTheDocument();
+ });
+ });
+
+ it("sets a non-default target as default", async () => {
+ renderChannelDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("target-card-1")).toBeInTheDocument();
+ });
+
+ // The second target is not default, it should have a "Set as Default" button
+ const targetCard = screen.getByTestId("target-card-1");
+ const setDefaultBtn = within(targetCard).getByText("Set as Default");
+ await user.click(setDefaultBtn);
+
+ // After clicking, the second target should now show the default badge
+ await waitFor(() => {
+ const defaultBadges = within(screen.getByTestId("target-card-1")).queryAllByText("default");
+ expect(defaultBadges.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("adds a trigger keyword via Enter key", async () => {
+ renderChannelDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("target-card-0")).toBeInTheDocument();
+ });
+
+ const targetCard = screen.getByTestId("target-card-0");
+ const triggerInput = within(targetCard).getByPlaceholderText("Add trigger...");
+ await user.type(triggerInput, "billing{enter}");
+
+ // The new trigger should appear as a chip
+ await waitFor(() => {
+ expect(within(targetCard).getByLabelText("Remove billing")).toBeInTheDocument();
+ });
+ });
+
+ it("copies webhook URL to clipboard", async () => {
+ const writeTextMock = vi.fn().mockResolvedValue(undefined);
+ let clipboardSpy: ReturnType | undefined;
+ if (!navigator.clipboard) {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: writeTextMock },
+ configurable: true
+ });
+ } else {
+ clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(writeTextMock as never) as unknown as ReturnType;
+ }
+
+ renderChannelDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByText(/\/integrations\/slack\/events/)).toBeInTheDocument();
+ });
+
+ // Click the Copy button next to the webhook URL
+ const copyBtn = screen.getByText("Copy");
+ await user.click(copyBtn);
+
+ expect(writeTextMock).toHaveBeenCalledWith(
+ expect.stringContaining("/integrations/slack/events")
);
+
+ // Restore clipboard spy to avoid leaking into other tests
+ clipboardSpy?.mockRestore();
+ });
+
+ it("collapses a target card when header is clicked", async () => {
+ renderChannelDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("target-card-0")).toBeInTheDocument();
+ });
+
+ // The target card should be expanded by default (showing name input)
+ const targetCard = screen.getByTestId("target-card-0");
+ expect(within(targetCard).getByTestId("target-name-0")).toBeInTheDocument();
+
+ // Click the header to collapse
+ const header = within(targetCard).getAllByText("default")[0]!.closest("div[class*='cursor-pointer']");
+ expect(header).toBeTruthy();
+
+ await user.click(header!);
+ // After collapsing, the name input should not be visible
+ await waitFor(() => {
+ expect(within(targetCard).queryByTestId("target-name-0")).not.toBeInTheDocument();
+ });
+ });
+
+ it("expands raw JSON section", async () => {
+ renderChannelDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByText("Raw Configuration")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Raw Configuration"));
+
+ await waitFor(() => {
+ // The JSON should now be visible
+ expect(screen.getByText(/"channelType"/)).toBeInTheDocument();
+ });
});
});
diff --git a/src/pages/__tests__/channels.test.tsx b/src/pages/__tests__/channels.test.tsx
index 3f75e406..d455e7fe 100644
--- a/src/pages/__tests__/channels.test.tsx
+++ b/src/pages/__tests__/channels.test.tsx
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders, userEvent } from "@/test/test-utils";
import { ChannelsPage } from "@/pages/channels";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
describe("ChannelsPage", () => {
it("renders heading and search input", () => {
@@ -23,13 +25,10 @@ describe("ChannelsPage", () => {
initialRoute: "/manage/channels",
});
- await waitFor(
- () => {
- const cards = screen.getAllByText("Engineering Slack");
- expect(cards.length).toBeGreaterThanOrEqual(1);
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ const cards = screen.getAllByText("Engineering Slack");
+ expect(cards.length).toBeGreaterThanOrEqual(1);
+ }, { timeout: 10000 });
});
it("displays channel cards in card view by default", async () => {
@@ -37,13 +36,10 @@ describe("ChannelsPage", () => {
initialRoute: "/manage/channels",
});
- await waitFor(
- () => {
- const cards = screen.getAllByTestId(/^channel-card-/);
- expect(cards.length).toBeGreaterThanOrEqual(1);
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ const cards = screen.getAllByTestId(/^channel-card-/);
+ expect(cards.length).toBeGreaterThanOrEqual(1);
+ });
});
it("shows Slack type badge on channel cards", async () => {
@@ -51,13 +47,10 @@ describe("ChannelsPage", () => {
initialRoute: "/manage/channels",
});
- await waitFor(
- () => {
- const badges = screen.getAllByText("Slack");
- expect(badges.length).toBeGreaterThanOrEqual(1);
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ const badges = screen.getAllByText("Slack");
+ expect(badges.length).toBeGreaterThanOrEqual(1);
+ });
});
it("opens create channel dialog on button click", async () => {
@@ -106,19 +99,16 @@ describe("ChannelsPage", () => {
initialRoute: "/manage/channels",
});
- await waitFor(
- () => {
- // Badge renders "3 channels" as adjacent text nodes inside a div.
- // Use getAllByText to avoid ambiguity, then find the badge element.
- const matches = screen.getAllByText((_content, element) => {
- if (!element || element.tagName !== "DIV") return false;
- const text = element.textContent ?? "";
- return /^\d+\s+channel/.test(text.trim());
- });
- expect(matches.length).toBeGreaterThanOrEqual(1);
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ // Badge renders "3 channels" as adjacent text nodes inside a div.
+ // Use getAllByText to avoid ambiguity, then find the badge element.
+ const matches = screen.getAllByText((_content, element) => {
+ if (!element || element.tagName !== "DIV") return false;
+ const text = element.textContent ?? "";
+ return /^\d+\s+channel/.test(text.trim());
+ });
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ }, { timeout: 10000 });
});
it("filters channels by search query", async () => {
@@ -127,14 +117,11 @@ describe("ChannelsPage", () => {
});
// Wait for channels to load
- await waitFor(
- () => {
- expect(
- screen.getAllByTestId(/^channel-card-/).length,
- ).toBeGreaterThanOrEqual(1);
- },
- { timeout: 10000 },
- );
+ await waitFor(() => {
+ expect(
+ screen.getAllByTestId(/^channel-card-/).length,
+ ).toBeGreaterThanOrEqual(1);
+ });
const user = userEvent.setup();
const searchInput = screen.getByTestId("channel-search");
@@ -146,4 +133,330 @@ describe("ChannelsPage", () => {
expect(cards.length).toBe(1);
});
});
+
+ // ─── List view ──────────────────────────────────────────────────────────
+
+ it("switches to list view and renders table", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ // Table headers should appear
+ await waitFor(() => {
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ expect(screen.getByText("Type")).toBeInTheDocument();
+ expect(screen.getByText("Channel ID")).toBeInTheDocument();
+ expect(screen.getByText("Targets")).toBeInTheDocument();
+ expect(screen.getByText("Version")).toBeInTheDocument();
+ });
+ });
+
+ it("list view renders rows with channel-row data-testid", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ const rows = screen.getAllByTestId(/^channel-row-/);
+ expect(rows.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ // ─── Delete dialog ──────────────────────────────────────────────────────
+
+ it("opens delete confirmation dialog", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ // Find a delete button on a channel card and click it
+ const deleteButtons = screen.getAllByTitle(/delete/i);
+ expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
+
+ const user = userEvent.setup();
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Delete channel?")).toBeInTheDocument();
+ });
+ });
+
+ it("can cancel delete confirmation dialog", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const deleteButtons = screen.getAllByTitle(/delete/i);
+ const user = userEvent.setup();
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Delete channel?")).toBeInTheDocument();
+ });
+
+ // Cancel button
+ const cancelBtn = screen.getByText("Cancel");
+ await user.click(cancelBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Delete channel?")).not.toBeInTheDocument();
+ });
+ });
+
+ // ─── Empty state ────────────────────────────────────────────────────────
+
+ it("shows empty state when no channels exist", async () => {
+ server.use(
+ http.get("*/channelstore/channels/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("No channels yet")).toBeInTheDocument();
+ });
+ });
+
+ it("shows create button in empty state", async () => {
+ server.use(
+ http.get("*/channelstore/channels/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ // Two create buttons: one in header, one in empty state
+ const createButtons = screen.getAllByText("Create Channel");
+ expect(createButtons.length).toBe(2);
+ });
+ });
+
+ it("shows no results message when search finds nothing", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ await user.type(screen.getByTestId("channel-search"), "zzzznonexistent");
+
+ await waitFor(() => {
+ expect(screen.getByText(/No results found/i)).toBeInTheDocument();
+ });
+ });
+
+ it("empty state from search does NOT show create button", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ await user.type(screen.getByTestId("channel-search"), "zzzznonexistent");
+
+ await waitFor(() => {
+ // Only the header button should remain, not the empty-state create button
+ const createButtons = screen.getAllByText("Create Channel");
+ expect(createButtons.length).toBe(1);
+ });
+ });
+
+ // ─── Confirm delete flow ──────────────────────────────────────────────
+
+ it("confirms channel deletion when Delete is clicked", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ const deleteButtons = screen.getAllByTitle(/delete/i);
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Delete channel?")).toBeInTheDocument();
+ });
+
+ // Click Delete to confirm
+ const confirmBtn = screen.getByText(/^Delete$/);
+ await user.click(confirmBtn);
+
+ // Dialog should close
+ await waitFor(() => {
+ expect(screen.queryByText("Delete channel?")).not.toBeInTheDocument();
+ });
+ });
+
+ // ─── Duplicate button ────────────────────────────────────────────────
+
+ it("clicking duplicate button on a channel card triggers duplication", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ const duplicateButtons = screen.getAllByTitle("Duplicate");
+ if (duplicateButtons.length > 0) {
+ await user.click(duplicateButtons[0]!);
+ // Just verify no crash
+ expect(screen.getByTestId("channel-search")).toBeInTheDocument();
+ }
+ });
+
+ // ─── Page title and subtitle ──────────────────────────────────────────
+
+ it("renders page title 'Channels'", () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+ expect(screen.getByText("Channels")).toBeInTheDocument();
+ });
+
+ it("renders page subtitle about messaging platforms", () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+ expect(
+ screen.getByText(/Connect agents and groups to messaging platforms/)
+ ).toBeInTheDocument();
+ });
+
+ // ─── Slack setup summary ──────────────────────────────────────────────
+
+ it("shows Slack setup description with scopes", () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+ expect(
+ screen.getByText(/Bot Token Scopes/)
+ ).toBeInTheDocument();
+ });
+
+ // ─── List view version display ────────────────────────────────────────
+
+ it("list view shows version number for each channel", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ }, { timeout: 10000 });
+
+ const user = userEvent.setup();
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ const rows = screen.getAllByTestId(/^channel-row-/);
+ expect(rows.length).toBeGreaterThanOrEqual(1);
+ // Each row should show version
+ expect(rows[0]!.textContent).toMatch(/v\d+/);
+ });
+ });
+
+ // ─── Channel search by type ──────────────────────────────────────────
+
+ it("filters channels by channel type", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId(/^channel-card-/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ const user = userEvent.setup();
+ await user.type(screen.getByTestId("channel-search"), "Slack");
+
+ await waitFor(() => {
+ // All channels are Slack type, so all should still show
+ const cards = screen.getAllByTestId(/^channel-card-/);
+ expect(cards.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ // ─── Singular "1 channel" ──────────────────────────────────────────────
+
+ it("shows singular 'channel' when only 1 result", async () => {
+ server.use(
+ http.get("*/channelstore/channels/descriptors", () => {
+ return HttpResponse.json([
+ {
+ resource: "eddi://ai.labs.channel/channelstore/channels/ch-solo?version=1",
+ name: "Solo Channel",
+ description: "",
+ channelType: "Slack",
+ },
+ ]);
+ }),
+ http.get("*/channelstore/channels/ch-solo", () => {
+ return HttpResponse.json({
+ name: "Solo Channel",
+ channelType: "slack",
+ platformConfig: {
+ channelId: "SOLO123",
+ },
+ targets: [],
+ defaultTargetName: "default",
+ });
+ })
+ );
+
+ renderWithProviders(, {
+ initialRoute: "/manage/channels",
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Solo Channel")).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ const badge = screen.getByText(/1\s+channel/i);
+ expect(badge).toBeInTheDocument();
+ });
+ });
});
+
diff --git a/src/pages/__tests__/config-editor.test.tsx b/src/pages/__tests__/config-editor.test.tsx
index 132fb29e..6d277c88 100644
--- a/src/pages/__tests__/config-editor.test.tsx
+++ b/src/pages/__tests__/config-editor.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/test/test-utils";
diff --git a/src/pages/__tests__/conversation-detail.test.tsx b/src/pages/__tests__/conversation-detail.test.tsx
new file mode 100644
index 00000000..3a50292f
--- /dev/null
+++ b/src/pages/__tests__/conversation-detail.test.tsx
@@ -0,0 +1,380 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import { renderPage, userEvent } from "@/test/test-utils";
+import { ConversationDetailPage } from "@/pages/conversation-detail";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
+
+function renderConvDetail(id = "conv1") {
+ return renderPage(
+ `/manage/conversationview/${id}`,
+ ,
+ "/manage/conversationview/:id"
+ );
+}
+
+// Save and restore globals that export test mutates
+let originalCreateObjectURL: typeof URL.createObjectURL;
+let originalRevokeObjectURL: typeof URL.revokeObjectURL;
+
+beforeEach(() => {
+ originalCreateObjectURL = globalThis.URL.createObjectURL;
+ originalRevokeObjectURL = globalThis.URL.revokeObjectURL;
+});
+
+afterEach(() => {
+ globalThis.URL.createObjectURL = originalCreateObjectURL;
+ globalThis.URL.revokeObjectURL = originalRevokeObjectURL;
+});
+
+describe("ConversationDetailPage", () => {
+ it("renders the conversation title heading", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("Conversation")).toBeInTheDocument();
+ });
+ });
+
+ it("shows the conversation ID", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("ID: conv1")).toBeInTheDocument();
+ });
+ });
+
+ it("renders the chat log section", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-chat")).toBeInTheDocument();
+ });
+ });
+
+ it("shows user input messages", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Hi, I need help with my order")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows agent responses", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/Hello! I'd be happy to help/)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows the step count badge", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ // "5 steps" text
+ expect(screen.getByText("5 steps")).toBeInTheDocument();
+ });
+ });
+
+ it("shows state badge as Active for READY state", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+ });
+
+ it("shows agent info badge", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("agent1 v3")).toBeInTheDocument();
+ });
+ });
+
+ it("renders export markdown button", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("export-md")).toBeInTheDocument();
+ });
+ });
+
+ it("renders continue in chat button for READY state", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("continue-in-chat")).toBeInTheDocument();
+ });
+ });
+
+ it("renders transcript search input", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("transcript-search")).toBeInTheDocument();
+ });
+ });
+
+ it("filters transcript by search query", async () => {
+ renderConvDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("transcript-search")).toBeInTheDocument();
+ });
+
+ // Verify all messages shown before search
+ await waitFor(() => {
+ expect(
+ screen.getByText("Hi, I need help with my order")
+ ).toBeInTheDocument();
+ });
+
+ await user.type(
+ screen.getByTestId("transcript-search"),
+ "delivery"
+ );
+
+ await waitFor(() => {
+ // Step with "delivery" should still be visible via highlight marks
+ const chatSection = screen.getByTestId("conversation-chat");
+ expect(chatSection.textContent).toContain("delivery");
+ // "Hi, I need help with my order" does NOT contain "delivery" so should be filtered out
+ expect(
+ screen.queryByText("Hi, I need help with my order")
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("toggles raw JSON data for a step", async () => {
+ renderConvDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
+ });
+
+ // Click step 1 to expand raw data
+ await user.click(screen.getByText("Step 1"));
+
+ // Raw JSON should appear inside a with data-testid
+ await waitFor(() => {
+ const rawBlock = screen.getByTestId("step-raw-1");
+ expect(rawBlock.textContent).toContain("input:initial");
+ });
+
+ // Click again to collapse
+ await user.click(screen.getByText("Step 1"));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("step-raw-1")).not.toBeInTheDocument();
+ });
+ });
+
+ it("renders conversation properties section when available", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Conversation Properties")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("expands conversation properties on click", async () => {
+ renderConvDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Conversation Properties")
+ ).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Conversation Properties"));
+
+ await waitFor(() => {
+ const propertiesJson = screen.getByTestId("properties-json");
+ expect(propertiesJson.textContent).toContain("agentName");
+ });
+ });
+
+ it("shows actions between messages", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ // Actions from step 1: greet, order_inquiry
+ expect(screen.getByText("greet")).toBeInTheDocument();
+ expect(screen.getByText("order_inquiry")).toBeInTheDocument();
+ });
+ });
+
+ it("shows processing time for steps", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ // Step 1 has timestamps separated by ~1s, should show processing time
+ // The processing time appears as text like "1.0s" within the chat section
+ const chatSection = screen.getByTestId("conversation-chat");
+ expect(chatSection.textContent).toMatch(/\d+(\.\d+)?s/);
+ });
+ });
+
+ it("shows loading spinner initially", () => {
+ server.use(
+ http.get("*/conversationstore/conversations/simple/:id", async () => {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ return HttpResponse.json({});
+ })
+ );
+
+ renderConvDetail("slow-conv");
+
+ expect(screen.getByTestId("conversation-loading")).toBeInTheDocument();
+ });
+
+ it("shows error state on API failure", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations/simple/:id", () => {
+ return new HttpResponse(null, { status: 500 });
+ })
+ );
+
+ renderConvDetail("error-conv");
+
+ await waitFor(() => {
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ });
+ });
+
+ it("shows retry button on error", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations/simple/:id", () => {
+ return new HttpResponse(null, { status: 500 });
+ })
+ );
+
+ renderConvDetail("error-conv");
+
+ await waitFor(() => {
+ expect(screen.getByText("Retry")).toBeInTheDocument();
+ });
+ });
+
+ it("shows back to conversations link", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Back to Conversations")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows Chat Log section heading", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("Chat Log")).toBeInTheDocument();
+ });
+ });
+
+ it("shows no steps message for empty conversation", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations/simple/:id", () => {
+ return HttpResponse.json({
+ agentId: "agent1",
+ agentVersion: 1,
+ conversationId: "empty-conv",
+ conversationState: "READY",
+ environment: "production",
+ conversationSteps: [],
+ conversationOutputs: [],
+ });
+ })
+ );
+
+ renderConvDetail("empty-conv");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("No conversation steps recorded")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("does not show continue button for ENDED state", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations/simple/:id", () => {
+ return HttpResponse.json({
+ agentId: "agent1",
+ agentVersion: 1,
+ conversationId: "ended-conv",
+ conversationState: "ENDED",
+ environment: "production",
+ conversationSteps: [],
+ conversationOutputs: [],
+ });
+ })
+ );
+
+ renderConvDetail("ended-conv");
+
+ await waitFor(() => {
+ expect(screen.getByText("Ended")).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTestId("continue-in-chat")).not.toBeInTheDocument();
+ });
+
+ it("shows delete button via data-testid", async () => {
+ renderConvDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("Conversation")).toBeInTheDocument();
+ });
+
+ // Use the data-testid we added to the delete button
+ expect(screen.getByTestId("delete-conversation-btn")).toBeInTheDocument();
+ });
+
+ it("export markdown creates a download", async () => {
+ renderConvDetail();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("export-md")).toBeInTheDocument();
+ });
+
+ // Mock URL.createObjectURL and revokeObjectURL
+ const createObjectURL = vi.fn(() => "blob:test");
+ const revokeObjectURL = vi.fn();
+ globalThis.URL.createObjectURL = createObjectURL;
+ globalThis.URL.revokeObjectURL = revokeObjectURL;
+
+ // Mock createElement to intercept the download link
+ const clickSpy = vi.fn();
+ const originalCreateElement = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
+ const el = originalCreateElement(tag);
+ if (tag === "a") {
+ vi.spyOn(el, "click").mockImplementation(clickSpy);
+ }
+ return el;
+ });
+
+ await user.click(screen.getByTestId("export-md"));
+
+ expect(createObjectURL).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ expect(revokeObjectURL).toHaveBeenCalled();
+
+ vi.restoreAllMocks();
+ });
+});
diff --git a/src/pages/__tests__/conversations.test.tsx b/src/pages/__tests__/conversations.test.tsx
index 5ff6d64d..43f02422 100644
--- a/src/pages/__tests__/conversations.test.tsx
+++ b/src/pages/__tests__/conversations.test.tsx
@@ -1,21 +1,35 @@
-import { describe, it, expect } from "vitest";
-import { screen } from "@testing-library/react";
-import { renderWithProviders } from "@/test/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import { renderWithProviders, userEvent } from "@/test/test-utils";
import { ConversationsPage } from "@/pages/conversations";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
+
+// Mock sonner toast so we can assert on toast calls
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+function renderConversations() {
+ return renderWithProviders();
+}
describe("ConversationsPage", () => {
it("renders page heading", () => {
- renderWithProviders();
+ renderConversations();
expect(screen.getByText("Conversations")).toBeInTheDocument();
});
it("renders search input", () => {
- renderWithProviders();
+ renderConversations();
expect(screen.getByTestId("conversation-search")).toBeInTheDocument();
});
it("renders state filter pills", () => {
- renderWithProviders();
+ renderConversations();
expect(screen.getByText("All")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Ended")).toBeInTheDocument();
@@ -23,9 +37,320 @@ describe("ConversationsPage", () => {
});
it("shows subtitle text", () => {
- renderWithProviders();
+ renderConversations();
expect(
screen.getByText("View and manage agent conversations")
).toBeInTheDocument();
});
+
+ // --- Data loading ---
+
+ it("renders conversation items after loading", async () => {
+ renderConversations();
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+ });
+
+ // --- View toggle ---
+
+ it("can switch to list view", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ // Find and click the list view toggle button (data-testid="view-toggle-list")
+ const listToggle = screen.getByTestId("view-toggle-list");
+ await user.click(listToggle);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
+ });
+ });
+
+ it("renders table headers in list view", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ const listToggle = screen.getByTestId("view-toggle-list");
+ await user.click(listToggle);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
+ });
+
+ // Table headers should be visible
+ expect(screen.getByText("Conversation")).toBeInTheDocument();
+ expect(screen.getByText("Agent")).toBeInTheDocument();
+ expect(screen.getByText("State")).toBeInTheDocument();
+ });
+
+ // --- Search ---
+
+ it("allows typing in the search input", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ const searchInput = screen.getByTestId("conversation-search");
+ await user.type(searchInput, "test search");
+ expect(searchInput).toHaveValue("test search");
+ });
+
+ // --- State filter ---
+
+ it("filters conversations by state when clicking Error filter pill", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ // Wait for conversations to load
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ // Count initial cards
+ const initialGrid = screen.getByTestId("conversation-grid");
+ const initialCardCount = initialGrid.children.length;
+
+ // Click "Error" filter (use getAllByText since Error appears in both pill and state badges)
+ const errorFilters = screen.getAllByText("Error");
+ // The first one is the filter pill button
+ await user.click(errorFilters[0]!);
+
+ // Wait for the filtered results — the MSW handler filters by conversationState
+ // Only ERROR conversations should remain, which is fewer than all
+ await waitFor(() => {
+ const grid = screen.getByTestId("conversation-grid");
+ expect(grid.children.length).toBeLessThan(initialCardCount);
+ });
+
+ // Verify all visible cards show Error state badges
+ const filteredGrid = screen.getByTestId("conversation-grid");
+ const errorBadges = within(filteredGrid).getAllByText("Error");
+ expect(errorBadges.length).toBeGreaterThan(0);
+ });
+
+ it("shows 'In Progress' filter pill", () => {
+ renderConversations();
+ expect(screen.getByText("In Progress")).toBeInTheDocument();
+ });
+
+ // --- Delete flow ---
+
+ it("shows delete button on conversation cards", async () => {
+ renderConversations();
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ // Delete buttons should exist (aria-label)
+ const deleteButtons = screen.getAllByLabelText("Delete conversation");
+ expect(deleteButtons.length).toBeGreaterThan(0);
+ });
+
+ it("opens delete confirmation dialog when delete is clicked", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByLabelText("Delete conversation");
+ await user.click(deleteButtons[0]!);
+
+ // Confirmation dialog should appear with the title
+ await waitFor(() => {
+ expect(
+ screen.getByText("Are you sure you want to delete this conversation?")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("can cancel the delete dialog", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByLabelText("Delete conversation");
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Cancel"));
+
+ // Dialog should close
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Are you sure you want to delete this conversation?")
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ // --- Error state ---
+
+ it("shows error state when API fails", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations", () => {
+ return HttpResponse.json(
+ { error: "Server error" },
+ { status: 500 }
+ );
+ })
+ );
+
+ renderConversations();
+
+ await waitFor(() => {
+ expect(screen.getByText("Retry")).toBeInTheDocument();
+ });
+ });
+
+ // --- Empty state ---
+
+ it("shows empty state when no conversations exist", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderConversations();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/no conversations/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows 'no results' when search + empty result", async () => {
+ server.use(
+ http.get("*/conversationstore/conversations", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderConversations();
+ const user = userEvent.setup();
+
+ const searchInput = screen.getByTestId("conversation-search");
+ await user.type(searchInput, "nonexistent");
+
+ await waitFor(() => {
+ expect(screen.getByText(/no results/i)).toBeInTheDocument();
+ });
+ });
+
+ // --- State badges on cards ---
+
+ it("shows state badges on conversation cards", async () => {
+ renderConversations();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ // State badges should be visible
+ await waitFor(() => {
+ const activeBadges = screen.getAllByText("Active");
+ // Should have at least one active conversation badge (plus the filter pill)
+ expect(activeBadges.length).toBeGreaterThan(1);
+ });
+ });
+
+ it("shows Ended state badges", async () => {
+ renderConversations();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ const endedBadges = screen.getAllByText("Ended");
+ expect(endedBadges.length).toBeGreaterThan(1);
+ });
+ });
+
+ // --- Delete in list view ---
+
+ it("shows delete buttons in list view", async () => {
+ renderConversations();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ const listToggle = screen.getByTestId("view-toggle-list");
+ await user.click(listToggle);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByLabelText("Delete conversation");
+ expect(deleteButtons.length).toBeGreaterThan(0);
+ });
+
+ // --- StepCountBadge ---
+
+ it("shows step count badges for conversations", async () => {
+ renderConversations();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ // Step count badges show "X steps" text
+ await waitFor(() => {
+ const stepTexts = screen.getAllByText(/steps?$/);
+ expect(stepTexts.length).toBeGreaterThan(0);
+ });
+ });
+
+ // --- Conversation count ---
+
+ it("shows conversation count text", async () => {
+ renderConversations();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-grid")).toBeInTheDocument();
+ });
+
+ // The count text includes "conversations"
+ await waitFor(() => {
+ const countTexts = screen.getAllByText(/conversations/i);
+ expect(countTexts.length).toBeGreaterThan(0);
+ });
+ });
+
+ // --- Card grid has items ---
+
+ it("renders multiple conversation cards", async () => {
+ renderConversations();
+
+ await waitFor(() => {
+ const grid = screen.getByTestId("conversation-grid");
+ expect(grid.children.length).toBeGreaterThan(0);
+ });
+ });
+
+ // --- View toggle component ---
+
+ it("renders the view toggle component", async () => {
+ renderConversations();
+ expect(screen.getByTestId("view-toggle")).toBeInTheDocument();
+ });
});
diff --git a/src/pages/__tests__/coordinator.test.tsx b/src/pages/__tests__/coordinator.test.tsx
index c461b5a9..75ee997f 100644
--- a/src/pages/__tests__/coordinator.test.tsx
+++ b/src/pages/__tests__/coordinator.test.tsx
@@ -1,28 +1,25 @@
-import { describe, it, expect } from "vitest";
-import { screen, waitFor } from "@testing-library/react";
-import { render } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MemoryRouter } from "react-router-dom";
-import { ThemeProvider } from "@/components/layout/theme-provider";
+import { describe, it, expect, vi } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import { renderWithProviders, userEvent } from "@/test/test-utils";
import { CoordinatorPage } from "@/pages/coordinator";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
+
+// Mock BearerEventSource for SSE
+vi.mock("@/lib/bearer-event-source", () => ({
+ BearerEventSource: vi.fn().mockImplementation(() => ({
+ addEventListener: vi.fn(),
+ close: vi.fn(),
+ onmessage: null,
+ onerror: null,
+ onopen: null,
+ })),
+}));
function renderCoordinator() {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
-
- return render(
-
-
-
-
-
-
-
- );
+ return renderWithProviders(, {
+ initialRoute: "/manage/coordinator",
+ });
}
describe("CoordinatorPage", () => {
@@ -33,48 +30,66 @@ describe("CoordinatorPage", () => {
});
});
- it("renders coordinator type card", async () => {
+ it("renders page subtitle", async () => {
+ renderCoordinator();
+ await waitFor(() => {
+ expect(screen.getByText(/Monitor conversation processing/)).toBeInTheDocument();
+ });
+ });
+
+ it("renders coordinator type card showing NATS JetStream", async () => {
renderCoordinator();
await waitFor(() => {
expect(screen.getByTestId("coordinator-type-card")).toBeInTheDocument();
+ expect(screen.getByText("NATS JetStream")).toBeInTheDocument();
});
});
- it("renders connection status card", async () => {
+ it("renders connection status card with CONNECTED", async () => {
renderCoordinator();
await waitFor(() => {
expect(screen.getByTestId("coordinator-connection-card")).toBeInTheDocument();
+ expect(screen.getByText(/CONNECTED/)).toBeInTheDocument();
});
});
- it("renders tasks processed card", async () => {
+ it("renders tasks processed card with count", async () => {
renderCoordinator();
await waitFor(() => {
- expect(screen.getByTestId("coordinator-processed-card")).toBeInTheDocument();
+ const card = screen.getByTestId("coordinator-processed-card");
+ expect(card).toBeInTheDocument();
+ // The number 142897 is formatted with toLocaleString() — locale-dependent separator
+ expect(within(card).getByText(/142/)).toBeInTheDocument();
});
});
- it("renders dead-lettered card", async () => {
+ it("renders dead-lettered card with count", async () => {
renderCoordinator();
await waitFor(() => {
- expect(screen.getByTestId("coordinator-dead-letter-card")).toBeInTheDocument();
+ const card = screen.getByTestId("coordinator-dead-letter-card");
+ expect(card).toBeInTheDocument();
+ expect(within(card).getByText("7")).toBeInTheDocument();
});
});
- it("shows NATS JetStream coordinator type from mock data", async () => {
+ it("renders success rate card with percentage", async () => {
renderCoordinator();
await waitFor(() => {
- expect(screen.getByText("NATS JetStream")).toBeInTheDocument();
+ const card = screen.getByTestId("coordinator-success-rate-card");
+ expect(card).toBeInTheDocument();
+ expect(card).toHaveTextContent(/%/);
});
});
- it("shows CONNECTED status from mock data", async () => {
+ it("renders success rate bar", async () => {
renderCoordinator();
await waitFor(() => {
- expect(screen.getByText(/CONNECTED/)).toBeInTheDocument();
+ expect(screen.getByTestId("success-rate-bar")).toBeInTheDocument();
});
});
+ // --- Dead-letter table ---
+
it("renders dead-letter table with entries", async () => {
renderCoordinator();
await waitFor(() => {
@@ -82,45 +97,186 @@ describe("CoordinatorPage", () => {
});
});
+ it("renders dead-letter table column headers", async () => {
+ renderCoordinator();
+ await waitFor(() => {
+ expect(screen.getByText("ID")).toBeInTheDocument();
+ expect(screen.getByText("Conversation")).toBeInTheDocument();
+ expect(screen.getByText("Error")).toBeInTheDocument();
+ expect(screen.getByText("Time")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+ });
+
it("shows dead-letter error messages", async () => {
renderCoordinator();
await waitFor(() => {
+ // These match the actual DEAD_LETTERS_MOCK data in handlers.ts
expect(screen.getByText("Connection timeout to external API")).toBeInTheDocument();
});
});
- it("renders purge all button", async () => {
+ it("shows dead-letter conversation IDs", async () => {
renderCoordinator();
await waitFor(() => {
- expect(screen.getByTestId("purge-dead-letters-btn")).toBeInTheDocument();
+ expect(screen.getByText("conv-fail-001")).toBeInTheDocument();
+ expect(screen.getByText("conv-fail-002")).toBeInTheDocument();
});
});
- it("renders replay and discard buttons for dead-letter entries", async () => {
+ // --- Replay button verifies API called ---
+
+ it("calls replay API when replay button is clicked", async () => {
+ let replayCalled = false;
+ server.use(
+ http.post("*/administration/coordinator/dead-letters/:id/replay", () => {
+ replayCalled = true;
+ return new HttpResponse(null, { status: 200 });
+ })
+ );
+
renderCoordinator();
+ const user = userEvent.setup();
+
await waitFor(() => {
expect(screen.getByTestId("replay-1")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("replay-1"));
+
+ await waitFor(() => {
+ expect(replayCalled).toBe(true);
+ });
+ });
+
+ // --- Discard button verifies API called ---
+
+ it("calls discard API when discard button is clicked", async () => {
+ let discardCalled = false;
+ server.use(
+ http.delete("*/administration/coordinator/dead-letters/:id", () => {
+ discardCalled = true;
+ return new HttpResponse(null, { status: 204 });
+ })
+ );
+
+ renderCoordinator();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
expect(screen.getByTestId("discard-1")).toBeInTheDocument();
});
+
+ await user.click(screen.getByTestId("discard-1"));
+
+ await waitFor(() => {
+ expect(discardCalled).toBe(true);
+ });
});
- it("renders active queue depths", async () => {
+ // --- Payload toggle ---
+
+ it("toggles payload visibility when payload button is clicked", async () => {
renderCoordinator();
+ const user = userEvent.setup();
+
await waitFor(() => {
- expect(screen.getByTestId("coordinator-queues")).toBeInTheDocument();
+ expect(screen.getByTestId("toggle-payload-1")).toBeInTheDocument();
+ });
+
+ // Initially payload content should not be visible
+ expect(screen.queryByText(/conv-fail-001/i)).toBeInTheDocument(); // The conversation ID shows in table
+ // But the JSON payload expansion is not visible
+ expect(screen.queryByTestId("payload-content-1")).not.toBeInTheDocument();
+
+ await user.click(screen.getByTestId("toggle-payload-1"));
+
+ // After toggle, payload JSON should be visible
+ await waitFor(() => {
+ expect(screen.getByTestId("payload-content-1")).toBeInTheDocument();
+ });
+
+ // Toggle again to hide
+ await user.click(screen.getByTestId("toggle-payload-1"));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("payload-content-1")).not.toBeInTheDocument();
});
});
- // ─── Hardening: new features ───────────────────────────────
+ // --- Purge all flow ---
- it("renders refresh interval selector", async () => {
+ it("shows purge button and purge confirmation flow", async () => {
renderCoordinator();
+ const user = userEvent.setup();
+
await waitFor(() => {
- expect(screen.getByTestId("refresh-interval")).toBeInTheDocument();
+ expect(screen.getByTestId("purge-dead-letters-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("purge-dead-letters-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Purge all?")).toBeInTheDocument();
+ expect(screen.getByText("Yes")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
});
});
- it("refresh interval defaults to 10s", async () => {
+ it("hides purge confirmation when cancel is clicked", async () => {
+ renderCoordinator();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("purge-dead-letters-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("purge-dead-letters-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Purge all?")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Cancel"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Purge all?")).not.toBeInTheDocument();
+ expect(screen.getByTestId("purge-dead-letters-btn")).toBeInTheDocument();
+ });
+ });
+
+ it("calls purge API when Yes is confirmed", async () => {
+ let purgeCalled = false;
+ server.use(
+ http.delete("*/administration/coordinator/dead-letters", () => {
+ purgeCalled = true;
+ return HttpResponse.json(3);
+ })
+ );
+
+ renderCoordinator();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("purge-dead-letters-btn")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("purge-dead-letters-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Yes")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Yes"));
+
+ await waitFor(() => {
+ expect(purgeCalled).toBe(true);
+ });
+ });
+
+ // --- Refresh interval ---
+
+ it("renders refresh interval selector with 10s default", async () => {
renderCoordinator();
await waitFor(() => {
const select = screen.getByTestId("refresh-interval") as HTMLSelectElement;
@@ -128,12 +284,165 @@ describe("CoordinatorPage", () => {
});
});
- it("renders dead-letter payload toggle buttons when payload exists", async () => {
+ it("changing refresh interval updates selector value", async () => {
+ renderCoordinator();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("refresh-interval")).toBeInTheDocument();
+ });
+
+ const select = screen.getByTestId("refresh-interval") as HTMLSelectElement;
+ await user.selectOptions(select, "30");
+ expect(select.value).toBe("30");
+
+ await user.selectOptions(select, "5");
+ expect(select.value).toBe("5");
+ });
+
+ // --- Active queues ---
+
+ it("shows active queues section with queue entries", async () => {
renderCoordinator();
await waitFor(() => {
- // Mock dead letter entries have payload field
- const table = screen.getByTestId("dead-letters-table");
- expect(table).toBeInTheDocument();
+ const queuesSection = screen.getByTestId("coordinator-queues");
+ expect(queuesSection).toBeInTheDocument();
+ expect(screen.getByText("Active Queues")).toBeInTheDocument();
+ // Mock has 12 queue entries including conv-abc123
+ expect(within(queuesSection).getByText("conv-abc123")).toBeInTheDocument();
+ });
+ });
+
+ it("shows pending count in active queues", async () => {
+ renderCoordinator();
+ await waitFor(() => {
+ // 3+1+2+4+1+2+1+3+2+1+2+1 = 23 total pending
+ const queuesSection = screen.getByTestId("coordinator-queues");
+ expect(within(queuesSection).getByText(/pending/)).toBeInTheDocument();
+ });
+ });
+
+ // --- Dead-letter section title ---
+
+ it("shows dead-letter queue section title", async () => {
+ renderCoordinator();
+ await waitFor(() => {
+ expect(screen.getByText("Dead-Letter Queue")).toBeInTheDocument();
+ });
+ });
+
+ // --- Empty dead-letter state ---
+
+ it("shows empty dead-letter state when no entries", async () => {
+ server.use(
+ http.get("*/administration/coordinator/dead-letters", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderCoordinator();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("dead-letters-empty")).toBeInTheDocument();
+ expect(screen.getByText("No dead-letter entries")).toBeInTheDocument();
+ });
+ });
+
+ // --- No active queues ---
+
+ it("shows no active queues message when empty", async () => {
+ server.use(
+ http.get("*/administration/coordinator/status", () => {
+ return HttpResponse.json({
+ coordinatorType: "inMemory",
+ connected: true,
+ connectionStatus: "CONNECTED",
+ activeConversations: 0,
+ totalProcessed: 0,
+ totalDeadLettered: 0,
+ queueDepths: {},
+ });
+ })
+ );
+
+ renderCoordinator();
+
+ await waitFor(() => {
+ expect(screen.getByText(/No active conversations being processed/)).toBeInTheDocument();
+ });
+ });
+
+ // --- In-Memory coordinator type ---
+
+ it("shows In-Memory when coordinator type is inMemory", async () => {
+ server.use(
+ http.get("*/administration/coordinator/status", () => {
+ return HttpResponse.json({
+ coordinatorType: "inMemory",
+ connected: false,
+ connectionStatus: "DISCONNECTED",
+ activeConversations: 0,
+ totalProcessed: 50,
+ totalDeadLettered: 3,
+ queueDepths: {},
+ });
+ })
+ );
+
+ renderCoordinator();
+
+ await waitFor(() => {
+ expect(screen.getByText("In-Memory")).toBeInTheDocument();
+ });
+ });
+
+ // --- No coordinator data ---
+
+ it("shows empty state when coordinator status API fails", async () => {
+ server.use(
+ http.get("*/administration/coordinator/status", () => {
+ return new HttpResponse(null, { status: 500 });
+ })
+ );
+
+ renderCoordinator();
+
+ await waitFor(() => {
+ expect(screen.getByText(/No coordinator data available/)).toBeInTheDocument();
+ });
+ });
+
+ // --- Error categories (based on actual dead letter data) ---
+
+ it("shows error category breakdown with rate-related category", async () => {
+ renderCoordinator();
+ await waitFor(() => {
+ // DEAD_LETTERS_MOCK has "rate limit" in entry 2 → "Rate Limited" category
+ expect(screen.getByText(/Rate Limited/)).toBeInTheDocument();
+ });
+ });
+
+ // --- DISCONNECTED status ---
+
+ it("shows disconnected status badge", async () => {
+ server.use(
+ http.get("*/administration/coordinator/status", () => {
+ return HttpResponse.json({
+ coordinatorType: "nats",
+ connected: false,
+ connectionStatus: "DISCONNECTED",
+ activeConversations: 0,
+ totalProcessed: 100,
+ totalDeadLettered: 0,
+ queueDepths: {},
+ });
+ })
+ );
+
+ renderCoordinator();
+
+ await waitFor(() => {
+ expect(screen.getByText("DISCONNECTED")).toBeInTheDocument();
});
});
});
diff --git a/src/pages/__tests__/dashboard.test.tsx b/src/pages/__tests__/dashboard.test.tsx
index 34b46703..363139cf 100644
--- a/src/pages/__tests__/dashboard.test.tsx
+++ b/src/pages/__tests__/dashboard.test.tsx
@@ -1,56 +1,147 @@
-import { describe, it, expect, vi } from "vitest";
-import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { screen, within } from "@testing-library/react";
import { renderWithProviders } from "@/test/test-utils";
import { DashboardPage } from "@/pages/dashboard";
-// Mock the dashboard hooks since they need API calls
+// ─── Dynamic mocks ────────────────────────────────────────────────────────
+
+const mockUseDashboardStats = vi.fn();
+const mockUseRecentAgents = vi.fn();
+const mockUseRecentConversations = vi.fn();
+const mockUseCoordinatorStatusLight = vi.fn();
+
vi.mock("@/hooks/use-dashboard", () => ({
- useDashboardStats: () => ({
+ useDashboardStats: (...args: unknown[]) => mockUseDashboardStats(...args),
+ useRecentAgents: (...args: unknown[]) => mockUseRecentAgents(...args),
+ useRecentConversations: (...args: unknown[]) => mockUseRecentConversations(...args),
+ useCoordinatorStatusLight: (...args: unknown[]) => mockUseCoordinatorStatusLight(...args),
+}));
+
+const mockUsePlatformStatus = vi.fn();
+vi.mock("@/hooks/use-platform-status", () => ({
+ usePlatformStatus: (...args: unknown[]) => mockUsePlatformStatus(...args),
+}));
+
+const mockUseVaultHealth = vi.fn();
+vi.mock("@/hooks/use-secrets", () => ({
+ useVaultHealth: (...args: unknown[]) => mockUseVaultHealth(...args),
+ useSecrets: () => ({ data: [], isLoading: false }),
+}));
+
+vi.mock("@/hooks/use-agents", () => ({
+ useAgentDescriptors: () => ({
+ data: [
+ { resource: "eddi://ai.labs.bot/botstore/bots/agent-1?version=1", name: "Support Agent" },
+ { resource: "eddi://ai.labs.bot/botstore/bots/agent-2?version=1", name: "FAQ Agent" },
+ ],
+ }),
+ groupAgentsByName: (agents: unknown[]) =>
+ (agents as { resource: string; name?: string; description?: string; lastModifiedOn?: string | null }[]).map((a) => {
+ const match = /\/bots\/([^?]+)\?version=(\d+)/.exec(a.resource);
+ return {
+ id: match?.[1] ?? "unknown",
+ version: Number(match?.[2] ?? 1),
+ name: a.name,
+ description: a.description,
+ lastModifiedOn: a.lastModifiedOn,
+ };
+ }),
+}));
+
+// ─── Default mock data ──────────────────────────────────────────────────────
+
+function setDefaultMocks() {
+ mockUseDashboardStats.mockReturnValue({
data: { agentCount: 5, workflowCount: 3, conversationCount: 42, resourceCount: 0 },
isLoading: false,
- }),
- useRecentAgents: () => ({
- data: [],
+ });
+ mockUseRecentAgents.mockReturnValue({
+ data: [
+ {
+ resource: "eddi://ai.labs.bot/botstore/bots/agent-1?version=1",
+ name: "Support Agent",
+ description: "Handles customer support",
+ lastModifiedOn: new Date().toISOString(),
+ },
+ {
+ resource: "eddi://ai.labs.bot/botstore/bots/agent-2?version=1",
+ name: "FAQ Agent",
+ description: null,
+ lastModifiedOn: null,
+ },
+ ],
isLoading: false,
- }),
- useRecentConversations: () => ({
- data: [],
+ });
+ mockUseRecentConversations.mockReturnValue({
+ data: [
+ {
+ resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv-1",
+ agentId: "agent-1",
+ agentVersion: 2,
+ name: "Customer chat",
+ conversationState: "READY",
+ lastModifiedOn: new Date().toISOString(),
+ },
+ {
+ resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv-2",
+ agentId: "agent-2",
+ agentVersion: 1,
+ name: null,
+ conversationState: "IN_PROGRESS",
+ lastModifiedOn: null,
+ },
+ {
+ resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv-3",
+ agentId: "agent-3",
+ agentVersion: 1,
+ name: null,
+ conversationState: "ERROR",
+ lastModifiedOn: new Date().toISOString(),
+ },
+ {
+ resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv-4",
+ agentId: "agent-1",
+ agentVersion: 2,
+ name: null,
+ conversationState: "ENDED",
+ lastModifiedOn: new Date().toISOString(),
+ },
+ ],
isLoading: false,
- }),
- useCoordinatorStatusLight: () => ({
+ });
+ mockUseCoordinatorStatusLight.mockReturnValue({
data: { coordinatorType: "nats", connected: true, connectionStatus: "CONNECTED", totalProcessed: 100, totalDeadLettered: 0, queueDepths: {}, activeConversations: 2 },
isLoading: false,
- }),
-}));
-
-vi.mock("@/hooks/use-agents", () => ({
- useAgentDescriptors: () => ({ data: [] }),
- groupAgentsByName: () => [],
-}));
-
-vi.mock("@/hooks/use-platform-status", () => ({
- usePlatformStatus: () => ({
+ });
+ mockUsePlatformStatus.mockReturnValue({
status: "online",
instanceId: "test-instance",
latencyMs: 15,
lastCheckedAt: new Date(),
- }),
-}));
-
-vi.mock("@/hooks/use-secrets", () => ({
- useVaultHealth: () => ({
+ });
+ mockUseVaultHealth.mockReturnValue({
data: { status: "UP", provider: "hashicorp", available: true },
isLoading: false,
- }),
- useSecrets: () => ({ data: [], isLoading: false }),
-}));
+ });
+}
describe("DashboardPage", () => {
+ beforeEach(() => {
+ setDefaultMocks();
+ });
+
+ // ─── Page structure ─────────────────────────────────────────────────────
+
it("renders page heading", () => {
renderWithProviders();
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
+ it("renders page subtitle", () => {
+ renderWithProviders();
+ expect(screen.getByRole("heading", { level: 1 }).parentElement?.querySelector("p")).toBeInTheDocument();
+ });
+
it("renders stat cards with real data", () => {
renderWithProviders();
expect(screen.getByText("5")).toBeInTheDocument();
@@ -58,16 +149,22 @@ describe("DashboardPage", () => {
expect(screen.getByText("42")).toBeInTheDocument();
});
- it("renders quick action buttons", () => {
+ it("renders exactly 3 visible stat cards when resource count is 0", () => {
renderWithProviders();
- // Quick actions section exists
- expect(screen.getByText("Agent Wizard")).toBeInTheDocument();
+ const agentLink = screen.getByLabelText(/: 5$/);
+ const workflowLink = screen.getByLabelText(/: 3$/);
+ const convLink = screen.getByLabelText(/: 42$/);
+ expect(agentLink).toBeInTheDocument();
+ expect(workflowLink).toBeInTheDocument();
+ expect(convLink).toBeInTheDocument();
+ expect(screen.queryByLabelText(/: 0$/)).not.toBeInTheDocument();
});
- it("renders platform health strip", () => {
+ // ─── Quick actions ──────────────────────────────────────────────────────
+
+ it("renders quick action buttons", () => {
renderWithProviders();
- expect(screen.getByTestId("platform-health-strip")).toBeInTheDocument();
- expect(screen.getByText("Online")).toBeInTheDocument();
+ expect(screen.getByText("Agent Wizard")).toBeInTheDocument();
});
it("renders expanded quick actions", () => {
@@ -77,4 +174,438 @@ describe("DashboardPage", () => {
expect(screen.getByText("Secret Vault")).toBeInTheDocument();
expect(screen.getByText("Create Group")).toBeInTheDocument();
});
+
+ it("quick action links navigate to correct routes", () => {
+ renderWithProviders();
+ const wizardLink = screen.getByText("Agent Wizard").closest("a");
+ expect(wizardLink).toHaveAttribute("href", "/manage/agents/wizard");
+
+ const logsLink = screen.getByText("View Logs").closest("a");
+ expect(logsLink).toHaveAttribute("href", "/manage/logs");
+
+ const auditLink = screen.getByText("Audit Trail").closest("a");
+ expect(auditLink).toHaveAttribute("href", "/manage/audit");
+
+ const vaultLink = screen.getByText("Secret Vault").closest("a");
+ expect(vaultLink).toHaveAttribute("href", "/manage/secrets");
+
+ const groupLink = screen.getByText("Create Group").closest("a");
+ expect(groupLink).toHaveAttribute("href", "/manage/groups/wizard");
+ });
+
+ // ─── Platform health strip ──────────────────────────────────────────────
+
+ it("renders platform health strip", () => {
+ renderWithProviders();
+ expect(screen.getByTestId("platform-health-strip")).toBeInTheDocument();
+ expect(screen.getByText("Online")).toBeInTheDocument();
+ });
+
+ it("shows coordinator connected status", () => {
+ renderWithProviders();
+ expect(screen.getByText("Coordinator connected")).toBeInTheDocument();
+ });
+
+ it("shows vault ready status", () => {
+ renderWithProviders();
+ expect(screen.getByText("Vault ready")).toBeInTheDocument();
+ });
+
+ // ─── Recent conversations ──────────────────────────────────────────────
+
+ it("renders recent conversations section", () => {
+ renderWithProviders();
+ expect(screen.getByText("Recent Conversations")).toBeInTheDocument();
+ expect(screen.getByTestId("recent-conversations")).toBeInTheDocument();
+ });
+
+ it("shows conversation names or agent names", () => {
+ renderWithProviders();
+ expect(screen.getByText("Customer chat")).toBeInTheDocument();
+ });
+
+ it("shows conversation state badges", () => {
+ renderWithProviders();
+ expect(screen.getByText("READY")).toBeInTheDocument();
+ expect(screen.getByText("IN_PROGRESS")).toBeInTheDocument();
+ expect(screen.getByText("ERROR")).toBeInTheDocument();
+ });
+
+ it("shows ENDED conversation state badge", () => {
+ renderWithProviders();
+ expect(screen.getByText("ENDED")).toBeInTheDocument();
+ });
+
+ it("conversations link to conversation view", () => {
+ renderWithProviders();
+ const recentConvSection = screen.getByTestId("recent-conversations");
+ const conversationLinks = within(recentConvSection).getAllByRole("link");
+ expect(conversationLinks.length).toBe(4);
+ expect(conversationLinks[0]).toHaveAttribute("href", expect.stringContaining("/manage/conversationview/"));
+ });
+
+ // ─── Recent agents ────────────────────────────────────────────────────
+
+ it("renders recent agents section", () => {
+ renderWithProviders();
+ expect(screen.getByTestId("recent-agents")).toBeInTheDocument();
+ });
+
+ it("shows recent agent names", () => {
+ renderWithProviders();
+ expect(screen.getAllByText("Support Agent").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("FAQ Agent").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("shows agent description or dash for no description", () => {
+ renderWithProviders();
+ expect(screen.getByText("Handles customer support")).toBeInTheDocument();
+ const dashes = screen.getAllByText("—");
+ expect(dashes.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("agent cards link to agent view", () => {
+ renderWithProviders();
+ const agentSection = screen.getByTestId("recent-agents");
+ const agentLinks = agentSection.querySelectorAll("a[href*='/manage/agentview/']");
+ expect(agentLinks.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("shows 'view all agents' link", () => {
+ renderWithProviders();
+ const viewAllLink = screen.getByTestId("recent-agents").querySelector("a[href='/manage/agents']");
+ expect(viewAllLink).toBeInTheDocument();
+ });
+
+ it("shows 'view all conversations' link", () => {
+ renderWithProviders();
+ const viewAllLink = screen.getByText("Recent Conversations")
+ .closest("div")
+ ?.querySelector("a[href='/manage/conversations']");
+ expect(viewAllLink).toBeInTheDocument();
+ });
+
+ // ─── Conversation display name fallback ─────────────────────────────────
+
+ it("shows 'Unnamed Agent' when conv has no name and agent not in map", () => {
+ renderWithProviders();
+ const unnamedElements = screen.getAllByText("Unnamed Agent");
+ expect(unnamedElements.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("falls back to agent name from agentNameMap when conv.name is null", () => {
+ renderWithProviders();
+ const faqElements = screen.getAllByText("FAQ Agent");
+ expect(faqElements.length).toBeGreaterThanOrEqual(1);
+ });
+
+ // ─── Conversation lastModifiedOn null ──────────────────────────────────
+
+ it("shows dash for conversation with no lastModifiedOn", () => {
+ renderWithProviders();
+ const dashes = screen.getAllByText("—");
+ expect(dashes.length).toBeGreaterThanOrEqual(1);
+ });
+
+ // ─── Chat quick action ────────────────────────────────────────────────
+
+ it("renders chat quick action with correct link", () => {
+ renderWithProviders();
+ const chatLink = screen.getByText("Chat").closest("a");
+ expect(chatLink).toHaveAttribute("href", "/manage/chat");
+ });
+
+ // ─── Stat card aria-labels ────────────────────────────────────────────
+
+ it("stat card links have aria-labels with label and value", () => {
+ renderWithProviders();
+ const statLinks = document.querySelectorAll("a[aria-label]");
+ expect(statLinks.length).toBeGreaterThanOrEqual(3);
+ });
+
+ // ─── Stat card non-zero values display ────────────────────────────────
+
+ it("stat cards show correct non-zero values", () => {
+ renderWithProviders();
+ expect(screen.getByText("5")).toBeInTheDocument();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(screen.getByText("42")).toBeInTheDocument();
+ });
+
+ // ─── Agent card lastModifiedOn null ────────────────────────────────────
+
+ it("shows FAQ Agent in recent agents section even with null lastModifiedOn", () => {
+ renderWithProviders();
+ const agentSection = screen.getByTestId("recent-agents");
+ expect(within(agentSection).getByText("FAQ Agent")).toBeInTheDocument();
+ });
+
+ // ─── Stat card links navigate to correct pages ────────────────────────
+
+ it("stat card links navigate to correct pages", () => {
+ renderWithProviders();
+ const agentLink = screen.getByLabelText(/: 5$/);
+ expect(agentLink).toHaveAttribute("href", "/manage/agents");
+
+ const wfLink = screen.getByLabelText(/: 3$/);
+ expect(wfLink).toHaveAttribute("href", "/manage/workflows");
+
+ const convLink = screen.getByLabelText(/: 42$/);
+ expect(convLink).toHaveAttribute("href", "/manage/conversations");
+ });
+
+ // ─── STATE_COLORS coverage — ENDED state ────────────────────────────────
+
+ it("renders ENDED state badge with correct styling class", () => {
+ renderWithProviders();
+ const endedBadge = screen.getByText("ENDED");
+ expect(endedBadge).toBeInTheDocument();
+ expect(endedBadge.className).toContain("bg-gray-500");
+ });
+
+ // ─── Agent section shows description fallback ─────────────────────────
+
+ it("agent card shows description text when available", () => {
+ renderWithProviders();
+ expect(screen.getByText("Handles customer support")).toBeInTheDocument();
+ });
+
+ // ─── Conversation version display ──────────────────────────────────────
+
+ it("shows agent version in conversation card", () => {
+ renderWithProviders();
+ expect(screen.getAllByText(/v2/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ // ─── Conversation card shows truncated ID ──────────────────────────────
+
+ it("shows truncated conversation ID in card", () => {
+ renderWithProviders();
+ const recentConvSection = screen.getByTestId("recent-conversations");
+ expect(within(recentConvSection).getAllByText(/…/).length).toBeGreaterThanOrEqual(1);
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Loading states
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows skeleton cards when stats are loading", () => {
+ mockUseDashboardStats.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ });
+ renderWithProviders();
+ // When stats loading, 4 skeleton cards are rendered
+ const skeletons = document.querySelectorAll(".animate-pulse");
+ expect(skeletons.length).toBeGreaterThan(0);
+ // No stat card links should appear
+ expect(screen.queryByLabelText(/: \d+$/)).not.toBeInTheDocument();
+ });
+
+ it("shows skeleton cards when conversations are loading", () => {
+ mockUseRecentConversations.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ });
+ renderWithProviders();
+ // Conversations section should show skeleton placeholders
+ const skeletons = document.querySelectorAll(".animate-pulse");
+ expect(skeletons.length).toBeGreaterThan(0);
+ expect(screen.queryByTestId("recent-conversations")).not.toBeInTheDocument();
+ });
+
+ it("shows skeleton cards when agents are loading", () => {
+ mockUseRecentAgents.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ });
+ renderWithProviders();
+ // Agent section should show skeleton placeholders
+ const agentSection = screen.getByTestId("recent-agents");
+ const skeletons = agentSection.querySelectorAll(".animate-pulse");
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Empty states
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows empty conversations message when no conversations exist", () => {
+ mockUseRecentConversations.mockReturnValue({
+ data: [],
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("No conversations yet")).toBeInTheDocument();
+ expect(screen.queryByTestId("recent-conversations")).not.toBeInTheDocument();
+ });
+
+ it("shows empty conversations message when data is null", () => {
+ mockUseRecentConversations.mockReturnValue({
+ data: null,
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("No conversations yet")).toBeInTheDocument();
+ });
+
+ it("shows empty agents state with create button when no agents", () => {
+ mockUseRecentAgents.mockReturnValue({
+ data: [],
+ isLoading: false,
+ });
+ renderWithProviders();
+ const agentSection = screen.getByTestId("recent-agents");
+ // Should show the "no recent agents" message and a create button
+ const createLink = agentSection.querySelector("a[href='/manage/agents/wizard']");
+ expect(createLink).toBeInTheDocument();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Platform health status variants
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows Offline when platform status is offline", () => {
+ mockUsePlatformStatus.mockReturnValue({
+ status: "offline",
+ instanceId: "test-instance",
+ latencyMs: 0,
+ lastCheckedAt: new Date(),
+ });
+ renderWithProviders();
+ expect(screen.getByText("Offline")).toBeInTheDocument();
+ });
+
+ it("shows Checking when platform status is checking", () => {
+ mockUsePlatformStatus.mockReturnValue({
+ status: "checking",
+ instanceId: null,
+ latencyMs: 0,
+ lastCheckedAt: null,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Checking…")).toBeInTheDocument();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Coordinator status variants
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows coordinator disconnected when coordinator is not connected", () => {
+ mockUseCoordinatorStatusLight.mockReturnValue({
+ data: { coordinatorType: "nats", connected: false, connectionStatus: "DISCONNECTED", totalProcessed: 0, totalDeadLettered: 0, queueDepths: {}, activeConversations: 0 },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Coordinator disconnected")).toBeInTheDocument();
+ });
+
+ it("shows in-memory coordinator icon when type is inMemory", () => {
+ mockUseCoordinatorStatusLight.mockReturnValue({
+ data: { coordinatorType: "inMemory", connected: true, connectionStatus: "CONNECTED", totalProcessed: 50, totalDeadLettered: 0, queueDepths: {}, activeConversations: 0 },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Coordinator connected")).toBeInTheDocument();
+ });
+
+ it("shows fallback coordinator status when coordinator data is null", () => {
+ mockUseCoordinatorStatusLight.mockReturnValue({
+ data: null,
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Coordinator —")).toBeInTheDocument();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Vault health variants
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows vault unavailable when vault health is down", () => {
+ mockUseVaultHealth.mockReturnValue({
+ data: { status: "DOWN", provider: "hashicorp", available: false },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Vault unavailable")).toBeInTheDocument();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Resource stat card visible when non-zero
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows resource stat card when resourceCount > 0", () => {
+ mockUseDashboardStats.mockReturnValue({
+ data: { agentCount: 5, workflowCount: 3, conversationCount: 42, resourceCount: 10 },
+ isLoading: false,
+ });
+ renderWithProviders();
+ // All 4 stat cards should be visible
+ expect(screen.getByText("10")).toBeInTheDocument();
+ const statLinks = document.querySelectorAll("a[aria-label]");
+ expect(statLinks.length).toBeGreaterThanOrEqual(4);
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Unknown conversation state fallback
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("renders unknown conversation state with fallback ENDED colors", () => {
+ mockUseRecentConversations.mockReturnValue({
+ data: [
+ {
+ resource: "eddi://ai.labs.conversation/conversationstore/conversations/conv-x",
+ agentId: "agent-1",
+ agentVersion: 1,
+ name: "Unknown state conv",
+ conversationState: "UNKNOWN_STATE",
+ lastModifiedOn: new Date().toISOString(),
+ },
+ ],
+ isLoading: false,
+ });
+ renderWithProviders();
+ const badge = screen.getByText("UNKNOWN_STATE");
+ expect(badge).toBeInTheDocument();
+ // Falls back to STATE_COLORS.ENDED which uses bg-gray-500
+ expect(badge.className).toContain("bg-gray-500");
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Agent with null name
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows Unnamed Agent for agent card with null name", () => {
+ mockUseRecentAgents.mockReturnValue({
+ data: [
+ {
+ resource: "eddi://ai.labs.bot/botstore/bots/agent-x?version=1",
+ name: null,
+ description: "A mysterious agent",
+ lastModifiedOn: new Date().toISOString(),
+ },
+ ],
+ isLoading: false,
+ });
+ renderWithProviders();
+ const agentSection = screen.getByTestId("recent-agents");
+ expect(within(agentSection).getByText("Unnamed Agent")).toBeInTheDocument();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Branch coverage: Stats data null (not loading but null)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ it("shows zero stat values with Plus icon when stats data is null", () => {
+ mockUseDashboardStats.mockReturnValue({
+ data: null,
+ isLoading: false,
+ });
+ renderWithProviders();
+ // All values default to 0 via ?? 0, and visibleStatCards filters out resources (0),
+ // so 3 cards remain, each showing 0 with a Plus icon
+ const statLinks = document.querySelectorAll("a[aria-label]");
+ expect(statLinks.length).toBe(3);
+ });
});
diff --git a/src/pages/__tests__/export-dialog.test.tsx b/src/pages/__tests__/export-dialog.test.tsx
index c7a4d1ed..7eafff56 100644
--- a/src/pages/__tests__/export-dialog.test.tsx
+++ b/src/pages/__tests__/export-dialog.test.tsx
@@ -42,8 +42,9 @@ describe("ExportAgentDialog", () => {
});
const checkboxes = screen.getAllByRole("checkbox");
- // The first checkbox (agent root) should be disabled (required)
- expect(checkboxes[0]).toBeDisabled();
+ // Find disabled checkboxes (required items)
+ const disabledCheckboxes = checkboxes.filter((cb) => cb.hasAttribute("disabled"));
+ expect(disabledCheckboxes.length).toBeGreaterThan(0);
});
it("non-required items can be toggled", async () => {
@@ -81,15 +82,30 @@ describe("ExportAgentDialog", () => {
expect(screen.getByTestId("export-confirm-btn")).toBeInTheDocument();
});
- // Find the toggle-all checkbox (the standalone checkbox outside the resource list)
- const checkboxes = screen.getAllByRole("checkbox");
- // Last should be the "select all" in the footer
- const selectAllCheckbox = checkboxes[checkboxes.length - 1]!;
+ const selectAllCheckbox = screen.getByTestId("select-all-checkbox") as HTMLInputElement;
+
+ // Initially all should be selected
+ expect(selectAllCheckbox.checked).toBe(true);
- // Toggle off
+ // Toggle off — should deselect non-required items
await user.click(selectAllCheckbox);
- // Toggle on
+
+ // The select-all checkbox should now be unchecked (only required remain)
+ const enabledCheckboxes = screen.getAllByRole("checkbox").filter(
+ (cb) => !cb.hasAttribute("disabled") && cb !== selectAllCheckbox
+ );
+ const allUnchecked = enabledCheckboxes.every(
+ (cb) => !(cb as HTMLInputElement).checked
+ );
+ expect(allUnchecked).toBe(true);
+
+ // Toggle on — should re-select all
await user.click(selectAllCheckbox);
+ expect(selectAllCheckbox.checked).toBe(true);
+ const allCheckedAfter = screen.getAllByRole("checkbox").every(
+ (cb) => (cb as HTMLInputElement).checked
+ );
+ expect(allCheckedAfter).toBe(true);
});
it("does not render when open is false", () => {
diff --git a/src/pages/__tests__/gdpr.test.tsx b/src/pages/__tests__/gdpr.test.tsx
index 1dc1ffa8..a6be14f0 100644
--- a/src/pages/__tests__/gdpr.test.tsx
+++ b/src/pages/__tests__/gdpr.test.tsx
@@ -1,31 +1,21 @@
-import { describe, it, expect } from "vitest";
-import { screen, waitFor, fireEvent } from "@testing-library/react";
-import { render } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MemoryRouter } from "react-router-dom";
-import { ThemeProvider } from "@/components/layout/theme-provider";
+import { describe, it, expect, afterEach } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import { renderWithProviders, userEvent } from "@/test/test-utils";
import { GdprPage } from "@/pages/gdpr";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
function renderPage() {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
-
- return render(
-
-
-
-
-
-
-
- );
+ return renderWithProviders(, {
+ initialRoute: "/manage/gdpr",
+ });
}
describe("GDPR Privacy Admin Page", () => {
+ afterEach(() => {
+ server.resetHandlers();
+ });
+
it("renders the page container", () => {
renderPage();
expect(screen.getByTestId("gdpr-page")).toBeInTheDocument();
@@ -57,8 +47,9 @@ describe("GDPR Privacy Admin Page", () => {
it("enables buttons when user ID is entered", async () => {
renderPage();
+ const user = userEvent.setup();
const input = screen.getByTestId("gdpr-user-id");
- fireEvent.change(input, { target: { value: "user-123" } });
+ await user.type(input, "user-123");
await waitFor(() => {
expect(screen.getByTestId("gdpr-export-btn")).not.toBeDisabled();
@@ -68,50 +59,359 @@ describe("GDPR Privacy Admin Page", () => {
it("shows confirmation dialog when delete is clicked", async () => {
renderPage();
+ const user = userEvent.setup();
const input = screen.getByTestId("gdpr-user-id");
- fireEvent.change(input, { target: { value: "user-123" } });
+ await user.type(input, "user-123");
await waitFor(() => {
expect(screen.getByTestId("gdpr-delete-btn")).not.toBeDisabled();
});
- fireEvent.click(screen.getByTestId("gdpr-delete-btn"));
+ await user.click(screen.getByTestId("gdpr-delete-btn"));
await waitFor(() => {
expect(screen.getByText(/Confirm Data Deletion/i)).toBeInTheDocument();
});
});
- it("shows deletion results after confirming delete", async () => {
+ it("shows deletion results after confirming delete and verifies API call", async () => {
+ let deleteCalled = false;
+ server.use(
+ http.delete("*/admin/gdpr/:userId", () => {
+ deleteCalled = true;
+ return HttpResponse.json({
+ memoriesDeleted: 14,
+ conversationsDeleted: 7,
+ auditPseudonymized: 42,
+ logsPseudonymized: 21,
+ });
+ })
+ );
+
renderPage();
+ const user = userEvent.setup();
const input = screen.getByTestId("gdpr-user-id");
- fireEvent.change(input, { target: { value: "user-123" } });
+ await user.type(input, "user-123");
await waitFor(() => {
expect(screen.getByTestId("gdpr-delete-btn")).not.toBeDisabled();
});
- fireEvent.click(screen.getByTestId("gdpr-delete-btn"));
+ await user.click(screen.getByTestId("gdpr-delete-btn"));
await waitFor(() => {
expect(screen.getByText(/Confirm Data Deletion/i)).toBeInTheDocument();
});
- // Click confirm in the dialog
const confirmBtn = screen.getByText(/Yes, Delete All Data/i);
- fireEvent.click(confirmBtn);
+ await user.click(confirmBtn);
await waitFor(() => {
expect(screen.getByTestId("gdpr-results")).toBeInTheDocument();
});
+ expect(deleteCalled).toBe(true);
// Check the result cards show the mock values
expect(screen.getByText("14")).toBeInTheDocument(); // memoriesDeleted
- expect(screen.getByText("7")).toBeInTheDocument(); // conversationsDeleted
+ expect(screen.getByText("7")).toBeInTheDocument(); // conversationsDeleted
});
it("renders the legal notice banner", () => {
renderPage();
expect(screen.getByText(/Data Protection Notice/i)).toBeInTheDocument();
});
+
+ // ─── Subtitle & descriptions ────────────────────────────────────────────
+
+ it("renders the page subtitle", () => {
+ renderPage();
+ expect(screen.getByText(/GDPR-compliant user data management/)).toBeInTheDocument();
+ });
+
+ it("renders the legal description about Art. 15/20", () => {
+ renderPage();
+ expect(screen.getByText(/Data export.*Art\. 15\/20/)).toBeInTheDocument();
+ });
+
+ it("renders the user lookup section header", () => {
+ renderPage();
+ expect(screen.getByText("User Lookup")).toBeInTheDocument();
+ });
+
+ // ─── Processing Restriction section ─────────────────────────────────────
+
+ it("renders the restriction section", () => {
+ renderPage();
+ expect(screen.getByTestId("gdpr-restriction-section")).toBeInTheDocument();
+ });
+
+ it("shows restriction section title", () => {
+ renderPage();
+ expect(screen.getByText(/Processing Restriction.*Art\. 18/)).toBeInTheDocument();
+ });
+
+ it("shows restriction description", () => {
+ renderPage();
+ expect(screen.getByText(/Restrict processing when a user disputes/)).toBeInTheDocument();
+ });
+
+ it("shows 'enter user ID first' hint when no user ID", () => {
+ renderPage();
+ expect(screen.getByText(/Enter a user ID above to check status/)).toBeInTheDocument();
+ });
+
+ it("shows restrict toggle button", () => {
+ renderPage();
+ expect(screen.getByTestId("gdpr-restrict-toggle")).toBeInTheDocument();
+ });
+
+ it("restrict toggle button is disabled when no user ID", () => {
+ renderPage();
+ expect(screen.getByTestId("gdpr-restrict-toggle")).toBeDisabled();
+ });
+
+ it("restrict toggle button is enabled after entering user ID", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "test-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-restrict-toggle")).not.toBeDisabled();
+ });
+ });
+
+ it("shows 'Processing Active' badge after entering user ID (non-restricted)", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "active-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("restriction-badge-active")).toBeInTheDocument();
+ expect(screen.getByText("Processing Active")).toBeInTheDocument();
+ });
+ });
+
+ it("shows 'Restrict Processing' label on toggle for non-restricted user", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "active-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-restrict-toggle")).toHaveTextContent("Restrict Processing");
+ });
+ });
+
+ it("clears result when user ID changes", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "user-123");
+
+ // Click delete
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-delete-btn")).not.toBeDisabled();
+ });
+ await user.click(screen.getByTestId("gdpr-delete-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Confirm Data Deletion/i)).toBeInTheDocument();
+ });
+
+ const confirmBtn = screen.getByText(/Yes, Delete All Data/i);
+ await user.click(confirmBtn);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-results")).toBeInTheDocument();
+ });
+
+ // Change user ID - result should clear
+ await user.clear(input);
+ await user.type(input, "different-user");
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("gdpr-results")).not.toBeInTheDocument();
+ });
+ });
+
+ it("result cards show all four metric labels", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "user-123");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-delete-btn")).not.toBeDisabled();
+ });
+ await user.click(screen.getByTestId("gdpr-delete-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Confirm Data Deletion/i)).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText(/Yes, Delete All Data/i));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-results")).toBeInTheDocument();
+ expect(screen.getByText("Memories Deleted")).toBeInTheDocument();
+ expect(screen.getByText("Conversations Deleted")).toBeInTheDocument();
+ expect(screen.getByText("Audit Pseudonymized")).toBeInTheDocument();
+ expect(screen.getByText("Logs Pseudonymized")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Export data flow ──────────────────────────────────────────────────
+
+ it("enables export button when user ID is entered", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "user-456");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-export-btn")).not.toBeDisabled();
+ });
+ });
+
+ it("export button text shows 'Export Data'", () => {
+ renderPage();
+ expect(screen.getByTestId("gdpr-export-btn")).toHaveTextContent("Export Data");
+ });
+
+ it("delete button text shows 'Delete All Data'", () => {
+ renderPage();
+ expect(screen.getByTestId("gdpr-delete-btn")).toHaveTextContent("Delete All Data");
+ });
+
+ // ─── Restrict processing toggle ─────────────────────────────────────
+
+ it("clicking restrict toggle triggers restrict mutation for non-restricted user", async () => {
+ server.use(
+ http.post("*/admin/gdpr/:userId/restrict", () => {
+ return new HttpResponse(null, { status: 200 });
+ })
+ );
+
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "active-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-restrict-toggle")).not.toBeDisabled();
+ });
+
+ await user.click(screen.getByTestId("gdpr-restrict-toggle"));
+
+ // After clicking, the toggle should have been activated
+ expect(screen.getByTestId("gdpr-restrict-toggle")).toBeInTheDocument();
+ });
+
+ // ─── Restricted user state ─────────────────────────────────────────────
+
+ it("shows 'Processing Restricted' badge for restricted user", async () => {
+ server.use(
+ http.get("*/admin/gdpr/:userId/restrict", () => {
+ return HttpResponse.json(true);
+ })
+ );
+
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "restricted-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("restriction-badge-restricted")).toBeInTheDocument();
+ expect(screen.getByText("Processing Restricted")).toBeInTheDocument();
+ });
+ });
+
+ it("shows 'Lift Restriction' label on toggle for restricted user", async () => {
+ server.use(
+ http.get("*/admin/gdpr/:userId/restrict", () => {
+ return HttpResponse.json(true);
+ })
+ );
+
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "restricted-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-restrict-toggle")).toHaveTextContent("Lift Restriction");
+ });
+ });
+
+ it("clicking lift restriction triggers unrestrict mutation", async () => {
+ server.use(
+ http.get("*/admin/gdpr/:userId/restrict", () => {
+ return HttpResponse.json(true);
+ })
+ );
+
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "restricted-user");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-restrict-toggle")).not.toBeDisabled();
+ });
+
+ await user.click(screen.getByTestId("gdpr-restrict-toggle"));
+
+ // Verify no crash
+ expect(screen.getByTestId("gdpr-restrict-toggle")).toBeInTheDocument();
+ });
+
+ // ─── Erasure complete heading ─────────────────────────────────────────
+
+ it("shows 'Erasure Complete' heading in results", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "user-123");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-delete-btn")).not.toBeDisabled();
+ });
+ await user.click(screen.getByTestId("gdpr-delete-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Confirm Data Deletion/i)).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText(/Yes, Delete All Data/i));
+
+ await waitFor(() => {
+ expect(screen.getByText("Erasure Complete")).toBeInTheDocument();
+ });
+ });
+
+ // ─── User ID input ────────────────────────────────────────────────────
+
+ it("shows user ID label", () => {
+ renderPage();
+ expect(screen.getByText("User ID")).toBeInTheDocument();
+ });
+
+ it("shows confirm dialog description with user ID", async () => {
+ renderPage();
+ const user = userEvent.setup();
+ const input = screen.getByTestId("gdpr-user-id");
+ await user.type(input, "test-user-abc");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("gdpr-delete-btn")).not.toBeDisabled();
+ });
+ await user.click(screen.getByTestId("gdpr-delete-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/test-user-abc/)).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/pages/__tests__/group-detail.test.tsx b/src/pages/__tests__/group-detail.test.tsx
new file mode 100644
index 00000000..57d975ed
--- /dev/null
+++ b/src/pages/__tests__/group-detail.test.tsx
@@ -0,0 +1,305 @@
+import { describe, it, expect } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import { renderPage } from "@/test/test-utils";
+import { GroupDetailPage } from "@/pages/group-detail";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
+
+function renderGroupDetail(id = "grp1", version = "1") {
+ return renderPage(
+ `/manage/groups/${id}?version=${version}`,
+ ,
+ "/manage/groups/:id"
+ );
+}
+
+describe("GroupDetailPage", () => {
+ it("renders the group name in heading", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ // Name appears in and also in config panel — use getAllByText
+ const matches = screen.getAllByText("Product Review Panel");
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("renders heading as h1", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ const h1 = screen.getByRole("heading", { level: 1 });
+ expect(h1).toHaveTextContent("Product Review Panel");
+ });
+ });
+
+ it("shows the group description", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ const matches = screen.getAllByText("Peer-review discussion for product decisions");
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows member count badge", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ // "2" appears in multiple places — just verify it exists
+ const matches = screen.getAllByText("2");
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows style badge", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ const matches = screen.getAllByText(/Peer Review/);
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it("shows no discussions message when empty", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("No discussions yet")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows ask below hint when no discussions", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Ask a question below to start")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows the Discussions sidebar label", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ const discussions = screen.getAllByText("Discussions");
+ expect(discussions.length).toBeGreaterThan(0);
+ });
+ });
+
+ it("shows error state on config load failure", async () => {
+ server.use(
+ http.get("*/groupstore/groups/:id", () => {
+ return new HttpResponse(null, { status: 500 });
+ })
+ );
+
+ renderGroupDetail("bad-group");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Something went wrong")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows retry button on error", async () => {
+ server.use(
+ http.get("*/groupstore/groups/:id", () => {
+ return new HttpResponse(null, { status: 500 });
+ })
+ );
+
+ renderGroupDetail("bad-group");
+
+ await waitFor(() => {
+ expect(screen.getByText("Retry")).toBeInTheDocument();
+ });
+ });
+
+ it("shows loading skeletons initially", () => {
+ server.use(
+ http.get("*/groupstore/groups/:id", async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10000));
+ return HttpResponse.json({});
+ })
+ );
+
+ renderGroupDetail("slow-group");
+
+ // Loading state renders Skeleton components; they should be visible
+ // No heading should be rendered yet
+ expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
+ });
+
+ it("shows fullscreen toggle button", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Product Review Panel");
+ });
+
+ // Fullscreen button has title "Fullscreen"
+ expect(screen.getByTitle("Fullscreen")).toBeInTheDocument();
+ });
+
+ it("renders the back link to groups", async () => {
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Product Review Panel");
+ });
+
+ expect(screen.getByTestId("back-to-list")).toBeInTheDocument();
+ const link = screen.getByTestId("back-to-list");
+ expect(link).toHaveAttribute("href", "/manage/groups");
+ });
+
+ it("renders discussion list with conversations", async () => {
+ server.use(
+ http.get("*/groups/:groupId/conversations", () => {
+ return HttpResponse.json([
+ {
+ id: "conv-1",
+ originalQuestion: "Should we use React or Vue?",
+ state: "COMPLETED",
+ created: Date.now() - 3600000,
+ },
+ {
+ id: "conv-2",
+ originalQuestion: "Quarterly planning review",
+ state: "IN_PROGRESS",
+ created: Date.now(),
+ },
+ ]);
+ })
+ );
+
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Should we use React or Vue?")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows COMPLETED state for a finished conversation", async () => {
+ server.use(
+ http.get("*/groups/:groupId/conversations", () => {
+ return HttpResponse.json([
+ {
+ id: "conv-1",
+ originalQuestion: "Test question",
+ state: "COMPLETED",
+ created: Date.now(),
+ },
+ ]);
+ })
+ );
+
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText("COMPLETED")).toBeInTheDocument();
+ });
+ });
+
+ it("renders group with no members shows 0 badge", async () => {
+ server.use(
+ http.get("*/groupstore/groups/:id", () => {
+ return HttpResponse.json({
+ name: "Empty Group",
+ description: "No members yet",
+ members: [],
+ moderatorAgentId: "",
+ style: "ROUND_TABLE",
+ maxRounds: 1,
+ phases: [],
+ protocol: {
+ agentTimeoutSeconds: 60,
+ onAgentFailure: "SKIP",
+ maxRetries: 2,
+ onMemberUnavailable: "SKIP",
+ },
+ });
+ })
+ );
+
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Empty Group");
+ });
+
+ // Member count badge should show "0" somewhere on the page
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+
+ it("handles null members gracefully", async () => {
+ server.use(
+ http.get("*/groupstore/groups/:id", () => {
+ return HttpResponse.json({
+ name: "Null Members Group",
+ description: "Null members field",
+ members: null,
+ moderatorAgentId: "",
+ style: "ROUND_TABLE",
+ maxRounds: 1,
+ phases: [],
+ protocol: {
+ agentTimeoutSeconds: 60,
+ onAgentFailure: "SKIP",
+ maxRetries: 2,
+ onMemberUnavailable: "SKIP",
+ },
+ });
+ })
+ );
+
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Null Members Group");
+ });
+
+ // safeConfig should have members = [] → shows "0"
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+
+ it("renders without description", async () => {
+ server.use(
+ http.get("*/groupstore/groups/:id", () => {
+ return HttpResponse.json({
+ name: "Simple Group",
+ description: "",
+ members: [],
+ moderatorAgentId: "agent1",
+ style: "ROUND_TABLE",
+ maxRounds: 3,
+ phases: [],
+ protocol: {
+ agentTimeoutSeconds: 60,
+ onAgentFailure: "SKIP",
+ maxRetries: 2,
+ onMemberUnavailable: "SKIP",
+ },
+ });
+ })
+ );
+
+ renderGroupDetail();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Simple Group");
+ });
+
+ // With empty description, no description paragraph should be rendered near the heading
+ // The description is "" so the conditional {groupConfig.description && ...} is falsy
+ const heading = screen.getByRole("heading", { level: 1 });
+ expect(heading).toHaveTextContent("Simple Group");
+ });
+});
diff --git a/src/pages/__tests__/group-wizard.test.tsx b/src/pages/__tests__/group-wizard.test.tsx
index 3040161a..a5fedaaf 100644
--- a/src/pages/__tests__/group-wizard.test.tsx
+++ b/src/pages/__tests__/group-wizard.test.tsx
@@ -1,8 +1,10 @@
-import { describe, it, expect } from "vitest";
+import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/test/test-utils";
import { GroupWizardPage } from "@/pages/group-wizard";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
describe("GroupWizardPage", () => {
it("renders wizard heading and step indicator", () => {
@@ -149,4 +151,386 @@ describe("GroupWizardPage", () => {
expect(screen.getByTestId("auto-create-notice")).toBeInTheDocument();
});
});
+
+ // ── Remove member ──────────────────────────────────────────────────
+
+ it("remove button deletes a member card", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "Test Group");
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Add 2 members
+ await user.click(screen.getByTestId("gw-add-member"));
+ await user.click(screen.getByTestId("gw-add-member"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("member-card-0")).toBeInTheDocument();
+ expect(screen.getByTestId("member-card-1")).toBeInTheDocument();
+ });
+
+ // Remove first member
+ await user.click(screen.getByTestId("remove-member-0"));
+
+ await waitFor(() => {
+ // Only 1 member card should remain
+ expect(screen.queryByTestId("member-card-1")).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Config step fields ─────────────────────────────────────────────
+
+ it("config step allows editing description", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "My Group");
+
+ const descInput = screen.getByTestId("gw-description");
+ await user.type(descInput, "A test group description");
+ expect((descInput as HTMLTextAreaElement).value).toBe("A test group description");
+ });
+
+ // ── Style selection ────────────────────────────────────────────────
+
+ it("config step shows discussion style selector", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "Test Group");
+
+ // Style selector shows style labels (not a select element, it's a grid of buttons)
+ expect(screen.getByText("Discussion Style")).toBeInTheDocument();
+ expect(screen.getByText("Peer Review")).toBeInTheDocument();
+ expect(screen.getByText("Round Table")).toBeInTheDocument();
+ });
+
+ // ── Create group mutation ──────────────────────────────────────────
+
+ it("calls create group API from review step", async () => {
+ let createCalled = false;
+ // Mock setup-agent for auto-creating new member agents
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ return HttpResponse.json({
+ agentId: `auto-agent-${Date.now()}`,
+ agentName: "Auto Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: true,
+ deploymentStatus: "deployed",
+ });
+ }),
+ http.post("*/groupstore/groups", () => {
+ createCalled = true;
+ return new HttpResponse(null, {
+ status: 201,
+ headers: { Location: "/groupstore/groups/new-grp?version=1" },
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ // Use advisory board template (5 pre-filled members)
+ await user.click(screen.getByTestId("template-advisory-board"));
+
+ // Config step → Next
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Members step → Next
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Review step → Create
+ await waitFor(() => {
+ expect(screen.getByTestId("group-wizard-create")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("group-wizard-create"));
+
+ await waitFor(() => {
+ expect(createCalled).toBe(true);
+ }, { timeout: 15000 });
+ });
+
+ // ── Members step: Next enabled with 2+ members ─────────────────────
+
+ it("enables Next when 2 members with displayNames are added", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "Test Group");
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Add 2 members
+ await user.click(screen.getByTestId("gw-add-member"));
+ await user.click(screen.getByTestId("gw-add-member"));
+
+ // Give them display names
+ const nameInputs = screen.getAllByTestId(/^member-name-/);
+ await user.type(nameInputs[0]!, "Alice");
+ await user.type(nameInputs[1]!, "Bob");
+
+ // Next should now be enabled
+ await waitFor(() => {
+ expect(screen.getByTestId("group-wizard-next")).not.toBeDisabled();
+ });
+ });
+
+ // ── Review step shows group configuration summary ──────────────────
+
+ it("review step shows the group name in summary", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-advisory-board"));
+
+ // Clear and type new name
+ const nameInput = screen.getByTestId("gw-name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "My Custom Board");
+
+ // Config → Members → Review
+ await user.click(screen.getByTestId("group-wizard-next"));
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ await waitFor(() => {
+ expect(screen.getByText("My Custom Board")).toBeInTheDocument();
+ });
+ });
+
+ // ── Discussion style switching ────────────────────────────────────
+
+ it("selects DEBATE style and shows style-specific badge", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "Debate Group");
+
+ // Switch to DEBATE style
+ await user.click(screen.getByTestId("gw-style-DEBATE"));
+
+ // The DEBATE style card should be visually selected (check it's in the DOM)
+ expect(screen.getByTestId("gw-style-DEBATE")).toBeInTheDocument();
+ });
+
+ it("selects DEVIL_ADVOCATE style", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "DA Group");
+
+ await user.click(screen.getByTestId("gw-style-DEVIL_ADVOCATE"));
+ expect(screen.getByTestId("gw-style-DEVIL_ADVOCATE")).toBeInTheDocument();
+ });
+
+ it("selects PEER_REVIEW style", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "PR Group");
+
+ await user.click(screen.getByTestId("gw-style-PEER_REVIEW"));
+ expect(screen.getByTestId("gw-style-PEER_REVIEW")).toBeInTheDocument();
+ });
+
+ it("selects DELPHI style", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "Delphi Group");
+
+ await user.click(screen.getByTestId("gw-style-DELPHI"));
+ expect(screen.getByTestId("gw-style-DELPHI")).toBeInTheDocument();
+ });
+
+ // ── Review step details ───────────────────────────────────────────
+
+ it("review step shows member count and rounds", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-advisory-board"));
+
+ // Config → Members → Review
+ await user.click(screen.getByTestId("group-wizard-next"));
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ await waitFor(() => {
+ // 5 members from advisory board template
+ expect(screen.getByText(/5 members/)).toBeInTheDocument();
+ expect(screen.getByText(/2 rounds/)).toBeInTheDocument();
+ });
+ });
+
+ it("review step shows Moderator badge when template includes moderator", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-advisory-board"));
+
+ // Config → Members
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Wait for the members step to render before navigating further
+ await waitFor(() => {
+ expect(screen.getByTestId("gw-add-member")).toBeInTheDocument();
+ });
+
+ // Verify the Next button is enabled before clicking
+ const nextBtn = screen.getByTestId("group-wizard-next");
+ expect(nextBtn).not.toBeDisabled();
+
+ // Members → Review
+ await user.click(nextBtn);
+
+ // Verify we're on the review step
+ await waitFor(() => {
+ expect(screen.getByText("Review & Create")).toBeInTheDocument();
+ });
+
+ // The review step should show the moderator section
+ expect(screen.getByText(/5 members/)).toBeInTheDocument();
+ });
+
+ it("review step shows discussion flow section", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-advisory-board"));
+
+ // Config → Members
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Wait for members step
+ await waitFor(() => {
+ expect(screen.getByTestId("gw-add-member")).toBeInTheDocument();
+ });
+
+ // Members → Review
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Discussion Flow")).toBeInTheDocument();
+ });
+ });
+
+ // ── Success state ──────────────────────────────────────────────────
+
+ it("shows success state after group creation", async () => {
+ server.use(
+ http.post("*/administration/agents/setup", () => {
+ return HttpResponse.json({
+ action: "created",
+ agentId: `auto-agent-${Date.now()}`,
+ agentName: "Auto Agent",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ deployed: true,
+ deploymentStatus: "deployed",
+ });
+ }),
+ http.post("*/groupstore/groups", () => {
+ return new HttpResponse(null, {
+ status: 201,
+ headers: { Location: "/groupstore/groups/success-grp?version=1" },
+ });
+ })
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-advisory-board"));
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Wait for members step to render
+ await waitFor(() => {
+ expect(screen.getByTestId("gw-add-member")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("group-wizard-create")).toBeInTheDocument();
+
+ });
+
+ await user.click(screen.getByTestId("group-wizard-create"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Group Created/)).toBeInTheDocument();
+ }, { timeout: 15000 });
+ });
+
+ // ── Needs at least 2 members warning ──────────────────────────────
+
+ it("shows warning when less than 2 members", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ await user.click(screen.getByTestId("template-blank"));
+ await user.type(screen.getByTestId("gw-name"), "Test Group");
+ await user.click(screen.getByTestId("group-wizard-next"));
+
+ // Add only one member
+ await user.click(screen.getByTestId("gw-add-member"));
+
+ // Should still show the < 2 member warning
+ await waitFor(() => {
+ expect(screen.getByTestId("group-wizard-next")).toBeDisabled();
+ });
+ });
+
+ // ── Template grid has multiple templates ──────────────────────────
+
+ it("template grid shows multiple templates beyond advisory board", async () => {
+ renderWithProviders(, {
+ initialRoute: "/manage/groups/wizard",
+ });
+
+ expect(screen.getByTestId("template-grid")).toBeInTheDocument();
+ // Advisory board is one template, there should be more
+ expect(screen.getByTestId("template-advisory-board")).toBeInTheDocument();
+ });
});
diff --git a/src/pages/__tests__/groups.test.tsx b/src/pages/__tests__/groups.test.tsx
index 5f5a4b8f..fe8b3cdd 100644
--- a/src/pages/__tests__/groups.test.tsx
+++ b/src/pages/__tests__/groups.test.tsx
@@ -1,27 +1,63 @@
import { describe, it, expect } from "vitest";
import { screen, waitFor } from "@testing-library/react";
-import { renderWithProviders } from "@/test/test-utils";
+import { renderWithProviders, userEvent } from "@/test/test-utils";
import { GroupsPage } from "@/pages/groups";
+import { server } from "@/test/mocks/server";
+import { http, HttpResponse } from "msw";
+
+function renderPage() {
+ return renderWithProviders(, {
+ initialRoute: "/manage/groups",
+ });
+}
describe("GroupsPage", () => {
- it("renders heading and search input", () => {
- renderWithProviders(, {
- initialRoute: "/manage/groups",
- });
+ // ─── Page structure ─────────────────────────────────────────────────────
+
+ it("renders heading with icon", () => {
+ renderPage();
+ expect(screen.getByText("Groups")).toBeInTheDocument();
+ });
+
+ it("renders subtitle description", () => {
+ renderPage();
+ expect(
+ screen.getByText(/Multi-agent discussion groups/)
+ ).toBeInTheDocument();
+ });
+
+ it("renders search input", () => {
+ renderPage();
+ expect(screen.getByTestId("group-search")).toBeInTheDocument();
+ });
+
+ it("shows create group button", () => {
+ renderPage();
+ expect(screen.getByTestId("create-group-btn")).toBeInTheDocument();
+ });
+
+ it("renders view toggle", () => {
+ renderPage();
+ expect(screen.getByTestId("view-toggle")).toBeInTheDocument();
+ });
+
+ // ─── Loading state ──────────────────────────────────────────────────────
+
+ it("shows loading skeletons while data is being fetched", () => {
+ // We can check by immediately rendering (before data loads)
+ renderPage();
+ // Loading skeletons are visible briefly
+ // The actual content will load, but at least the page renders without errors
expect(screen.getByTestId("group-search")).toBeInTheDocument();
});
+ // ─── Data loaded — Card view ──────────────────────────────────────────
+
it("renders group cards after loading", async () => {
- renderWithProviders(, {
- initialRoute: "/manage/groups",
- });
+ renderPage();
- // MSW handler for */groupstore/groups/:id returns "Product Review Panel"
- // for all IDs, so enriched descriptors all get that name.
await waitFor(
() => {
- // The enriched fetch replaces the descriptor name with the config name.
- // The mock GET /groupstore/groups/:id always returns "Product Review Panel".
const cards = screen.getAllByText("Product Review Panel");
expect(cards.length).toBeGreaterThanOrEqual(1);
},
@@ -29,25 +65,398 @@ describe("GroupsPage", () => {
);
});
- it("shows create group button", () => {
- renderWithProviders(, {
- initialRoute: "/manage/groups",
+ it("displays group cards in card view by default", async () => {
+ renderPage();
+
+ await waitFor(
+ () => {
+ const cards = screen.getAllByTestId(/^group-card-/);
+ expect(cards.length).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+ });
+
+ it("renders group count text after loading", async () => {
+ renderPage();
+
+ await waitFor(
+ () => {
+ expect(screen.getByText(/groups$/i)).toBeInTheDocument();
+ },
+ { timeout: 10000 }
+ );
+ });
+
+ // ─── List view ──────────────────────────────────────────────────────────
+
+ it("switches to list view when list toggle is clicked", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ // Find the list view button in the view toggle
+ const listButton = screen.getByTestId("view-toggle-list");
+ await user.click(listButton);
+
+ // Should now show the list table
+ await waitFor(() => {
+ expect(screen.getByTestId("group-list")).toBeInTheDocument();
});
- expect(screen.getByTestId("create-group-btn")).toBeInTheDocument();
});
- it("displays group cards in card view by default", async () => {
- renderWithProviders(, {
- initialRoute: "/manage/groups",
+ it("list view shows Name, ID, Version, Modified, and Actions columns", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ expect(screen.getByText("ID")).toBeInTheDocument();
+ expect(screen.getByText("Version")).toBeInTheDocument();
+ expect(screen.getByText("Modified")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
});
+ });
+
+ it("list view shows duplicate and delete action buttons", async () => {
+ renderPage();
+ const user = userEvent.setup();
await waitFor(
() => {
- // GroupCard components have data-testid="group-card-{id}"
- const cards = screen.getAllByTestId(/^group-card-/);
- expect(cards.length).toBeGreaterThanOrEqual(1);
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ // Each row should have Duplicate and Delete buttons
+ const duplicateButtons = screen.getAllByTitle("Duplicate");
+ expect(duplicateButtons.length).toBeGreaterThanOrEqual(1);
+ const deleteButtons = screen.getAllByTitle(/delete/i);
+ expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ // ─── Search / filter ────────────────────────────────────────────────────
+
+ it("filters groups by search query (server-side)", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ // Override the handler to return no results for this search
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ await user.type(screen.getByTestId("group-search"), "nonexistentquery");
+
+ // Wait for re-fetch with empty result
+ await waitFor(() => {
+ expect(screen.queryAllByTestId(/^group-card-/).length).toBe(0);
+ });
+ });
+
+ // ─── Create dialog flow ─────────────────────────────────────────────────
+
+ it("opens create-or-wizard dialog when create button is clicked", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await user.click(screen.getByTestId("create-group-btn"));
+
+ await waitFor(() => {
+ // The CreateOrWizardDialog should open, showing Quick Create and Guided Setup
+ expect(screen.getByText("Quick Create")).toBeInTheDocument();
+ expect(screen.getByText("Guided Setup")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Delete confirmation ────────────────────────────────────────────────
+
+ it("shows delete confirmation dialog and can cancel it", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ // Switch to list view for easier access to delete buttons
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
},
{ timeout: 10000 }
);
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("group-list")).toBeInTheDocument();
+ });
+
+ // Click delete on the first group
+ const deleteButtons = screen.getAllByTitle(/delete/i);
+ await user.click(deleteButtons[0]!);
+
+ // Confirm dialog should appear
+ await waitFor(() => {
+ expect(screen.getByText("Delete this group?")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "This will permanently delete the group configuration."
+ )
+ ).toBeInTheDocument();
+ });
+
+ // Click cancel
+ const cancelBtn = screen.getByText(/cancel/i);
+ await user.click(cancelBtn);
+
+ // Dialog should close
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Delete this group?")
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ // ─── Empty state ────────────────────────────────────────────────────────
+
+ it("shows empty state when no groups are returned", async () => {
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("No groups yet")).toBeInTheDocument();
+ });
+ });
+
+ it("empty state shows create group action button", async () => {
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Create Group")).toBeInTheDocument();
+ });
+ });
+
+ it("shows 'no results' empty state when search has no matches", async () => {
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderPage();
+ const user = userEvent.setup();
+
+ await user.type(screen.getByTestId("group-search"), "nonexistent");
+
+ await waitFor(() => {
+ // "No results found" (from common.noResults i18n key)
+ expect(screen.queryByText("Create Group")).not.toBeInTheDocument();
+ });
+ });
+
+ // ─── Error state ────────────────────────────────────────────────────────
+
+ it("shows error state when API request fails", async () => {
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json({ error: "Server error" }, { status: 500 });
+ })
+ );
+
+ renderPage();
+
+ await waitFor(() => {
+ // ErrorState component renders "Something went wrong"
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Error retry ─────────────────────────────────────────────────────
+
+ it("error state shows retry button", async () => {
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json({ error: "Server error" }, { status: 500 });
+ })
+ );
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Retry")).toBeInTheDocument();
+ });
+ });
+
+ // ─── Confirm delete flow ───────────────────────────────────────────────
+
+ it("confirms delete when confirm button is clicked", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("group-list")).toBeInTheDocument();
+ });
+
+ const deleteButtons = screen.getAllByTitle(/delete/i);
+ await user.click(deleteButtons[0]!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Delete this group?")).toBeInTheDocument();
+ });
+
+ // Click delete to confirm
+ const confirmBtn = screen.getByText(/^Delete$/);
+ await user.click(confirmBtn);
+
+ // Dialog should close after successful deletion
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Delete this group?")
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ // ─── Duplicate in list view ────────────────────────────────────────────
+
+ it("clicking duplicate button in list view triggers duplication", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("group-list")).toBeInTheDocument();
+ });
+
+ const duplicateButtons = screen.getAllByTitle("Duplicate");
+ expect(duplicateButtons.length).toBeGreaterThanOrEqual(1);
+ await user.click(duplicateButtons[0]!);
+
+ // Duplicate should succeed without error (toast)
+ // Just verify no crash
+ expect(screen.getByTestId("group-list")).toBeInTheDocument();
+ });
+
+ // ─── Empty state create button ─────────────────────────────────────────
+
+ it("empty state create button opens create dialog", async () => {
+ server.use(
+ http.get("*/groupstore/groups/descriptors", () => {
+ return HttpResponse.json([]);
+ })
+ );
+
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByText("Create Group")).toBeInTheDocument();
+ });
+
+ // Click the create button in the empty state
+ const createButtons = screen.getAllByText("Create Group");
+ // Find the one that's NOT in the header (the empty state one)
+ const emptyStateBtn = createButtons.find(
+ (btn) => !btn.closest("[data-testid='create-group-btn']")
+ );
+ if (emptyStateBtn) {
+ await user.click(emptyStateBtn);
+ await waitFor(() => {
+ // Should trigger the create dialog or wizard dialog
+ expect(screen.getByText("Quick Create")).toBeInTheDocument();
+ });
+ }
+ });
+
+ // ─── List view links ──────────────────────────────────────────────────
+
+ it("list view group names link to group detail page", async () => {
+ renderPage();
+ const user = userEvent.setup();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getAllByTestId(/^group-card-/).length
+ ).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 10000 }
+ );
+
+ await user.click(screen.getByTestId("view-toggle-list"));
+
+ await waitFor(() => {
+ const listEl = screen.getByTestId("group-list");
+ const links = listEl.querySelectorAll("a[href*='/manage/groups/']");
+ expect(links.length).toBeGreaterThanOrEqual(1);
+ });
});
});
diff --git a/src/pages/__tests__/logs.test.tsx b/src/pages/__tests__/logs.test.tsx
index 497a0b51..be2956b6 100644
--- a/src/pages/__tests__/logs.test.tsx
+++ b/src/pages/__tests__/logs.test.tsx
@@ -1,10 +1,53 @@
-import { describe, it, expect } from "vitest";
-import { screen } from "@testing-library/react";
-import { render } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { screen, waitFor, render, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { ThemeProvider } from "@/components/layout/theme-provider";
import { LogsPage } from "@/pages/logs";
+import userEvent from "@testing-library/user-event";
+
+// ─── Mocks ─────────────────────────────────────────────────────────────
+
+vi.mock("@/hooks/use-logs", () => ({
+ useLogStream: vi.fn().mockReturnValue({
+ entries: [
+ { timestamp: 1700000000000, level: "INFO", message: "Server started", loggerName: "main", agentId: "agent1", conversationId: "conv1" },
+ { timestamp: 1700000001000, level: "ERROR", message: "NullPointerException\n at com.example.Main.run(Main.java:42)\n at com.example.App.start(App.java:10)\nCaused by: java.lang.RuntimeException", loggerName: "error-logger" },
+ { timestamp: 1700000002000, level: "WARNING", message: "Low memory", loggerName: "sys" },
+ ],
+ sseConnected: true,
+ paused: false,
+ setPaused: vi.fn(),
+ clearEntries: vi.fn(),
+ }),
+ useHistoryLogs: vi.fn().mockReturnValue({
+ data: [
+ { timestamp: "2024-01-15T10:30:00Z", level: "INFO", message: "History log entry", agentId: "agent1", instanceId: "inst-abc" },
+ ],
+ isLoading: false,
+ refetch: vi.fn()
+ }),
+ useInstanceId: vi.fn().mockReturnValue({ data: { instanceId: "test-instance-123" } }),
+}));
+
+vi.mock("@/hooks/use-chat", () => ({
+ useDeployedAgents: vi.fn().mockReturnValue({
+ data: [
+ { id: "agent1", name: "Test Agent" },
+ ]
+ }),
+}));
+
+vi.mock("@/hooks/use-onboarding", () => ({
+ useOnboarding: vi.fn().mockReturnValue(vi.fn()),
+}));
+
+vi.mock("@/lib/api/conversations", () => ({
+ getConversationDescriptors: vi.fn().mockResolvedValue([]),
+ parseConversationUri: vi.fn((uri: string) => uri.split("/").pop() ?? ""),
+}));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────
function renderLogs() {
const queryClient = new QueryClient({
@@ -25,7 +68,13 @@ function renderLogs() {
);
}
+// ─── Tests ─────────────────────────────────────────────────────────────
+
describe("LogsPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
it("renders the logs-page container", () => {
renderLogs();
expect(screen.getByTestId("logs-page")).toBeInTheDocument();
@@ -71,4 +120,116 @@ describe("LogsPage", () => {
renderLogs();
expect(screen.getByTestId("export-logs-btn")).toBeInTheDocument();
});
+
+ // ─── Interaction tests (mocked data) ────────────────────────────────────
+
+ it("renders log entries when useLogStream returns data", () => {
+ renderLogs();
+ expect(screen.getByText("Server started")).toBeInTheDocument();
+ expect(screen.getByText(/NullPointerException/)).toBeInTheDocument();
+ expect(screen.getByText("Low memory")).toBeInTheDocument();
+ });
+
+ it("renders level badges for entries", () => {
+ renderLogs();
+ // Scope to the log entries scroll area to avoid matching