Files
InkNote/e2e/lib.mjs
T
marcandClaude Opus 5 a32d6eba99 Export a PDF with the drawings written into it
Stage seven, and the last of the PDF work. A PDF note now offers the
document with your marks stamped in, the untouched original, or just the
notes written beside it.

pdf.js only reads, so writing needs pdf-lib — behind a dynamic import
like pdf.js, so the bundle everyone downloads carries no PDF writer for
the sake of people who never export one. Main bundle grew 6 kB; the
180 kB writer arrives only when used.

The coordinate decision from stage four is what makes this a translation
rather than a reconstruction: strokes were stored in page points from the
start, the only space a PDF understands, so nothing has to be guessed
back from screen pixels. Only the origin moves, PDF putting it at the
bottom-left.

The stored file is never modified — pdf-lib builds a new document — so a
clean copy stays available however often it has been drawn on. The suite
re-fetches the stored bytes after exporting and compares them, and checks
the plain export is byte-identical to what was imported.

Eraser strokes are dropped. On screen they remove ink by drawing in
destination-out; a PDF content stream has no equivalent, and stamping
them would put grey scribbles exactly where something had been rubbed
out.

The suite renders both files with pdf.js and counts dark pixels, 1633
against 2557. A larger file only proves bytes were added; this proves ink
can be seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:49:01 +02:00

207 lines
6.9 KiB
JavaScript

/**
* Shared plumbing for the browser suites.
*
* Each suite runs against a throwaway account created through the admin API and
* deletes it again at the end, so repeated runs leave the database as they found
* it. (An earlier generation of these tests registered users through the sign-up
* form and left a trail of them behind; sign-up is gone now, and this is both
* the replacement and the tidier arrangement.)
*/
import { chromium } from "playwright";
export const BASE = process.env.INKNOTE_URL || "http://localhost:3000";
export const OUT = process.env.INKNOTE_SHOTS || "/out";
const ADMIN = {
user: process.env.INKNOTE_ADMIN || "marc",
pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
};
/* ── reporting ── */
const state = { failures: 0, checks: 0, errors: [] };
export function check(ok, label, extra = "") {
state.checks++;
if (!ok) state.failures++;
console.log(`${ok ? "PASS" : "FAIL"}: ${label}${extra ? ` (${extra})` : ""}`);
return ok;
}
/** Console noise that says nothing about the app under test. */
const IGNORED = /401|403|404|Failed to load resource|favicon/;
export function report() {
const errs = state.errors.filter((e) => !IGNORED.test(e));
console.log("\nERRORS=" + JSON.stringify(errs));
const bad = state.failures > 0 || errs.length > 0;
console.log(
bad
? `\n*** ${state.failures} of ${state.checks} CHECK(S) FAILED${errs.length ? `, ${errs.length} PAGE ERROR(S)` : ""} ***`
: `\n*** ALL ${state.checks} CHECKS PASSED ***`
);
return bad ? 1 : 0;
}
/* ── browser ── */
export async function launch() {
return chromium.launch();
}
export async function newPage(browser, width = 1400, height = 950) {
const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
const page = await ctx.newPage();
page.on("pageerror", (e) => state.errors.push(`[err] ${e.message}`));
page.on("console", (m) => {
if (m.type() === "error") state.errors.push(`[console] ${m.text()}`);
});
return page;
}
export async function signIn(page, user, pass) {
await page.goto(BASE, { waitUntil: "networkidle" });
await page.getByPlaceholder("Username").fill(user);
await page.getByPlaceholder("Password").fill(pass);
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForSelector("text=Dashboard", { timeout: 20000 });
await page.waitForTimeout(1200);
}
/** Runs a fetch inside the page, carrying that page's own token. */
export const api = (page, method, path, body) =>
page.evaluate(
async ([m, u, b]) => {
const r = await fetch("/api" + u, {
method: m,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + localStorage.getItem("inknote_token"),
},
body: b ? JSON.stringify(b) : undefined,
});
const text = await r.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
/* some routes answer with no body */
}
return { status: r.status, body: json };
},
[method, path, body]
);
/**
* Signs in as the admin, creates a fresh account, and signs into it on a second
* page. Call the returned `cleanup` when done — it deletes the account and with
* it every note the test made.
*/
export async function withFreshUser(browser, prefix = "e2e") {
const user = `${prefix}${Math.floor(Math.random() * 1e6)}`;
const pass = "e2epass123";
const adminPage = await newPage(browser);
await signIn(adminPage, ADMIN.user, ADMIN.pass);
const created = await api(adminPage, "POST", "/admin/users", {
username: user,
password: pass,
displayName: "E2E",
});
if (created.status !== 201) {
throw new Error(`could not create test user (${created.status}): ${JSON.stringify(created.body)}`);
}
const page = await newPage(browser);
await signIn(page, user, pass);
return {
page,
user,
pass,
async cleanup() {
await api(adminPage, "DELETE", `/admin/users/${created.body.id}`);
await adminPage.context().close();
},
};
}
/**
* Intercepts downloads so their bytes stay in the page and can be inspected.
*
* A real download would either navigate away or land somewhere the suite cannot
* read; this keeps the blob and reports it as base64.
*/
export 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({
type: blob.type,
size: blob.size,
base64: String(reader.result).split(",")[1] || "",
});
reader.readAsDataURL(blob);
return url;
};
HTMLAnchorElement.prototype.click = function () {
window.__lastDownloadName = this.download;
};
});
}
export const downloads = (page) => page.evaluate(() => window.__downloads || []);
export const lastDownloadName = (page) => page.evaluate(() => window.__lastDownloadName || "");
export const clearDownloads = (page) => page.evaluate(() => (window.__downloads = []));
/* ── app actions the suites share ── */
export async function createNotebook(page, name) {
await page.locator("button").filter({ hasText: "Dashboard" }).first().click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Add Notebook" }).click();
await page.waitForTimeout(400);
await page.getByPlaceholder("Notebook name...").fill(name);
await page.getByRole("button", { name: "Create", exact: true }).click();
await page.waitForTimeout(1200);
}
export async function createNote(page) {
await page.getByTitle("New Note").click();
await page.waitForSelector(".ProseMirror", { timeout: 15000 });
await page.waitForTimeout(1200);
}
/** The sidebar search box is also a text input, so the title needs its own handle. */
export async function setTitle(page, title) {
await page.getByLabel("Note title").fill(title);
await page.waitForTimeout(1200);
}
/** The toolbar's table button opens a menu; the table comes from the entry inside. */
export async function insertTable(page) {
await page.getByTitle("Table").click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: /Insert 3 . 3 table/ }).click();
await page.waitForTimeout(900);
}
export async function typeInEditor(page, text) {
await page.locator(".ProseMirror").click();
await page.keyboard.type(text);
// Longer than the editor's save debounce, so the write is on its way out.
await page.waitForTimeout(1500);
}
/** Reopens the note from a cold page load — the check that a write really landed. */
export async function reopenNote(page, title) {
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(2500);
await page.locator("button").filter({ hasText: title }).first().click();
await page.waitForSelector(".ProseMirror", { timeout: 15000 });
await page.waitForTimeout(1500);
}