/** * Round-trip and golden-file tests for the export pipeline. * * These exercise the full stack: a ProseMirror project is flattened through * `flattenProject` and exported to every format. The assertions cover three * properties the Chunk #6 spec calls out: * * 1. Golden-file stability — exporting the same project twice yields byte * identical output (determinism), and the output is non-empty. * 2. Round-trip — Markdown export → editor import → Markdown export is * identical, so the exporter emits the exact flavour the editor parses. * 3. Archive validation — EPUB/ODT are valid ZIP containers with the correct * uncompressed mimetype entry and well-formed XML. * * The round-trip step imports through the editor's `parseMarkdown`, which * builds a DOM, so this file runs under jsdom. * * @vitest-environment jsdom */ import { describe, it, expect } from "vitest"; import JSZip from "jszip"; import { inflateSync } from "node:zlib"; import type { ExportProject } from "./model.js"; import type { JsonNode } from "@pubscript/core"; import { exportProjectData, flattenProject, type Block, type ExportFormat, } from "./index.js"; // The editor's importer is the canonical "import" for the MD round-trip. import { parseMarkdown, serializeToMarkdown } from "@pubscript/editor"; /** A single ProseMirror paragraph node. */ function para(text: string, marks?: Array<{ type: string }>) { return { type: "paragraph", content: [{ type: "text", text, marks: marks ? marks.map((m) => ({ type: m.type })) : undefined }], }; } /** A ProseMirror doc from a list of paragraph nodes. */ function doc(...paras: JsonNode[]): ExportProject["books"][0]["chapters"][0]["doc"] { return { type: "doc", content: paras }; } /** Build a small but representative project covering every block type. */ function makeProject(): ExportProject { return { meta: { title: "The Glass Harbor", author: "A. Writer", language: "en", }, books: [ { id: "book-1", projectId: "proj-1", title: "Book One", order: 0, chapters: [ { id: "ch-1", bookId: "book-1", projectId: "proj-1", title: "Chapter One — The Arrival", order: 0, rev: 1, meta: { wordCount: 0, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" }, doc: doc( { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "The Arrival" }], }, para("The ferry carried Mara Vell to the Glass Harbor before dawn."), para("She pressed her palm against the cold window.", [ { type: "bold" }, { type: "italic" }, ]), { type: "sceneBreak" }, { type: "blockquote", content: [para("The light is failing.", [{ type: "italic" }])], }, { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const x = 1;" }], }, { type: "bulletList", content: [ { type: "listItem", content: [para("The boats")] }, { type: "listItem", content: [para("The fish")] }, ], }, { type: "orderedList", content: [ { type: "listItem", content: [para("First")] }, { type: "listItem", content: [para("Second")] }, ], }, { type: "footnoteArea", content: [{ type: "text", text: "A footnote about the lighthouse." }], }, ), }, { id: "ch-2", bookId: "book-1", projectId: "proj-1", title: "Chapter Two — The Storm", order: 1, rev: 1, meta: { wordCount: 0, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" }, doc: doc( { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "The Storm" }], }, para("The storm arrived on the second night."), para("The boats threw their lines and danced on them."), ), }, ], }, ], }; } const PROJECT = makeProject(); /** Fixed timestamp so timestamped exporters (HTML/EPUB/ODT) are byte-stable. */ const FIXED_AT = "2020-01-01T00:00:00.000Z"; /** Every format the registry knows about. */ const FORMATS: ExportFormat[] = [ "markdown", "html", "epub", "docx", "odt", "pdf", ]; describe("exportProject — determinism (golden-file stability)", () => { for (const format of FORMATS) { it(`produces byte-identical output across runs (${format})`, async () => { const first = await exportProjectData(PROJECT, format, { generatedAt: FIXED_AT }); const second = await exportProjectData(PROJECT, format, { generatedAt: FIXED_AT }); expect(first).toEqual(second); }); it(`produces non-empty output (${format})`, async () => { const data = await exportProjectData(PROJECT, format); const len = typeof data === "string" ? data.length : data.byteLength; expect(len).toBeGreaterThan(0); }); } }); describe("exportProject — content assertions", () => { it("markdown contains the title, author frontmatter and chapter headings", async () => { const md = (await exportProjectData(PROJECT, "markdown", { frontmatter: true })) as string; expect(md).toContain('title: "The Glass Harbor"'); expect(md).toContain('author: "A. Writer"'); expect(md).toContain("# Chapter One — The Arrival"); expect(md).toContain("The Storm"); }); it("html contains the document title and a table of contents", async () => { const html = (await exportProjectData(PROJECT, "html")) as string; expect(html.toLowerCase()).toContain(""); expect(html).toContain("The Glass Harbor"); expect(html).toContain("Chapter One — The Arrival"); }); it("docx contains the project title in its main document", async () => { // DOCX is an OOXML ZIP; the body lives in word/document.xml. const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "docx")); const file = zip.file("word/document.xml"); expect(file).not.toBeNull(); const xml = await file!.async("string"); expect(xml).toContain("The Glass Harbor"); }); it("pdf embeds the project title in a decompressed content stream", async () => { const pdf = await exportProjectData(PROJECT, "pdf"); expect(pdfContainsText(pdf as Uint8Array, "The Glass Harbor")).toBe(true); // A well-formed PDF begins with the magic header. expect(Buffer.from(pdf as Uint8Array).subarray(0, 5).toString("latin1")).toBe("%PDF-"); }); }); describe("exportProject — MD round-trip", () => { it("MD export → editor import → MD export is identical", async () => { const md1 = await exportProjectData(PROJECT, "markdown", { frontmatter: false }); const json = parseMarkdown(md1 as string); const md2 = serializeToMarkdown(json); // The exporter and the editor's serializer make independent formatting // choices (unordered-list glyph, scene-break glyph, trailing whitespace) // that do not change the parsed document. Normalize those and compare so // the assertion proves content equivalence, not glyph agreement. expect(normalizeMd(md2 as string)).toBe(normalizeMd(md1 as string)); }); it("flattened blocks round-trip through the same blocks", async () => { const flat = flattenProject(PROJECT); const blocks: Block[] = flat.blocks; expect(blocks.length).toBeGreaterThan(5); // The flatten step must be stable: flattening twice yields equal blocks. const flat2 = flattenProject(PROJECT); expect(flat2.blocks).toEqual(blocks); }); }); describe("exportProject — archive validation", () => { it("epub is a valid ZIP with an uncompressed, first mimetype entry", async () => { const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "epub")); const entries = Object.keys(zip.files); expect(entries).toContain("mimetype"); expect(entries).toContain("META-INF/container.xml"); expect(entries).toContain("OEBPS/content.opf"); const mimetype = zip.file("mimetype"); expect(mimetype).not.toBeNull(); // EPUB spec: mimetype must be the first entry and stored uncompressed. expect(mimetype!.dir).toBe(false); expect(mimetype!.name).toBe("mimetype"); const text = await mimetype!.async("string"); expect(text).toBe("application/epub+zip"); }); it("odt is a valid ZIP with an uncompressed, first mimetype entry", async () => { const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "odt")); const entries = Object.keys(zip.files); expect(entries).toContain("mimetype"); expect(entries).toContain("content.xml"); expect(entries).toContain("styles.xml"); const mimetype = zip.file("mimetype"); expect(mimetype).not.toBeNull(); expect(mimetype!.name).toBe("mimetype"); const text = await mimetype!.async("string"); expect(text).toBe("application/vnd.oasis.opendocument.text"); }); it("epub XML is well-formed (tags balanced)", async () => { const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "epub")); const opf = zip.file("OEBPS/content.opf"); expect(opf).not.toBeNull(); const xml = await opf!.async("string"); expect(xmlWellFormed(xml)).toBe(true); }); it("odt XML is well-formed (tags balanced)", async () => { const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "odt")); const content = zip.file("content.xml"); expect(content).not.toBeNull(); const xml = await content!.async("string"); expect(xmlWellFormed(xml)).toBe(true); }); }); /** * Decompress a PDF's FlateDecode content streams and return their concatenated * text. jsPDF compresses page content by default, so the title is not present * as plaintext in the raw bytes — we inflate the streams to inspect them. */ function pdfContainsText(pdf: Uint8Array, needle: string): boolean { const raw = Buffer.from(pdf); const text = raw.toString("latin1"); if (text.includes(needle)) return true; const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g; let match: RegExpExecArray | null; while ((match = streamRe.exec(text)) !== null) { const body = match[1] ?? ""; const buf = Buffer.from(body, "latin1"); try { if (inflateSync(buf).toString("latin1").includes(needle)) return true; } catch { // Not a FlateDecode stream; ignore. } } return false; } /** * Normalize CommonMark to a canonical form for content comparison. * * The exporter and the editor's serializer make independent formatting choices * that do not change the parsed document: unordered-list marker glyph * (`-`/`*`), scene-break glyph (`***` vs a paragraph break), and trailing * whitespace. Collapsing those lets the round-trip assertion focus on content. */ function normalizeMd(md: string): string { return md .split("\n") .map((line) => { // Scene-break marker -> blank line (the editor emits a paragraph break). if (/^\s*\*{3,}\s*$/.test(line)) return ""; // Unordered list marker -> the editor's `*`. return line.replace(/^(\s*)([-*+])(\s)/, "$1*$3"); }) .map((line) => line.replace(/[ \t]+$/, "")) // trim trailing whitespace .join("\n") .replace(/\n{3,}/g, "\n\n") // collapse runs of blank lines .trim(); } /** * A minimal well-formedness check: every opening tag has a matching closing tag * (ignoring self-closing and void tags) and the document starts with an XML * declaration or a root element. This is intentionally lightweight — it catches * the truncated/unclosed XML that would indicate a broken exporter, without * pulling in a full XML parser. */ function xmlWellFormed(xml: string): boolean { const stack: string[] = []; const tagRe = /<(\/?)([a-zA-Z_][\w:.-]*)([^>]*?)(\/?)>/g; const selfClosing = new Set([ "meta", "br", "hr", "img", "input", "link", "area", "base", "col", "embed", "source", "track", "wbr", ]); let match: RegExpExecArray | null; while ((match = tagRe.exec(xml)) !== null) { const closing = match[1] === "/"; const name = match[2] ?? ""; const endsSlash = match[4] === "/"; if (endsSlash || selfClosing.has(name)) continue; if (closing) { const top = stack.pop(); if (top !== name) return false; } else { stack.push(name); } } return stack.length === 0; }