Files
InkNote/e2e/search-attachments.mjs
T
marcandClaude Opus 5 52bd2b8f3d Put the interface into English, and give the notebook one way in
The interface had drifted into two languages — the frame in English, everything
added later in German — which is worse than either, since the same idea appeared
under two names depending on when that part was written. Sorting by title still
uses the German collation, so Übung 2 comes before Übung 10: that is about the
notes, not the chrome.

Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ →
drawings/). The importer reads both spellings, and both the old and new marker
line for a drawing — archives exported before this are on disk, and an import
that quietly dropped their drawings would be the worst kind of tidy-up.

Making a note, uploading a PDF and importing Markdown were three buttons side by
side, two of them icons you had to hover to identify. They are the same act —
putting something new in this notebook — so they are now one + menu.

The search box moves out of the sidebar and into the note list, labelled with the
notebook it narrows. Nothing changed about what it does: it has always searched
only the notebook you are in, but from the sidebar it read as searching
everything.

A separator no longer carries a sentence explaining the order the sort menu
already names. Renaming joins removing on the right-click menu — a left-click on
the label started an edit, so the row could not be touched without falling into
one.

218 unit tests, 238 browser checks across eleven suites. Two of those caught real
breakage from this change rather than needing a translation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:17:22 +02:00

115 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,
search,
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);
/** Types into the note list's search box and reports what is left showing. */
const find = async (q) => {
await search(p, q);
await p.waitForTimeout(1200);
return titles(p);
};
check(JSON.stringify(await find("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p)));
const hits = await find("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 find("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND");
check((await find("Belege Kühlschrank")).length === 0, "terms from different notes match nothing");
check(JSON.stringify(await find("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts");
await p.screenshot({ path: `${OUT}/search-results.png` });
await search(p, "");
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);
}