2026-02-20 12:48:50 +00:00
|
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
|
|
|
import userEvent from "@testing-library/user-event";
|
2026-02-23 14:27:15 +00:00
|
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
2026-02-20 14:11:53 +00:00
|
|
|
import type { AgentConfigInfo, AgentInfo } from "../api/agents";
|
2026-02-20 12:48:50 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-02-20 19:39:19 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-20 12:48:50 +00:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-02-20 19:39:19 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-20 12:48:50 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-02-20 19:39:19 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-20 12:48:50 +00:00
|
|
|
|
|
|
|
|
const expandButton = await screen.findByText("▶");
|
|
|
|
|
await userEvent.click(expandButton);
|
|
|
|
|
|
|
|
|
|
expect(
|
2026-02-20 14:11:53 +00:00
|
|
|
await screen.findByText('cd "/tmp/wt" && git difftool develop...HEAD'),
|
2026-02-20 12:48:50 +00:00
|
|
|
).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);
|
|
|
|
|
|
2026-02-20 19:39:19 +00:00
|
|
|
render(<AgentPanel />);
|
2026-02-20 12:48:50 +00:00
|
|
|
|
|
|
|
|
const expandButton = await screen.findByText("▶");
|
|
|
|
|
await userEvent.click(expandButton);
|
|
|
|
|
|
|
|
|
|
expect(
|
2026-02-20 14:11:53 +00:00
|
|
|
await screen.findByText('cd "/tmp/wt" && git difftool master...HEAD'),
|
2026-02-20 12:48:50 +00:00
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-23 14:27:15 +00:00
|
|
|
|
|
|
|
|
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(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
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(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
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(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
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(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
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(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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(<AgentPanel />);
|
|
|
|
|
|
|
|
|
|
// Wait for the agent entry to appear
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
container.querySelector(
|
|
|
|
|
'[data-testid="agent-entry-73_remove_test:coder-1"]',
|
|
|
|
|
),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Advance timers by 60 seconds
|
|
|
|
|
vi.advanceTimersByTime(60_000);
|
|
|
|
|
|
|
|
|
|
// Entry should be removed
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
container.querySelector(
|
|
|
|
|
'[data-testid="agent-entry-73_remove_test:coder-1"]',
|
|
|
|
|
),
|
|
|
|
|
).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|