diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md
index 60d96adc2f..81a24d1039 100644
--- a/doc/gui/0_gui.md
+++ b/doc/gui/0_gui.md
@@ -74,12 +74,17 @@ Click the panel toggle in the ribbon to open the conversations sidebar. This pan
#### Exporting a Conversation
-Click the **Export** button in the ribbon to download the conversation that is currently displayed. Two formats are offered from the button's menu:
+Click the **Export** button in the ribbon to download the conversation that is currently displayed. Three formats are offered from the button's menu:
- **Markdown (`.md`):** A human-readable transcript with each message labeled by role. Best for reading, sharing, or pasting into reports.
- **JSON (`.json`):** A structured record of the conversation for tooling and further processing.
+- **HTML (`.html`):** A single self-contained page with the images, audio, and video inside the file itself. Best for sharing a conversation as evidence, and for printing — open it and use your browser's **Print → Save as PDF**.
-The export runs entirely in your browser and captures exactly what is shown in the chat, including the system prompt shown in the banner — no data is sent to the server. Export stays available for read-only historical conversations, and is disabled while a conversation is empty, still loading, or sending. The button is disabled until there is at least one user or model message to export.
+Every format includes the whole conversation as shown in the chat, including the system prompt shown in the banner.
+
+Markdown records the names of attachments but never the media itself. JSON keeps media that is already inline, drops the source link for everything else, and so cannot be relied on to carry pictures either. HTML is the format to pick when the media matters. It puts each attachment it can read into the page, and lists the rest by name with the reason it was left out — media stored in Azure Blob storage cannot be read by the browser, an attachment that is too large on its own is skipped, and one that no longer fits in the page is marked as having no room left. The page keeps filling after that, so an attachment later in the conversation that still fits can make it in. Files that are not images, audio, or video are never embedded. The count of what was and was not included is printed at the top of the exported page, so an incomplete export is never mistaken for a complete one. Source links are deliberately left out of every export.
+
+Export stays available for read-only historical conversations, and is disabled while a conversation is empty, still loading, or sending. The button is disabled until there is at least one user or model message to export.
> **Note:** Exported files can contain adversarial prompts, model responses, and other sensitive material. Store and share them responsibly.
diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts
index 87879faeb4..107927320e 100644
--- a/frontend/e2e/chat.spec.ts
+++ b/frontend/e2e/chat.spec.ts
@@ -956,4 +956,112 @@ test.describe("Conversation export", () => {
expect(content).toContain("Export me please");
expect(content).toContain("Mock response for: Export me please");
});
+
+ test("downloads the displayed conversation as a self-contained HTML transcript", async ({ page }) => {
+ const { filename, content } = await triggerExport(page, "export-html-item");
+
+ expect(filename).toMatch(/^copyrit-conversation-e2e-conv-001-.*\.html$/);
+ expect(content).toContain("
CoPyRIT conversation export
");
+ expect(content).toContain("Export me please");
+ expect(content).toContain("Mock response for: Export me please");
+ // Print rules travel with the file so it can be saved as PDF as-is.
+ expect(content).toContain("@media print");
+ });
+});
+
+test.describe("Conversation export with media", () => {
+ const setupImageMock = buildModalityMock(
+ [
+ {
+ id: "img-export-1",
+ original_value_data_type: "text",
+ converted_value_data_type: "image_path",
+ original_value: "generated image",
+ converted_value: WIDE_IMAGE_DATA_URI,
+ converted_value_mime_type: "image/svg+xml",
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ "e2e-export-media-conv",
+ );
+
+ test("embeds the image in the HTML export so the file stands alone", async ({ page }) => {
+ await setupImageMock(page);
+ await page.goto("/");
+ await activateMockTarget(page);
+
+ await page.getByRole("textbox").fill("Generate an image");
+ await page.getByRole("button", { name: /send/i }).click();
+ await expect(page.locator('img:not([alt="Co-PyRIT Logo"])')).toBeVisible({ timeout: 10000 });
+
+ const exportButton = page.getByTestId("export-conversation-btn");
+ await expect(exportButton).toBeEnabled();
+ const downloadPromise = page.waitForEvent("download");
+ await exportButton.click();
+ await page.getByTestId("export-html-item").click();
+
+ const download = await downloadPromise;
+ const filePath = await download.path();
+ expect(filePath).not.toBeNull();
+ const content = readFileSync(filePath, "utf-8");
+
+ expect(download.suggestedFilename()).toMatch(/\.html$/);
+ expect(content).toContain(" {
+ // A 1x1 PNG, served by a stubbed /api/media route so the export exercises
+ // the fetch → blob → base64 path rather than an already-inline data URI.
+ const pngBase64 =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGIAAgAABQABDQotsgAAAABJRU5ErkJggg==";
+ const mediaPath = "/api/media?path=/dbdata/prompt-memory-entries/images/e2e.png";
+
+ await buildModalityMock(
+ [
+ {
+ id: "img-fetch-1",
+ original_value_data_type: "text",
+ converted_value_data_type: "image_path",
+ original_value: "generated image",
+ converted_value: mediaPath,
+ converted_value_url: mediaPath,
+ converted_value_mime_type: "image/png",
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ "e2e-export-fetch-conv",
+ )(page);
+ await page.route("**/api/media**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "image/png" },
+ body: Buffer.from(pngBase64, "base64"),
+ });
+ });
+
+ await page.goto("/");
+ await activateMockTarget(page);
+
+ await page.getByRole("textbox").fill("Generate an image");
+ await page.getByRole("button", { name: /send/i }).click();
+ await expect(page.locator('img:not([alt="Co-PyRIT Logo"])')).toBeVisible({ timeout: 10000 });
+
+ const exportButton = page.getByTestId("export-conversation-btn");
+ await expect(exportButton).toBeEnabled();
+ const downloadPromise = page.waitForEvent("download");
+ await exportButton.click();
+ await page.getByTestId("export-html-item").click();
+
+ const download = await downloadPromise;
+ const filePath = await download.path();
+ expect(filePath).not.toBeNull();
+ const content = readFileSync(filePath, "utf-8");
+
+ expect(content).toContain(`data:image/png;base64,${pngBase64}`);
+ expect(content).toContain("Attachments: 1 of 1 embedded");
+ // The path the bytes came from must not travel with the file.
+ expect(content).not.toContain("/api/media");
+ });
});
diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx
index ad3da9d594..d6cd0ab1ec 100644
--- a/frontend/src/components/Chat/ChatWindow.test.tsx
+++ b/frontend/src/components/Chat/ChatWindow.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import ChatWindow from "./ChatWindow";
@@ -49,6 +49,7 @@ jest.mock("../../services/api", () => ({
jest.mock("../../utils/messageMapper", () => ({
buildMessagePieces: jest.fn(),
backendMessagesToFrontend: jest.fn(),
+ fileToBase64: jest.fn(),
}));
const mockedAttacksApi = attacksApi as jest.Mocked;
@@ -3507,6 +3508,83 @@ describe("ChatWindow Integration", () => {
expect(mockedAttacksApi.getMessages.mock.calls.length).toBe(callsBefore);
});
+ it("exports the displayed conversation as a self-contained HTML transcript", async () => {
+ const user = userEvent.setup();
+ await renderWithLoadedConversation();
+ const callsBefore = mockedAttacksApi.getMessages.mock.calls.length;
+ const { getDownloadAnchor } = spyOnDownloadAnchor();
+
+ await user.click(screen.getByRole("button", { name: /export conversation/i }));
+ await user.click(screen.getByRole("menuitem", { name: /export as html/i }));
+
+ await waitFor(() => expect(URL.createObjectURL as jest.Mock).toHaveBeenCalled());
+ const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob;
+ expect(blob.type).toBe("text/html;charset=utf-8");
+ expect(getDownloadAnchor().download).toMatch(/^copyrit-conversation-conv-1-.*\.html$/);
+ // WYSIWYG: export serializes in-state messages and makes no extra API call.
+ expect(mockedAttacksApi.getMessages.mock.calls.length).toBe(callsBefore);
+ });
+
+ it("shows progress and ignores a second request while an export is in flight", async () => {
+ const user = userEvent.setup();
+ const messagesWithMedia: Message[] = [
+ ...mockMessages,
+ {
+ role: "assistant",
+ content: "",
+ timestamp: new Date().toISOString(),
+ attachments: [
+ {
+ type: "image",
+ name: "r.png",
+ url: "blob:http://localhost/pending",
+ mimeType: "image/png",
+ file: new File(["x"], "r.png", { type: "image/png" }),
+ },
+ ],
+ },
+ ];
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(messagesWithMedia);
+ // Hold the media read open so the export stays in flight across clicks.
+ let releaseMedia: (value: string) => void = () => {};
+ mockedMapper.fileToBase64.mockImplementation(
+ () => new Promise((resolve) => { releaseMedia = resolve; })
+ );
+ render(
+
+
+
+ );
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: /export conversation/i })).toBeEnabled()
+ );
+ const { clickSpy } = spyOnDownloadAnchor();
+
+ await user.click(screen.getByRole("button", { name: /export conversation/i }));
+ await user.click(screen.getByTestId("export-html-item"));
+ const exportButton = screen.getByRole("button", { name: /export conversation/i });
+ await waitFor(() => expect(within(exportButton).getByRole("progressbar")).toBeInTheDocument());
+
+ await user.click(screen.getByRole("button", { name: /export conversation/i }));
+ await user.click(screen.getByTestId("export-html-item"));
+
+ // The menu shows the export is already running, and the guard stops a
+ // second one from starting even if the click lands anyway.
+ expect(screen.getByTestId("export-html-item")).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByTestId("export-markdown-item")).toHaveAttribute("aria-disabled", "true");
+ expect(mockedMapper.fileToBase64).toHaveBeenCalledTimes(1);
+
+ releaseMedia("eA==");
+ await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(within(exportButton).queryByRole("progressbar")).not.toBeInTheDocument());
+ });
+
it("exports the displayed conversation id when it differs from the attack's main conversation", async () => {
const user = userEvent.setup();
// Viewing a branch: activeConversationId (displayed) differs from the
diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx
index 1bcd7cf34d..65bd48a224 100644
--- a/frontend/src/components/Chat/ChatWindow.tsx
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -9,6 +9,7 @@ import {
MenuPopover,
MenuTrigger,
mergeClasses,
+ Spinner,
Switch,
Text,
Tooltip,
@@ -126,6 +127,8 @@ export default function ChatWindow({
const [loadedConversationId, setLoadedConversationId] = useState(null)
const isSending = activeConversationId ? sendingConversations.has(activeConversationId) : Boolean(sendingConversations.size)
const [isPanelOpen, setIsPanelOpen] = useState(false)
+ const [isExporting, setIsExporting] = useState(false)
+ const isExportingRef = useRef(false)
const [isNarrowScreen, setIsNarrowScreen] = useState(matchesNarrowScreen)
const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false)
// Conversation-wide preference for rendering message text as Markdown.
@@ -715,8 +718,22 @@ export default function ChatWindow({
!isLoadingMessages &&
!awaitingConversationLoad
- const handleExport = (format: ExportFormat) => {
- exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format })
+ const handleExport = async (format: ExportFormat) => {
+ // A ref, not the state flag: two clicks in the same tick would both read
+ // the pre-render value and start duplicate exports.
+ if (isExportingRef.current) {
+ return
+ }
+ isExportingRef.current = true
+ setIsExporting(true)
+ try {
+ await exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format })
+ } catch (err) {
+ console.error('Failed to export conversation:', err)
+ } finally {
+ isExportingRef.current = false
+ setIsExporting(false)
+ }
}
return (
@@ -762,7 +779,7 @@ export default function ChatWindow({
}
+ icon={isExporting ? : }
disabled={!canExportConversation}
aria-label="Export conversation"
data-testid="export-conversation-btn"
@@ -771,12 +788,19 @@ export default function ChatWindow({
-
diff --git a/frontend/src/utils/conversationExport.test.ts b/frontend/src/utils/conversationExport.test.ts
index aae2eaf3f1..ba459318c4 100644
--- a/frontend/src/utils/conversationExport.test.ts
+++ b/frontend/src/utils/conversationExport.test.ts
@@ -2,11 +2,13 @@ import {
exportConversation,
conversationToMarkdown,
conversationToJson,
+ conversationToHtml,
buildExportFilename,
downloadTextFile,
EXPORT_MIME_TYPES,
+ MAX_TOTAL_INLINE_CHARACTERS,
} from "./conversationExport";
-import type { Message } from "../types";
+import type { Message, MessageAttachment } from "../types";
const FIXED_NOW = new Date("2026-07-22T02:34:01.059Z");
@@ -19,6 +21,25 @@ function message(overrides: Partial = {}): Message {
};
}
+function attachment(overrides: Partial = {}): MessageAttachment {
+ return {
+ type: "image",
+ name: "result.png",
+ url: "/api/media?path=/home/op/dbdata/result.png",
+ mimeType: "image/png",
+ ...overrides,
+ };
+}
+
+function mockFetchOnce(body: string, { ok = true, type = "image/png" } = {}): jest.Mock {
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok,
+ blob: () => Promise.resolve(new Blob([body], { type })),
+ });
+ global.fetch = fetchMock as unknown as typeof fetch;
+ return fetchMock;
+}
+
function blobToText(blob: Blob): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -295,6 +316,83 @@ describe("conversationExport", () => {
expect(attachment.pieceId).toBe("piece-9");
});
+ it("drops the signed storage url so a shared file carries no credentials", () => {
+ const json = conversationToJson(
+ [
+ message({
+ attachments: [
+ {
+ type: "image",
+ name: "result.png",
+ url: "https://acct.blob.core.windows.net/c/result.png?sv=2024&sig=SECRETSIG",
+ mimeType: "image/png",
+ },
+ ],
+ }),
+ ],
+ "conv-1"
+ );
+ expect(json).not.toContain("SECRETSIG");
+ expect(json).not.toContain("blob.core.windows.net");
+ expect(JSON.parse(json).messages[0].attachments[0].url).toBe("");
+ expect(JSON.parse(json).messages[0].attachments[0].name).toBe("result.png");
+ });
+
+ it("drops the local media path so a shared file does not expose the operator's disk", () => {
+ const json = conversationToJson(
+ [
+ message({
+ attachments: [
+ {
+ type: "image",
+ name: "result.png",
+ url: "/api/media?path=/home/op/dbdata/prompt-memory-entries/images/1.png",
+ mimeType: "image/png",
+ },
+ ],
+ }),
+ ],
+ "conv-1"
+ );
+ expect(json).not.toContain("/home/op/dbdata");
+ expect(json).not.toContain("/api/media");
+ });
+
+ it("leaves the live conversation untouched while stripping the exported copy", () => {
+ const attachments: MessageAttachment[] = [
+ {
+ type: "image",
+ name: "result.png",
+ url: "/api/media?path=/home/op/dbdata/result.png",
+ mimeType: "image/png",
+ },
+ ];
+ const messages = [message({ attachments })];
+ conversationToJson(messages, "conv-1");
+ // The chat still needs the url to render the image on screen.
+ expect(attachments[0].url).toBe("/api/media?path=/home/op/dbdata/result.png");
+ expect(messages[0].attachments).toBe(attachments);
+ });
+
+ it("keeps an inline data uri, which is the payload rather than a pointer to it", () => {
+ const json = conversationToJson(
+ [
+ message({
+ attachments: [
+ {
+ type: "image",
+ name: "result.png",
+ url: "data:image/png;base64,AAAA",
+ mimeType: "image/png",
+ },
+ ],
+ }),
+ ],
+ "conv-1"
+ );
+ expect(JSON.parse(json).messages[0].attachments[0].url).toBe("data:image/png;base64,AAAA");
+ });
+
it("keeps a serializable metadata field named 'file' (only the attachment File handle is stripped)", () => {
const file = new File(["x"], "local.png", { type: "image/png" });
const json = conversationToJson(
@@ -424,6 +522,635 @@ describe("conversationExport", () => {
});
});
+ describe("conversationToHtml", () => {
+ it("renders a header with the conversation id, exported time, and message count", async () => {
+ const html = await conversationToHtml([message()], "conv-1", FIXED_NOW);
+ expect(html).toContain("
CoPyRIT conversation export
");
+ expect(html).toContain("Conversation: conv-1");
+ expect(html).toContain("Exported: 2026-07-22T02:34:01.059Z");
+ expect(html).toContain("Messages: 1");
+ });
+
+ it("includes print styles so the file can be saved as PDF as-is", async () => {
+ const html = await conversationToHtml([message()], "conv-1", FIXED_NOW);
+ expect(html).toContain("@media print");
+ expect(html).toContain("page-break-inside: avoid");
+ });
+
+ it("includes the system message that the chat view hides", async () => {
+ const html = await conversationToHtml(
+ [message({ role: "system", content: "You are a helpful assistant." })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("System");
+ expect(html).toContain("You are a helpful assistant.");
+ });
+
+ it("drops loading placeholders", async () => {
+ const html = await conversationToHtml(
+ [message({ content: "kept" }), message({ content: "pending", isLoading: true })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("kept");
+ expect(html).not.toContain("pending");
+ expect(html).toContain("Messages: 1");
+ });
+
+ it("escapes markup so model output cannot execute when the file is opened", async () => {
+ const html = await conversationToHtml(
+ [message({ content: " & \"quoted\"" })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("<script>alert(1)</script>");
+ expect(html).not.toContain("");
+ });
+
+ it("escapes a hostile url so it cannot break out of the src attribute", async () => {
+ const html = await conversationToHtml(
+ [
+ message({
+ content: "",
+ attachments: [attachment({ url: 'data:image/png;base64,AAAA" onerror="alert(1)' })],
+ }),
+ ],
+ "conv-1",
+ FIXED_NOW,
+ );
+ const img = new DOMParser().parseFromString(html, "text/html").querySelector("img");
+ expect(img).not.toBeNull();
+ expect(img?.getAttribute("onerror")).toBeNull();
+ expect(img?.getAttributeNames().sort()).toEqual(["alt", "src"]);
+ });
+
+ it("escapes a hostile filename so it cannot break out of the alt attribute", async () => {
+ const html = await conversationToHtml(
+ [
+ message({
+ content: "",
+ attachments: [
+ attachment({ url: "data:image/png;base64,AAAA", name: 'x" onerror="alert(1)' }),
+ ],
+ }),
+ ],
+ "conv-1",
+ FIXED_NOW,
+ );
+ const img = new DOMParser().parseFromString(html, "text/html").querySelector("img");
+ expect(img?.getAttribute("onerror")).toBeNull();
+ expect(img?.getAttribute("alt")).toBe('x" onerror="alert(1)');
+ });
+
+ it("escapes a hostile mime type instead of letting it break out of the src attribute", async () => {
+ const html = await conversationToHtml(
+ [
+ message({
+ content: "",
+ attachments: [attachment({ url: 'data:image/png;base64,AAAA', mimeType: 'image/png" onerror="alert(1)' })],
+ }),
+ ],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).not.toContain('onerror="alert(1)"');
+ expect(html).toContain(""");
+ });
+
+ it("escapes a hostile mime type on media it fetched, which is the only path that writes one", async () => {
+ // The fetched path builds the data uri itself, so the mime type reaches
+ // the src attribute here and nowhere else.
+ mockFetchOnce("hi");
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ mimeType: 'image/png" onerror="alert(1)' })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ const src = html.match(/ {
+ const html = await conversationToHtml(
+ [message({ content: "", attachments: [attachment({ url: "data:image/png;base64,AAAA" })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).not.toContain("");
+ expect(html).toContain(" {
+ const fetchMock = mockFetchOnce("bytes");
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url: "data:image/png;base64,AAAA" })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(html).toContain("data:image/png;base64,AAAA");
+ });
+
+ it("reads a pending upload from its local file without fetching", async () => {
+ const fetchMock = mockFetchOnce("bytes");
+ const file = new File(["hello"], "pending.png", { type: "image/png" });
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url: "blob:http://localhost/abc", file })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(html).toContain("data:image/png;base64,aGVsbG8=");
+ });
+
+ it("embeds same-origin media fetched from the media endpoint", async () => {
+ const fetchMock = mockFetchOnce("hello");
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment()] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).toHaveBeenCalledWith("/api/media?path=/home/op/dbdata/result.png");
+ expect(html).toContain("data:image/png;base64,aGVsbG8=");
+ });
+
+ it("names but does not embed blob-hosted media, and never writes its signed url", async () => {
+ const fetchMock = mockFetchOnce("hello");
+ const url = "https://acct.blob.core.windows.net/c/result.png?sv=2024&sig=SECRETSIG";
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(html).not.toContain("SECRETSIG");
+ expect(html).not.toContain("blob.core.windows.net");
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ });
+
+ it("names but does not embed a blob url with no local file", async () => {
+ const fetchMock = mockFetchOnce("hello");
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url: "blob:http://localhost/abc" })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ });
+
+ it("falls back to a placeholder when the media endpoint refuses the file", async () => {
+ mockFetchOnce(JSON.stringify({ detail: "Access denied" }), { ok: false, type: "application/json" });
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment()] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ expect(html).not.toContain("Access denied");
+ expect(html).not.toContain("/api/media");
+ });
+
+ it("falls back to a placeholder when the fetch throws", async () => {
+ global.fetch = jest.fn().mockRejectedValue(new Error("network down")) as unknown as typeof fetch;
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment()] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ });
+
+ it("names but does not embed an attachment over the inline size cap", async () => {
+ mockFetchOnce("x".repeat(10 * 1024 * 1024 + 1));
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment()] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("[Image: result.png (image/png) — too large to embed]");
+ expect(html).not.toContain("base64");
+ });
+
+ it("names but does not embed an inline data uri over the size cap", async () => {
+ const oversized = `data:image/png;base64,${"A".repeat(14 * 1024 * 1024)}`;
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url: oversized })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("[Image: result.png (image/png) — too large to embed]");
+ expect(html).not.toContain("base64,AAAA");
+ expect(html.length).toBeLessThan(10000);
+ });
+
+ it("names but does not embed an empty inline data uri", async () => {
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url: "data:image/png;base64," })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ expect(html).not.toContain(" {
+ // Any other same-origin path is answered by the single-page app, whose
+ // HTML would otherwise be embedded as if it were the image.
+ const fetchMock = mockFetchOnce("app shell", { type: "text/html" });
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ url: "/attacks/conv-1" })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ expect(html).not.toContain("app shell");
+ });
+
+ it("prefers the attachment mime type over the one the server reports", async () => {
+ mockFetchOnce("hello", { type: "text/plain" });
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ mimeType: "image/png" })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("data:image/png;base64,aGVsbG8=");
+ });
+
+ it("names file attachments rather than embedding them as links", async () => {
+ const fetchMock = mockFetchOnce("hello");
+ const html = await conversationToHtml(
+ [
+ message({
+ content: "",
+ attachments: [
+ attachment({ type: "file", name: "evil.html", mimeType: "text/html", url: "data:text/html;base64,AAAA" }),
+ ],
+ }),
+ ],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(html).toContain("[File: evil.html (text/html) — not a media file]");
+ expect(html).not.toContain(" {
+ mockFetchOnce("");
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment()] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("[Image: result.png (image/png) — could not be read]");
+ });
+
+ it("falls back to a generic mime type when the attachment has none", async () => {
+ mockFetchOnce("hello", { type: "" });
+ const html = await conversationToHtml(
+ [message({ attachments: [attachment({ mimeType: "" })] })],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("data:application/octet-stream;base64,aGVsbG8=");
+ });
+
+ it("renders players for audio and video and names other files", async () => {
+ const html = await conversationToHtml(
+ [
+ message({
+ content: "",
+ attachments: [
+ attachment({ type: "audio", name: "a.mp3", mimeType: "audio/mpeg", url: "data:audio/mpeg;base64,AAAA" }),
+ attachment({ type: "video", name: "v.mp4", mimeType: "video/mp4", url: "data:video/mp4;base64,AAAA" }),
+ attachment({ type: "file", name: "f.txt", mimeType: "text/plain", url: "data:text/plain;base64,AAAA" }),
+ ],
+ }),
+ ],
+ "conv-1",
+ FIXED_NOW,
+ );
+ expect(html).toContain("