Files
InkNote/e2e/search-attachments.mjs
marcandClaude Opus 5 830af6532a Take the paper back out, and quiet the surfaces around it
The sheet was not what it wanted to be, so writing is plain text again: about 73
characters to the line, centred, no border and no shadow. The fixed page belongs
to the canvas — a stroke has to land in the same place on every screen, which
needs one agreed page — so it appears exactly when something is on it or about
to be: in Draw, or when the note already has a drawing. The cap lives in the
stylesheet rather than on a container, so it holds on a page as well as off one.

Emoji are gone from the note rows and the sidebar. They are drawn by the
operating system, so they looked different on every machine and were loud enough
that a row's picture caught the eye before its title did. Rows are tighter by
three pixels of padding.

The trash button no longer stands permanently in the danger colour beside the
star, which made the most destructive control the loudest thing on screen. It
sits behind a divider and turns red under the pointer, when it is about to be
used.

On a phone the toolbar's fourteen controls scrolled sideways, so the ones at the
end could only be reached by dragging the bar and nothing said they were there.
Nine remain, plus a menu holding the headings, quote, code and undo.

A new `sync` suite proves what was assumed: a PDF written on one device — the
document, the drawing and the pinned notes — reaches a second one. Two browser
contexts are two devices.

Both search suites read a row's title as the second line of its text, which only
worked while the icon was itself a text emoji; they read it by name now.

218 unit tests, 249 browser checks across twelve suites.

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

115 lines
4.6 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. */
/* By name, not by position. Reading the second line of the row's text worked
only while the icon was a text emoji; the moment it became an SVG the second
line was the excerpt. */
const titles = (p) =>
p.$$eval("[data-note-row] [data-note-title]", (els) => els.map((e) => e.textContent.trim()));
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);
}