diff --git a/e2e/.gitignore b/e2e/.gitignore
new file mode 100644
index 0000000..a4db53f
--- /dev/null
+++ b/e2e/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+shots/
diff --git a/e2e/admin.mjs b/e2e/admin.mjs
new file mode 100644
index 0000000..c2f23e5
--- /dev/null
+++ b/e2e/admin.mjs
@@ -0,0 +1,138 @@
+/* No public sign-up, and user administration.
+ * Checks the interface and, separately, that the server refuses on its own —
+ * a hidden button is not access control. */
+import { BASE, OUT, api, check, launch, newPage, report, signIn } from "./lib.mjs";
+
+const ADMIN = {
+ user: process.env.INKNOTE_ADMIN || "marc",
+ pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
+};
+const NEW = {
+ user: "kollege" + Math.floor(Math.random() * 1e6),
+ pass: "startpass1",
+ pass2: "zweitpass9",
+};
+
+const openAdmin = async (p) => {
+ await p.getByTitle("Account").click();
+ await p.waitForTimeout(500);
+ await p.getByRole("button", { name: "Administration" }).click();
+ await p.waitForTimeout(1200);
+};
+
+const browser = await launch();
+try {
+ /* ── 1. sign-up is gone ── */
+ const p = await newPage(browser);
+ await p.goto(BASE, { waitUntil: "networkidle" });
+ await p.waitForTimeout(800);
+ const labels = await p.$$eval("button, a", (els) => els.map((e) => e.innerText.trim()).filter(Boolean));
+ check(!labels.some((t) => /sign ?up|create account/i.test(t)), "no sign-up control on the login screen");
+ await p.screenshot({ path: `${OUT}/admin-login.png` });
+
+ const reg = await p.evaluate(async () => {
+ const r = await fetch("/api/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: "sneaky" + Date.now(), password: "hunter22" }),
+ });
+ return r.status;
+ });
+ check(reg === 404, "POST /api/auth/register is gone", "status " + reg);
+
+ /* ── 2. an admin can create ── */
+ await signIn(p, ADMIN.user, ADMIN.pass);
+ await openAdmin(p);
+ check((await p.locator("text=Administration").count()) > 0, "admin panel opens");
+
+ await p.getByRole("button", { name: "New account" }).click();
+ await p.waitForTimeout(400);
+ await p.getByPlaceholder("Username").fill(NEW.user);
+ await p.getByPlaceholder("Display name (optional)").fill("Kollege");
+ await p.getByPlaceholder("Initial password").fill(NEW.pass);
+ await p.getByRole("button", { name: "Create account" }).click();
+ await p.waitForTimeout(1800);
+ check((await p.locator(`[data-user="${NEW.user}"]`).count()) === 1, "created user appears in the list");
+ await p.screenshot({ path: `${OUT}/admin-panel.png` });
+
+ /* ── 3. the account works, and is not privileged ── */
+ const p2 = await newPage(browser);
+ await signIn(p2, NEW.user, NEW.pass);
+ check(true, "created user can sign in");
+ const seesAdmin = await p2.evaluate(async () => {
+ document.querySelector('[title="Account"]')?.click();
+ await new Promise((r) => setTimeout(r, 600));
+ return [...document.querySelectorAll("button")].some((b) => /Administration/.test(b.innerText));
+ });
+ check(!seesAdmin, "ordinary user sees no Administration entry");
+ check((await api(p2, "GET", "/admin/users")).status === 403, "GET /admin/users refused for non-admin");
+ check(
+ (await api(p2, "POST", "/admin/users", { username: "elev" + Date.now(), password: "hunter22" })).status ===
+ 403,
+ "POST /admin/users refused for non-admin"
+ );
+
+ /* ── 4. password reset ── */
+ await p.reload({ waitUntil: "networkidle" });
+ await p.waitForTimeout(1500);
+ await openAdmin(p);
+ const row = p.locator(`[data-user="${NEW.user}"]`);
+ await row.getByRole("button", { name: "Reset password" }).click();
+ await p.waitForTimeout(400);
+ await row.getByPlaceholder(`New password for ${NEW.user}`).fill(NEW.pass2);
+ await row.getByRole("button", { name: "Set password", exact: true }).click();
+ await p.waitForTimeout(1500);
+
+ const p3 = await newPage(browser);
+ await p3.goto(BASE, { waitUntil: "networkidle" });
+ await p3.getByPlaceholder("Username").fill(NEW.user);
+ await p3.getByPlaceholder("Password").fill(NEW.pass);
+ await p3.getByRole("button", { name: "Sign In" }).click();
+ await p3.waitForTimeout(1500);
+ check((await p3.locator("text=Invalid credentials").count()) > 0, "the old password stopped working");
+ await p3.getByPlaceholder("Password").fill(NEW.pass2);
+ await p3.getByRole("button", { name: "Sign In" }).click();
+ await p3.waitForSelector("text=Dashboard", { timeout: 20000 });
+ check(true, "the new password works");
+
+ /* ── 5. deletion, and its guards ── */
+ await p.reload({ waitUntil: "networkidle" });
+ await p.waitForTimeout(1500);
+ await openAdmin(p);
+ const me = await api(p, "GET", "/auth/me");
+ check(
+ (await api(p, "DELETE", `/admin/users/${me.body.id}`)).status === 400,
+ "the server refuses an admin deleting themselves"
+ );
+ check(
+ (await p.locator(`[data-user="${ADMIN.user}"] button[title^="Delete"]`).count()) === 0,
+ "no delete button on the admin's own row"
+ );
+
+ await p.locator(`button[title="Delete ${NEW.user}"]`).click();
+ await p.waitForTimeout(500);
+ check(
+ await p.getByRole("button", { name: "Delete permanently" }).isDisabled(),
+ "delete stays disabled until the name is typed back"
+ );
+ await p.getByPlaceholder(NEW.user).fill(NEW.user);
+ await p.waitForTimeout(300);
+ await p.getByRole("button", { name: "Delete permanently" }).click();
+ await p.waitForTimeout(1800);
+ // The success banner repeats the name, so match the row itself, not any text.
+ check((await p.locator(`[data-user="${NEW.user}"]`).count()) === 0, "the user is gone from the list");
+
+ const p4 = await newPage(browser);
+ await p4.goto(BASE, { waitUntil: "networkidle" });
+ await p4.getByPlaceholder("Username").fill(NEW.user);
+ await p4.getByPlaceholder("Password").fill(NEW.pass2);
+ await p4.getByRole("button", { name: "Sign In" }).click();
+ await p4.waitForTimeout(1500);
+ check((await p4.locator("text=Invalid credentials").count()) > 0, "the deleted user cannot sign in");
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/e2e/drawing.mjs b/e2e/drawing.mjs
new file mode 100644
index 0000000..40083fe
--- /dev/null
+++ b/e2e/drawing.mjs
@@ -0,0 +1,140 @@
+/* Drawings must look the same on every screen.
+ *
+ * Strokes are stored as vectors in a fixed 1200x1600 page space, so the same
+ * note rendered at different viewport widths should differ only by rasterising
+ * error. Each rendering is reduced to a coarse ink grid and compared cell by
+ * cell, which ignores antialiasing but catches a stroke that moved or vanished.
+ *
+ * The second thing checked here is the bug that started all of this: the 6-second
+ * poller adopting a server response that was issued before the local save, which
+ * made strokes disappear again seconds after being drawn. */
+import {
+ OUT,
+ check,
+ createNote,
+ createNotebook,
+ launch,
+ reopenNote,
+ report,
+ setTitle,
+ withFreshUser,
+} from "./lib.mjs";
+
+const GRID_W = 48,
+ GRID_H = 64;
+
+/** Rasterises the drawing surface into a GRID_W x GRID_H map of "has ink". */
+const inkGrid = (page) =>
+ page.evaluate(
+ ([gw, gh]) => {
+ const c = document.querySelector("canvas");
+ if (!c) return null;
+ const off = document.createElement("canvas");
+ off.width = gw;
+ off.height = gh;
+ const ctx = off.getContext("2d", { willReadFrequently: true });
+ ctx.drawImage(c, 0, 0, gw, gh);
+ const d = ctx.getImageData(0, 0, gw, gh).data;
+ const out = [];
+ for (let i = 0; i < gw * gh; i++) out.push(d[i * 4 + 3] > 40 ? 1 : 0);
+ return out;
+ },
+ [GRID_W, GRID_H]
+ );
+
+const diff = (a, b) => a.reduce((n, v, i) => n + (v === b[i] ? 0 : 1), 0);
+const ink = (g) => g.reduce((n, v) => n + v, 0);
+
+async function draw(page) {
+ await page.locator("button").filter({ hasText: "Draw" }).first().click();
+ await page.waitForTimeout(1200);
+ const box = await page.locator("canvas").first().boundingBox();
+ // Three strokes in different parts of the page, so a shifted origin shows up.
+ const strokes = [
+ [0.2, 0.2, 0.7, 0.35],
+ [0.25, 0.55, 0.75, 0.5],
+ [0.5, 0.7, 0.55, 0.85],
+ ];
+ for (const [x1, y1, x2, y2] of strokes) {
+ 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: 14 });
+ await page.mouse.up();
+ await page.waitForTimeout(300);
+ }
+ await page.waitForTimeout(2500);
+ return box;
+}
+
+const browser = await launch();
+let session;
+try {
+ session = await withFreshUser(browser, "draw");
+ const p = session.page;
+ await createNotebook(p, "Zeichen Buch");
+ await createNote(p);
+ await setTitle(p, "Zeichnung");
+
+ await draw(p);
+ const reference = await inkGrid(p);
+ check(reference !== null && ink(reference) > 0, "strokes were drawn", `${ink(reference || [])} ink cells`);
+ await p.screenshot({ path: `${OUT}/drawing-1400.png` });
+
+ /* ── the poller must not undo what was just drawn ── */
+ await p.waitForTimeout(14000);
+ const afterPolling = await inkGrid(p);
+ check(diff(reference, afterPolling) === 0, "strokes survive 14s of background polling", `${diff(reference, afterPolling)} cells changed`);
+
+ /* ── same note, other screen widths ── */
+ const noteId = await p.evaluate(async () => {
+ const r = await fetch("/api/notes", {
+ headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
+ });
+ return (await r.json())[0].id;
+ });
+
+ for (const width of [1000, 700, 420]) {
+ await p.setViewportSize({ width, height: 900 });
+ await p.waitForTimeout(2500);
+ if (width < 768) {
+ // Shrinking past the breakpoint leaves the sidebar open as a full-screen
+ // overlay, and its backdrop swallows every click. Dismiss it by clicking
+ // to the right of the panel, then reopen the note from the list.
+ await p.mouse.click(width - 12, 450);
+ await p.waitForTimeout(800);
+ const row = p.locator(`[data-note-row="${noteId}"]`);
+ if (await row.count()) {
+ await row.click();
+ await p.waitForTimeout(2500);
+ }
+ }
+ const g = await inkGrid(p);
+ if (!g) {
+ check(false, `drawing visible at ${width}px`);
+ continue;
+ }
+ const d = diff(reference, g);
+ const pct = ((d / (GRID_W * GRID_H)) * 100).toFixed(1);
+ check(d / (GRID_W * GRID_H) < 0.02, `drawing matches at ${width}px`, `${d} cells, ${pct}%`);
+ await p.screenshot({ path: `${OUT}/drawing-${width}.png` });
+ }
+
+ /* ── and after a reload ── */
+ // A reload lands on the dashboard, which has no note list — reopen by title.
+ await p.setViewportSize({ width: 1400, height: 950 });
+ await reopenNote(p, "Zeichnung");
+ await p.waitForTimeout(2000);
+ const afterReload = await inkGrid(p);
+ check(afterReload !== null && ink(afterReload) > 0, "the drawing is still there after a reload");
+ if (afterReload) {
+ const d = diff(reference, afterReload);
+ check(d / (GRID_W * GRID_H) < 0.02, "the reloaded drawing matches the original", `${d} cells`);
+ }
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ await session?.cleanup();
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/e2e/editor.mjs b/e2e/editor.mjs
new file mode 100644
index 0000000..c99d212
--- /dev/null
+++ b/e2e/editor.mjs
@@ -0,0 +1,110 @@
+/* Editor: tables, task lists, links between notes, and whether any of it
+ * survives a reload — the place where a stale server response used to eat
+ * freshly typed content. */
+import {
+ OUT,
+ check,
+ createNote,
+ createNotebook,
+ insertTable,
+ launch,
+ reopenNote,
+ report,
+ setTitle,
+ typeInEditor,
+ withFreshUser,
+} from "./lib.mjs";
+
+const browser = await launch();
+let session;
+try {
+ session = await withFreshUser(browser, "editor");
+ const p = session.page;
+
+ await createNotebook(p, "Editor Book");
+ await createNote(p);
+ await typeInEditor(p, "Grundtext");
+
+ /* ── tables ── */
+ await insertTable(p);
+ check((await p.locator(".ProseMirror table").count()) > 0, "table inserted");
+ check((await p.locator(".ProseMirror table th").count()) >= 3, "table has a header row");
+
+ /* ── task list ── */
+ 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("Milch kaufen");
+ await p.waitForTimeout(1200);
+ check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list inserted");
+ await p.locator('.ProseMirror input[type="checkbox"]').first().click();
+ await p.waitForTimeout(1500);
+
+ await p.screenshot({ path: `${OUT}/editor-content.png` });
+
+ /* ── a second note to link to ── */
+ await setTitle(p, "Quelle");
+ await createNote(p);
+ await setTitle(p, "Ziel");
+ await typeInEditor(p, "Ich bin das Ziel.");
+
+ await p.locator("button").filter({ hasText: "Quelle" }).first().click();
+ await p.waitForSelector(".ProseMirror", { timeout: 15000 });
+ await p.waitForTimeout(1200);
+
+ /* ── note link via the @ menu ── */
+ // Control+End lands inside the task list, where a plain Enter would only make
+ // another task. A second Enter on the empty item leaves the list, so the link
+ // goes into a paragraph of its own and the checked task above stays untouched.
+ await p.locator(".ProseMirror").click();
+ await p.keyboard.press("Control+End");
+ await p.keyboard.press("Enter");
+ await p.keyboard.press("Enter");
+ await p.waitForTimeout(300);
+ await p.keyboard.type("@Ziel");
+ await p.waitForTimeout(1400);
+ const menuOpen = (await p.locator("text=Ziel").count()) > 0;
+ check(menuOpen, "@ opens the link menu");
+ await p.keyboard.press("Enter");
+ await p.waitForTimeout(1800);
+
+ const linkCount = await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count();
+ check(linkCount > 0, "note link inserted into the document", `${linkCount} found`);
+ await p.screenshot({ path: `${OUT}/editor-link.png` });
+
+ /* ── the server learned about it ── */
+ await p.waitForTimeout(1500);
+ await p.locator("button").filter({ hasText: "Ziel" }).first().click();
+ await p.waitForSelector(".ProseMirror", { timeout: 15000 });
+ await p.waitForTimeout(2000);
+ const backlink = await p.locator("text=Quelle").count();
+ check(backlink > 0, "the target shows a backlink to the source");
+
+ /* ── everything survives a cold reload ── */
+ await reopenNote(p, "Quelle");
+ check((await p.locator(".ProseMirror table").count()) > 0, "table survived a reload");
+ check(
+ (await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count()) > 0,
+ "note link survived a reload"
+ );
+ check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list survived a reload");
+ check(
+ (await p.locator('.ProseMirror input[type="checkbox"]:checked').count()) === 1,
+ "the ticked task stayed ticked, and only that one"
+ );
+ check(
+ (await p.locator(".ProseMirror li").filter({ hasText: "Ziel" }).count()) === 0,
+ "the note link went into a paragraph, not into the task list"
+ );
+ check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
+ await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ await session?.cleanup();
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/e2e/history.mjs b/e2e/history.mjs
new file mode 100644
index 0000000..06ff665
--- /dev/null
+++ b/e2e/history.mjs
@@ -0,0 +1,97 @@
+/* Version history: the state before a write is kept, and restoring one brings
+ * the old text back into the open editor without a reload. */
+import {
+ OUT,
+ api,
+ check,
+ createNote,
+ createNotebook,
+ launch,
+ report,
+ setTitle,
+ typeInEditor,
+ withFreshUser,
+} from "./lib.mjs";
+
+const LONG = "Der ausführliche erste Entwurf, der später verloren gehen soll. ".repeat(6);
+
+const browser = await launch();
+let session;
+try {
+ session = await withFreshUser(browser, "hist");
+ const p = session.page;
+ await createNotebook(p, "Verlauf Buch");
+ await createNote(p);
+ await setTitle(p, "Mit Verlauf");
+
+ await typeInEditor(p, LONG);
+ check((await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"), "the long draft is in the note");
+
+ const id = await p.evaluate(async () => {
+ const r = await fetch("/api/notes", {
+ headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
+ });
+ return (await r.json())[0].id;
+ });
+
+ /* Replace it with a stub. Shrinking a note by more than half is treated as a
+ * large deletion and bypasses the 5-minute coalescing window, so a version is
+ * kept immediately — exactly the case worth being able to undo. */
+ await p.locator(".ProseMirror").click();
+ await p.keyboard.press("Control+A");
+ await p.keyboard.type("Kurz.");
+ await p.waitForTimeout(3000);
+ check((await p.locator(".ProseMirror").innerText()).trim().startsWith("Kurz"), "the note now shows the stub");
+
+ const versions = await api(p, "GET", `/notes/${id}/versions`);
+ check(versions.status === 200 && versions.body.length > 0, "a version was kept", `${versions.body?.length} versions`);
+ check(
+ versions.body.some((v) => v.reason === "large-deletion"),
+ "the large deletion was recorded as such",
+ JSON.stringify(versions.body?.map((v) => v.reason))
+ );
+
+ /* ── restore through the interface ── */
+ await p.getByTitle("Version history").click();
+ await p.waitForTimeout(1800);
+ check((await p.locator("text=Restore this version").count()) > 0, "the history dialog opens");
+ await p.screenshot({ path: `${OUT}/history-open.png` });
+
+ const preview = await p.locator("text=erste Entwurf").count();
+ check(preview > 0, "the preview shows the lost text");
+
+ await p.getByRole("button", { name: "Restore this version" }).click();
+ await p.waitForTimeout(2500);
+ check((await p.locator("text=Restore this version").count()) === 0, "the dialog closed after restoring");
+ check(
+ (await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"),
+ "the editor shows the restored text without a reload"
+ );
+
+ /* ── and it really went to the server ── */
+ await p.reload({ waitUntil: "networkidle" });
+ await p.waitForTimeout(2500);
+ await p.locator("button").filter({ hasText: "Mit Verlauf" }).first().click();
+ await p.waitForSelector(".ProseMirror", { timeout: 15000 });
+ await p.waitForTimeout(2000);
+ check(
+ (await p.locator(".ProseMirror").innerText()).includes("erste Entwurf"),
+ "the restored text survived a reload"
+ );
+
+ /* Restoring is itself a change, so the stub must now be recoverable too. */
+ const after = await api(p, "GET", `/notes/${id}/versions`);
+ check(
+ after.body.some((v) => v.reason === "before-restore"),
+ "restoring kept a version of what it replaced",
+ JSON.stringify(after.body?.map((v) => v.reason))
+ );
+ await p.screenshot({ path: `${OUT}/history-restored.png` });
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ await session?.cleanup();
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/e2e/lib.mjs b/e2e/lib.mjs
new file mode 100644
index 0000000..579572d
--- /dev/null
+++ b/e2e/lib.mjs
@@ -0,0 +1,174 @@
+/**
+ * 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();
+ },
+ };
+}
+
+/* ── 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);
+}
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
new file mode 100644
index 0000000..d570647
--- /dev/null
+++ b/e2e/package-lock.json
@@ -0,0 +1,57 @@
+{
+ "name": "inknote-e2e",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "inknote-e2e",
+ "dependencies": {
+ "playwright": "1.49.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz",
+ "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.49.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz",
+ "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000..6cbda35
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "inknote-e2e",
+ "private": true,
+ "type": "module",
+ "dependencies": { "playwright": "1.49.0" }
+}
diff --git a/e2e/run.sh b/e2e/run.sh
new file mode 100755
index 0000000..58a7454
--- /dev/null
+++ b/e2e/run.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Runs the browser suites against a running InkNote.
+#
+# ./e2e/run.sh # all suites
+# ./e2e/run.sh editor drawing # only these
+#
+# Needs the dev stack up (docker compose -f docker-compose.dev.yml up -d) and an
+# admin account to create throwaway users with — marc/geheim123 by default,
+# override with INKNOTE_ADMIN / INKNOTE_ADMIN_PASS.
+set -uo pipefail
+cd "$(dirname "$0")"
+
+IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
+URL="${INKNOTE_URL:-http://localhost:3000}"
+SUITES=("$@")
+[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing)
+
+mkdir -p shots
+if [ ! -d node_modules ]; then
+ echo "→ installing playwright once"
+ docker run --rm -v "$PWD:/w" -w /w "$IMAGE" npm install --no-audit --no-fund >/dev/null
+fi
+
+if ! curl -sf -o /dev/null "$URL"; then
+ echo "✗ $URL is not answering — start the stack first:"
+ echo " docker compose -f docker-compose.dev.yml up -d --build"
+ exit 1
+fi
+
+failed=()
+for s in "${SUITES[@]}"; do
+ echo
+ echo "═══ $s ═══"
+ docker run --rm --network host \
+ -v "$PWD:/w" -v "$PWD/shots:/out" -w /w \
+ -e INKNOTE_URL="$URL" \
+ -e INKNOTE_ADMIN="${INKNOTE_ADMIN:-marc}" \
+ -e INKNOTE_ADMIN_PASS="${INKNOTE_ADMIN_PASS:-geheim123}" \
+ "$IMAGE" node "$s.mjs" || failed+=("$s")
+done
+
+echo
+if [ ${#failed[@]} -eq 0 ]; then
+ echo "═══ all suites passed ═══"
+else
+ echo "═══ FAILED: ${failed[*]} ═══"
+ exit 1
+fi
diff --git a/e2e/search-attachments.mjs b/e2e/search-attachments.mjs
new file mode 100644
index 0000000..0cab5c1
--- /dev/null
+++ b/e2e/search-attachments.mjs
@@ -0,0 +1,113 @@
+/* Full-text search over locally held bodies, and image attachments.
+ * Search runs in the client because the server cannot read encrypted notes, so
+ * the interesting part is whether the backfill fills the index at all. */
+import {
+ OUT,
+ check,
+ createNote,
+ createNotebook,
+ launch,
+ report,
+ setTitle,
+ typeInEditor,
+ withFreshUser,
+} from "./lib.mjs";
+
+/** A 3x3 red PNG, small enough to paste inline. */
+const PNG =
+ "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC";
+
+/* Rows in the note list carry data-note-row; matching on the emoji instead would
+ * also pick up the sidebar's Trash entry, which wears the same one. */
+const titles = (p) =>
+ p.$$eval("[data-note-row]", (els) =>
+ // The button holds emoji, title and date on separate lines.
+ els.map((e) => e.innerText.split("\n").filter(Boolean)[1]).filter(Boolean)
+ );
+
+const browser = await launch();
+let session;
+try {
+ session = await withFreshUser(browser, "search");
+ const p = session.page;
+ await createNotebook(p, "Such Buch");
+
+ const seed = [
+ ["Notiz A", "Der Kühlschrank braucht eine neue Dichtung."],
+ ["Notiz B", "Steuererklärung abgeben, Belege liegen im Ordner."],
+ ["Notiz C", "Ein völlig eigenständiges Stichwort: Zebrastreifen."],
+ ["Dichtung", "Hier steht das Wort nur im Titel."],
+ ];
+ for (const [title, body] of seed) {
+ await createNote(p);
+ await setTitle(p, title);
+ await typeInEditor(p, body);
+ }
+ console.log(`seeded ${seed.length} notes`);
+ // Give the background backfill time to index the bodies.
+ await p.waitForTimeout(3000);
+
+ const search = async (q) => {
+ await p.getByPlaceholder("Search notes...").fill(q);
+ await p.waitForTimeout(1200);
+ return titles(p);
+ };
+
+ check(JSON.stringify(await search("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p)));
+ const hits = await search("Dichtung");
+ check(hits.length === 2, "a word in one title and another body finds both", JSON.stringify(hits));
+ check(hits[0] === "Dichtung", "the title match ranks first", JSON.stringify(hits));
+ check(JSON.stringify(await search("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND");
+ check((await search("Belege Kühlschrank")).length === 0, "terms from different notes match nothing");
+ check(JSON.stringify(await search("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts");
+ await p.screenshot({ path: `${OUT}/search-results.png` });
+
+ await p.getByPlaceholder("Search notes...").fill("");
+ await p.waitForTimeout(1000);
+ check((await titles(p)).length === seed.length, "clearing the box restores the full list");
+
+ /* ── attachments ── */
+ await p.locator("button").filter({ hasText: "Notiz A" }).first().click();
+ await p.waitForSelector(".ProseMirror", { timeout: 15000 });
+ await p.waitForTimeout(1200);
+ 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 file = new File([bytes], "dot.png", { type: "image/png" });
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ document
+ .querySelector(".ProseMirror")
+ .dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
+ }, PNG);
+ await p.waitForTimeout(3500);
+
+ const img = p.locator(".ProseMirror img").first();
+ check((await p.locator(".ProseMirror img").count()) > 0, "the pasted image was inserted");
+ const src = (await img.getAttribute("src")) || "";
+ check(src.startsWith("blob:"), "the image renders from a blob URL, not inline bytes", src.slice(0, 24));
+ check(!src.startsWith("data:"), "no base64 payload sits in the document");
+ await p.screenshot({ path: `${OUT}/attachment.png` });
+
+ await p.reload({ waitUntil: "networkidle" });
+ await p.waitForTimeout(2500);
+ await p.locator("button").filter({ hasText: "Notiz A" }).first().click();
+ await p.waitForSelector(".ProseMirror", { timeout: 15000 });
+ await p.waitForTimeout(2500);
+ check((await p.locator(".ProseMirror img").count()) > 0, "the image is still there after a reload");
+ const loaded = await p
+ .locator(".ProseMirror img")
+ .first()
+ .evaluate((e) => e.complete && e.naturalWidth > 0)
+ .catch(() => false);
+ check(loaded, "the image actually loaded its pixels after the reload");
+} catch (e) {
+ check(false, "suite ran to the end", e.message);
+} finally {
+ await session?.cleanup();
+ const code = report();
+ await browser.close();
+ process.exit(code);
+}
diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx
index 0f1d2ba..4fbf75a 100644
--- a/frontend/src/features/notes/EditorPane.jsx
+++ b/frontend/src/features/notes/EditorPane.jsx
@@ -180,6 +180,7 @@ export function EditorPane({
{isMobile && }
onUpdate({ title: e.target.value })}
style={{
diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx
index 5dd744f..49dffa9 100644
--- a/frontend/src/features/notes/NoteList.jsx
+++ b/frontend/src/features/notes/NoteList.jsx
@@ -154,6 +154,7 @@ export function NoteList({
return (