/* Export to Markdown and ZIP. * * The unit tests cover the serialiser; this checks the part they cannot — that * a click produces a real download whose bytes are what they should be. The * files are caught in the browser rather than written to disk, so the archive * can be unpacked and read here. */ import { OUT, check, createNote, createNotebook, insertTable, launch, report, setTitle, typeInEditor, withFreshUser, } from "./lib.mjs"; const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC"; /** Intercepts download() so the bytes stay in the page and can be inspected. */ async function captureDownloads(page) { await page.evaluate(() => { window.__downloads = []; const realCreate = URL.createObjectURL.bind(URL); URL.createObjectURL = (blob) => { const url = realCreate(blob); const reader = new FileReader(); reader.onload = () => window.__downloads.push({ url, type: blob.type, size: blob.size, // base64 of the payload, so binary survives the trip out of the page base64: String(reader.result).split(",")[1] || "", }); reader.readAsDataURL(blob); return url; }; // A real navigation would abandon the test run. HTMLAnchorElement.prototype.click = function () { window.__lastDownloadName = this.download; }; }); } const downloads = (page) => page.evaluate(() => window.__downloads || []); const lastName = (page) => page.evaluate(() => window.__lastDownloadName || ""); /** Unpacks a stored-only ZIP far enough to list names and read text entries. */ function readZip(bytes) { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const dec = new TextDecoder(); const endAt = bytes.length - 22; const count = view.getUint16(endAt + 8, true); let p = view.getUint32(endAt + 16, true); const out = {}; for (let i = 0; i < count; i++) { const size = view.getUint32(p + 24, true); const nameLen = view.getUint16(p + 28, true); const localAt = view.getUint32(p + 42, true); const name = dec.decode(bytes.slice(p + 46, p + 46 + nameLen)); const dataAt = localAt + 30 + view.getUint16(localAt + 26, true) + view.getUint16(localAt + 28, true); out[name] = bytes.slice(dataAt, dataAt + size); p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true); } return out; } const openExportDialog = async (page) => { await page.getByTitle("Export").click(); await page.waitForSelector("[data-export-dialog]", { timeout: 10000 }); await page.waitForTimeout(500); }; const browser = await launch(); let session; try { session = await withFreshUser(browser, "export"); const p = session.page; await createNotebook(p, "Export Buch"); /* ── a note with everything that is hard to serialise ── */ await createNote(p); await setTitle(p, "Alles drin"); await typeInEditor(p, "Einleitender Satz."); await insertTable(p); await p.locator(".ProseMirror table th").first().click(); await p.keyboard.type("Spalte"); await p.waitForTimeout(600); await p.locator(".ProseMirror").click(); await p.keyboard.press("Control+End"); await p.keyboard.press("Enter"); await p.getByTitle("Task list").click(); await p.waitForTimeout(400); await p.keyboard.type("Erledigte Sache"); await p.waitForTimeout(800); await p.locator('.ProseMirror input[type="checkbox"]').first().click(); await p.waitForTimeout(1800); await captureDownloads(p); /* ── single note as Markdown ── */ await openExportDialog(p); await p.getByRole("button", { name: /Als Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(600); let files = await downloads(p); check(files.length === 1, "a single note produces one download", `${files.length}`); check(/\.md$/.test(await lastName(p)), "it is named .md", await lastName(p)); const md = Buffer.from(files[0].base64, "base64").toString("utf8"); check(md.startsWith("# Alles drin"), "the title became a heading"); check(md.includes("Einleitender Satz."), "the body text is there"); check(/\|\s*Spalte\s*\|/.test(md), "the table came along", md.match(/\|.*\|/)?.[0] || "no table row"); check(md.includes("| --- |"), "the table has a separator row so it renders as a table"); check(md.includes("- [x] Erledigte Sache"), "the ticked task is a ticked checkbox"); console.log("--- Markdown ---\n" + md + "\n--- Ende ---"); // The dialog stays up after an export so the result can be read; close it or // its backdrop swallows the next click. await p.keyboard.press("Escape"); await p.waitForTimeout(600); /* ── a second note and an image, then the whole notebook ── */ await createNote(p); await setTitle(p, "Zweite Notiz"); await typeInEditor(p, "Kurzer Text mit Bild."); await p.locator(".ProseMirror").click(); await p.keyboard.press("Control+End"); await p.evaluate(async (b64) => { const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const dt = new DataTransfer(); dt.items.add(new File([bytes], "dot.png", { type: "image/png" })); document .querySelector(".ProseMirror") .dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })); }, PNG); await p.waitForTimeout(3500); await p.evaluate(() => (window.__downloads = [])); await openExportDialog(p); await p.getByRole("button", { name: /Als Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); files = await downloads(p); check(files.length === 1 && /zip/.test(files[0].type), "a note with an image is packed into a ZIP", files[0]?.type); const zip = readZip(new Uint8Array(Buffer.from(files[0].base64, "base64"))); const names = Object.keys(zip); check(names.some((n) => n.endsWith(".md")), "the archive holds the Markdown file", names.join(", ")); check(names.some((n) => /bilder\/.*\.png$/.test(n)), "and the image beside it", names.join(", ")); await p.keyboard.press("Escape"); await p.waitForTimeout(600); const imgBytes = zip[names.find((n) => n.endsWith(".png"))]; check( imgBytes[0] === 0x89 && imgBytes[1] === 0x50 && imgBytes[2] === 0x4e && imgBytes[3] === 0x47, "the extracted image really is a PNG", Array.from(imgBytes.slice(0, 4)).join(",") ); await p.screenshot({ path: `${OUT}/export-dialog.png` }); /* ── whole notebook, via the sidebar's context menu ── */ await p.evaluate(() => (window.__downloads = [])); await p.locator("[data-note-row]").first().click({ button: "right" }); await p.waitForTimeout(600); await p.getByText("Export...", { exact: true }).click(); await p.waitForSelector("[data-export-dialog]", { timeout: 10000 }); await p.waitForTimeout(500); check(true, "the context menu opens the export dialog"); await p.keyboard.press("Escape"); } catch (e) { check(false, "suite ran to the end", e.message); } finally { await session?.cleanup(); const code = report(); await browser.close(); process.exit(code); }