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

+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);
}