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 (