import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, } from "vitest"; import type { AgentConfigInfo, AgentInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; vi.mock("../api/agents", () => { const agentsApi = { listAgents: vi.fn(), getAgentConfig: vi.fn(), startAgent: vi.fn(), stopAgent: vi.fn(), reloadConfig: vi.fn(), }; return { agentsApi, subscribeAgentStream: vi.fn(() => () => {}) }; }); // Dynamic import so the mock is in place before the module loads const { AgentPanel } = await import("./AgentPanel"); const mockedAgents = { listAgents: vi.mocked(agentsApi.listAgents), getAgentConfig: vi.mocked(agentsApi.getAgentConfig), startAgent: vi.mocked(agentsApi.startAgent), }; const ROSTER: AgentConfigInfo[] = [ { name: "coder-1", role: "Full-stack engineer", model: "sonnet", allowed_tools: null, max_turns: 50, max_budget_usd: 5.0, }, ]; describe("AgentPanel diff command", () => { beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); }); beforeEach(() => { mockedAgents.getAgentConfig.mockResolvedValue(ROSTER); mockedAgents.listAgents.mockResolvedValue([]); }); it("shows diff command when an agent has a worktree path", async () => { const agentList: AgentInfo[] = [ { story_id: "33_diff_commands", agent_name: "coder-1", status: "running", session_id: null, worktree_path: "/tmp/project-story-33", base_branch: "master", }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); render(); // Expand the agent detail by clicking the expand button const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); // The diff command should be rendered expect( await screen.findByText( 'cd "/tmp/project-story-33" && git difftool master...HEAD', ), ).toBeInTheDocument(); // A copy button should exist expect(screen.getByText("Copy")).toBeInTheDocument(); }); it("copies diff command to clipboard on click", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText }, }); const agentList: AgentInfo[] = [ { story_id: "33_diff_commands", agent_name: "coder-1", status: "completed", session_id: null, worktree_path: "/home/user/my-project-story-33", base_branch: "main", }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); render(); const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); const copyButton = await screen.findByText("Copy"); await userEvent.click(copyButton); await waitFor(() => { expect(writeText).toHaveBeenCalledWith( 'cd "/home/user/my-project-story-33" && git difftool main...HEAD', ); }); expect(await screen.findByText("Copied")).toBeInTheDocument(); }); it("uses base_branch from the server in the diff command", async () => { const agentList: AgentInfo[] = [ { story_id: "33_diff_commands", agent_name: "coder-1", status: "running", session_id: null, worktree_path: "/tmp/wt", base_branch: "develop", }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); render(); const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); expect( await screen.findByText('cd "/tmp/wt" && git difftool develop...HEAD'), ).toBeInTheDocument(); }); it("defaults to master when base_branch is null", async () => { const agentList: AgentInfo[] = [ { story_id: "33_diff_commands", agent_name: "coder-1", status: "running", session_id: null, worktree_path: "/tmp/wt", base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); render(); const expandButton = await screen.findByText("▶"); await userEvent.click(expandButton); expect( await screen.findByText('cd "/tmp/wt" && git difftool master...HEAD'), ).toBeInTheDocument(); }); }); describe("AgentPanel fade-out", () => { beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); }); beforeEach(() => { mockedAgents.getAgentConfig.mockResolvedValue(ROSTER); mockedAgents.listAgents.mockResolvedValue([]); }); it("applies fade animation to a completed agent", async () => { const agentList: AgentInfo[] = [ { story_id: "73_fade_test", agent_name: "coder-1", status: "completed", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); const { container } = render(); const entry = await waitFor(() => { const el = container.querySelector( '[data-testid="agent-entry-73_fade_test:coder-1"]', ); expect(el).toBeInTheDocument(); return el as HTMLElement; }); expect(entry.style.animationName).toBe("agentFadeOut"); }); it("applies fade animation to a failed agent", async () => { const agentList: AgentInfo[] = [ { story_id: "73_fade_fail", agent_name: "coder-1", status: "failed", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); const { container } = render(); const entry = await waitFor(() => { const el = container.querySelector( '[data-testid="agent-entry-73_fade_fail:coder-1"]', ); expect(el).toBeInTheDocument(); return el as HTMLElement; }); expect(entry.style.animationName).toBe("agentFadeOut"); }); it("does not apply fade animation to a running agent", async () => { const agentList: AgentInfo[] = [ { story_id: "73_running", agent_name: "coder-1", status: "running", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); const { container } = render(); const entry = await waitFor(() => { const el = container.querySelector( '[data-testid="agent-entry-73_running:coder-1"]', ); expect(el).toBeInTheDocument(); return el as HTMLElement; }); expect(entry.style.animationName).not.toBe("agentFadeOut"); }); it("pauses the fade when the entry is expanded", async () => { const agentList: AgentInfo[] = [ { story_id: "73_pause_test", agent_name: "coder-1", status: "completed", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); const { container } = render(); const entry = await waitFor(() => { const el = container.querySelector( '[data-testid="agent-entry-73_pause_test:coder-1"]', ); expect(el).toBeInTheDocument(); return el as HTMLElement; }); // Initially running (not paused) expect(entry.style.animationPlayState).toBe("running"); // Expand the agent const expandButton = screen.getByRole("button", { name: "▶" }); await userEvent.click(expandButton); // Animation should be paused expect(entry.style.animationPlayState).toBe("paused"); }); it("resumes the fade when the entry is collapsed", async () => { const agentList: AgentInfo[] = [ { story_id: "73_resume_test", agent_name: "coder-1", status: "completed", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); const { container } = render(); const entry = await waitFor(() => { const el = container.querySelector( '[data-testid="agent-entry-73_resume_test:coder-1"]', ); expect(el).toBeInTheDocument(); return el as HTMLElement; }); const expandButton = screen.getByRole("button", { name: "▶" }); // Expand await userEvent.click(expandButton); expect(entry.style.animationPlayState).toBe("paused"); // Collapse await userEvent.click(expandButton); expect(entry.style.animationPlayState).toBe("running"); }); describe("removes the agent entry after 60s", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { vi.useRealTimers(); }); it("removes the agent entry after the 60-second fade completes", async () => { const agentList: AgentInfo[] = [ { story_id: "73_remove_test", agent_name: "coder-1", status: "completed", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); const { container } = render(); // With fake timers active, waitFor's polling setInterval never fires. // Use act to flush pending promises and React state updates instead. await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect( container.querySelector( '[data-testid="agent-entry-73_remove_test:coder-1"]', ), ).toBeInTheDocument(); // Advance timers by 60 seconds and flush React state updates await act(async () => { vi.advanceTimersByTime(60_000); }); // Entry should be removed expect( container.querySelector( '[data-testid="agent-entry-73_remove_test:coder-1"]', ), ).not.toBeInTheDocument(); }); }); }); describe("RosterBadge availability state", () => { beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); }); beforeEach(() => { mockedAgents.getAgentConfig.mockResolvedValue(ROSTER); mockedAgents.listAgents.mockResolvedValue([]); }); it("shows a green dot for an idle agent", async () => { render(); const dot = await screen.findByTestId("roster-dot-coder-1"); // JSDOM normalizes #3fb950 to rgb(63, 185, 80) expect(dot.style.background).toBe("rgb(63, 185, 80)"); expect(dot.style.animation).toBe(""); }); it("shows green badge styling for an idle agent", async () => { render(); const badge = await screen.findByTestId("roster-badge-coder-1"); // JSDOM normalizes #3fb95015 to rgba(63, 185, 80, 0.082) and #3fb950 to rgb(63, 185, 80) expect(badge.style.background).toBe("rgba(63, 185, 80, 0.082)"); expect(badge.style.color).toBe("rgb(63, 185, 80)"); }); it("shows a blue pulsing dot for an active agent", async () => { const agentList: AgentInfo[] = [ { story_id: "81_active", agent_name: "coder-1", status: "running", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); render(); const dot = await screen.findByTestId("roster-dot-coder-1"); // JSDOM normalizes #58a6ff to rgb(88, 166, 255) expect(dot.style.background).toBe("rgb(88, 166, 255)"); expect(dot.style.animation).toBe("pulse 1.5s infinite"); }); it("shows blue badge styling for an active agent", async () => { const agentList: AgentInfo[] = [ { story_id: "81_active", agent_name: "coder-1", status: "running", session_id: null, worktree_path: null, base_branch: null, }, ]; mockedAgents.listAgents.mockResolvedValue(agentList); render(); const badge = await screen.findByTestId("roster-badge-coder-1"); // JSDOM normalizes #58a6ff18 to rgba(88, 166, 255, 0.094) and #58a6ff to rgb(88, 166, 255) expect(badge.style.background).toBe("rgba(88, 166, 255, 0.094)"); expect(badge.style.color).toBe("rgb(88, 166, 255)"); }); });