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 & { uuid: string } ): ParsedMessage { return { category: "assistant_text", content: "Test content", rawIndex: 0, ...overrides, }; } function makeExportRequest( messages: ParsedMessage[], visible?: string[], redacted?: string[], toolProgress?: Record ): 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(/^/); expect(html).toContain(""); }); 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(/ { const msg = makeMessage({ uuid: "msg-1", content: "Hello **world**", category: "assistant_text", }); const html = await generateExportHtml(makeExportRequest([msg])); expect(html).toContain("world"); }); 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(""); expect(html).toContain(" { 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(/
]*>/); 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("