Download src/auto-reply/reply/export-html/template.security.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 32.2 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/auto-reply/reply/export-html/template.security.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/auto-reply/reply/export-html/template.security.test.ts
-
curl -L -o template.security.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/auto-reply/reply/export-html/template.security.test.ts
32.2 kB
| // Tests exported HTML transcript escaping and template safety. | |
| import { expectDefined } from "@openclaw/normalization-core"; | |
| import { describe, expect, it } from "vitest"; | |
| import { | |
| now, | |
| renderTemplate, | |
| requireElement, | |
| type SessionData, | |
| type SessionEntry, | |
| templateCss, | |
| templateHtml, | |
| } from "../../../../test/helpers/export-html-template.js"; | |
| function selectorSpecificity(selector: string): [number, number, number] { | |
| const ids = selector.match(/#[\w-]+/g)?.length ?? 0; | |
| const classes = selector.match(/\.[\w-]+/g)?.length ?? 0; | |
| const withoutIdsOrClasses = selector.replace(/#[\w-]+|\.[\w-]+/g, " "); | |
| let elements = 0; | |
| for (const part of withoutIdsOrClasses.split(/[\s>+~]+/)) { | |
| if (/^[a-z][\w-]*$/i.test(part)) { | |
| elements++; | |
| } | |
| } | |
| return [ids, classes, elements]; | |
| } | |
| function compareSpecificity(left: [number, number, number], right: [number, number, number]) { | |
| for (let index = 0; index < left.length; index += 1) { | |
| if (left[index] !== right[index]) { | |
| return expectDefined(left[index], "left[index] test invariant") - expectDefined(right[index], "right[index] test invariant"); | |
| } | |
| } | |
| return 0; | |
| } | |
| function firstSelectorForDisplay(css: string, display: string, startAt: number): string | null { | |
| const displayRule = new RegExp(`([^{}]+)\\{[^{}]*\\bdisplay\\s*:\\s*${display}\\s*;`, "g"); | |
| displayRule.lastIndex = startAt; | |
| const match = displayRule.exec(css); | |
| return match?.[1]?.split(",").at(-1)?.trim() ?? null; | |
| } | |
| describe("export html sidebar trigger affordance", () => { | |
| it("keeps the hamburger sidebar trigger accessible and visibly interactive", () => { | |
| expect(templateHtml).toContain('id="hamburger" class="sidebar-menu-trigger"'); | |
| expect(templateHtml).toContain('aria-label="Open sidebar"'); | |
| expect(templateHtml).toContain('<line x1="4" x2="20" y1="6" y2="6" />'); | |
| expect(templateHtml).toContain('<line x1="4" x2="20" y1="12" y2="12" />'); | |
| expect(templateHtml).toContain('<line x1="4" x2="20" y1="18" y2="18" />'); | |
| expect(templateCss).toContain("#hamburger.sidebar-menu-trigger {"); | |
| expect(templateCss).toContain("cursor: pointer;"); | |
| expect(templateCss).toContain("#hamburger.sidebar-menu-trigger:hover {"); | |
| expect(templateCss).toContain("background: var(--container-bg);"); | |
| expect(templateCss).toContain("#hamburger.sidebar-menu-trigger:focus-visible {"); | |
| }); | |
| it("lets the mobile hamburger display rule win the CSS cascade", () => { | |
| const baseSelector = "#hamburger.sidebar-menu-trigger"; | |
| const mobileMediaIndex = templateCss.indexOf("@media (max-width: 900px)"); | |
| const mobileSelector = firstSelectorForDisplay(templateCss, "inline-flex", mobileMediaIndex); | |
| expect(mobileMediaIndex).toBeGreaterThan(templateCss.indexOf(`${baseSelector} {`)); | |
| expect(mobileSelector).toBe(baseSelector); | |
| if (!mobileSelector) { | |
| throw new Error("Missing mobile hamburger display rule"); | |
| } | |
| expect( | |
| compareSpecificity(selectorSpecificity(mobileSelector), selectorSpecificity(baseSelector)), | |
| ).toBeGreaterThanOrEqual(0); | |
| }); | |
| }); | |
| describe("export html security hardening", () => { | |
| it.each(["consult", "answer"])("honors hidden input with %s selected while preserving raw export", async (leafId) => { | |
| function message(id: string, parentId: string | null, content: string, role = "assistant") { | |
| return { | |
| id, | |
| parentId, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role, content }, | |
| }; | |
| } | |
| const session: SessionData = { | |
| header: { id: "session-hidden-input", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "hidden-root", | |
| parentId: "hidden-root", | |
| timestamp: now(), | |
| type: "custom_message", | |
| customType: "context", | |
| content: "Hidden root context", | |
| display: false, | |
| }, | |
| message("speech", "hidden-root", "Explain the change. Context: Spoken style:", "user"), | |
| { | |
| id: "consult", | |
| parentId: "speech", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "user", | |
| content: "Synthetic consultation instructions", | |
| display: false, | |
| provenance: { kind: "internal_system", sourceTool: "openclaw_agent_consult" }, | |
| }, | |
| }, | |
| message("answer", "consult", "The change is ready."), | |
| message("alternative", "speech", "An alternative reply."), | |
| message("other-root", null, "Another conversation."), | |
| ], | |
| leafId, | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document, downloadJson } = await renderTemplate(session); | |
| expect(document.querySelectorAll(".user-message")).toHaveLength(1); | |
| expect(document.getElementById("entry-speech")?.textContent).toContain( | |
| "Explain the change. Context: Spoken style:", | |
| ); | |
| expect(document.getElementById("entry-consult")).toBeNull(); | |
| expect(document.getElementById("entry-hidden-root")).toBeNull(); | |
| const treeIds = () => Array.from(document.querySelectorAll(".tree-node"), (node) => node.getAttribute("data-id")); | |
| const treePrefixes = () => | |
| Array.from(document.querySelectorAll(".tree-prefix"), (node) => node.textContent); | |
| expect(treeIds()).toEqual(["speech", "answer", "alternative", "other-root"]); | |
| expect(treePrefixes()).toEqual(["", "├─ ", "└─ ", ""]); | |
| const selectFilter = (filter: string) => requireElement( | |
| document.querySelector<HTMLButtonElement>(`[data-filter="${filter}"]`), "filter missing", | |
| ).click(); | |
| selectFilter("all"); | |
| expect(treeIds()).toEqual([ | |
| "hidden-root", | |
| "speech", | |
| "consult", | |
| "answer", | |
| "alternative", | |
| "other-root", | |
| ]); | |
| expect(treePrefixes()).toEqual(["", " ", " ├─ ", " │ ", " └─ ", ""]); | |
| expect(document.querySelector('[data-id="consult"]')?.textContent).toContain("[hidden] user:"); | |
| requireElement(document.querySelector<HTMLElement>('[data-id="consult"]'), "hidden tree entry missing").click(); | |
| expect(document.getElementById("entry-answer")?.textContent).toContain("The change is ready."); | |
| expect(document.getElementById("entry-consult")).toBeNull(); | |
| selectFilter("user-only"); | |
| expect(treeIds()).not.toContain("consult"); | |
| selectFilter("default"); | |
| expect(treeIds()).toEqual(["speech", "answer", "alternative", "other-root"]); | |
| expect(treePrefixes()).toEqual(["", "├─ ", "└─ ", ""]); | |
| expect(document.getElementById("header-container")?.textContent).toContain("2 user, 3 assistant, 1 custom"); | |
| const encoded = requireElement(document.getElementById("session-data"), "session data missing"); | |
| expect(JSON.parse(Buffer.from(encoded.textContent ?? "", "base64").toString("utf8"))).toEqual( | |
| session, | |
| ); | |
| expect((await downloadJson()).split("\n").map((line) => JSON.parse(line))).toEqual([ | |
| { type: "header", ...session.header }, ...session.entries, | |
| ]); | |
| }); | |
| it("renders export warnings in the header without interpreting HTML", async () => { | |
| const warning = 'Backend <img src=x onerror="alert(1)"> transcript is incomplete'; | |
| const session: SessionData = { | |
| header: { id: "session-warning", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "hello" }, | |
| }, | |
| ], | |
| leafId: "1", | |
| systemPrompt: "", | |
| tools: [], | |
| warning, | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const header = requireElement( | |
| document.getElementById("header-container"), | |
| "header root missing", | |
| ); | |
| const warningNode = requireElement( | |
| header.querySelector(".export-warning"), | |
| "export warning missing", | |
| ); | |
| expect(warningNode.textContent).toBe(warning); | |
| expect(warningNode.querySelector("img[onerror]")).toBeNull(); | |
| expect(warningNode.innerHTML).toContain( | |
| 'Backend <img src=x onerror="alert(1)"> transcript is incomplete', | |
| ); | |
| }); | |
| it("renders an explicitly selected empty branch without inactive messages", async () => { | |
| const session: SessionData = { | |
| header: { id: "session-empty", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "inactive-tail", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "assistant", content: "inactive history" }, | |
| }, | |
| { | |
| id: "empty-leaf", | |
| parentId: "inactive-tail", | |
| timestamp: now(), | |
| type: "leaf", | |
| targetId: null, | |
| }, | |
| ], | |
| leafId: null, | |
| hasLeafControl: true, | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| expect(messages.textContent).not.toContain("inactive history"); | |
| }); | |
| it("escapes raw HTML from markdown blocks", async () => { | |
| const attack = "<img src=x onerror=alert(1)>"; | |
| const session: SessionData = { | |
| header: { id: "session-1", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: attack }, | |
| }, | |
| { | |
| id: "2", | |
| parentId: "1", | |
| timestamp: now(), | |
| type: "branch_summary", | |
| summary: attack, | |
| }, | |
| { | |
| id: "3", | |
| parentId: "2", | |
| timestamp: now(), | |
| type: "custom_message", | |
| customType: "x", | |
| display: true, | |
| content: attack, | |
| }, | |
| ], | |
| leafId: "3", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| expect(messages.querySelector("img[onerror]")).toBeNull(); | |
| expect(messages.innerHTML).toContain("<img src=x onerror=alert(1)>"); | |
| }); | |
| it("renders Markdown and TypeScript highlighting without external assets", async () => { | |
| const session: SessionData = { | |
| header: { id: "session-render", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: "**rendered**\n\n```typescript\nconst answer = true;\n```", | |
| }, | |
| }, | |
| ], | |
| leafId: "1", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| expect(messages.querySelector("strong")?.textContent).toBe("rendered"); | |
| const code = requireElement(messages.querySelector("pre code.hljs"), "highlighted code missing"); | |
| expect(code.querySelector(".hljs-keyword")?.textContent).toBe("const"); | |
| expect(code.textContent).toContain("answer = true"); | |
| }); | |
| it.each(["user", "assistant"] as const)( | |
| "renders tight-list inline formatting in %s transcript exports", | |
| async (role) => { | |
| const inline = | |
| "**Important**: read [the guide](https://example.test/guide) and use `config.json`."; | |
| const literal = "**literal** [not a link](https://example.test/literal)"; | |
| const session: SessionData = { | |
| header: { id: "session-tight-list", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "tight-list", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role, | |
| content: [ | |
| `Paragraph control: ${inline}`, | |
| "", | |
| `- ${inline}`, | |
| "- Plain item.", | |
| "", | |
| "`" + literal + "`", | |
| ].join("\n"), | |
| }, | |
| }, | |
| ], | |
| leafId: "tight-list", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const entry = requireElement(document.getElementById("entry-tight-list"), "entry missing"); | |
| const paragraph = requireElement(entry.querySelector("p"), "paragraph control missing"); | |
| expect(paragraph.querySelector("strong")?.textContent).toBe("Important"); | |
| expect(paragraph.querySelector("a")?.getAttribute("href")).toBe("https://example.test/guide"); | |
| expect(paragraph.querySelector("code")?.textContent).toBe("config.json"); | |
| const list = requireElement(entry.querySelector("ul"), "tight list missing"); | |
| expect(list.querySelectorAll("li")).toHaveLength(2); | |
| const item = requireElement(list.querySelector("li"), "list item missing"); | |
| expect(item.querySelector("strong")?.textContent).toBe("Important"); | |
| const link = requireElement(item.querySelector("a"), "list link missing"); | |
| expect(link.getAttribute("href")).toBe("https://example.test/guide"); | |
| expect(link.textContent).toBe("the guide"); | |
| expect(item.querySelector("code")?.textContent).toBe("config.json"); | |
| expect(item.textContent?.trim()).toBe("Important: read the guide and use config.json."); | |
| const literalParagraph = requireElement( | |
| Array.from(entry.querySelectorAll("p")).find( | |
| (candidate) => candidate.querySelector("code")?.textContent === literal, | |
| ) ?? null, | |
| "literal code control missing", | |
| ); | |
| expect(literalParagraph.textContent).toBe(literal); | |
| expect(literalParagraph.querySelector("a, strong")).toBeNull(); | |
| }, | |
| ); | |
| it("escapes tree and header metadata fields", async () => { | |
| const attack = "<img src=x onerror=alert(9)>"; | |
| const baseEntries: SessionEntry[] = [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "ok" }, | |
| }, | |
| { | |
| id: "2", | |
| parentId: "1", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| model: attack, | |
| provider: "p", | |
| content: [{ type: "text", text: "assistant" }], | |
| }, | |
| }, | |
| { | |
| id: "3", | |
| parentId: "2", | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "toolResult", toolName: attack }, | |
| }, | |
| { | |
| id: "4", | |
| parentId: "3", | |
| timestamp: now(), | |
| type: "model_change", | |
| provider: "p", | |
| modelId: attack, | |
| }, | |
| { | |
| id: "5", | |
| parentId: "4", | |
| timestamp: now(), | |
| type: "thinking_level_change", | |
| thinkingLevel: attack, | |
| }, | |
| { | |
| id: "6", | |
| parentId: "5", | |
| timestamp: now(), | |
| type: attack, | |
| }, | |
| ]; | |
| const headerSession: SessionData = { | |
| header: { id: "session-2", timestamp: now() }, | |
| entries: baseEntries, | |
| leafId: "6", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(headerSession); | |
| const tree = requireElement(document.getElementById("tree-container"), "tree root missing"); | |
| const header = requireElement( | |
| document.getElementById("header-container"), | |
| "header root missing", | |
| ); | |
| expect(tree.querySelector("img[onerror]")).toBeNull(); | |
| expect(header.querySelector("img[onerror]")).toBeNull(); | |
| expect(tree.innerHTML).toContain("<img src=x onerror=alert(9)>"); | |
| expect(header.innerHTML).toContain("<img src=x onerror=alert(9)>"); | |
| const modelLeafSession: SessionData = { | |
| header: { id: "session-2-model", timestamp: now() }, | |
| entries: baseEntries, | |
| leafId: "4", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const modelLeaf = (await renderTemplate(modelLeafSession)).document; | |
| expect(modelLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull(); | |
| expect(modelLeaf.getElementById("tree-container")?.innerHTML).toContain( | |
| "<img src=x onerror=alert(9)>", | |
| ); | |
| const thinkingLeafSession: SessionData = { | |
| header: { id: "session-2-thinking", timestamp: now() }, | |
| entries: baseEntries, | |
| leafId: "5", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const thinkingLeaf = (await renderTemplate(thinkingLeafSession)).document; | |
| expect(thinkingLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull(); | |
| expect(thinkingLeaf.getElementById("tree-container")?.innerHTML).toContain( | |
| "<img src=x onerror=alert(9)>", | |
| ); | |
| }); | |
| it("sanitizes image MIME types used in data URLs", async () => { | |
| const session: SessionData = { | |
| header: { id: "session-3", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "user", | |
| content: [ | |
| { | |
| type: "image", | |
| data: "AAAA", | |
| mimeType: 'image/png" onerror="alert(7)', | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| leafId: "1", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const img = requireElement( | |
| document.querySelector("#messages .message-image"), | |
| "message image missing", | |
| ); | |
| expect(img.getAttribute("onerror")).toBeNull(); | |
| expect(img.getAttribute("src")).toBe("data:application/octet-stream;base64,AAAA"); | |
| }); | |
| it("flattens remote markdown images but keeps data-image markdown", async () => { | |
| const dataImage = "data:image/png;base64,AAAA"; | |
| const session: SessionData = { | |
| header: { id: "session-4", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { | |
| type: "text", | |
| text: `Leak:\n\n\n\n`, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| leafId: "1", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| expect(messages.querySelector('img[src^="https://"]')).toBeNull(); | |
| expect(messages.textContent).toContain("exfil"); | |
| requireElement(messages.querySelector(`img[src="${dataImage}"]`), "data markdown image missing"); | |
| }); | |
| it("flattens unsafe markdown links while preserving safe links", async () => { | |
| const session: SessionData = { | |
| header: { id: "session-5", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "user", | |
| content: [ | |
| "[script](javascript:alert(1))", | |
| "[encoded](javascript:alert(2))", | |
| "[split](java	script:alert(3))", | |
| "[zero-width](java​script:alert(4))", | |
| "[surrogate](java�script:alert(5))", | |
| '[safe](https://example.com/report "report")', | |
| ].join("\n"), | |
| }, | |
| }, | |
| { | |
| id: "2", | |
| parentId: "1", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { | |
| type: "text", | |
| text: "[data](data:text/html;base64,PGgxPnBvYzwvaDE+) [mail](mailto:test@example.com)", | |
| }, | |
| ], | |
| }, | |
| }, | |
| { | |
| id: "3", | |
| parentId: "2", | |
| timestamp: now(), | |
| type: "branch_summary", | |
| summary: "[relative](./notes.md)", | |
| }, | |
| { | |
| id: "4", | |
| parentId: "3", | |
| timestamp: now(), | |
| type: "custom_message", | |
| customType: "x", | |
| display: true, | |
| content: "[hash](#entry-1)", | |
| }, | |
| ], | |
| leafId: "4", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| const hrefs = Array.from(messages.querySelectorAll("a"), (link) => link.getAttribute("href")); | |
| expect(hrefs).toEqual([ | |
| "https://example.com/report", | |
| "mailto:test@example.com", | |
| "./notes.md", | |
| "#entry-1", | |
| ]); | |
| expect(messages.querySelector("a")?.getAttribute("title")).toBe("report"); | |
| expect(messages.textContent).toContain("script"); | |
| expect(messages.textContent).toContain("encoded"); | |
| expect(messages.textContent).toContain("split"); | |
| expect(messages.textContent).toContain("zero-width"); | |
| expect(messages.textContent).toContain("surrogate"); | |
| expect(messages.textContent).toContain("data"); | |
| expect(hrefs.some((href) => href?.startsWith("javascript:") || href?.startsWith("data:"))).toBe( | |
| false, | |
| ); | |
| }); | |
| it("escapes entry.id in element id and data-entry-id attributes", async () => { | |
| const xssId = `"><script>alert(1)</script><div data-x="`; | |
| const session: SessionData = { | |
| header: { id: "session-xss-id", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: xssId, | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "hello" }, | |
| }, | |
| { | |
| id: "safe-child", | |
| parentId: xssId, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "text", text: "world" }], | |
| }, | |
| }, | |
| ], | |
| leafId: "safe-child", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| // Core XSS prevention: no <script> tags should be injected into the DOM | |
| expect(messages.querySelectorAll("script").length).toBe(0); | |
| // No attribute breakout: onmouseover, data-x must not appear as real attributes | |
| expect(messages.querySelector("[onmouseover]")).toBeNull(); | |
| // The copy-link button must exist with the payload safely contained | |
| const copyBtn = requireElement( | |
| messages.querySelector(".copy-link-btn"), | |
| "copy-link button missing", | |
| ); | |
| // data-entry-id must be present as a proper attribute (not broken out) | |
| expect(copyBtn.hasAttribute("data-entry-id")).toBe(true); | |
| // No stray attributes from the payload | |
| expect(copyBtn.hasAttribute("data-x")).toBe(false); | |
| // The user message element must not have attribute breakout either | |
| const userMsg = requireElement( | |
| messages.querySelector(".user-message"), | |
| "user message element missing", | |
| ); | |
| expect(userMsg.getAttribute("data-x")).toBeNull(); | |
| // The element id must start with entry- (the payload is contained within) | |
| const elementId = userMsg.getAttribute("id") ?? ""; | |
| expect(elementId.startsWith("entry-")).toBe(true); | |
| }); | |
| it("truncates tree node text without splitting surrogate pairs", async () => { | |
| const emoji = "😀"; | |
| const prefix = "x".repeat(99); | |
| const session: SessionData = { | |
| header: { id: "session-surrogate-truncation", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "surrogate-user", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: `${prefix}${emoji}tail` }, | |
| }, | |
| ], | |
| leafId: "surrogate-user", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const treeText = | |
| Array.from(document.querySelectorAll(".tree-content")).find((node) => | |
| node.textContent?.includes("user:"), | |
| )?.textContent ?? ""; | |
| expect(treeText).toContain(`user: ${prefix}...`); | |
| expect(treeText).not.toContain(emoji); | |
| expect(treeText).not.toMatch( | |
| /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u, | |
| ); | |
| }); | |
| it("copy-link round-trip: dataset.entryId matches raw entry.id after browser decoding", async () => { | |
| // IDs with characters that need HTML escaping but should round-trip correctly | |
| const specialId = `msg-with"quotes&'s`; | |
| const session: SessionData = { | |
| header: { id: "session-roundtrip", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: specialId, | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "test" }, | |
| }, | |
| ], | |
| leafId: specialId, | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const messages = requireElement(document.getElementById("messages"), "messages root missing"); | |
| // The copy-link button should exist | |
| const copyBtn = requireElement( | |
| messages.querySelector(".copy-link-btn"), | |
| "copy-link button missing", | |
| ); | |
| // Browser decodes HTML entities in dataset reads, so dataset.entryId | |
| // must return the RAW entry.id (not the HTML-escaped version). | |
| // This is essential for buildShareUrl() to produce the correct URL. | |
| const datasetValue = (copyBtn as HTMLElement).dataset.entryId; | |
| expect(datasetValue).toBe(specialId); | |
| // The DOM element id must also round-trip: getElementById should find it | |
| const userMsg = document.getElementById(`entry-${specialId}`); | |
| expect(userMsg).not.toBeNull(); | |
| expect(userMsg?.classList.contains("user-message")).toBe(true); | |
| }); | |
| it("escapes markdown data-image attributes", async () => { | |
| const dataImage = "data:image/png;base64,AAAA"; | |
| const session: SessionData = { | |
| header: { id: "session-6", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { | |
| type: "text", | |
| text: ``, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| leafId: "1", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const img = requireElement(document.querySelector("#messages img"), "message image missing"); | |
| expect(img.getAttribute("onerror")).toBeNull(); | |
| expect(img.getAttribute("alt")).toBe('x" onerror="alert(1)'); | |
| expect(img.getAttribute("src")).toBe(dataImage); | |
| }); | |
| it("does not crash when assistant message has non-array content", async () => { | |
| for (const content of [null, undefined, "plain string", 42]) { | |
| const session: SessionData = { | |
| header: { id: "session-malformed-assistant", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "hello" }, | |
| }, | |
| { | |
| id: "2", | |
| parentId: "1", | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "assistant", content }, | |
| }, | |
| ], | |
| leafId: "2", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| if (typeof content === "string") { | |
| expect(document.querySelector(".assistant-message")?.textContent).toContain(content); | |
| } | |
| } | |
| }); | |
| it("renders string tool-result content", async () => { | |
| const session: SessionData = { | |
| header: { id: "session-string-tool-result", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "run a command" }, | |
| }, | |
| { | |
| id: "2", | |
| parentId: "1", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { | |
| type: "toolCall", | |
| id: "call-1", | |
| name: "bash", | |
| arguments: { command: "echo legacy" }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| { | |
| id: "3", | |
| parentId: "2", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "toolResult", | |
| toolCallId: "call-1", | |
| content: "legacy output", | |
| }, | |
| }, | |
| ], | |
| leafId: "3", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| expect(document.querySelector(".tool-output")?.textContent).toContain("legacy output"); | |
| }); | |
| it("does not crash when user message has non-array non-string content", async () => { | |
| for (const content of [null, undefined, 42]) { | |
| const session: SessionData = { | |
| header: { id: "session-malformed-user", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content }, | |
| }, | |
| ], | |
| leafId: "1", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| await expect(renderTemplate(session)).resolves.toBeDefined(); | |
| } | |
| }); | |
| }); | |
| describe("export html tool call previews", () => { | |
| it("truncates tool previews without splitting emoji", async () => { | |
| const bashPrefix = "a".repeat(49); | |
| const genericPrefix = "b".repeat(29); | |
| const bashExecutionPrefix = "c".repeat(99); | |
| const session: SessionData = { | |
| header: { id: "session-tool-preview-emoji", timestamp: now() }, | |
| entries: [ | |
| { | |
| id: "1", | |
| parentId: null, | |
| timestamp: now(), | |
| type: "message", | |
| message: { role: "user", content: "run tools" }, | |
| }, | |
| { | |
| id: "2", | |
| parentId: "1", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { | |
| type: "toolCall", | |
| id: "call-bash", | |
| name: "bash", | |
| arguments: { command: `${bashPrefix}😀tail` }, | |
| }, | |
| { | |
| type: "toolCall", | |
| id: "call-custom", | |
| name: "custom", | |
| arguments: { value: `${genericPrefix}😀tail` }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| { | |
| id: "3", | |
| parentId: "2", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "toolResult", | |
| toolCallId: "call-bash", | |
| content: "bash output", | |
| }, | |
| }, | |
| { | |
| id: "4", | |
| parentId: "3", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "toolResult", | |
| toolCallId: "call-custom", | |
| content: "custom output", | |
| }, | |
| }, | |
| { | |
| id: "5", | |
| parentId: "4", | |
| timestamp: now(), | |
| type: "message", | |
| message: { | |
| role: "bashExecution", | |
| command: `${bashExecutionPrefix}😀tail`, | |
| }, | |
| }, | |
| ], | |
| leafId: "5", | |
| systemPrompt: "", | |
| tools: [], | |
| }; | |
| const { document } = await renderTemplate(session); | |
| const previews = ["3", "4", "5"].map((id) => | |
| requireElement( | |
| document.querySelector(`.tree-node[data-id="${id}"] .tree-content`), | |
| `tool preview ${id} missing`, | |
| ).textContent, | |
| ); | |
| expect(previews).toEqual([ | |
| `[bash: ${bashPrefix}...]`, | |
| `[custom: {"value":"${genericPrefix}...]`, | |
| `[bash]: ${bashExecutionPrefix}...`, | |
| ]); | |
| }); | |
| }); | |