import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { AgentsView } from "../AgentsView"; import { loadAllAppCss } from "../../test/cssFixture"; import type { Agent, AgentCapability, AgentState } from "../../api"; function extractRuleBlock(css: string, selector: string): string { const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); return match?.[1] ?? ""; } function extractMobileMediaBlocks(content: string): string { const blocks: string[] = []; const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g; let match; while ((match = regex.exec(content)) !== null) { const startIdx = match.index + match[0].length; let braceCount = 1; let endIdx = startIdx; while (braceCount > 0 && endIdx < content.length) { if (content[endIdx] === "{") braceCount += 1; if (content[endIdx] === "}") braceCount -= 1; endIdx += 1; } if (braceCount === 0) { blocks.push(content.slice(startIdx, endIdx - 1)); } } return blocks.join("\n"); } vi.mock("../../api", () => ({ fetchAgents: vi.fn(), fetchAgentStats: vi.fn(), createAgent: vi.fn(), updateAgent: vi.fn(), updateAgentState: vi.fn(), deleteAgent: vi.fn(), startAgentRun: vi.fn(), fetchPluginRuntimes: vi.fn(() => Promise.resolve([])), fetchModels: vi.fn(() => Promise.resolve({ models: [] })), fetchOrgTree: vi.fn(), fetchSettings: vi.fn(() => Promise.resolve({ heartbeatMultiplier: 1 })), updateSettings: vi.fn(() => Promise.resolve({})), })); import { fetchAgents, fetchAgentStats, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, } from "../../api"; const mockOrgTree = [ { agent: { id: "agent-root-mobile", name: "Mobile Root", role: "scheduler" as AgentCapability, state: "active" as AgentState, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [ { agent: { id: "agent-child-mobile", name: "Mobile Child", role: "executor" as AgentCapability, state: "running" as AgentState, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, children: [], }, ], }, ]; const mockAgents: Agent[] = [ { id: "agent-001", name: "Mobile Executor", role: "executor" as AgentCapability, state: "active" as AgentState, taskId: "FN-101", totalInputTokens: 60, totalOutputTokens: 20, lastHeartbeatAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, { id: "agent-002", name: "Mobile Reviewer", role: "reviewer" as AgentCapability, state: "idle" as AgentState, totalInputTokens: 15, totalOutputTokens: 5, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {}, }, ]; const eventSourceFactory = vi.fn(() => ({ addEventListener: vi.fn(), close: vi.fn(), })); describe("AgentsView mobile adaptations", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); vi.stubGlobal("EventSource", eventSourceFactory as unknown as typeof EventSource); vi.mocked(fetchAgents).mockResolvedValue(mockAgents); vi.mocked(fetchAgentStats).mockResolvedValue({ activeCount: 1, assignedTaskCount: 1, completedRuns: 0, failedRuns: 0, successRate: 1, }); vi.mocked(updateAgent).mockResolvedValue(mockAgents[0]); vi.mocked(updateAgentState).mockResolvedValue(mockAgents[0]); vi.mocked(deleteAgent).mockResolvedValue(undefined); vi.mocked(startAgentRun).mockResolvedValue({ id: "run-1", agentId: "agent-001", startedAt: new Date().toISOString(), endedAt: null, status: "active", }); vi.mocked(fetchOrgTree).mockResolvedValue([]); }); it("renders board view grid and board cards", async () => { const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "Board view" })); await waitFor(() => { expect(container.querySelector(".agent-board")).toBeTruthy(); expect(container.querySelectorAll(".agent-board-card").length).toBeGreaterThan(0); }); }); it("renders list view cards", async () => { const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "List view" })); await waitFor(() => { expect(container.querySelector(".agent-list")).toBeTruthy(); expect(container.querySelectorAll(".agent-card").length).toBeGreaterThan(0); }); // Token-stats panel now lives in the controls popup; open it before // asserting on the panel content. fireEvent.click(screen.getByRole("button", { name: "Controls" })); await waitFor(() => { expect(container.querySelector(".agent-token-stats-panel")).toBeTruthy(); expect(screen.getByText("Combined Tokens")).toBeTruthy(); expect(screen.getByText("100")).toBeTruthy(); }); }); it("renders controls trigger and reveals panel controls on demand", async () => { const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); const controlsTrigger = screen.getByRole("button", { name: "Controls" }); expect(controlsTrigger.getAttribute("aria-expanded")).toBe("false"); fireEvent.click(controlsTrigger); await waitFor(() => { expect(screen.getByRole("dialog", { name: "Agent controls" })).toBeTruthy(); expect(container.querySelector(".agent-controls")).toBeTruthy(); expect(container.querySelector(".agent-controls-filters")).toBeTruthy(); expect(container.querySelector(".agent-state-filter")).toBeTruthy(); expect(container.querySelector(".agent-controls-actions")).toBeTruthy(); }); }); it("switches between board, list, and org views", async () => { vi.mocked(fetchOrgTree).mockResolvedValue(mockOrgTree); const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); await waitFor(() => expect(container.querySelector(".agent-org-chart")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "Board view" })); await waitFor(() => expect(container.querySelector(".agent-board")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "List view" })); await waitFor(() => expect(container.querySelector(".agent-list")).toBeTruthy()); }); it("renders state filter select with expected options", async () => { render(); const controlsTrigger = await screen.findByRole("button", { name: "Controls" }); fireEvent.click(controlsTrigger); await waitFor(() => expect(screen.getByLabelText("Filter agents by state")).toBeTruthy()); const select = screen.getByLabelText("Filter agents by state") as HTMLSelectElement; expect(select).toBeTruthy(); const optionValues = Array.from(select.options).map((option) => option.value); expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error"]); }); }); describe("agents-view mobile CSS", () => { const cssContent = loadAllAppCss(); const mobileMediaBlock = extractMobileMediaBlocks(cssContent); it("defines .agents-view-content with reduced padding on mobile", () => { expect(mobileMediaBlock).toContain(".agents-view-content"); const block = extractRuleBlock(mobileMediaBlock, ".agents-view-content"); expect(block).toContain("padding: var(--space-md)"); }); it("defines .agents-view-header with compact padding on mobile", () => { expect(mobileMediaBlock).toContain(".agents-view-header"); const block = extractRuleBlock(mobileMediaBlock, ".agents-view-header"); expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/); }); it("defines .agents-view-title h2 with token font size on mobile", () => { expect(mobileMediaBlock).toContain(".agents-view-title h2"); const block = extractRuleBlock(mobileMediaBlock, ".agents-view-title h2"); expect(block).toContain("font-size: var(--space-lg)"); }); it("defines .agents-view-controls with flex-wrap on mobile", () => { expect(mobileMediaBlock).toContain(".agents-view-controls"); const block = extractRuleBlock(mobileMediaBlock, ".agents-view-controls"); expect(block).toContain("flex-wrap: wrap"); }); it("keeps mobile split layout constrained so inner panes own vertical scrolling", () => { expect(mobileMediaBlock).toContain(".agents-split-layout"); const splitLayoutBlock = extractRuleBlock(mobileMediaBlock, ".agents-split-layout"); expect(splitLayoutBlock).toContain("min-height: 0"); expect(splitLayoutBlock).not.toContain("height: 100%"); expect(extractRuleBlock(mobileMediaBlock, ".agents-split-sidebar")).toContain("min-height: 0"); expect(extractRuleBlock(mobileMediaBlock, ".agents-split-sidebar")).toContain("overflow: hidden"); expect(extractRuleBlock(mobileMediaBlock, ".agents-split-detail")).toContain("min-height: 0"); expect(extractRuleBlock(mobileMediaBlock, ".agents-split-detail")).toContain("overflow: hidden"); const contentBlock = extractRuleBlock(mobileMediaBlock, ".agents-view-content"); expect(contentBlock).toContain("overflow-y: auto"); expect(contentBlock).toContain("-webkit-overflow-scrolling: touch"); expect(contentBlock).toContain("overscroll-behavior: contain"); expect(contentBlock).not.toContain("var(--mobile-nav-height)"); expect(contentBlock).toContain("env(safe-area-inset-bottom, 0px)"); expect(contentBlock).toContain("var(--standalone-bottom-gap)"); }); it("keeps grouped filter controls token-driven", () => { const block = extractRuleBlock(cssContent, ".agent-controls-filters"); expect(block).toContain("display: flex"); expect(block).toContain("gap: var(--space-sm)"); }); it("defines .agents-view-title with flex-wrap on mobile", () => { expect(mobileMediaBlock).toContain(".agents-view-title"); const block = extractRuleBlock(mobileMediaBlock, ".agents-view-title"); expect(block).toContain("flex-wrap: wrap"); }); it("defines mobile org chart sizing and pan/zoom controls rules", () => { expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)"); const viewportBlock = extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport"); expect(viewportBlock).toContain("min-height: calc(var(--space-2xl) * 4)"); expect(viewportBlock).toContain("overflow: auto"); expect(viewportBlock).toContain("overscroll-behavior: contain"); expect(viewportBlock).toContain("-webkit-overflow-scrolling: touch"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-sibling-gap: var(--space-sm)"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-children-offset: var(--space-lg)"); const childrenConnectorBlock = extractRuleBlock(cssContent, ".org-chart-children::before"); expect(childrenConnectorBlock).toContain("left: var(--org-chart-first-child-center-offset)"); expect(childrenConnectorBlock).toContain("right: var(--org-chart-last-child-center-offset)"); expect(childrenConnectorBlock).not.toContain("rgba("); const verticalConnectorBlock = extractRuleBlock(cssContent, ".agent-org-chart--vertical .org-chart-children::before"); expect(verticalConnectorBlock).toContain("left: var(--space-sm)"); expect(verticalConnectorBlock).not.toContain("rgba("); expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)"); expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node__badge")).toContain("font-size: calc(var(--space-sm) + var(--space-xs) * 0.625)"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-shell")).toContain("overflow: hidden"); }); it("keeps org chart viewport as scroll owner while mobile zoom and selection work", async () => { vi.mocked(fetchOrgTree).mockResolvedValue(mockOrgTree); const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); fireEvent.click(screen.getByRole("button", { name: "Org Chart view" })); const shell = await screen.findByTestId("agent-org-chart-shell"); expect(shell.className).toContain("agent-org-chart-shell"); const viewport = await screen.findByTestId("agent-org-chart-viewport"); expect(viewport.className).toContain("agent-org-chart-viewport"); const chart = await screen.findByTestId("agent-org-chart"); expect(chart.getAttribute("data-layout-mode")).toBeTruthy(); expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100"); fireEvent.click(screen.getByText("Mobile Child")); await waitFor(() => { expect(container.querySelector(".org-chart-node-card--running.agent-card--selected")).toBeTruthy(); }); }); });