// @vitest-environment happy-dom import { describe, expect, it, vi } from "vitest"; import { buildToolGroup, buildTranscriptRow, renderInlineMarkdown, renderMarkdown, } from "./structuredRender.js"; import type { TranscriptRow } from "./types.js"; function row( kind: K, data: unknown, id = "r1", ): TranscriptRow & { kind: K } { return { id, kind, data }; } describe("buildTranscriptRow: user/agent/thought", () => { it("renders a user row with the text content and no markdown", () => { const el = buildTranscriptRow( row("user", { content: { type: "text", text: "hi there" } }), ); expect(el.className).toContain("st-row-user"); expect(el.textContent).toContain("hi there"); expect(el.querySelector(".st-who")).toBeNull(); // never actual markup from user input expect(el.querySelector("b")).toBeNull(); }); it("renders agent prose as paragraphs with inline markdown", () => { const el = buildTranscriptRow( row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }), ); expect(el.className).toContain("st-row-agent"); const ps = el.querySelectorAll("p"); expect(ps).toHaveLength(2); expect(ps[0].innerHTML).toContain("two"); expect(ps[0].innerHTML).toContain("three"); }); it("trims ACP framing whitespace and renders thought-row inline markdown", () => { const el = buildTranscriptRow( row("agent_thought", { content: { type: "text", text: "\n\n**Planning comply tool commentary update**\n" }, }), ); expect(el.className).toContain("st-row-thought"); expect(el.textContent).toBe("Planning comply tool commentary update"); expect(el.querySelector("strong")?.textContent).toBe("Planning comply tool commentary update"); }); it("falls back to a labelled placeholder for non-text content, never crashing", () => { const el = buildTranscriptRow(row("user", { content: { type: "image" } })); expect(el.textContent).toContain("[image]"); }); it("never clips text: a very long user message is not truncated", () => { const long = "x".repeat(5000); const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } })); expect(el.textContent).toContain(long); }); it("copies message blocks on click and supports keyboard activation", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); const el = buildTranscriptRow( row("agent_text", { content: { type: "text", text: "copy **this** exactly" } }), ); expect(el.getAttribute("role")).toBe("button"); el.click(); await Promise.resolve(); expect(writeText).toHaveBeenCalledWith("copy **this** exactly"); el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); await Promise.resolve(); expect(writeText).toHaveBeenCalledTimes(2); }); }); describe("renderInlineMarkdown", () => { it("escapes raw HTML before applying markdown", () => { expect(renderInlineMarkdown("")).not.toContain("[bad](javascript:alert(3))', ); expect(html).not.toContain(" { it("shows glyph, name, argument and no meta for a completed non-diff call", () => { const el = buildTranscriptRow( row("tool_call", { toolCallId: "t1", title: "Read(src/lib.rs)", kind: "read", status: "completed", }), ); const head = el.querySelector(".st-tool-head") as HTMLButtonElement; expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read"); expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs"); expect(head.querySelector(".st-tool-meta")).toBeNull(); }); it("falls back to the kind label and full title when the title has no Name(arg) shape", () => { const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" })); const head = el.querySelector(".st-tool-head")!; expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute"); expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd"); }); it("shows a running badge for an in-progress call", () => { const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" })); const meta = el.querySelector(".st-tool-meta")!; expect(meta.textContent).toBe("running"); expect(meta.className).toContain("st-tool-meta-running"); }); it("shows a failed badge for a failed call", () => { const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" })); expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed"); }); it("starts collapsed and expands its text output on click", () => { const el = buildTranscriptRow( row("tool_call", { title: "Grep(x)", status: "completed", content: [{ type: "content", content: { type: "text", text: "match found" } }], }), ); const head = el.querySelector(".st-tool-head") as HTMLButtonElement; const out = el.querySelector(".st-tool-out") as HTMLElement; expect(out.hidden).toBe(true); expect(head.getAttribute("aria-expanded")).toBe("false"); head.click(); expect(out.hidden).toBe(false); expect(head.getAttribute("aria-expanded")).toBe("true"); expect(out.textContent).toContain("match found"); head.click(); expect(out.hidden).toBe(false); expect(out.getAttribute("aria-hidden")).toBe("true"); expect(out.inert).toBe(true); expect(out.classList.contains("is-expanded")).toBe(false); }); it("does not expand when there is nothing to show", () => { const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" })); const head = el.querySelector(".st-tool-head") as HTMLButtonElement; head.click(); expect(head.getAttribute("aria-expanded")).toBe("false"); }); it("renders command fields and keeps the exact JSON available", () => { const el = buildTranscriptRow( row("tool_call", { title: "Bash(test)", rawInput: { command: "printf ok", cwd: "/workspace", timeout_ms: 1000 }, rawOutput: { formatted_output: "ok", exit_code: 0 }, }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok"); expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0"); const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement; expect(raw.open).toBe(false); expect(raw.textContent).toContain('"timeout_ms": 1000'); expect(raw.textContent).toContain('"exit_code": 0'); }); it("renders MCP arguments and results without hiding the source payload", () => { const el = buildTranscriptRow( row("tool_call", { title: "mcp.github.search", rawInput: { server: "github", tool: "search", arguments: { query: "ACP" } }, rawOutput: { result: { count: 2 }, error: null }, }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); const labels = [ ...el.querySelectorAll( ".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail", ), ].map((node) => node.querySelector(".st-tool-detail-label")?.textContent); expect(labels).toEqual(["Arguments", "Result"]); expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"'); }); it("renders a recognized web search query", () => { const el = buildTranscriptRow( row("tool_call", { title: "Search the web", rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } }, }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query"); expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol"); }); it("falls back to raw JSON for an unknown provider shape", () => { const el = buildTranscriptRow( row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); expect( el.querySelectorAll( ".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail", ), ).toHaveLength(0); expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"'); expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"'); }); it("renders standardized content before provider-specific and raw data", () => { const el = buildTranscriptRow( row("tool_call", { title: "Bash(test)", content: [ { type: "content", content: { type: "text", text: "typed result" } }, { type: "content", content: { type: "resource_link", name: "report", uri: "file:///tmp/report" } }, { type: "terminal", terminalId: "term-1" }, ], rawInput: { command: "test" }, rawOutput: { formatted_output: "typed result", exit_code: 0 }, }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!; expect([...inner.children].map((node) => node.className)).toEqual([ "st-tool-text st-copyable", "st-tool-resource", "st-tool-terminal", "st-tool-detail", "st-tool-fact", "st-tool-raw", ]); expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report"); expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1"); }); it("renders safe typed image and audio blocks as media controls", () => { const el = buildTranscriptRow( row("tool_call", { title: "Media", content: [ { type: "content", content: { type: "image", mimeType: "image/png", data: "aW1hZ2U=" } }, { type: "content", content: { type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" } }, ], }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U="); expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8="); }); }); describe("buildToolGroup", () => { it("starts collapsed, then reveals independently expandable tool calls", () => { const rows = [ row("tool_call", { title: "Read(a.ts)", content: [{ type: "content", content: { type: "text", text: "a" } }] }, "t1"), row("tool_call", { title: "Bash(test)", content: [{ type: "content", content: { type: "text", text: "ok" } }] }, "t2"), ]; const children = rows.map((tool) => buildTranscriptRow(tool)); const onExpanded = vi.fn(); const group = buildToolGroup("turn:u1", rows, children, false, onExpanded); const head = group.querySelector(".st-tool-group-head")!; const body = group.querySelector(".st-tool-group-body")!; expect(head.textContent).toContain("2 tool calls"); expect(body.getAttribute("aria-hidden")).toBe("true"); expect(body.inert).toBe(true); head.click(); expect(body.getAttribute("aria-hidden")).toBe("false"); expect(body.classList.contains("is-expanded")).toBe(true); expect(onExpanded).toHaveBeenCalledWith(true); const toolHeads = group.querySelectorAll(".st-tool-head"); toolHeads[1].click(); expect(toolHeads[0].getAttribute("aria-expanded")).toBe("false"); expect(toolHeads[1].getAttribute("aria-expanded")).toBe("true"); }); }); describe("buildTranscriptRow: tool diffs", () => { it("shows +added -removed derived from the diff, and renders coloured hunks on expand", () => { const el = buildTranscriptRow( row("tool_diff", { title: "Edit(x.rs)", status: "completed", content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }], }), ); const meta = el.querySelector(".st-tool-meta")!; expect(meta.textContent).toContain("+1"); expect(meta.textContent).toContain("−1"); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); const diff = el.querySelector(".st-diff") as HTMLElement; expect(diff).not.toBeNull(); expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c"); expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b"); }); it("gives the diff its own horizontally-scrolling container", () => { const el = buildTranscriptRow( row("tool_diff", { title: "Edit(x.rs)", content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }], }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); // jsdom/happy-dom doesn't compute layout CSS, so assert the class the // stylesheet keys `overflow-x: auto` off, not a computed style. expect(el.querySelector(".st-diff")!.classList).toContain("st-diff"); }); it("renders a visible marker for a removed final newline", () => { const el = buildTranscriptRow( row("tool_diff", { title: "Edit(x.rs)", content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }], }), ); (el.querySelector(".st-tool-head") as HTMLButtonElement).click(); expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file"); }); }); describe("buildTranscriptRow: approval", () => { const approvalRow = (options: Array<{ optionId: string; name: string; kind: string }>) => row("approval", { sessionId: "s1", toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] }, options, }); const allThreeOptions = [ { optionId: "a1", name: "Allow once", kind: "allow_once" }, { optionId: "a2", name: "Allow always", kind: "allow_always" }, { optionId: "r1", name: "Reject once", kind: "reject_once" }, ]; it("shows the three decision buttons only when the request offers all three option kinds", () => { const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide: () => Promise.resolve(), }); const buttons = el.querySelectorAll(".st-approval-acts button"); expect(buttons).toHaveLength(3); expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x"); }); it("hides the buttons and shows an answered note once resolved", () => { const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: false, onDecide: () => Promise.resolve(), }); expect(el.querySelector(".st-approval-acts")).toBeNull(); expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered"); }); it("invokes onDecide with the clicked decision and disables the buttons", () => { const onDecide = vi.fn().mockResolvedValue(undefined); const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide }); const buttons = el.querySelectorAll(".st-approval-acts button"); buttons[2].click(); expect(onDecide).toHaveBeenCalledWith("deny"); expect(buttons[0].disabled).toBe(true); }); it("treats a row with no approval state as not pending, defensively", () => { const el = buildTranscriptRow(approvalRow(allThreeOptions)); expect(el.querySelector(".st-approval-acts")).toBeNull(); }); it("omits Allow for session when the request offers no allow_always option", () => { const el = buildTranscriptRow( approvalRow([ { optionId: "a1", name: "Allow once", kind: "allow_once" }, { optionId: "r1", name: "Reject once", kind: "reject_once" }, ]), { pending: true, onDecide: () => Promise.resolve() }, ); const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent); expect(labels).toEqual(["Allow once", "Deny"]); }); it("offers Deny for either reject_once or reject_always, never for neither", () => { const rejectAlwaysOnly = buildTranscriptRow( approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]), { pending: true, onDecide: () => Promise.resolve() }, ); expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([ "Deny", ]); }); it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => { const el = buildTranscriptRow(approvalRow([{ optionId: "x", name: "Something else", kind: "allow_once" }]), { pending: true, onDecide: () => Promise.resolve(), }); // allow_once is offered, so this specific case still shows a button — // use a kind the client doesn't map to any decision at all. expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1); const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() }); expect(none.querySelector(".st-approval-acts")).toBeNull(); expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered"); }); it("re-enables the buttons when onDecide rejects", async () => { const onDecide = vi.fn().mockRejectedValue(new Error("network down")); const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide }); const buttons = el.querySelectorAll(".st-approval-acts button"); buttons[0].click(); expect(buttons[0].disabled).toBe(true); await Promise.resolve().then(() => Promise.resolve()); expect(buttons[0].disabled).toBe(false); expect(buttons[1].disabled).toBe(false); }); it("leaves the buttons disabled when onDecide resolves", async () => { const onDecide = vi.fn().mockResolvedValue(undefined); const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide }); const buttons = el.querySelectorAll(".st-approval-acts button"); buttons[0].click(); await Promise.resolve().then(() => Promise.resolve()); expect(buttons[0].disabled).toBe(true); }); }); describe("buildTranscriptRow: notice / unknown kinds", () => { it("renders durable per-turn duration and context usage", () => { const el = buildTranscriptRow( row("notice", { maestroTurnMeta: { elapsedMs: 72_000, contextTokens: 18_400, contextDelta: 2_350, outcome: "completed", model: "opus", }, }, "turn:1"), )!; expect(el.classList.contains("st-turn-meta")).toBe(true); expect(el.textContent).toContain("1m 12s · +2.4k context tokens · opus"); expect(el.getAttribute("aria-label")).toBe("Copy turn details"); }); it("renders notice and unknown row kinds safely", () => { expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe( "[notice]", ); const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow; expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]"); }); it("omits command-catalog and session-info notices used only as UI state", () => { expect( buildTranscriptRow( row("notice", { sessionUpdate: "available_commands_update", availableCommands: [] }), ), ).toBeNull(); expect(buildTranscriptRow(row("notice", { sessionUpdate: "session_info_update" }))).toBeNull(); }); it("returns no transcript element for a plan row", () => { expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull(); }); });