diff --git a/web/tests/integration/search-browser.html b/web/tests/integration/search-browser.html new file mode 100644 index 0000000..c9b458f --- /dev/null +++ b/web/tests/integration/search-browser.html @@ -0,0 +1,25 @@ + + +
+ + + + ++ Synthetic account session · real App and encrypted WASM/IndexedDB · no + account backend or human profile +
+ + + + diff --git a/web/tests/integration/search-browser.mjs b/web/tests/integration/search-browser.mjs new file mode 100644 index 0000000..1b3966b --- /dev/null +++ b/web/tests/integration/search-browser.mjs @@ -0,0 +1,888 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const output = process.env.NOTES_UI_OUTPUT ?? "test-results/search-browser"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +const context = await browser.newContext({ + viewport: { width: 1160, height: 850 }, +}); +const page = await context.newPage(); +let diagnosticPage = page; +const errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +async function runQuery(raw) { + const input = page.getByRole("textbox", { + name: "Search notes, clips and cards", + exact: true, + }); + await input.fill(raw); + await input.press("Enter"); +} +try { + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/search-browser.html`, + ); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + await page.evaluate(() => { + window.__searchMotion = []; + let prior = null; + let closingAt = 0; + new MutationObserver(() => { + const element = document.querySelector(".workspace-search"); + if (!element) { + if (prior && closingAt) + window.__searchMotion.push({ + phase: "removed", + elapsed: performance.now() - closingAt, + }); + prior = null; + closingAt = 0; + return; + } + const phase = element.getAttribute("data-phase"); + if (prior === phase) return; + prior = phase; + const css = getComputedStyle(element); + window.__searchMotion.push({ + phase, + duration: css.animationDuration, + name: css.animationName, + transform: css.transform, + inert: element.inert, + hidden: element.getAttribute("aria-hidden"), + frames: element.getAnimations().flatMap((animation) => + animation.effect.getKeyframes().map((frame) => ({ + opacity: frame.opacity, + transform: frame.transform, + })), + ), + }); + if (phase === "closing") closingAt = performance.now(); + }).observe(document.getElementById("main"), { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-phase"], + }); + }); + await page + .getByRole("button", { name: "Search notes, clips and cards", exact: true }) + .click(); + await runQuery("lisbon"); + await expect( + page.getByText("69 results for “lisbon”", { exact: true }), + ).toBeVisible(); + const searchCss = await page.evaluate(() => { + const row = document.querySelector(".search-result"), + content = document.querySelector(".search-content"), + heading = document.querySelector(".search-heading h1"), + group = document.querySelector(".search-group"), + label = group.querySelector("h2"), + count = label.querySelector("span"), + list = group.querySelector("ul"); + const r = getComputedStyle(row), + c = getComputedStyle(content), + h = getComputedStyle(heading), + g = getComputedStyle(group), + b = getComputedStyle(label), + n = getComputedStyle(count), + u = getComputedStyle(list); + const rows = [...list.querySelectorAll(".search-result")]; + const inactive = getComputedStyle( + document.querySelector('.search-tabs button[aria-pressed="false"]'), + ), + active = getComputedStyle( + document.querySelector('.search-tabs button[aria-pressed="true"]'), + ), + sub = getComputedStyle(document.querySelector(".search-heading p")), + ghost = getComputedStyle( + document.querySelector(".search-heading > button"), + ); + const token = (name) => { + const probe = document.createElement("span"); + probe.hidden = true; + probe.style.color = `var(${name})`; + document.body.append(probe); + const value = getComputedStyle(probe).color; + probe.remove(); + return value; + }; + const pill = (style) => ({ + height: style.height, + minHeight: style.minHeight, + padding: style.padding, + radius: style.borderRadius, + border: style.borderTopWidth, + gap: style.gap, + size: style.fontSize, + background: style.backgroundColor, + color: style.color, + }); + return { + row: { + border: r.borderTopWidth, + gap: r.gap, + padding: r.padding, + radius: r.borderRadius, + margin: r.marginBottom, + }, + content: { padding: c.padding, gap: c.gap }, + heading: { size: h.fontSize, spacing: h.letterSpacing }, + sub: { size: sub.fontSize, color: sub.color }, + ghost: { + height: ghost.height, + minHeight: ghost.minHeight, + padding: ghost.padding, + radius: ghost.borderRadius, + gap: ghost.gap, + size: ghost.fontSize, + background: ghost.backgroundColor, + color: ghost.color, + }, + pills: { inactive: pill(inactive), active: pill(active) }, + tokens: { + raise: token("--raise"), + t2: token("--t2"), + t3: token("--t3"), + btn: token("--btn"), + btnt: token("--btnt"), + }, + section: { + size: b.fontSize, + weight: b.fontWeight, + padding: b.padding, + margin: b.margin, + alignment: b.justifyContent, + countSize: n.fontSize, + countColor: n.color, + countExpected: getComputedStyle(document.documentElement) + .getPropertyValue("--t3") + .trim(), + gap: g.gap, + listGap: u.gap, + headingToRow: + rows[0].getBoundingClientRect().top - + label.getBoundingClientRect().bottom, + rowToRow: + rows[1].getBoundingClientRect().top - + rows[0].getBoundingClientRect().bottom, + }, + }; + }); + expect(searchCss.row).toEqual({ + border: "0px", + gap: "3px", + padding: "10px 12px", + radius: "10px", + margin: "0px", + }); + expect(searchCss.content).toEqual({ padding: "6px 28px 24px", gap: "8px" }); + expect(searchCss.heading).toEqual({ size: "22px", spacing: "-0.33px" }); + expect(searchCss.section).toMatchObject({ + size: "13px", + weight: "500", + padding: "12px 0px 6px", + margin: "0px", + alignment: "space-between", + countSize: "12px", + gap: "8px", + listGap: "8px", + headingToRow: 8, + rowToRow: 8, + }); + expect(searchCss.section.countColor).toBe(searchCss.tokens.t3); + expect(searchCss.sub).toEqual({ size: "13px", color: searchCss.tokens.t3 }); + expect(searchCss.ghost).toEqual({ + height: "30px", + minHeight: "30px", + padding: "0px 12px", + radius: "9px", + gap: "6px", + size: "12.5px", + background: searchCss.tokens.raise, + color: searchCss.tokens.t2, + }); + expect(searchCss.pills.inactive).toEqual({ + height: "28px", + minHeight: "28px", + padding: "0px 11px", + radius: "999px", + border: "0px", + gap: "6px", + size: "12px", + background: searchCss.tokens.raise, + color: searchCss.tokens.t2, + }); + expect(searchCss.pills.active).toMatchObject({ + border: "0px", + background: searchCss.tokens.btn, + color: searchCss.tokens.btnt, + }); + await writeFile( + output + "/computed-css.json", + JSON.stringify(searchCss, null, 2), + ); + const noteGroup = page.getByRole("region", { + name: "Notes results", + exact: true, + }); + await expect(noteGroup.getByRole("listitem")).toHaveCount(50); + await page + .getByRole("button", { name: "Next Notes results", exact: true }) + .click(); + await expect( + page.getByText("Notes 51–62 of 62", { exact: true }), + ).toBeVisible(); + await expect(noteGroup.getByRole("listitem")).toHaveCount(12); + await page + .getByRole("button", { name: "Previous Notes results", exact: true }) + .click(); + await expect(noteGroup.getByRole("listitem")).toHaveCount(50); + await page + .getByRole("button", { name: "Next Notes results", exact: true }) + .click(); + await expect( + page.getByText("Notes 51–62 of 62", { exact: true }), + ).toBeVisible(); + await noteGroup.getByRole("button").nth(5).scrollIntoViewIfNeeded(); + const retainedScroll = await page + .locator(".unified-search") + .evaluate((element) => element.scrollTop); + await noteGroup.getByRole("button").nth(5).click(); + await expect( + page.getByRole("textbox", { name: "Note title", exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Back to search", exact: true }) + .click(); + await expect( + page.getByText("69 results for “lisbon”", { exact: true }), + ).toBeVisible(); + await expect + .poll(async () => + Math.abs( + (await page + .locator(".unified-search") + .evaluate((element) => element.scrollTop)) - retainedScroll, + ), + ) + .toBeLessThan(2); + await expect( + page.getByText("Notes 51–62 of 62", { exact: true }), + ).toBeVisible(); + await expect(noteGroup.getByRole("listitem")).toHaveCount(12); + await page + .getByRole("button", { name: "Previous Notes results", exact: true }) + .click(); + await expect(noteGroup.getByRole("listitem")).toHaveCount(50); + const before = await page.evaluate(() => window.__searchFixture.metadata()); + await page + .getByRole("region", { name: "Clips results", exact: true }) + .getByRole("button") + .filter({ hasText: "Archived" }) + .click(); + await expect( + page.getByRole("button", { name: "← Back to clips", exact: true }), + ).toBeVisible(); + const after = await page.evaluate(() => window.__searchFixture.metadata()); + expect(after.clips).toEqual(before.clips); + await page + .getByRole("button", { name: "Back to search", exact: true }) + .click(); + await page + .getByRole("region", { name: "Cards results", exact: true }) + .getByRole("button") + .first() + .click(); + const card = page.getByRole("dialog", { name: "Card", exact: true }); + await expect(card).toBeVisible(); + await expect( + card.getByText("Loading card content…", { exact: true }), + ).toHaveCount(0); + await expect( + card.getByRole("button", { name: "Show answer", exact: true }), + ).toBeEnabled(); + await card.getByRole("button", { name: "Show answer", exact: true }).click(); + await expect( + card.getByRole("heading", { name: "Answer", exact: true }), + ).toBeVisible(); + await card + .getByRole("button", { name: "Back to search", exact: true }) + .click(); + await expect(card).toHaveCount(0); + expect( + (await page.evaluate(() => window.__searchFixture.metadata())).eventCount, + ).toBe(before.eventCount); + + await runQuery("strasse"); + await expect( + page.getByText("2 results for “strasse”", { exact: true }), + ).toBeVisible(); + await runQuery("\u{10428}"); + await expect( + page.getByText("1 result for “\u{10428}”", { exact: true }), + ).toBeVisible(); + await runQuery(". Budget €20.", + ), + }); + for (let i = 1; i <= 61; i++) + await notes.create({ + title: `Lisbon page ${String(i).padStart(2, "0")}`, + tags: ["pagination"], + document: doc(`Travel item ${i}.`), + }); + await notes.create({ + title: "Other ordinary note", + tags: [], + document: doc("Independent writing."), + }); + const target = await notes.create({ + title: "Connected label", + tags: [], + document: doc("Target writing."), + }); + const emptyTarget = await notes.create({ + title: "", + tags: [], + document: doc("Empty title target writing."), + }); + const links = createNoteLinkService(notes); + await links.verifyGraph(); + const targetReference = await links.reference(target.id), + emptyReference = await links.reference(emptyTarget.id); + await notes.create({ + title: "Reference source", + tags: [], + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Current " }, + { + type: "noteLink", + attrs: { version: 1, target: targetReference.target }, + }, + { type: "text", text: " label and " }, + { type: "text", text: "Stra", marks: [{ type: "bold" }] }, + { type: "text", text: "ße \u{10400}" }, + ], + }, + ], + }, + }); + await notes.create({ + title: "Boundary source", + tags: [], + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "prefixZX" }, + { + type: "noteLink", + attrs: { version: 1, target: emptyReference.target }, + }, + { type: "text", text: "QVsuffix" }, + ], + }, + ], + }, + }); + const trash = await notes.create({ + title: "Lisbon trash-only-sentinel", + tags: [], + document: doc("trash-only-sentinel"), + }); + await notes.trash(trash.id, trash.revision); + const at = new Date("2026-01-01T12:00:00.000Z"), + current = await collections.read(); + await collections.update(current, (state) => { + let study = createDeck(state.study, "Lisbon travel", at); + const base = { + deckId: study.decks[0]!.id, + revision: 1, + tags: ["trip"], + source: { + noteId: note.id, + noteRevision: note.revision, + quote: "private-source-sentinel", + }, + }; + study = saveEntry( + study, + { + ...base, + id: crypto.randomUUID(), + type: "basic", + front: "Where is Lisbon?", + back: "Portugal", + }, + at, + ); + study = saveEntry( + study, + { + ...base, + id: crypto.randomUUID(), + type: "reversed", + front: "Lisbon departure", + back: "Train arrival", + }, + at, + ); + study = saveEntry( + study, + { + ...base, + id: crypto.randomUUID(), + type: "cloze", + text: "Lisbon is in {{city}} and costs {{cost}}.", + blanks: [ + { id: "city", answer: "Portugal" }, + { id: "cost", answer: "€20" }, + ], + }, + at, + ); + return { + ...state, + study, + clips: { + ...state.clips, + items: [ + { + id: crypto.randomUUID(), + revision: 1, + title: "Lisbon saved archive", + originalUrl: "https://example.test/lisbon-archive", + canonicalUrl: "https://example.test/lisbon-archive", + text: "Archived saved text with Café.", + createdAt: at.toISOString(), + updatedAt: at.toISOString(), + readAt: null, + readLater: true, + archivedAt: at.toISOString(), + }, + { + id: crypto.randomUUID(), + revision: 1, + title: "Lisbon link only", + originalUrl: "https://example.test/lisbon-link", + canonicalUrl: "https://example.test/lisbon-link", + text: null, + createdAt: at.toISOString(), + updatedAt: at.toISOString(), + readAt: null, + readLater: false, + archivedAt: null, + }, + ], + }, + }; + }); + const latest = await collections.read(); + const journal = writeJournalEntry( + emptyJournal(), + journalToday(), + doc("Lisbon private-journal-sentinel"), + null, + new Date(), + ); + // Synthetic-only passphrase exists solely in this disposable test entry. + const sealed = await createJournalEnvelope( + "Synthetic Journal fixture phrase 2026", + journal, + { accountId: identity.accountId, epoch: latest.epoch }, + () => {}, + ); + await replaceJournal(collections, latest, sealed.envelope); + } + Object.assign(window, { + __searchFixture: { + metadata: async () => { + const state = await collections.read(); + return { + accountId: identity.accountId, + collectionsEpoch: state.epoch, + notes: (await notes.list()).map( + ({ id, title, revision, trashedAt }) => ({ + id, + title, + revision, + trashedAt, + }), + ), + clips: state.clips.items.map( + ({ id, title, revision, archivedAt, readAt, readLater }) => ({ + id, + title, + revision, + archivedAt, + readAt, + readLater, + }), + ), + cards: state.study.cards.map(({ id, entryId, key, revision }) => ({ + id, + entryId, + key, + revision, + })), + entries: state.study.entries.map( + ({ id, type, deckId, revision }) => ({ + id, + type, + deckId, + revision, + }), + ), + eventCount: state.study.events.length, + }; + }, + renameNote: async (id: string, title: string) => { + const note = await notes.get(id); + if (!note) throw new Error("Fixture note unavailable"); + await notes.update(id, { title }, note.revision); + }, + invalidateWrappingProof: async () => { + // Unnotified authority failure in this disposable profile only. Keep + // the sealed bytes unchanged; expose neither the old nor new key. + const replacement = await newWrappingKey(); + await mutateRecord