import { describe, it, expect } from "vitest"; import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts"; import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts"; /** * Tests for the client-only Markdown export builder. The output embeds a live * `new Date().toISOString()` export timestamp; we never assert that value, only * the deterministic structure (headings, numbering, fenced blocks, totals). * * A pass-through translator keeps role/tool labels predictable so the * structural assertions are stable without an i18n runtime. */ const t = (key: string, values?: Record): string => { if (values && typeof values.name === "string") { return key.replace("{{name}}", values.name); } return key; }; function row(partial: Partial): IAiChatMessageRow { return { id: partial.id ?? "id", role: partial.role ?? "user", content: partial.content ?? null, metadata: partial.metadata ?? null, createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z", }; } describe("buildChatMarkdown — structure", () => { it("emits the title heading, chat id and message count", () => { const md = buildChatMarkdown({ title: "My chat", chatId: "chat-123", rows: [], t, }); expect(md).toContain("# My chat"); expect(md).toContain("- Chat ID: `chat-123`"); expect(md).toContain("- Messages: 0"); expect(md).toContain("- Exported:"); // timestamp present, value not asserted }); it("falls back to the translated 'Untitled chat' for empty/blank titles", () => { expect( buildChatMarkdown({ title: null, chatId: "c", rows: [], t }), ).toContain("# Untitled chat"); expect( buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }), ).toContain("# Untitled chat"); }); it("numbers rows sequentially with role headings", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "user", content: "hi" }), row({ role: "assistant", content: "hello" }), row({ role: "user", content: "again" }), ], t, }); expect(md).toContain("## 1. You"); expect(md).toContain("## 2. AI agent"); expect(md).toContain("## 3. You"); // Heading numbering is strictly index+1, not e.g. role-relative. expect(md).not.toContain("## 0."); }); it("renders the per-row text content from `content` when no metadata.parts", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [row({ role: "user", content: "plain body" })], t, }); expect(md).toContain("plain body"); }); }); describe("buildChatMarkdown — text parts", () => { it("skips empty / whitespace-only text parts", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "ignored-content", metadata: { parts: [ { type: "text", text: " " }, { type: "text", text: "" }, { type: "text", text: "kept line" }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any, }, }), ], t, }); expect(md).toContain("kept line"); // Whitespace-only part contributed no block of its own. expect(md).not.toContain(" \n\n"); // When metadata.parts exists, the plain `content` fallback is NOT used. expect(md).not.toContain("ignored-content"); }); }); describe("buildChatMarkdown — tool parts", () => { it("renders a tool label, name, state and fenced Input/Output blocks", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "", metadata: { parts: [ { type: "tool-getPage", state: "output-available", input: { pageId: "p1" }, output: { id: "p1", title: "Home" }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, ], }, }), ], t, }); // Known tool name maps to its label key; raw name in backticks; done state. expect(md).toContain("**Tool: Read page** (`getPage`) — done"); expect(md).toContain("Input:"); expect(md).toContain("Output:"); // Fenced JSON blocks contain the stringified payloads. expect(md).toContain('"pageId": "p1"'); expect(md).toContain('"title": "Home"'); expect(md).toContain("```json"); }); it("renders the generic label for an unknown tool and surfaces errorText", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "", metadata: { parts: [ { type: "tool-mysteryTool", state: "output-error", input: { a: 1 }, errorText: "boom", // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, ], }, }), ], t, }); expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error"); expect(md).toContain("**Error:** boom"); }); it("does not throw on a circular tool input (falls back to String)", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const circular: any = {}; circular.self = circular; expect(() => buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "", metadata: { parts: [ { type: "tool-getPage", state: "input-available", input: circular, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, ], }, }), ], t, }), ).not.toThrow(); }); }); describe("buildChatMarkdown — fence anti-breakout", () => { it("lengthens the delimiter so embedded ``` cannot break out of the block", () => { // Tool input whose stringified string form contains a literal ``` run. const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "", metadata: { parts: [ { type: "tool-getPage", state: "output-available", // A bare string passes through stringify() verbatim. input: "before ``` after", output: "x", // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, ], }, }), ], t, }); // The fence around the 3-backtick content must use at least 4 backticks so // the embedded ``` run cannot terminate the block. expect(md).toContain("````json\nbefore ``` after\n````"); // Robust anti-breakout check: the opening fence delimiter is strictly // longer than the longest backtick run inside the wrapped content. (A naive // `not.toContain("```json...")` is a false negative — a 4-backtick fence // textually contains the 3-backtick substring.) const open = md.match(/(`{3,})json\nbefore/); expect(open).not.toBeNull(); expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content }); it("uses a 5-backtick fence when the content has a 4-backtick run", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "", metadata: { parts: [ { type: "tool-getPage", state: "output-available", input: "a ```` b", // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, ], }, }), ], t, }); expect(md).toContain("`````json\na ```` b\n`````"); }); }); describe("buildChatMarkdown — token totals", () => { it("prints the total-tokens line only when the summed usage is > 0", () => { const withTokens = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "x", metadata: { usage: { inputTokens: 10, outputTokens: 5 } }, }), ], t, }); expect(withTokens).toContain("- Total tokens: 15"); // Per-row usage footer too. expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_"); }); it("omits the total-tokens line when the sum is 0 / usage absent", () => { const noTokens = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "user", content: "hi" }), row({ role: "assistant", content: "x", metadata: { usage: { inputTokens: 0, outputTokens: 0 } }, }), ], t, }); expect(noTokens).not.toContain("- Total tokens:"); }); it("uses totalTokens when present rather than summing in/out", () => { const md = buildChatMarkdown({ title: "t", chatId: "c", rows: [ row({ role: "assistant", content: "x", metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } }, }), ], t, }); expect(md).toContain("- Total tokens: 99"); }); });