Add comprehensive test suite for progress tracking system
Test fixture updates: - Add toolUseId fields (toolu_read1, toolu_edit1) to tool_use blocks - Add parentToolUseID-linked progress events for read and edit tools - Add orphaned SessionStart progress event (no parent) - Update tool_result references to match new toolUseId values - Add bash_progress and mcp_progress subtypes for subtype derivation session-parser tests (7 new): - toolUseId extraction from tool_use blocks with and without id field - parentToolUseId and progressSubtype extraction from hook_progress - Subtype derivation for bash_progress, mcp_progress, agent_progress - Fallback to "hook" for unknown data types - Undefined parentToolUseId when field is absent progress-grouper tests (7 new): - Partition parented progress into toolProgress map - Remove parented progress from filtered messages array - Keep orphaned progress (no parentToolUseId) in main stream - Keep progress with invalid parentToolUseId (no matching tool_call) - Empty input handling - Sort each group by rawIndex - Multiple tool_call parents tracked independently agent-progress-parser tests (full suite): - Parse user text events with prompt/agentId metadata extraction - Parse tool_use blocks into AgentToolCall events - Parse tool_result blocks with content extraction - Parse text content as text_response with line counting - Handle multiple content blocks in single turn - Post-pass tool_result→tool_call linking (sourceTool, language) - Empty input and malformed JSON → raw_content fallback - stripLineNumbers for cat-n prefixed output - summarizeToolCall for Read, Grep, Glob, Bash, Task, WarpGrep, etc. ProgressBadge component tests: - Collapsed state shows pill counts, hides content - Expanded state shows all event content via markdown - Subtype counting accuracy - Agent-only events route to AgentProgressView AgentProgressView component tests: - Prompt banner rendering with truncation - Agent ID and turn count display - Summary rows with timestamps and tool names - Click-to-expand drill-down content html-exporter tests (8 new): - Collapsible rendering for thinking, tool_call, tool_result - Toggle button and JavaScript inclusion - Non-collapsible messages lack collapse attributes - Diff content detection and highlighting - Progress badge rendering with toolProgress data filters tests (2 new): - hook_progress included/excluded by category toggle Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
254
src/client/components/AgentProgressView.test.tsx
Normal file
254
src/client/components/AgentProgressView.test.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { AgentProgressView } from "./AgentProgressView";
|
||||
import type { ParsedMessage } from "../lib/types";
|
||||
|
||||
// Mock markdown renderer to avoid pulling in marked/hljs in jsdom
|
||||
vi.mock("../lib/markdown", () => ({
|
||||
renderMarkdown: (text: string) => `<p>${text}</p>`,
|
||||
}));
|
||||
|
||||
/** Build a ParsedMessage whose content is a JSON agent_progress payload */
|
||||
function makeAgentProgressEvent(
|
||||
messageOverrides: Record<string, unknown> = {},
|
||||
dataOverrides: Record<string, unknown> = {},
|
||||
msgOverrides: Partial<ParsedMessage> = {}
|
||||
): ParsedMessage {
|
||||
const data = {
|
||||
message: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_abc",
|
||||
name: "Read",
|
||||
input: { file_path: "/src/foo.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: "2026-01-30T16:22:21.000Z",
|
||||
...messageOverrides,
|
||||
},
|
||||
normalizedMessages: [],
|
||||
type: "agent_progress",
|
||||
prompt: "Explore the codebase",
|
||||
agentId: "a6945d4",
|
||||
...dataOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
category: "hook_progress",
|
||||
content: JSON.stringify(data),
|
||||
rawIndex: 0,
|
||||
timestamp: "2026-01-30T16:22:21.000Z",
|
||||
progressSubtype: "agent",
|
||||
...msgOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentProgressView", () => {
|
||||
it("renders prompt banner with truncated prompt text", () => {
|
||||
const events = [makeAgentProgressEvent()];
|
||||
const { getByTestId } = render(<AgentProgressView events={events} />);
|
||||
|
||||
const prompt = getByTestId("agent-prompt");
|
||||
expect(prompt.textContent).toContain("Explore the codebase");
|
||||
});
|
||||
|
||||
it("renders agent ID and turn count in header", () => {
|
||||
const events = [makeAgentProgressEvent()];
|
||||
const { getByTestId } = render(<AgentProgressView events={events} />);
|
||||
|
||||
const view = getByTestId("agent-progress-view");
|
||||
expect(view.textContent).toContain("a6945d4");
|
||||
expect(view.textContent).toContain("1 turn");
|
||||
});
|
||||
|
||||
it("renders summary rows with timestamps and tool names", () => {
|
||||
const events = [makeAgentProgressEvent()];
|
||||
const { getAllByTestId } = render(<AgentProgressView events={events} />);
|
||||
|
||||
const rows = getAllByTestId("agent-event-row");
|
||||
expect(rows.length).toBe(1);
|
||||
// Should contain tool name "Read" and the file path
|
||||
expect(rows[0].textContent).toContain("Read");
|
||||
expect(rows[0].textContent).toContain("src/foo.ts");
|
||||
});
|
||||
|
||||
it("summary row for Read shows file path", () => {
|
||||
const events = [
|
||||
makeAgentProgressEvent({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Read",
|
||||
input: { file_path: "/src/components/App.tsx" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const { getAllByTestId } = render(<AgentProgressView events={events} />);
|
||||
const rows = getAllByTestId("agent-event-row");
|
||||
expect(rows[0].textContent).toContain("src/components/App.tsx");
|
||||
});
|
||||
|
||||
it("summary row for text response shows line count", () => {
|
||||
const events = [
|
||||
makeAgentProgressEvent({
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Line 1\nLine 2\nLine 3" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const { getAllByTestId } = render(<AgentProgressView events={events} />);
|
||||
const rows = getAllByTestId("agent-event-row");
|
||||
expect(rows[0].textContent).toContain("3 lines");
|
||||
});
|
||||
|
||||
it("clicking a summary row expands drill-down panel", () => {
|
||||
const events = [makeAgentProgressEvent()];
|
||||
const { getAllByTestId, queryByTestId } = render(
|
||||
<AgentProgressView events={events} />
|
||||
);
|
||||
|
||||
// Initially no drill-down
|
||||
expect(queryByTestId("agent-drilldown")).toBeNull();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||
expect(queryByTestId("agent-drilldown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drill-down shows pretty-printed tool input JSON", () => {
|
||||
const events = [makeAgentProgressEvent()];
|
||||
const { getAllByTestId, getByTestId } = render(
|
||||
<AgentProgressView events={events} />
|
||||
);
|
||||
|
||||
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||
const drilldown = getByTestId("agent-drilldown");
|
||||
expect(drilldown.textContent).toContain("file_path");
|
||||
expect(drilldown.textContent).toContain("/src/foo.ts");
|
||||
});
|
||||
|
||||
it("drill-down shows full tool result content", () => {
|
||||
const events = [
|
||||
makeAgentProgressEvent({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_abc",
|
||||
content: "Full file contents here",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const { getAllByTestId, getByTestId } = render(
|
||||
<AgentProgressView events={events} />
|
||||
);
|
||||
|
||||
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||
const drilldown = getByTestId("agent-drilldown");
|
||||
expect(drilldown.textContent).toContain("Full file contents here");
|
||||
});
|
||||
|
||||
it("clicking expanded row collapses it", () => {
|
||||
const events = [makeAgentProgressEvent()];
|
||||
const { getAllByTestId, queryByTestId } = render(
|
||||
<AgentProgressView events={events} />
|
||||
);
|
||||
|
||||
// Expand
|
||||
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||
expect(queryByTestId("agent-drilldown")).toBeInTheDocument();
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||
expect(queryByTestId("agent-drilldown")).toBeNull();
|
||||
});
|
||||
|
||||
it("only one drill-down open at a time (accordion behavior)", () => {
|
||||
const events = [
|
||||
makeAgentProgressEvent(
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ uuid: "ev-1" }
|
||||
),
|
||||
makeAgentProgressEvent(
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "t2", name: "Grep", input: { pattern: "foo" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ uuid: "ev-2" }
|
||||
),
|
||||
];
|
||||
const { getAllByTestId } = render(
|
||||
<AgentProgressView events={events} />
|
||||
);
|
||||
|
||||
const rows = getAllByTestId("agent-event-row");
|
||||
|
||||
// Expand first
|
||||
fireEvent.click(rows[0]);
|
||||
let drilldowns = getAllByTestId("agent-drilldown");
|
||||
expect(drilldowns.length).toBe(1);
|
||||
|
||||
// Click second - should close first and open second
|
||||
fireEvent.click(rows[1]);
|
||||
drilldowns = getAllByTestId("agent-drilldown");
|
||||
expect(drilldowns.length).toBe(1);
|
||||
// Second drill-down should show Grep pattern
|
||||
expect(drilldowns[0].textContent).toContain("foo");
|
||||
});
|
||||
|
||||
it("handles events with missing timestamps gracefully", () => {
|
||||
const events = [
|
||||
makeAgentProgressEvent(
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
|
||||
],
|
||||
},
|
||||
timestamp: undefined,
|
||||
},
|
||||
{},
|
||||
{ timestamp: undefined }
|
||||
),
|
||||
];
|
||||
// Should render without crashing
|
||||
const { getByTestId } = render(<AgentProgressView events={events} />);
|
||||
expect(getByTestId("agent-progress-view")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
240
src/client/components/ProgressBadge.test.tsx
Normal file
240
src/client/components/ProgressBadge.test.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { ProgressBadge } from "./ProgressBadge";
|
||||
import type { ParsedMessage } from "../lib/types";
|
||||
|
||||
// Mock the markdown module to avoid pulling in marked/hljs in jsdom
|
||||
vi.mock("../lib/markdown", () => ({
|
||||
renderMarkdown: (text: string) => `<p>${text}</p>`,
|
||||
}));
|
||||
|
||||
function makeEvent(
|
||||
overrides: Partial<ParsedMessage> = {}
|
||||
): ParsedMessage {
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
category: "hook_progress",
|
||||
content: "Running pre-commit hook",
|
||||
rawIndex: 0,
|
||||
timestamp: "2025-01-15T10:30:00Z",
|
||||
progressSubtype: "hook",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProgressBadge", () => {
|
||||
describe("collapsed state", () => {
|
||||
it("shows pill counts but hides event content", () => {
|
||||
const events = [
|
||||
makeEvent({ progressSubtype: "hook" }),
|
||||
makeEvent({ progressSubtype: "hook" }),
|
||||
makeEvent({ progressSubtype: "bash" }),
|
||||
];
|
||||
const { container, queryByText } = render(
|
||||
<ProgressBadge events={events} />
|
||||
);
|
||||
|
||||
// Pill counts visible
|
||||
expect(container.textContent).toContain("hook: 2");
|
||||
expect(container.textContent).toContain("bash: 1");
|
||||
|
||||
// Event content should NOT be visible when collapsed
|
||||
expect(queryByText("Running pre-commit hook")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expanded state", () => {
|
||||
it("shows all event content when clicked", () => {
|
||||
const events = [
|
||||
makeEvent({ content: "First event" }),
|
||||
makeEvent({ content: "Second event" }),
|
||||
];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
// Content should be visible (rendered as markdown via mock)
|
||||
expect(container.innerHTML).toContain("First event");
|
||||
expect(container.innerHTML).toContain("Second event");
|
||||
});
|
||||
|
||||
it("renders content through markdown into prose-message-progress container", () => {
|
||||
const events = [
|
||||
makeEvent({ content: "**bold text**" }),
|
||||
];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
// Expand
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
// Our mock wraps in <p>, so the prose container should have rendered HTML
|
||||
const proseEl = container.querySelector(".prose-message-progress");
|
||||
expect(proseEl).toBeInTheDocument();
|
||||
// Content is from local JSONL session files owned by the user,
|
||||
// same trust model as MessageBubble's markdown rendering
|
||||
expect(proseEl?.innerHTML).toContain("<p>**bold text**</p>");
|
||||
});
|
||||
|
||||
it("does not have max-h-48 or overflow-y-auto on expanded container", () => {
|
||||
const events = [makeEvent()];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
const expandedDiv = container.querySelector("[data-testid='progress-expanded']");
|
||||
expect(expandedDiv).toBeInTheDocument();
|
||||
expect(expandedDiv?.className).not.toContain("max-h-48");
|
||||
expect(expandedDiv?.className).not.toContain("overflow-y-auto");
|
||||
});
|
||||
|
||||
it("does not have truncate class on content", () => {
|
||||
const events = [makeEvent()];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
// Generic (non-agent) expanded view should not truncate
|
||||
const proseElements = container.querySelectorAll(".prose-message-progress");
|
||||
for (const el of proseElements) {
|
||||
expect(el.className).not.toContain("truncate");
|
||||
}
|
||||
});
|
||||
|
||||
it("displays timestamps and subtype badges per event", () => {
|
||||
const events = [
|
||||
makeEvent({
|
||||
timestamp: "2025-01-15T10:30:00Z",
|
||||
progressSubtype: "bash",
|
||||
content: "npm test",
|
||||
}),
|
||||
];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
// Subtype badge visible
|
||||
expect(container.textContent).toContain("bash");
|
||||
// Timestamp visible (formatted by toLocaleTimeString)
|
||||
const expandedDiv = container.querySelector("[data-testid='progress-expanded']");
|
||||
expect(expandedDiv).toBeInTheDocument();
|
||||
// The timestamp text should exist somewhere in the expanded area
|
||||
expect(expandedDiv?.textContent).toMatch(/\d{1,2}:\d{2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty events array", () => {
|
||||
const { container } = render(<ProgressBadge events={[]} />);
|
||||
// Should render without crashing, no pills
|
||||
expect(container.querySelector("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles missing timestamps", () => {
|
||||
const events = [makeEvent({ timestamp: undefined })];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
expect(container.textContent).toContain("--:--:--");
|
||||
});
|
||||
|
||||
it("defaults undefined subtype to hook", () => {
|
||||
const events = [makeEvent({ progressSubtype: undefined })];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
// In pill counts
|
||||
expect(container.textContent).toContain("hook: 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent subtype delegation", () => {
|
||||
function makeAgentEvent(
|
||||
overrides: Partial<ParsedMessage> = {}
|
||||
): ParsedMessage {
|
||||
const data = {
|
||||
message: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_abc",
|
||||
name: "Read",
|
||||
input: { file_path: "/src/foo.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: "2026-01-30T16:22:21.000Z",
|
||||
},
|
||||
type: "agent_progress",
|
||||
prompt: "Explore the codebase",
|
||||
agentId: "a6945d4",
|
||||
};
|
||||
|
||||
return {
|
||||
uuid: crypto.randomUUID(),
|
||||
category: "hook_progress",
|
||||
content: JSON.stringify(data),
|
||||
rawIndex: 0,
|
||||
timestamp: "2026-01-30T16:22:21.000Z",
|
||||
progressSubtype: "agent",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders AgentProgressView when all events are agent subtype", () => {
|
||||
const events = [makeAgentEvent(), makeAgentEvent()];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
// Expand
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
// Should render AgentProgressView
|
||||
expect(
|
||||
container.querySelector("[data-testid='agent-progress-view']")
|
||||
).toBeInTheDocument();
|
||||
// Should NOT render generic prose-message-progress
|
||||
expect(
|
||||
container.querySelector(".prose-message-progress")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders generic list when events are mixed subtypes", () => {
|
||||
const events = [
|
||||
makeAgentEvent(),
|
||||
makeEvent({ progressSubtype: "hook" }),
|
||||
];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
// Expand
|
||||
fireEvent.click(container.querySelector("button")!);
|
||||
|
||||
// Should NOT render AgentProgressView
|
||||
expect(
|
||||
container.querySelector("[data-testid='agent-progress-view']")
|
||||
).toBeNull();
|
||||
// Should render generic view
|
||||
expect(
|
||||
container.querySelector(".prose-message-progress")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pills and collapsed state unchanged regardless of subtype", () => {
|
||||
const events = [makeAgentEvent(), makeAgentEvent(), makeAgentEvent()];
|
||||
const { container } = render(<ProgressBadge events={events} />);
|
||||
|
||||
// Pills show agent count
|
||||
expect(container.textContent).toContain("agent: 3");
|
||||
|
||||
// No expanded content initially
|
||||
expect(
|
||||
container.querySelector("[data-testid='progress-expanded']")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user