2026-03-22 19:07:07 +00:00
|
|
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import type { AgentEvent, AgentInfo } from "../api/agents";
|
|
|
|
|
import type { TestResultsResponse, TokenCostResponse } from "../api/client";
|
|
|
|
|
|
|
|
|
|
vi.mock("../api/client", async () => {
|
|
|
|
|
const actual =
|
|
|
|
|
await vi.importActual<typeof import("../api/client")>("../api/client");
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
api: {
|
|
|
|
|
...actual.api,
|
|
|
|
|
getWorkItemContent: vi.fn(),
|
|
|
|
|
getTestResults: vi.fn(),
|
|
|
|
|
getTokenCost: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.mock("../api/agents", () => ({
|
|
|
|
|
agentsApi: {
|
|
|
|
|
listAgents: vi.fn(),
|
|
|
|
|
getAgentConfig: vi.fn(),
|
|
|
|
|
stopAgent: vi.fn(),
|
|
|
|
|
startAgent: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
subscribeAgentStream: vi.fn(() => () => {}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
|
|
|
|
import { api } from "../api/client";
|
|
|
|
|
|
|
|
|
|
const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
|
|
|
|
|
|
|
|
|
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
|
|
|
|
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
|
|
|
|
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
|
|
|
|
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
|
|
|
|
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
|
|
|
|
|
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
|
|
|
|
|
|
|
|
|
const DEFAULT_CONTENT = {
|
|
|
|
|
content: "# Big Title\n\nSome content here.",
|
|
|
|
|
stage: "current",
|
|
|
|
|
name: "Big Title Story",
|
|
|
|
|
agent: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sampleTestResults: TestResultsResponse = {
|
|
|
|
|
unit: [
|
|
|
|
|
{ name: "test_add", status: "pass", details: null },
|
|
|
|
|
{ name: "test_subtract", status: "fail", details: "expected 3, got 4" },
|
|
|
|
|
],
|
|
|
|
|
integration: [{ name: "test_api_endpoint", status: "pass", details: null }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
|
|
|
|
mockedGetTestResults.mockResolvedValue(null);
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
|
|
|
|
mockedListAgents.mockResolvedValue([]);
|
|
|
|
|
mockedGetAgentConfig.mockResolvedValue([]);
|
|
|
|
|
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("WorkItemDetailPanel", () => {
|
2026-04-02 10:57:18 +00:00
|
|
|
it("renders the story name in the header with type and ID prefix", async () => {
|
2026-03-22 19:07:07 +00:00
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="237_bug_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
|
2026-04-02 10:57:18 +00:00
|
|
|
"Bug 237: Big Title Story",
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows loading state initially", () => {
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="237_bug_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByTestId("detail-panel-loading")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls onClose when close button is clicked", async () => {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="237_bug_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
const closeButton = screen.getByTestId("detail-panel-close");
|
|
|
|
|
closeButton.click();
|
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders markdown headings with constrained inline font size", async () => {
|
2026-04-02 10:57:18 +00:00
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
...DEFAULT_CONTENT,
|
|
|
|
|
content: "# Title Heading\n\n## Section Heading\n\nSome content.",
|
|
|
|
|
});
|
2026-03-22 19:07:07 +00:00
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="237_bug_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const content = screen.getByTestId("detail-panel-content");
|
2026-04-02 10:57:18 +00:00
|
|
|
// H1 is stripped by stripDisplayContent; h2 should be constrained
|
|
|
|
|
const h2 = content.querySelector("h2");
|
|
|
|
|
expect(h2).not.toBeNull();
|
|
|
|
|
expect(h2?.style.fontSize).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("strips YAML front matter so 'name' is not shown as a prefix in content", async () => {
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
content:
|
|
|
|
|
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...',
|
|
|
|
|
stage: "current",
|
|
|
|
|
name: "My Story Name",
|
|
|
|
|
agent: null,
|
2026-03-22 19:07:07 +00:00
|
|
|
});
|
2026-04-02 10:57:18 +00:00
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
const content = await screen.findByTestId("detail-panel-content");
|
|
|
|
|
expect(content.textContent).not.toMatch(/name:/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("strips the first H1 heading so the story title is not shown twice", async () => {
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
content:
|
|
|
|
|
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...',
|
|
|
|
|
stage: "current",
|
|
|
|
|
name: "My Story Name",
|
|
|
|
|
agent: null,
|
|
|
|
|
});
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
const content = await screen.findByTestId("detail-panel-content");
|
|
|
|
|
expect(content.querySelector("h1")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows 'Type N: Name' format in the panel header title (story ID/title left-justified)", async () => {
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
content: "## User Story\n\nAs a user...",
|
|
|
|
|
stage: "current",
|
|
|
|
|
name: "My Story Name",
|
|
|
|
|
agent: null,
|
|
|
|
|
});
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
|
|
|
|
|
"Story 42: My Story Name",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not show the work item type label twice when front matter and H1 are stripped", async () => {
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
content:
|
|
|
|
|
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nContent.',
|
|
|
|
|
stage: "current",
|
|
|
|
|
name: "My Story Name",
|
|
|
|
|
agent: null,
|
|
|
|
|
});
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await screen.findByTestId("detail-panel-content");
|
|
|
|
|
// "Story" type label appears exactly once — in the panel header title
|
|
|
|
|
const title = screen.getByTestId("detail-panel-title");
|
|
|
|
|
expect(title.textContent).toContain("Story 42:");
|
|
|
|
|
// The content body should not contain an H1 repeating the type + title
|
|
|
|
|
const content = screen.getByTestId("detail-panel-content");
|
|
|
|
|
expect(content.querySelector("h1")).toBeNull();
|
2026-03-22 19:07:07 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("WorkItemDetailPanel - Agent Logs", () => {
|
|
|
|
|
it("shows placeholder when no agent is assigned to the story", async () => {
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
await screen.findByTestId("detail-panel-content");
|
|
|
|
|
const placeholder = screen.getByTestId("placeholder-agent-logs");
|
|
|
|
|
expect(placeholder).toBeInTheDocument();
|
|
|
|
|
expect(placeholder).toHaveTextContent("Coming soon");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows agent name and running status when agent is running", async () => {
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const statusBadge = await screen.findByTestId("agent-status-badge");
|
|
|
|
|
expect(statusBadge).toHaveTextContent("coder-1");
|
|
|
|
|
expect(statusBadge).toHaveTextContent("running");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows log output when agent emits output events", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("agent-status-badge");
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "output",
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "Writing tests...",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const logOutput = screen.getByTestId("agent-log-output");
|
|
|
|
|
expect(logOutput).toHaveTextContent("Writing tests...");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("appends multiple output events to the log", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("agent-status-badge");
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "output",
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "Line one\n",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "output",
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
text: "Line two\n",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const logOutput = screen.getByTestId("agent-log-output");
|
|
|
|
|
expect(logOutput.textContent).toContain("Line one");
|
|
|
|
|
expect(logOutput.textContent).toContain("Line two");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updates status to completed after done event", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("agent-status-badge");
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "done",
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
session_id: "session-123",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const statusBadge = screen.getByTestId("agent-status-badge");
|
|
|
|
|
expect(statusBadge).toHaveTextContent("completed");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows failed status after error event", async () => {
|
|
|
|
|
let emitEvent: ((e: AgentEvent) => void) | null = null;
|
|
|
|
|
mockedSubscribeAgentStream.mockImplementation(
|
|
|
|
|
(_storyId, _agentName, onEvent) => {
|
|
|
|
|
emitEvent = onEvent;
|
|
|
|
|
return () => {};
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("agent-status-badge");
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
emitEvent?.({
|
|
|
|
|
type: "error",
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
message: "Process failed",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const statusBadge = screen.getByTestId("agent-status-badge");
|
|
|
|
|
expect(statusBadge).toHaveTextContent("failed");
|
|
|
|
|
|
|
|
|
|
const logOutput = screen.getByTestId("agent-log-output");
|
|
|
|
|
expect(logOutput.textContent).toContain("[ERROR] Process failed");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows completed agent status without subscribing to stream", async () => {
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "completed",
|
|
|
|
|
session_id: "session-123",
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const statusBadge = await screen.findByTestId("agent-status-badge");
|
|
|
|
|
expect(statusBadge).toHaveTextContent("completed");
|
|
|
|
|
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows failed agent status for a failed agent without subscribing to stream", async () => {
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "failed",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: null,
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const statusBadge = await screen.findByTestId("agent-status-badge");
|
|
|
|
|
expect(statusBadge).toHaveTextContent("failed");
|
|
|
|
|
expect(mockedSubscribeAgentStream).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows agent logs section (not placeholder) when agent is assigned", async () => {
|
|
|
|
|
const agentList: AgentInfo[] = [
|
|
|
|
|
{
|
|
|
|
|
story_id: "42_story_test",
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
status: "running",
|
|
|
|
|
session_id: null,
|
|
|
|
|
worktree_path: "/tmp/wt",
|
|
|
|
|
base_branch: "master",
|
|
|
|
|
log_session_id: null,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
mockedListAgents.mockResolvedValue(agentList);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("agent-logs-section");
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
screen.queryByTestId("placeholder-agent-logs"),
|
|
|
|
|
).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("WorkItemDetailPanel - Assigned Agent", () => {
|
|
|
|
|
it("shows assigned agent name when agent front matter field is set", async () => {
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
...DEFAULT_CONTENT,
|
|
|
|
|
agent: "coder-opus",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="271_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
|
|
|
|
expect(agentEl).toHaveTextContent("coder-opus");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("omits assigned agent field when no agent is set in front matter", async () => {
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="271_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await screen.findByTestId("detail-panel-content");
|
|
|
|
|
expect(
|
|
|
|
|
screen.queryByTestId("detail-panel-assigned-agent"),
|
|
|
|
|
).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows the specific agent name not just 'assigned'", async () => {
|
|
|
|
|
mockedGetWorkItemContent.mockResolvedValue({
|
|
|
|
|
...DEFAULT_CONTENT,
|
|
|
|
|
agent: "coder-haiku",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="271_story_test"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
|
|
|
|
expect(agentEl).toHaveTextContent("coder-haiku");
|
|
|
|
|
expect(agentEl).not.toHaveTextContent("assigned");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("WorkItemDetailPanel - Test Results", () => {
|
|
|
|
|
it("shows empty test results message when no results exist", async () => {
|
|
|
|
|
mockedGetTestResults.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("test-results-empty")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText("No test results recorded")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows unit and integration test results when available", async () => {
|
|
|
|
|
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Unit test section
|
|
|
|
|
expect(screen.getByTestId("test-section-unit")).toBeInTheDocument();
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByText("Unit Tests (1 passed, 1 failed)"),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
// Integration test section
|
|
|
|
|
expect(screen.getByTestId("test-section-integration")).toBeInTheDocument();
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByText("Integration Tests (1 passed, 0 failed)"),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows pass/fail status and details for each test", async () => {
|
|
|
|
|
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("test-case-test_add")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Passing test
|
|
|
|
|
expect(screen.getByTestId("test-status-test_add")).toHaveTextContent(
|
|
|
|
|
"PASS",
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByText("test_add")).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
// Failing test with details
|
|
|
|
|
expect(screen.getByTestId("test-status-test_subtract")).toHaveTextContent(
|
|
|
|
|
"FAIL",
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByText("test_subtract")).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByTestId("test-details-test_subtract")).toHaveTextContent(
|
|
|
|
|
"expected 3, got 4",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Integration test
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByTestId("test-status-test_api_endpoint"),
|
|
|
|
|
).toHaveTextContent("PASS");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("re-fetches test results when pipelineVersion changes", async () => {
|
|
|
|
|
mockedGetTestResults.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedGetTestResults).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update with new results and bump pipelineVersion.
|
|
|
|
|
mockedGetTestResults.mockResolvedValue(sampleTestResults);
|
|
|
|
|
|
|
|
|
|
rerender(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={1}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedGetTestResults).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("test-results-content")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("WorkItemDetailPanel - Token Cost", () => {
|
|
|
|
|
const sampleTokenCost: TokenCostResponse = {
|
|
|
|
|
total_cost_usd: 0.012345,
|
|
|
|
|
agents: [
|
|
|
|
|
{
|
|
|
|
|
agent_name: "coder-1",
|
|
|
|
|
model: "claude-sonnet-4-6",
|
|
|
|
|
input_tokens: 1000,
|
|
|
|
|
output_tokens: 500,
|
|
|
|
|
cache_creation_input_tokens: 200,
|
|
|
|
|
cache_read_input_tokens: 100,
|
|
|
|
|
total_cost_usd: 0.009,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
agent_name: "coder-2",
|
|
|
|
|
model: null,
|
|
|
|
|
input_tokens: 800,
|
|
|
|
|
output_tokens: 300,
|
|
|
|
|
cache_creation_input_tokens: 0,
|
|
|
|
|
cache_read_input_tokens: 0,
|
|
|
|
|
total_cost_usd: 0.003345,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("shows empty state when no token data exists", async () => {
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("token-cost-empty")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText("No token data recorded")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows per-agent breakdown and total cost when data exists", async () => {
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId("token-cost-total")).toHaveTextContent(
|
|
|
|
|
"$0.012345",
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByTestId("token-cost-agent-coder-1")).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByTestId("token-cost-agent-coder-2")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows agent name and model when model is present", async () => {
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByTestId("token-cost-agent-coder-1"),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const agentRow = screen.getByTestId("token-cost-agent-coder-1");
|
|
|
|
|
expect(agentRow).toHaveTextContent("coder-1");
|
|
|
|
|
expect(agentRow).toHaveTextContent("claude-sonnet-4-6");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows agent name without model when model is null", async () => {
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByTestId("token-cost-agent-coder-2"),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const agentRow = screen.getByTestId("token-cost-agent-coder-2");
|
|
|
|
|
expect(agentRow).toHaveTextContent("coder-2");
|
|
|
|
|
expect(agentRow).not.toHaveTextContent("null");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("re-fetches token cost when pipelineVersion changes", async () => {
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
|
|
|
|
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={0}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedGetTokenCost).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
|
|
|
|
|
|
|
|
|
rerender(
|
|
|
|
|
<WorkItemDetailPanel
|
|
|
|
|
storyId="42_story_foo"
|
|
|
|
|
pipelineVersion={1}
|
|
|
|
|
onClose={() => {}}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedGetTokenCost).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|