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>
269 lines
9.2 KiB
TypeScript
269 lines
9.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { generateExportHtml } from "../../src/server/services/html-exporter.js";
|
|
import type { ExportRequest, ParsedMessage } from "../../src/shared/types.js";
|
|
|
|
function makeMessage(
|
|
overrides: Partial<ParsedMessage> & { uuid: string }
|
|
): ParsedMessage {
|
|
return {
|
|
category: "assistant_text",
|
|
content: "Test content",
|
|
rawIndex: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeExportRequest(
|
|
messages: ParsedMessage[],
|
|
visible?: string[],
|
|
redacted?: string[],
|
|
toolProgress?: Record<string, ParsedMessage[]>
|
|
): ExportRequest {
|
|
return {
|
|
session: {
|
|
id: "test-session",
|
|
project: "test-project",
|
|
messages,
|
|
toolProgress,
|
|
},
|
|
visibleMessageUuids: visible || messages.map((m) => m.uuid),
|
|
redactedMessageUuids: redacted || [],
|
|
};
|
|
}
|
|
|
|
describe("html-exporter", () => {
|
|
it("generates valid HTML with DOCTYPE", async () => {
|
|
const msg = makeMessage({ uuid: "msg-1", content: "Hello" });
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toMatch(/^<!DOCTYPE html>/);
|
|
expect(html).toContain("</html>");
|
|
});
|
|
|
|
it("contains no external URL references", async () => {
|
|
const msg = makeMessage({ uuid: "msg-1", content: "Hello" });
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
// No script src or link href pointing to external URLs
|
|
expect(html).not.toMatch(/<script\s+src\s*=/i);
|
|
expect(html).not.toMatch(/<link\s+.*href\s*=\s*"https?:\/\//i);
|
|
});
|
|
|
|
it("includes visible messages as HTML", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "msg-1",
|
|
content: "Hello **world**",
|
|
category: "assistant_text",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("<strong>world</strong>");
|
|
});
|
|
|
|
it("excludes filtered messages", async () => {
|
|
const msg1 = makeMessage({ uuid: "msg-1", content: "Visible" });
|
|
const msg2 = makeMessage({ uuid: "msg-2", content: "Invisible" });
|
|
const html = await generateExportHtml(
|
|
makeExportRequest([msg1, msg2], ["msg-1"], [])
|
|
);
|
|
expect(html).toContain("Visible");
|
|
expect(html).not.toContain("Invisible");
|
|
});
|
|
|
|
it("excludes redacted messages", async () => {
|
|
const msg1 = makeMessage({ uuid: "msg-1", content: "Kept" });
|
|
const msg2 = makeMessage({ uuid: "msg-2", content: "Redacted" });
|
|
const html = await generateExportHtml(
|
|
makeExportRequest([msg1, msg2], ["msg-1", "msg-2"], ["msg-2"])
|
|
);
|
|
expect(html).toContain("Kept");
|
|
expect(html).not.toContain("Redacted");
|
|
});
|
|
|
|
it("includes redacted divider between non-redacted messages where redaction occurred", async () => {
|
|
const msg1 = makeMessage({ uuid: "msg-1", content: "Before" });
|
|
const msg2 = makeMessage({ uuid: "msg-2", content: "Middle removed" });
|
|
const msg3 = makeMessage({ uuid: "msg-3", content: "After" });
|
|
const html = await generateExportHtml(
|
|
makeExportRequest(
|
|
[msg1, msg2, msg3],
|
|
["msg-1", "msg-2", "msg-3"],
|
|
["msg-2"]
|
|
)
|
|
);
|
|
expect(html).toContain("content redacted");
|
|
expect(html).toContain("Before");
|
|
expect(html).toContain("After");
|
|
expect(html).not.toContain("Middle removed");
|
|
});
|
|
|
|
it("renders markdown to HTML", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "msg-1",
|
|
content: "# Heading\n\n- item 1\n- item 2\n\n```js\nconst x = 1;\n```",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("<h1");
|
|
expect(html).toContain("<li>");
|
|
expect(html).toContain("<code");
|
|
});
|
|
|
|
it("includes syntax highlighting CSS", async () => {
|
|
const msg = makeMessage({ uuid: "msg-1", content: "```js\nconst x = 1;\n```" });
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain(".hljs");
|
|
});
|
|
|
|
it("includes session metadata header", async () => {
|
|
const msg = makeMessage({ uuid: "msg-1", content: "Hello" });
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("test-project");
|
|
expect(html).toContain("Session Export");
|
|
expect(html).toContain("1 message");
|
|
// Verify singular — should NOT contain "1 messages"
|
|
expect(html).not.toMatch(/\b1 messages\b/);
|
|
});
|
|
|
|
// ─── Collapsible messages ───
|
|
|
|
it("thinking messages render collapsed by default", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "think-1",
|
|
category: "thinking",
|
|
content: "Line one\nLine two\nLine three\nLine four\nLine five",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain('data-collapsed="true"');
|
|
});
|
|
|
|
it("tool_call messages render collapsed with tool name preview", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "tc-1",
|
|
category: "tool_call",
|
|
content: "",
|
|
toolName: "Read",
|
|
toolInput: '{"path": "/foo/bar.ts"}',
|
|
toolUseId: "tu-1",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain('data-collapsed="true"');
|
|
expect(html).toContain("collapsed-preview");
|
|
expect(html).toContain("Read");
|
|
});
|
|
|
|
it("tool_result messages render collapsed with content preview", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "tr-1",
|
|
category: "tool_result",
|
|
content: "This is the first line of a tool result that should be truncated in the preview display when collapsed",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain('data-collapsed="true"');
|
|
expect(html).toContain("collapsed-preview");
|
|
});
|
|
|
|
it("collapsible messages include toggle button", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "think-2",
|
|
category: "thinking",
|
|
content: "Some thoughts",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("collapsible-toggle");
|
|
});
|
|
|
|
it("non-collapsible messages do not have collapse attributes on their message div", async () => {
|
|
const msg = makeMessage({
|
|
uuid: "user-1",
|
|
category: "user_message",
|
|
content: "Hello there",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
// Extract the message div — it should NOT have data-collapsed (CSS will have it as a selector)
|
|
const messageDiv = html.match(/<div class="message"[^>]*>/);
|
|
expect(messageDiv).not.toBeNull();
|
|
expect(messageDiv![0]).not.toContain("data-collapsed");
|
|
expect(messageDiv![0]).not.toContain("collapsible-toggle");
|
|
});
|
|
|
|
it("export includes toggle JavaScript", async () => {
|
|
const msg = makeMessage({ uuid: "msg-js", content: "Hello" });
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("<script>");
|
|
expect(html).toContain("collapsible-toggle");
|
|
});
|
|
|
|
it("tool_result with diff content gets diff highlighting", async () => {
|
|
const diffContent = [
|
|
"diff --git a/foo.ts b/foo.ts",
|
|
"--- a/foo.ts",
|
|
"+++ b/foo.ts",
|
|
"@@ -1,3 +1,3 @@",
|
|
" unchanged",
|
|
"-old line",
|
|
"+new line",
|
|
].join("\n");
|
|
const msg = makeMessage({
|
|
uuid: "tr-diff",
|
|
category: "tool_result",
|
|
content: diffContent,
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("diff-add");
|
|
expect(html).toContain("diff-del");
|
|
expect(html).toContain("diff-hunk");
|
|
});
|
|
|
|
it("tool_call with progress events renders progress badge", async () => {
|
|
const toolMsg = makeMessage({
|
|
uuid: "tc-prog",
|
|
category: "tool_call",
|
|
content: "",
|
|
toolName: "Bash",
|
|
toolUseId: "tu-prog",
|
|
});
|
|
const progressEvents: ParsedMessage[] = [
|
|
makeMessage({
|
|
uuid: "pe-1",
|
|
category: "hook_progress",
|
|
content: "Running...",
|
|
progressSubtype: "bash",
|
|
timestamp: "2025-01-01T12:00:00Z",
|
|
}),
|
|
makeMessage({
|
|
uuid: "pe-2",
|
|
category: "hook_progress",
|
|
content: "Done",
|
|
progressSubtype: "bash",
|
|
timestamp: "2025-01-01T12:00:01Z",
|
|
}),
|
|
];
|
|
const html = await generateExportHtml(
|
|
makeExportRequest([toolMsg], undefined, undefined, { "tu-prog": progressEvents })
|
|
);
|
|
expect(html).toContain("progress-badge");
|
|
expect(html).toContain("progress-drawer");
|
|
expect(html).toContain("bash");
|
|
expect(html).toContain("2");
|
|
});
|
|
|
|
it("tool_call without progress events has no badge", async () => {
|
|
const toolMsg = makeMessage({
|
|
uuid: "tc-no-prog",
|
|
category: "tool_call",
|
|
content: "",
|
|
toolName: "Read",
|
|
toolUseId: "tu-no-prog",
|
|
});
|
|
const html = await generateExportHtml(makeExportRequest([toolMsg]));
|
|
// The CSS will contain .progress-badge as a selector, but the message HTML should not
|
|
// have an actual progress-badge div element
|
|
expect(html).not.toContain('<div class="progress-badge">');
|
|
});
|
|
|
|
it("print CSS forces content visible", async () => {
|
|
const msg = makeMessage({ uuid: "msg-print", content: "Hello" });
|
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
|
expect(html).toContain("@media print");
|
|
// Should override collapsed hidden state for print
|
|
expect(html).toMatch(/\.message-body\s*\{[^}]*display:\s*block/);
|
|
});
|
|
});
|