/* 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, captureDownloads, check, clearDownloads, createNote, createNotebook, downloads, insertTable, lastDownloadName, launch, report, setTitle, typeInEditor, withFreshUser, } from "./lib.mjs"; const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC"; /** 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", { exact: true }).click(); await page.waitForSelector("[data-export-dialog]", { timeout: 10000 }); await page.waitForTimeout(800); }; /** Draws a couple of strokes so the note has something to export as a picture. */ async function drawSomething(page) { await page.locator("button").filter({ hasText: "Draw" }).first().click(); await page.waitForTimeout(1200); const box = await page.locator("canvas").first().boundingBox(); for (const [x1, y1, x2, y2] of [ [0.2, 0.25, 0.7, 0.4], [0.3, 0.6, 0.7, 0.55], ]) { await page.mouse.move(box.x + box.width * x1, box.y + box.height * y1); await page.mouse.down(); await page.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 12 }); await page.mouse.up(); await page.waitForTimeout(250); } await page.waitForTimeout(2500); await page.locator("button").filter({ hasText: "Write" }).first().click(); await page.waitForTimeout(800); } 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); /* A quote holding more than one kind of block. Written on consecutive lines, Markdown reads the closing sentence as more of the last bullet — so the blocks inside a quote have to be divided by a quoted blank line. */ await p.locator(".ProseMirror").click(); await p.keyboard.press("Control+End"); await p.keyboard.press("Enter"); await p.keyboard.press("Enter"); // out of the task list again await p.keyboard.type("> ### Quartalszahlen"); await p.keyboard.press("Enter"); await p.keyboard.type("- Umsatz"); await p.keyboard.press("Enter"); await p.keyboard.type("Gewinn"); await p.keyboard.press("Enter"); await p.keyboard.press("Enter"); await p.keyboard.type("Alles nach Plan."); await p.waitForTimeout(1800); await captureDownloads(p); /* ── single note as Markdown ── */ await openExportDialog(p); await p.locator("[data-export-dialog]").getByRole("button", { name: /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 lastDownloadName(p)), "it is named .md", await lastDownloadName(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"); check( md.includes("> ### Quartalszahlen\n>\n> - Umsatz"), "the blocks inside a quote are divided by a quoted blank line", md.split("\n").filter((l) => l.startsWith(">")).join(" ⏎ ") ); check( md.includes("> - Gewinn\n>\n> Alles nach Plan."), "so the sentence after a quoted list stays a sentence" ); 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 clearDownloads(p); await openExportDialog(p); await p.locator("[data-export-dialog]").getByRole("button", { name: /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) => /images\/.*\.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` }); /* ── the wizard on a note that has a drawing ── */ await createNote(p); await setTitle(p, "Mit Zeichnung"); await typeInEditor(p, "Text und Bild."); await drawSomething(p); await clearDownloads(p); await openExportDialog(p); check( (await p.getByText("What should come along?").count()) > 0, "a note with a drawing is asked what should come along" ); check( (await p.getByRole("button", { name: /Only the drawing/ }).count()) > 0, "the drawing-only option is offered" ); await p.screenshot({ path: `${OUT}/export-wizard.png` }); // drawing only → PNG await p.getByRole("button", { name: /Only the drawing/ }).click(); await p.waitForTimeout(500); check((await p.getByRole("button", { name: /PNG image/ }).count()) > 0, "PNG is offered for the drawing"); check( (await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0, "Markdown is not offered for a drawing, since it cannot hold one" ); await p.getByRole("button", { name: /PNG image/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); files = await downloads(p); check(files.length === 1 && files[0].type === "image/png", "the PNG was produced", files[0]?.type); const png = Buffer.from(files[0].base64, "base64"); check( png[0] === 0x89 && png[1] === 0x50 && png[2] === 0x4e && png[3] === 0x47, "and it really is a PNG", Array.from(png.slice(0, 4)).join(",") ); check(/\.png$/.test(await lastDownloadName(p)), "named .png", await lastDownloadName(p)); check(png.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`); // back, then text only → Markdown must carry no drawing await p.getByRole("button", { name: /Back/ }).click(); await p.waitForTimeout(500); await p.getByRole("button", { name: /Only the note text/ }).click(); await p.waitForTimeout(500); await clearDownloads(p); await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); files = await downloads(p); const textMd = Buffer.from(files[0].base64, "base64").toString("utf8"); check(textMd.includes("Text und Bild."), "the text-only export has the text"); await p.keyboard.press("Escape"); await p.waitForTimeout(600); /* ── whole notebook, via the sidebar's context menu ── */ await clearDownloads(p); 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); }