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:
1 parent
41ba99c017
commit
311e992496
13 files changed
+915
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
shots/
|
||||
+138
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Generated
+57
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "inknote-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": { "playwright": "1.49.0" }
|
||||
}
|
||||
Executable
+48
@@ -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
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user