/** * Tests for frontend/src/lib/export.ts * * Covers: * - Unit tests for specific export format details * - Property 7: Markdown export round-trip completeness * * **Validates: Requirements 11.1, 11.2, 11.3, 11.4** */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as fc from "fast-check"; import { exportConversation, exportSingleResponse, exportAllSavedResponses, } from "./export"; import type { Citation, Conversation, Message, QueryResult, SavedResponse, } from "../types"; // --------------------------------------------------------------------------- // DOM stubs — jsdom doesn't implement URL.createObjectURL or anchor.click // --------------------------------------------------------------------------- let downloadedContent: string | null = null; let downloadedFilename: string | null = null; beforeEach(() => { downloadedContent = null; downloadedFilename = null; // Stub URL.createObjectURL / revokeObjectURL vi.stubGlobal("URL", { createObjectURL: (_blob: Blob) => { // Capture blob content synchronously via FileReaderSync isn't available // in jsdom; instead we intercept via the Blob constructor mock below. return "blob:mock-url"; }, revokeObjectURL: vi.fn(), }); // Intercept anchor click to capture filename and content const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, "createElement").mockImplementation((tag: string) => { const el = originalCreateElement(tag); if (tag === "a") { Object.defineProperty(el, "click", { value: () => { downloadedFilename = (el as HTMLAnchorElement).download; }, writable: true, }); } return el; }); // Intercept Blob to capture content vi.stubGlobal( "Blob", class MockBlob { content: string; type: string; constructor(parts: BlobPart[], options?: BlobPropertyBag) { this.content = parts.join(""); this.type = options?.type ?? ""; downloadedContent = this.content; } } ); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeQueryResult(citations: Citation[]): QueryResult { return { citations, domains_searched: [], sections_retrieved: citations.length, timing: {}, token_usage: { total_tokens: 0 }, }; } function makeCitation( index: number, title: string, domain: string, source_url?: string ): Citation { return { index, domain, title, source_url }; } function makeConversation( messages: Message[], results: Record ): Conversation { return { id: "conv-1", title: "Test conversation", created_at: "2024-01-15T00:00:00Z", last_active_at: "2024-01-15T00:00:00Z", messages, history: [], results, }; } // --------------------------------------------------------------------------- // Unit tests — exportSingleResponse // --------------------------------------------------------------------------- describe("exportSingleResponse", () => { it("includes YAML front-matter with date, model, and type fields", () => { const result = makeQueryResult([]); exportSingleResponse("What is the rule?", "The rule is X.", result); expect(downloadedContent).toContain("---"); expect(downloadedContent).toMatch(/date: \d{4}-\d{2}-\d{2}/); expect(downloadedContent).toContain("model: claude-3-5-sonnet"); expect(downloadedContent).toContain("type: response"); }); it("includes the question as an H1 heading", () => { const result = makeQueryResult([]); exportSingleResponse("What is the rule?", "The rule is X.", result); expect(downloadedContent).toContain("# What is the rule?"); }); it("includes the answer body", () => { const result = makeQueryResult([]); exportSingleResponse("What is the rule?", "The rule is X.", result); expect(downloadedContent).toContain("The rule is X."); }); it("includes a Sources section with citations when present", () => { const result = makeQueryResult([ makeCitation(1, "Section A", "example.com", "https://example.com/a"), makeCitation(2, "Section B", "other.com"), ]); exportSingleResponse("What is the rule?", "The rule is X.", result); expect(downloadedContent).toContain("## Sources"); expect(downloadedContent).toContain( "[Section A](https://example.com/a) — example.com" ); expect(downloadedContent).toContain("Section B — other.com"); }); it("omits the Sources section when there are no citations", () => { const result = makeQueryResult([]); exportSingleResponse("What is the rule?", "The rule is X.", result); expect(downloadedContent).not.toContain("## Sources"); }); it("renders citations with source_url as hyperlinks", () => { const result = makeQueryResult([ makeCitation(1, "My Section", "example.com", "https://example.com"), ]); exportSingleResponse("Q?", "A.", result); expect(downloadedContent).toContain("[My Section](https://example.com)"); }); it("renders citations without source_url as plain text", () => { const result = makeQueryResult([ makeCitation(1, "My Section", "example.com"), ]); exportSingleResponse("Q?", "A.", result); expect(downloadedContent).toContain("1. My Section — example.com"); expect(downloadedContent).not.toContain("[My Section]"); }); it("generates a filename with the correct pattern", () => { const result = makeQueryResult([]); exportSingleResponse("What are the rules?", "Answer.", result); // Filename: health-marketing-{YYYYMMDD}-{slug}.md expect(downloadedFilename).toMatch( /^health-marketing-\d{8}-what-are-the-rules\.md$/ ); }); it("slugifies the question: lowercase, hyphens, max 40 chars", () => { const result = makeQueryResult([]); exportSingleResponse( "What Are The ADVERTISING Rules For Chiropractors In New Zealand?", "Answer.", result ); // slug should be lowercase, hyphens, truncated to 40 chars expect(downloadedFilename).toMatch(/^health-marketing-\d{8}-[a-z0-9-]+\.md$/); const slug = downloadedFilename!.replace(/^health-marketing-\d{8}-/, "").replace(/\.md$/, ""); expect(slug.length).toBeLessThanOrEqual(40); }); }); // --------------------------------------------------------------------------- // Unit tests — exportConversation // --------------------------------------------------------------------------- describe("exportConversation", () => { it("includes type: conversation in front-matter", () => { const messages: Message[] = [ { role: "user", content: "Question 1?" }, { role: "assistant", content: "Answer 1." }, ]; const results = { "1": makeQueryResult([]) }; const conv = makeConversation(messages, results); exportConversation(conv); expect(downloadedContent).toContain("type: conversation"); }); it("generates one H1 section per Q&A pair", () => { const messages: Message[] = [ { role: "user", content: "Question 1?" }, { role: "assistant", content: "Answer 1." }, { role: "user", content: "Question 2?" }, { role: "assistant", content: "Answer 2." }, ]; const results = { "1": makeQueryResult([]), "3": makeQueryResult([]), }; const conv = makeConversation(messages, results); exportConversation(conv); expect(downloadedContent).toContain("# Question 1?"); expect(downloadedContent).toContain("# Question 2?"); expect(downloadedContent).toContain("Answer 1."); expect(downloadedContent).toContain("Answer 2."); }); it("uses the first user question for the filename slug", () => { const messages: Message[] = [ { role: "user", content: "First question here?" }, { role: "assistant", content: "Answer." }, ]; const conv = makeConversation(messages, { "1": makeQueryResult([]) }); exportConversation(conv); expect(downloadedFilename).toMatch(/first-question-here/); }); }); // --------------------------------------------------------------------------- // Unit tests — exportAllSavedResponses // --------------------------------------------------------------------------- describe("exportAllSavedResponses", () => { it("includes type: saved-responses in front-matter", () => { exportAllSavedResponses([]); expect(downloadedContent).toContain("type: saved-responses"); }); it("concatenates multiple saved responses with --- separators", () => { const saved: SavedResponse[] = [ { id: "conv-1:1", question: "Q1?", answer: "A1.", saved_at: "2024-01-15T00:00:00Z", source_conversation_id: "conv-1", source_conversation_title: "Conv 1", msg_index: 1, messages_pair: [], history_pair: [], result: makeQueryResult([makeCitation(1, "Sec A", "a.com")]), }, { id: "conv-2:1", question: "Q2?", answer: "A2.", saved_at: "2024-01-16T00:00:00Z", source_conversation_id: "conv-2", source_conversation_title: "Conv 2", msg_index: 1, messages_pair: [], history_pair: [], result: makeQueryResult([makeCitation(1, "Sec B", "b.com")]), }, ]; exportAllSavedResponses(saved); expect(downloadedContent).toContain("# Q1?"); expect(downloadedContent).toContain("# Q2?"); expect(downloadedContent).toContain("---"); }); it("uses saved-responses in the filename", () => { exportAllSavedResponses([]); expect(downloadedFilename).toMatch(/saved-responses\.md$/); }); }); // --------------------------------------------------------------------------- // Property 7: Markdown export round-trip completeness // // For any completed assistant message with citations, the Markdown export // generated by the browser SHALL contain the question text, the answer body, // and a Sources section listing every citation title that was present in the // QueryResult. // // **Validates: Requirements 11.3** // --------------------------------------------------------------------------- describe("Property 7: Markdown export round-trip completeness", () => { // Arbitraries const nonEmptyString = fc.string({ minLength: 1, maxLength: 200 }); const citationArb = fc .record({ index: fc.integer({ min: 1, max: 20 }), domain: fc.stringMatching(/^[a-z]{3,10}\.[a-z]{2,4}$/), title: nonEmptyString, source_url: fc.option( fc.webUrl({ validSchemes: ["https"] }), { nil: undefined } ), }) .map( (c): Citation => ({ index: c.index, domain: c.domain, title: c.title, source_url: c.source_url ?? undefined, }) ); const queryResultArb = fc .array(citationArb, { minLength: 0, maxLength: 10 }) .map( (citations): QueryResult => ({ citations, domains_searched: [], sections_retrieved: citations.length, timing: {}, token_usage: { total_tokens: 0 }, }) ); it("export contains question, answer, and all citation titles", () => { fc.assert( fc.property(nonEmptyString, nonEmptyString, queryResultArb, (question, answer, result) => { exportSingleResponse(question, answer, result); const content = downloadedContent ?? ""; // Must contain the question as H1 expect(content).toContain(`# ${question}`); // Must contain the answer body expect(content).toContain(answer); // Must contain every citation title for (const citation of result.citations) { expect(content).toContain(citation.title); } // If there are citations, must have a Sources section if (result.citations.length > 0) { expect(content).toContain("## Sources"); } }), { numRuns: 100 } ); }); it("export contains all citation titles for a full conversation", () => { const messagesPairArb = fc .tuple(nonEmptyString, nonEmptyString) .map(([q, a]): Message[] => [ { role: "user", content: q }, { role: "assistant", content: a }, ]); fc.assert( fc.property( fc.array(fc.tuple(messagesPairArb, queryResultArb), { minLength: 1, maxLength: 5, }), (pairs) => { const messages: Message[] = pairs.flatMap(([msgs]) => msgs); const results: Record = {}; pairs.forEach(([_msgs, result], i) => { // assistant message index = i*2 + 1 results[String(i * 2 + 1)] = result; }); const conv = makeConversation(messages, results); exportConversation(conv); const content = downloadedContent ?? ""; // Every question and answer must appear pairs.forEach(([msgs, result]) => { const question = msgs[0].content; const answer = msgs[1].content; expect(content).toContain(`# ${question}`); expect(content).toContain(answer); // Every citation title must appear for (const citation of result.citations) { expect(content).toContain(citation.title); } }); } ), { numRuns: 50 } ); }); });