Visual overhaul of exported HTML to match the new client dark design:
- Replace category-specific CSS classes with inline border/dot/text styles
from a CATEGORY_STYLES map matching client-side colors
- Add message header layout with category dot, label, and timestamp
- Add Inter font family, refined prose typography, and proper code styling
- Add print-friendly media query
- Redesign redacted divider with SVG eye-slash icon and red accent
- Add SVG icons to session header metadata (project, date, message count)
- Fix singular/plural for '1 message' vs 'N messages'
Performance: Skip markdown parsing for hook_progress, tool_result, and
file_snapshot categories (structured data). Render as preformatted text
instead, avoiding expensive marked.parse() on large JSON blobs (~300ms each).
Replace local escapeHtml with shared/escape-html module. Add formatTimestamp
helper. Add cast safety comment for marked.parse() sync usage.
Update test to verify singular message count ('1 message' not '1 messages').
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
122 lines
4.2 KiB
TypeScript
122 lines
4.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[]
|
|
): ExportRequest {
|
|
return {
|
|
session: {
|
|
id: "test-session",
|
|
project: "test-project",
|
|
messages,
|
|
},
|
|
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/);
|
|
});
|
|
});
|