Move the browser suites into the repository

They lived in a scratch directory under /tmp, which was cleared overnight;
four of the five were simply gone. Whatever they were worth, they were
worth nothing kept there.

Rewritten against a shared lib.mjs. The old ones registered their users
through the sign-up form, which no longer exists — each suite now creates
a throwaway account through the admin API and deletes it afterwards, so a
run leaves the database as it found it. The previous generation left nine
accounts behind; those are gone too.

Three test hooks went into the app, each of which earns its place beyond
testing: aria-label on the note title (the sidebar search box is also a
text input, and a screen reader could not tell them apart either),
data-note-row on list rows, data-user on admin rows.

Two suites were flaky in ways worth naming, because both were the test
misreading the app rather than the app misbehaving:
- ticking a task then inserting a note link put the link inside the task
  list, since Control+End lands there. Verified against the server that
  the tick itself persists correctly; the suite now leaves the list first
  and asserts where the link ended up.
- the note-title lookup matched the sidebar's Trash entry, which wears the
  same emoji as a note row.

56 checks across five suites, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 14:30:08 +02:00
1 parent 41ba99c017
commit 311e992496
13 files changed
+915

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
shots/
+138
View File
@@ -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);
}
+140
View File
@@ -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);
}
+110
View File
@@ -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);
}
+97
View File
@@ -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);
}
+174
View File
@@ -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);
}
+57
View File
@@ -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"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "inknote-e2e",
"private": true,
"type": "module",
"dependencies": { "playwright": "1.49.0" }
}
Executable
+48
View File
@@ -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
+113
View File
@@ -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);
}
@@ -180,6 +180,7 @@ export function EditorPane({
{isMobile && <BackButton T={T} onClick={onBack} />}
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
+1
View File
@@ -154,6 +154,7 @@ export function NoteList({
return (
<button
key={note.id}
data-note-row={note.id}
onClick={() => onSelectNote(note.id)}
onContextMenu={(e) => onNoteContextMenu(e, note)}
style={{
+28
View File
@@ -309,6 +309,34 @@ bug they cover actually happened:
- `hasPendingWrites` must cover the debounce window, not just requests in flight
- v1 note bodies must migrate without losing task state or smuggling in markup
### Browser suites
The unit tests cover the data layer; these drive a real browser against a real
server, because the failures that hurt most in this app were never visible in a
unit test — a stale response overwriting fresh strokes, a note link surviving in
memory but not through a reload.
```bash
docker compose -f docker-compose.dev.yml up -d --build # must be running
./e2e/run.sh # all suites
./e2e/run.sh editor drawing # only these
```
| Suite | Checks | What it holds down |
| --- | --- | --- |
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
| `editor` | 12 | tables, task lists, note links, backlinks — and that each survives a reload |
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
Each suite creates a throwaway account through the admin API and deletes it
afterwards, so runs leave the database as they found it. They need an admin to
do that with — `marc`/`geheim123` by default, override with `INKNOTE_ADMIN` and
`INKNOTE_ADMIN_PASS`.
Screenshots land in `e2e/shots/`, which is worth a look when a check fails.
### How the frontend is arranged
`App.jsx` holds only *where the user is* — the open notebook, the open note, the