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>
114 lines
4.5 KiB
JavaScript
114 lines
4.5 KiB
JavaScript
/* 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);
|
|
}
|