diff --git a/web/tests/integration/editor-preferences-browser.mjs b/web/tests/integration/editor-preferences-browser.mjs new file mode 100644 index 0000000..c2738f8 --- /dev/null +++ b/web/tests/integration/editor-preferences-browser.mjs @@ -0,0 +1,109 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +const browser = await chromium.launch({ + channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome", + headless: true, +}); +try { + const page = await browser.newPage({ + viewport: { width: 1160, height: 760 }, + }); + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/ui/preview.html`, + ); + await page.getByRole("button", { name: /^A quiet place to think/ }).click(); + const editor = page.getByRole("textbox", { + name: "Note content", + exact: true, + }); + const saved = () => + expect(page.locator('.editor-chrome [role="status"]')).toHaveText( + "Saved in this browser", + ); + const type = async (value) => { + await editor.focus(); + await page.keyboard.press("Control+A"); + await page.keyboard.type(value); + }; + const settings = page.getByRole("dialog", { name: "Settings", exact: true }); + const open = async () => { + await saved(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await settings.getByRole("button", { name: "Editor", exact: true }).click(); + }; + const done = () => + settings.getByRole("button", { name: "Done", exact: true }).click(); + await type("## "); + await expect(editor.locator("h2")).toHaveCount(1); + await open(); + await settings + .getByRole("group", { name: "Text size" }) + .getByRole("button", { name: "Large" }) + .click(); + await settings + .getByRole("group", { name: "Line width" }) + .getByRole("button", { name: "Wide" }) + .click(); + await settings.getByRole("checkbox", { name: /Turn Markdown/ }).uncheck(); + await settings.getByRole("checkbox", { name: "Check spelling" }).uncheck(); + await expect(page.locator(".tiptap")).toHaveCSS("font-size", "16px"); + await expect(page.locator(".editor-page")).toHaveCSS("max-width", "800px"); + await expect(page.locator(".tiptap")).toHaveAttribute("spellcheck", "false"); + await done(); + await page.getByRole("button", { name: "Heading", exact: true }).click(); + await type("## "); + await expect(editor.locator("h2")).toHaveCount(0); + await expect(editor).toHaveText("## "); + await open(); + await settings.getByRole("checkbox", { name: /Turn Markdown/ }).check(); + await done(); + await type("## "); + await expect(editor.locator("h2")).toHaveCount(1); + await open(); + await settings.getByRole("checkbox", { name: /Turn Markdown/ }).uncheck(); + await done(); + await page.reload(); + await page.getByRole("button", { name: /^A quiet place to think/ }).click(); + await expect(page.locator(".tiptap")).toHaveCSS("font-size", "16px"); + await expect(page.locator(".tiptap")).toHaveAttribute("spellcheck", "false"); + await type("## "); + await expect(editor.locator("h2")).toHaveCount(0); + await expect(editor).toHaveText("## "); + await saved(); + await open(); + await settings.getByRole("checkbox", { name: /Turn Markdown/ }).check(); + await done(); + // Changing a preference must not recreate the editor/history and lose Undo. + await editor.focus(); + await page.keyboard.press("Control+z"); + await expect(editor).not.toHaveText("## "); + const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/editor-preferences"; + await mkdir(output, { recursive: true }); + await writeFile( + join(output, "result.json"), + JSON.stringify( + { + fixture: + "Synthetic in-memory Notes content; real editor and device preference storage; not account/encryption qualification", + checks: [ + "Markdown on converts h2", + "off keeps literal typing", + "on restores conversion", + "live font/width/spelling", + "preferences survive reload", + "Undo history survives preference change", + ], + passed: true, + }, + null, + 2, + ), + ); + console.log( + "Editor browser checks passed: Markdown toggle, live preferences, reload and retained Undo.", + ); +} finally { + await browser.close(); +} diff --git a/web/tests/integration/incoming-note-link-layout.mjs b/web/tests/integration/incoming-note-link-layout.mjs new file mode 100644 index 0000000..44e788b --- /dev/null +++ b/web/tests/integration/incoming-note-link-layout.mjs @@ -0,0 +1,183 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/incoming-note-link-layout"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ channel: "chrome", headless: true }), + context = await browser.newContext(); +const results = []; +const errors = []; +await context.route( + "**/tests/integration/note-link-entry-browser.html**", + async (route) => { + const response = await route.fetch(); + const body = (await response.text()).replace( + "font:12px sans-serif", + "position:fixed;left:4px;bottom:4px;z-index:40;max-width:200px;pointer-events:none;font:10px sans-serif", + ); + await route.fulfill({ response, body }); + }, +); +await context.addInitScript(() => { + window.__entryLayoutShifts = []; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) + window.__entryLayoutShifts.push({ + value: entry.value, + hadRecentInput: entry.hadRecentInput, + sources: entry.sources.map((source) => ({ + node: source.node?.className || source.node?.nodeName, + previous: source.previousRect.toJSON(), + current: source.currentRect.toJSON(), + })), + }); + }).observe({ type: "layout-shift", buffered: true }); +}); +async function settled(page) { + await page.locator(".app").evaluate(async (element) => { + await document.fonts.ready; + await Promise.all( + element + .getAnimations({ subtree: true }) + .filter((a) => a.effect?.getTiming().iterations !== Infinity) + .map((a) => a.finished.catch(() => {})), + ); + await new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => setTimeout(resolve, 0)), + ), + ); + }); +} +async function box(page) { + return page.locator(".app").evaluate((element) => { + const r = element.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + bottom: r.bottom, + innerWidth, + innerHeight, + scrollWidth: document.documentElement.scrollWidth, + scrollHeight: document.documentElement.scrollHeight, + }; + }); +} +try { + const page = await context.newPage(); + page.on("pageerror", (error) => errors.push(error.message)); + await page.setViewportSize({ width: 1160, height: 850 }); + const base = `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/note-link-entry-browser.html`; + await page.goto(base); + await page.getByRole("button", { name: "New note", exact: true }).click(); + await page.getByLabel("Note title").fill("Layout target"); + await page + .getByLabel("Note content", { exact: true }) + .fill("Synthetic layout proof."); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + const reference = await page.evaluate(async () => { + const value = await window.__noteEntryFixture.metadata(); + return window.__noteEntryFixture.reference( + value.notes.find((note) => note.title === "Layout target").id, + ); + }); + const hash = "#note/v1/" + reference.split("/v1/")[1]; + for (const viewport of [ + { width: 1160, height: 850 }, + { width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await page.evaluate((value) => { + location.hash = value; + }, hash); + await expect(page.getByLabel("Note title")).toHaveValue("Layout target"); + await settled(page); + const before = await box(page); + await page.evaluate(() => { + window.__entryLayoutShifts = []; + location.hash = "#note/v9/invalid"; + }); + const notice = page.getByRole("alert", { name: "Incoming note link" }); + await expect(notice).toBeVisible(); + await settled(page); + const during = await box(page); + const n = await notice.boundingBox(); + if (JSON.stringify(before) !== JSON.stringify(during)) + throw Error("Notice changed workspace geometry"); + if ( + during.y < 0 || + during.bottom > viewport.height + 1 || + during.scrollWidth > viewport.width || + during.scrollHeight > viewport.height + 1 + ) + throw Error("Workspace exceeded viewport"); + const header = await page.locator(".editor-chrome").boundingBox(); + if (n && header && n.y < header.y + header.height) + throw Error("Notice overlapped essential editor header"); + await page.screenshot({ path: `${output}/invalid-${viewport.width}.png` }); + const shifts = await page.evaluate(() => window.__entryLayoutShifts); + const cls = shifts + .filter((entry) => !entry.hadRecentInput) + .reduce((sum, entry) => sum + entry.value, 0); + if (cls !== 0 || shifts.length) + throw Error(`Notice layout shifts ${JSON.stringify(shifts)}`); + await notice.getByRole("button", { name: "Dismiss" }).click(); + await expect(notice).toHaveCount(0); + await settled(page); + if (JSON.stringify(await box(page)) !== JSON.stringify(before)) + throw Error("Dismiss changed workspace geometry"); + await page.evaluate((value) => { + location.hash = value; + }, hash); + await expect(page.getByLabel("Note title")).toHaveValue("Layout target"); + await page.reload(); + await expect(page.getByLabel("Note title")).toHaveValue("Layout target"); + await settled(page); + const reload = await box(page), + reloadShifts = await page.evaluate(() => window.__entryLayoutShifts); + const reloadCls = reloadShifts + .filter((entry) => !entry.hadRecentInput) + .reduce((sum, entry) => sum + entry.value, 0); + results.push({ + viewport, + before, + during, + notice: n, + noticeCls: cls, + reload, + reloadCls, + allReloadShifts: reloadShifts, + }); + if (reloadCls !== 0) { + await writeFile( + output + "/reload-shifts.json", + JSON.stringify(results, null, 2), + ); + throw Error(`Reload opening→hidden CLS ${reloadCls}`); + } + } + if (errors.length) throw Error(errors.join("\n")); + await writeFile( + output + "/result.json", + JSON.stringify( + { + qualified: + "Actual App in StrictMode; synthetic session real WASM/IndexedDB. Fixture-only provenance label taken out of flow; product CSS/DOM unmodified by test. No native/physical keyboard claim.", + checks: results, + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log("Incoming notice layout passed"); +} catch (error) { + await writeFile(output + "/failure.txt", String(error)); + throw error; +} finally { + await browser.close(); +} diff --git a/web/tests/integration/library-browser.html b/web/tests/integration/library-browser.html new file mode 100644 index 0000000..ad80898 --- /dev/null +++ b/web/tests/integration/library-browser.html @@ -0,0 +1 @@ +Encrypted library test harness
Test harness — synthetic account session; real local WASM encryption and IndexedDB. No account-server qualification.
\ No newline at end of file diff --git a/web/tests/integration/library-browser.mjs b/web/tests/integration/library-browser.mjs new file mode 100644 index 0000000..76d132e --- /dev/null +++ b/web/tests/integration/library-browser.mjs @@ -0,0 +1,41 @@ +import { chromium, expect } from '@playwright/test'; +import { mkdir,writeFile } from 'node:fs/promises'; +const output=process.env.NOTES_UI_OUTPUT ?? 'test-results/library-browser';await mkdir(output,{recursive:true}); +const browser=await chromium.launch({channel:'chrome',headless:true}); +const page=await browser.newPage({viewport:{width:1160,height:800}});const errors=[];page.on('pageerror',error=>errors.push(error.message)); +try { +await page.goto('http://127.0.0.1:5179/tests/integration/library-browser.html'); +await expect(page.getByRole('heading',{name:'All notes',exact:true})).toBeVisible(); +await page.getByRole('button',{name:'New note',exact:true}).click(); +await page.getByLabel('Note title',{exact:true}).fill('Encrypted library journey'); +await page.getByLabel('Note content',{exact:true}).fill('First retained writing'); +await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await page.getByLabel('Note content',{exact:true}).fill('Second retained writing'); +await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await page.getByRole('button',{name:'Note menu',exact:true}).click();await page.getByRole('menuitem',{name:'Pin',exact:true}).click(); +await page.getByRole('button',{name:'← Back to notes',exact:true}).click();await page.getByRole('button',{name:/^Pinned/}).click(); +await expect(page.getByRole('heading',{name:'Pinned',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:/Encrypted library journey/}).first()).toBeVisible(); +await page.getByLabel('Sort notes').selectOption('title');await page.reload();await expect(page.getByLabel('Sort notes')).toHaveValue('title'); +await page.getByRole('button',{name:'Manage tags',exact:true}).click();await page.getByRole('button',{name:'New tag',exact:true}).click(); +let dialog=page.getByRole('dialog',{name:'New tag',exact:true});await dialog.getByLabel('Name',{exact:true}).fill('Real encrypted tag');await dialog.getByRole('button',{name:'Blue',exact:true}).click();await dialog.getByRole('button',{name:'Create tag',exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByRole('button',{name:'Real encrypted tag',exact:true})).toBeVisible(); +await page.screenshot({path:output+'/tags-light.png'}); +await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:/Encrypted library journey/}).first().click();await page.getByLabel('Add existing tag').selectOption({label:'Real encrypted tag'});await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await page.getByLabel('New tag',{exact:true}).fill('work, play');await page.getByRole('button',{name:'Add tag',exact:true}).click();await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();await page.getByLabel('New tag',{exact:true}).fill('Another exact tag');await page.getByRole('button',{name:'Add tag',exact:true}).click();await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await page.getByRole('button',{name:'← Back to notes',exact:true}).click();await page.getByRole('button',{name:'Manage tags',exact:true}).click(); +await page.getByRole('button',{name:'Actions for Real encrypted tag',exact:true}).click();await page.getByRole('menuitem',{name:'Rename tag',exact:true}).click();dialog=page.getByRole('dialog',{name:'Rename tag',exact:true});await dialog.getByLabel('Name').fill('Renamed encrypted tag');await dialog.getByRole('button',{name:'Save name',exact:true}).click();await expect(dialog).toHaveCount(0); +await page.getByRole('button',{name:'Actions for Renamed encrypted tag',exact:true}).click();await page.getByRole('menuitem',{name:'Change color',exact:true}).click();dialog=page.getByRole('dialog',{name:'Change color',exact:true});await dialog.getByRole('button',{name:'Green',exact:true}).click();await dialog.getByRole('button',{name:'Save color',exact:true}).click();await expect(dialog).toHaveCount(0); +await page.getByRole('button',{name:'New tag',exact:true}).click();dialog=page.getByRole('dialog',{name:'New tag',exact:true});await dialog.getByLabel('Name').fill('Merge target');await dialog.getByRole('button',{name:'Create tag',exact:true}).click();await expect(dialog).toHaveCount(0); +await page.getByRole('button',{name:'Actions for Renamed encrypted tag',exact:true}).click();await page.getByRole('menuitem',{name:'Merge tag',exact:true}).click();dialog=page.getByRole('dialog',{name:'Merge tag',exact:true});await dialog.getByLabel('Merge into').selectOption({label:'Merge target'});await dialog.getByRole('button',{name:'Merge tags',exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByRole('button',{name:'Renamed encrypted tag',exact:true})).toHaveCount(0); +await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:/Encrypted library journey/}).first().click();await expect(page.getByRole('button',{name:'Remove tag Merge target',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'Remove tag work, play',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'Remove tag Another exact tag',exact:true})).toBeVisible(); +await page.getByRole('button',{name:'Version history',exact:true}).click();await expect(page.getByRole('button',{name:/Previous edit/}).first()).toBeVisible();await page.getByRole('button',{name:/Previous edit/}).first().click();await expect(page.getByText('Restores the title and writing only. Current tags and pin stay unchanged.')).toBeVisible();await page.screenshot({path:output+'/history-light.png'});await page.getByRole('button',{name:'Restore this version',exact:true}).click();dialog=page.getByRole('dialog',{name:'Restore this version?',exact:true});await dialog.getByRole('button',{name:'Restore version',exact:true}).click();await expect(page.getByRole('dialog',{name:'Version history',exact:true})).toHaveCount(0);await expect(page.getByRole('button',{name:'Remove tag Merge target',exact:true})).toBeVisible();await page.getByRole('button',{name:'Note menu',exact:true}).click();await expect(page.getByRole('menuitem',{name:'Unpin',exact:true})).toBeVisible();await page.getByRole('menuitem',{name:'Unpin',exact:true}).focus();await page.keyboard.press('Escape');await expect(page.getByRole('menu')).toHaveCount(0); +await page.getByRole('button',{name:'Note menu',exact:true}).click();await page.getByRole('menuitem',{name:'Move to Trash',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Note moved to Trash'})).toBeVisible();await page.getByRole('button',{name:'Undo',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Action undone'})).toBeVisible(); +await page.getByRole('button',{name:/Encrypted library journey/}).first().click();await page.getByRole('button',{name:'Note menu',exact:true}).click();await page.getByRole('menuitem',{name:'Move to Trash',exact:true}).click();await page.getByRole('button',{name:/^Trash/}).click();await page.getByRole('button',{name:'Restore',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Note restored'})).toBeVisible();await page.getByRole('button',{name:'Undo',exact:true}).click();await expect(page.getByRole('button',{name:'Restore',exact:true})).toBeVisible(); +await page.getByRole('button',{name:'Empty Trash',exact:true}).click();dialog=page.getByRole('dialog',{name:'Empty Trash?',exact:true});await expect(dialog.getByText(/can’t be undone/)).toBeVisible();await dialog.getByRole('button',{name:'Empty Trash',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Removed 1 notes permanently'})).toBeVisible();await page.reload();await expect(page.getByRole('heading',{name:'No notes yet',exact:true})).toBeVisible(); +await page.getByRole('button',{name:'Manage tags',exact:true}).click();for(const theme of ['light','dark','dim']) {await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;},theme);await page.screenshot({path:output+`/tags-${theme}.png`});} +await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:'reduce'});await page.screenshot({path:output+'/tags-narrow-dim.png'});const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth);if(overflow)throw Error('Narrow horizontal page overflow');if(errors.length)throw Error(errors.join('\n')); +await page.getByRole('button',{name:'New tag',exact:true}).click();dialog=page.getByRole('dialog',{name:'New tag',exact:true});await dialog.getByLabel('Name').fill('Protected unsubmitted tag'); +const guard=await page.evaluate(async()=>{const module=await import('/src/desktop/closeGuard.ts');return module.checkClose();});if(guard!=='draft')throw Error('Unsubmitted tag not close guarded'); +await page.screenshot({path:output+'/new-tag-narrow-dim.png'});await page.keyboard.press('Escape');await expect(page.getByRole('dialog',{name:'Discard tag changes?',exact:true})).toBeVisible();await page.getByRole('button',{name:'Keep editing',exact:true}).click();await expect(dialog.getByLabel('Name')).toHaveValue('Protected unsubmitted tag');await dialog.getByRole('button',{name:'Cancel',exact:true}).click();await page.getByRole('button',{name:'Discard changes',exact:true}).click();await expect(dialog).toHaveCount(0); +await page.setViewportSize({width:1160,height:800});await page.getByRole('button',{name:'Settings',exact:true}).click();await page.getByRole('button',{name:'Data and export',exact:true}).click();await page.getByRole('button',{name:'Erase this browser',exact:true}).click();dialog=page.getByRole('dialog',{name:'Erase notes from this browser?',exact:true});await expect(dialog).toContainText('Study decks, cards and reviews');await expect(dialog).toContainText('saved Clips');await expect(dialog.getByRole('button',{name:'Open Clips to export',exact:true})).toBeVisible();await expect(dialog).toContainText('protected Journal entries, habits and recovery drafts');await expect(dialog).toContainText('Notes Markdown export does not include Study, Journal, Clips, tag colors, pins or version history');await expect(dialog.getByRole('button',{name:'Erase notes',exact:true})).toBeDisabled();await page.screenshot({path:output+'/erase-scope-dim.png'});await dialog.getByRole('button',{name:'Open Journal to export',exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.getByRole('heading',{name:'Set a Journal passphrase',exact:true})).toBeVisible(); +await writeFile(output+'/result.json',JSON.stringify({qualified:'Synthetic local account session; actual production WASM+IndexedDB, not backend authentication/native window',checks:['create/edit/history capture','real pin/filter','persisted sort reload','real tag create/color/rename/atomic merge membership','exact comma tag preserved after another addition/save/reopen','history context preview/confirmed restore preserves tags and pin','trash Undo','restore semantic Undo','confirmed permanent empty/reload','three themes','390px reduced motion no page overflow','dirty new-tag immediate native close guard/Escape confirmed discard/Keep editing','whole-workspace erase fullscope/export-exclusion copy without erasing; deliberate Journal export route'],pageErrors:errors},null,2));console.log('Real library browser journey passed'); +}finally{await browser.close();} diff --git a/web/tests/integration/library-browser.tsx b/web/tests/integration/library-browser.tsx new file mode 100644 index 0000000..08d48af --- /dev/null +++ b/web/tests/integration/library-browser.tsx @@ -0,0 +1,60 @@ +// Test-only entry. Never imported by the production application. +import { createRoot } from "react-dom/client"; +import { Workspace } from "../../src/ui/Workspace"; +import { applyInitialPreferences } from "../../src/ui/primitives"; +import { openNotesStore } from "../../src/data"; +import { establish } from "../../src/account/session"; +import { loadEngine, DeviceKeys } from "../../src/data/engine"; +import { + newWrappingKey, + wrappingSecret, + readRecord, + mutateRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import "../../src/styles/app.css"; +import "@fontsource/geist/400.css"; +import "@fontsource/geist/500.css"; + +const identity = { + accountId: "33333333-3333-4333-8333-333333333333", + deviceId: "44444444-4444-4444-8444-444444444444", + email: "isolated-library-fixture@example.test", + deviceState: "trusted" as const, +}; +applyInitialPreferences(); +async function start() { + await loadEngine(); + if (!(await readRecord("devices", identity.email))) { + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + })); + } finally { + secret.fill(0); + keys.free(); + } + } + establish(identity); + const notes = await openNotesStore(identity); + createRoot(document.getElementById("root")!).render( + { + notes.close(); + }} + />, + ); +} +void start().catch((error) => { + document.getElementById("root")!.textContent = + error instanceof Error ? error.message : "Fixture failed"; +}); diff --git a/web/tests/integration/link-browser.mjs b/web/tests/integration/link-browser.mjs new file mode 100644 index 0000000..7c1186f --- /dev/null +++ b/web/tests/integration/link-browser.mjs @@ -0,0 +1,116 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +const browser = await chromium.launch({ + channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome", + headless: true, +}); +const output = process.env.NOTES_UI_OUTPUT ?? "test-results/link-browser"; +const results = []; +try { + await mkdir(output, { recursive: true }); + for (const [theme, width] of [ + ["light", 1160], + ["dark", 1160], + ["dim", 1160], + ["dim", 390], + ]) { + const page = await browser.newPage({ + viewport: { width, height: 844 }, + reducedMotion: "reduce", + }); + const dialogs = [], + errors = []; + page.on("dialog", async (dialog) => { + dialogs.push(dialog.type()); + await dialog.dismiss(); + }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.addInitScript((theme) => { + localStorage.setItem("notes.theme", theme); + localStorage.setItem("notes.motion", "reduced"); + }, theme); + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/ui/preview.html`, + ); + await page.getByRole("button", { name: /^A quiet place to think/ }).click(); + const editor = page.getByRole("textbox", { + name: "Note content", + exact: true, + }); + await editor.focus(); + await page.keyboard.press("Control+A"); + await page.keyboard.type("Link this text"); + await editor.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element.querySelector("p").firstChild); + const selection = getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + }); + await page.getByRole("button", { name: "Link", exact: true }).click(); + const popup = page.getByRole("dialog", { name: "Web link", exact: true }); + await expect(popup.getByLabel("URL", { exact: true })).toBeFocused(); + await popup.getByLabel("URL", { exact: true }).fill("javascript:bad"); + await popup.getByRole("button", { name: "Apply link" }).click(); + await expect( + popup.getByText("Enter a valid web or email link."), + ).toBeVisible(); + await expect(editor.locator("a")).toHaveCount(0); + const rect = await popup.boundingBox(); + if ( + !rect || + rect.x < 0 || + rect.y < 0 || + rect.x + rect.width > width || + rect.y + rect.height > 844 + ) + throw new Error("Link form outside viewport"); + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: join(output, `link-${theme}-${width}.png`) }); + await popup + .getByLabel("URL", { exact: true }) + .fill("https://example.test/path"); + await popup.getByLabel("URL", { exact: true }).press("Enter"); + await expect(popup).toHaveCount(0); + await expect(editor.locator("a")).toHaveText("Link this text"); + await expect(editor.locator("a")).toHaveAttribute( + "href", + "https://example.test/path", + ); + await page.getByRole("button", { name: "Link", exact: true }).click(); + await popup.getByRole("button", { name: "Remove link" }).click(); + await expect(editor.locator("a")).toHaveCount(0); + await expect(editor).toHaveText("Link this text"); + if (dialogs.length || errors.length) + throw new Error(JSON.stringify({ dialogs, errors })); + results.push({ + theme, + width, + viewportContained: true, + applyPreservesText: true, + removePreservesText: true, + nativeDialogs: dialogs.length, + errors, + }); + await page.close(); + } + await writeFile( + join(output, "result.json"), + JSON.stringify( + { + fixture: + "Synthetic in-memory Notes; real Tiptap link controls, no account/encryption claim", + results, + }, + null, + 2, + ), + ); + console.log( + "Link browser checks passed: three themes, narrow viewport, safe apply/remove and no native dialogs.", + ); +} finally { + await browser.close(); +} diff --git a/web/tests/integration/note-link-entry-browser.html b/web/tests/integration/note-link-entry-browser.html new file mode 100644 index 0000000..aa686a3 --- /dev/null +++ b/web/tests/integration/note-link-entry-browser.html @@ -0,0 +1 @@ +

Synthetic account session · real App, encrypted WASM/IndexedDB · no account backend or human profile

\ No newline at end of file diff --git a/web/tests/integration/note-link-entry-browser.mjs b/web/tests/integration/note-link-entry-browser.mjs new file mode 100644 index 0000000..0c54455 --- /dev/null +++ b/web/tests/integration/note-link-entry-browser.mjs @@ -0,0 +1,143 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const output = process.env.NOTES_UI_OUTPUT ?? "test-results/note-link-entry"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +const context = await browser.newContext({ + viewport: { width: 1160, height: 850 }, +}); +await context.grantPermissions(["clipboard-read", "clipboard-write"]); +const page = await context.newPage(); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +try { + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/note-link-entry-browser.html?invite=private-not-for-copy`, + ); + const setup = page.getByRole("dialog", { name: "Set up Orbyn Notes" }); + await expect(setup).toBeVisible(); + await setup.getByRole("button", { name: "Skip", exact: true }).click(); + await setup.getByRole("button", { name: "Skip", exact: true }).click(); + await expect(setup).not.toBeVisible(); + for (const title of ["Entry target", "Entry other"]) { + await page.getByRole("button", { name: "New note", exact: true }).first().click(); + await page.getByLabel("Note title").fill(title); + await page + .getByLabel("Note content", { exact: true }) + .fill("Saved body for " + title); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Back to notes" }).click(); + } + const data = await page.evaluate(async () => { + const metadata = await window.__noteEntryFixture.metadata(); + const target = metadata.notes.find((note) => note.title === "Entry target"); + return { + ...target, + reference: await window.__noteEntryFixture.reference(target.id), + }; + }); + const hash = "#note/v1/" + data.reference.split("/v1/")[1]; + await page.evaluate((value) => { + location.hash = value; + }, hash); + await expect(page.getByLabel("Note title")).toHaveValue("Entry target"); + await page.reload(); + await expect(page.getByLabel("Note title")).toHaveValue("Entry target"); + await page.getByRole("button", { name: "Note menu", exact: true }).click(); + await page.getByRole("menuitem", { name: /Copy (note )?link/i }).click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toContain(hash); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + if (copied.includes("private-not-for-copy") || new URL(copied).search) + throw Error("Copied private query"); + await page.getByRole("button", { name: "Back to notes" }).click(); + await page + .getByRole("button", { name: /Entry other/ }) + .first() + .click(); + await page.evaluate(() => { + location.hash = "note/v9/invalid"; + }); + await expect( + page.getByText("This note link isn’t valid.", { exact: true }), + ).toBeVisible(); + await expect(page.getByLabel("Note title")).toHaveValue("Entry other"); + await page.getByRole("button", { name: "Dismiss", exact: true }).click(); + // The existing unsaved-note boundary must also guard external hash entry. + await page.getByRole("button", { name: "Back to notes" }).click(); + await page.getByRole("button", { name: "New note", exact: true }).click(); + // New note itself is already committed. Make an actual unsaved title edit. + await page.getByLabel("Note title").fill("Keep this draft"); + await page.evaluate((value) => { + location.hash = value; + }, hash); + await expect( + page.getByText("Finish your current work before opening this note.", { + exact: true, + }), + ).toBeVisible(); + await expect(page.getByLabel("Note title")).toHaveValue("Keep this draft"); + await page + .getByLabel("Note content", { exact: true }) + .fill("Actually save before retry."); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + // Saving must not implicitly retry the incoming intent. + await expect(page.getByLabel("Note title")).toHaveValue("Keep this draft"); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await expect(page.getByLabel("Note title")).toHaveValue("Entry target"); + // Check immediately: the old five-second notice timer is not a navigation fix. + expect( + await page + .getByRole("status") + .filter({ + hasText: "Finish or discard your unsaved changes before leaving.", + }) + .count(), + ).toBe(0); + await page.locator(".editor-pane").evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => {})), + ); + }); + await expect(page.locator(".editor-pane")).toHaveCSS("opacity", "1"); + await page.screenshot({ path: output + "/web-entry.png" }); + if (errors.length) throw Error(errors.join("\n")); + await writeFile( + output + "/result.json", + JSON.stringify( + { + qualified: + "Actual App, navigation, encrypted store and clipboard in isolated Chrome synthetic session; not account authentication/native association", + checks: [ + "hash opens exact target", + "reload opens target", + "copied web link excludes query", + "invalid link preserves current note", + "unsaved new draft blocks entry", + "save does not auto retry", + "explicit retry opens target", + ], + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log("Web note entry browser passed"); +} catch (error) { + await page.screenshot({ path: output + "/failure.png" }); + await writeFile( + output + "/failure.txt", + String(error) + "\n" + (await page.locator("body").innerText()), + ); + throw error; +} finally { + await browser.close(); +} diff --git a/web/tests/integration/note-link-entry-browser.tsx b/web/tests/integration/note-link-entry-browser.tsx new file mode 100644 index 0000000..5f1939d --- /dev/null +++ b/web/tests/integration/note-link-entry-browser.tsx @@ -0,0 +1,75 @@ +// Test-only entry. Never imported by the production application. +import { createRoot } from "react-dom/client"; +import { StrictMode } from "react"; +import { App } from "../../src/App"; +import { createNoteLinkService } from "../../src/links/store"; +import { encodeNoteReference } from "../../src/links/reference"; +import { applyInitialPreferences } from "../../src/ui/primitives"; +import { openNotesStore } from "../../src/data"; +import { establish } from "../../src/account/session"; +import { loadEngine, DeviceKeys } from "../../src/data/engine"; +import { + newWrappingKey, + wrappingSecret, + readRecord, + mutateRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import "../../src/styles/app.css"; +import "@fontsource/geist/400.css"; +import "@fontsource/geist/500.css"; + +const identity = { + accountId: "77777777-7777-4777-8777-777777777777", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + email: "isolated-note-entry-fixture@example.test", + deviceState: "trusted" as const, +}; +applyInitialPreferences(); +async function start() { + await loadEngine(); + if (!(await readRecord("devices", identity.email))) { + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + })); + } finally { + secret.fill(0); + keys.free(); + } + } + establish(identity); + const notes = await openNotesStore(identity); + const links = createNoteLinkService(notes); + Object.assign(window, { + __noteEntryFixture: { + metadata: async () => ({ + notes: (await notes.list()).map((note) => ({ + id: note.id, + title: note.title, + revision: note.revision, + })), + }), + reference: async (id: string) => { + await links.verifyGraph(); + return encodeNoteReference((await links.reference(id)).target); + }, + erase: () => notes.erase(), + }, + }); + createRoot(document.getElementById("root")!).render( + + + , + ); +} +void start().catch((error) => { + document.getElementById("root")!.textContent = + error instanceof Error ? error.message : "Fixture failed"; +}); diff --git a/web/tests/integration/note-links-browser.html b/web/tests/integration/note-links-browser.html new file mode 100644 index 0000000..3b9b986 --- /dev/null +++ b/web/tests/integration/note-links-browser.html @@ -0,0 +1 @@ +

Synthetic account session · real encrypted WASM/IndexedDB · no account backend or human profile

\ No newline at end of file diff --git a/web/tests/integration/note-links-browser.mjs b/web/tests/integration/note-links-browser.mjs new file mode 100644 index 0000000..db98676 --- /dev/null +++ b/web/tests/integration/note-links-browser.mjs @@ -0,0 +1,26 @@ +import {unzipSync} from 'fflate'; +import {chromium,expect} from '@playwright/test'; +import {mkdir,writeFile,readFile} from 'node:fs/promises'; +const output=process.env.NOTES_UI_OUTPUT??'test-results/note-links-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();const errors=[];page.on('pageerror',e=>errors.push(e.message)); +async function stable(node){await node.evaluate(async element=>{await Promise.all(element.getAnimations({subtree:true}).map(a=>a.finished.catch(()=>{})))});await expect(node).toHaveCSS('opacity','1')} +try{ +await page.goto(`${process.env.NOTES_PREVIEW_URL??'http://127.0.0.1:5179'}/tests/integration/note-links-browser.html`);const firstRun=page.getByRole('dialog',{name:'Set up Orbyn Notes'});await expect(firstRun).toBeVisible();await firstRun.getByRole('button',{name:'Skip',exact:true}).click();await firstRun.getByRole('button',{name:'Skip',exact:true}).click();await expect(firstRun).toHaveCount(0); +await page.getByRole('button',{name:'New note',exact:true}).first().click();await page.getByLabel('Note title').fill('Actual target');await page.getByLabel('Note content',{exact:true}).fill('Target writing.');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await page.getByRole('button',{name:'Back to notes'}).click();await page.getByRole('button',{name:'New note',exact:true}).first().click();await page.getByLabel('Note title').fill('Actual source');const editor=page.getByLabel('Note content',{exact:true});await editor.click();await page.keyboard.type('Reference [[');const picker=page.getByRole('dialog',{name:'Link picker'});await expect(picker).toBeVisible();await picker.getByRole('combobox').fill('Actual target');const prepare=picker.getByRole('button',{name:'Continue preparation',exact:true});await expect.poll(async()=>await picker.getByRole('option').count()||await prepare.count()).toBeGreaterThan(0);if(await prepare.count())await prepare.click();await expect(picker.getByRole('option',{name:/Actual target/})).toBeVisible();await stable(picker);await page.screenshot({path:output+'/picker-light.png'});await picker.getByRole('option',{name:/Actual target/}).click();await expect(picker).toHaveCount(0);await expect(page.getByRole('button',{name:'Open note: Actual target',exact:true})).toBeVisible();await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +const focusedChip=page.getByRole('button',{name:'Open note: Actual target',exact:true});await focusedChip.focus();await focusedChip.press('Control+z');await expect(focusedChip).toHaveCount(0);await page.getByLabel('Note content',{exact:true}).press('Control+Shift+z');await expect(focusedChip).toBeVisible();await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await page.getByRole('button',{name:'Links and backlinks',exact:true}).click();let panel=page.getByRole('complementary',{name:'Links and backlinks'});await expect(panel).toContainText('Actual target');const check=panel.getByRole('button',{name:/Check note links|Continue verification/});if(await check.count())await check.first().click();await expect(panel).toContainText('No notes link here yet.');await panel.getByRole('button',{name:'Close links and backlinks'}).click();await page.getByRole('button',{name:'Open note: Actual target',exact:true}).press('Enter');await expect(page.getByLabel('Note title')).toHaveValue('Actual target');await page.getByRole('button',{name:'Links and backlinks',exact:true}).click();panel=page.getByRole('complementary',{name:'Links and backlinks'});const check2=panel.getByRole('button',{name:/Check note links|Continue verification/});if(await check2.count())await check2.first().click();await expect(panel).toContainText('Actual source');await stable(panel);await page.screenshot({path:output+'/backlinks-light.png'}); +await panel.getByRole('button',{name:'Close links and backlinks'}).click();await page.getByLabel('Note title').fill('Renamed actual target');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();await page.getByRole('button',{name:'Back to notes'}).click();await page.getByRole('button',{name:/Actual source/}).first().click();await expect(page.getByRole('button',{name:'Open note: Renamed actual target',exact:true})).toBeVisible();const before=await page.evaluate(()=>window.__noteLinkFixture.metadata());await page.reload();await expect(page.getByRole('button',{name:/Actual source/}).first()).toBeVisible();await page.getByRole('button',{name:/Actual source/}).first().click();await expect(page.getByRole('button',{name:'Open note: Renamed actual target',exact:true})).toBeVisible();const after=await page.evaluate(()=>window.__noteLinkFixture.metadata());if(before.notes.find(n=>n.title==='Actual source').revision!==after.notes.find(n=>n.title==='Actual source').revision)throw Error('Projection changed source revision'); + +// Export uses the real authorized facade and retains typed identity in notes.json. +await page.getByRole('button',{name:'Note menu',exact:true}).click();await page.getByRole('menuitem',{name:'Export Markdown',exact:true}).click();let exportDialog=page.getByRole('dialog',{name:'Export Markdown'});await expect(exportDialog).toContainText('Markdown import does not restore');await exportDialog.getByRole('button',{name:'Export',exact:true}).click();await expect(exportDialog).toHaveCount(0);const downloadWait=page.waitForEvent('download');await page.getByRole('link',{name:/Download/}).click();const downloaded=await downloadWait;const zipped=unzipSync(await readFile(await downloaded.path()));const exported=JSON.parse(new TextDecoder().decode(zipped['notes.json']));if(!JSON.stringify(exported).includes('"type":"noteLink"')||JSON.stringify(exported).includes('Current title'))throw Error('Structured export lost reference identity or embedded cached label'); +await page.getByRole('button',{name:'Open note: Renamed actual target',exact:true}).click();await page.getByRole('button',{name:'Note menu',exact:true}).click();await page.getByRole('menuitem',{name:'Move to Trash',exact:true}).click();await page.getByRole('button',{name:/Actual source/}).first().click();await expect(page.getByRole('button',{name:'Note in Trash',exact:true})).toBeVisible();await page.getByRole('button',{name:'Note in Trash',exact:true}).click();await expect(page.getByText('This note is in Trash.',{exact:true})).toBeVisible();await page.getByRole('button',{name:/^Trash/}).click();await page.locator('article').filter({hasText:'Renamed actual target'}).getByRole('button',{name:'Restore',exact:true}).click();await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:/Actual source/}).first().click();await expect(page.getByRole('button',{name:'Open note: Renamed actual target',exact:true})).toBeVisible();await page.getByRole('button',{name:'Open note: Renamed actual target',exact:true}).press('Space');await expect(page.getByLabel('Note title')).toHaveValue('Renamed actual target');await page.getByRole('button',{name:'Back to notes'}).click();await page.getByRole('button',{name:/Actual source/}).first().click(); +await page.getByRole('button',{name:'Back to notes'}).click();await page.getByRole('button',{name:'Linked',exact:true}).click();const continuation=page.getByRole('button',{name:'Continue verification',exact:true});if(await continuation.count())await continuation.first().click();await expect(page.getByRole('button',{name:/Actual source/}).first()).toBeVisible();await expect(page.getByRole('button',{name:/Renamed actual target/}).first()).toBeVisible();await page.getByRole('button',{name:/Actual source/}).first().click(); +await page.getByRole('button',{name:'Link a note',exact:true}).click();const visualPicker=page.getByRole('dialog',{name:'Link picker'});for(const theme of ['light','dark','dim']){await page.evaluate(value=>{document.documentElement.dataset.theme=value},theme);await stable(visualPicker);await page.screenshot({path:output+`/picker-${theme}.png`})} +await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:'reduce'});await stable(visualPicker);await expect(visualPicker).toHaveAttribute('data-sheet','true');await visualPicker.getByRole('button',{name:'Close link picker'}).scrollIntoViewIfNeeded();await expect(visualPicker.getByRole('button',{name:'Close link picker'})).toBeInViewport();if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Narrow notes overflow');await page.screenshot({path:output+'/picker-narrow-dim-reduced.png'});await visualPicker.getByRole('combobox').press('Escape');await expect(visualPicker).toHaveCount(0);await page.getByRole('button',{name:'Note menu',exact:true}).click();await page.getByRole('menuitem',{name:'Links and backlinks',exact:true}).click();const narrowPanel=page.getByRole('dialog',{name:'Links and backlinks',exact:true});await expect(narrowPanel).toBeVisible();await stable(narrowPanel);await page.screenshot({path:output+'/backlinks-narrow-dim-reduced.png'});await narrowPanel.getByRole('button',{name:'Close links and backlinks',exact:true}).click();await expect(narrowPanel).toHaveCount(0); + +await page.getByLabel('Note content',{exact:true}).press('Control+End');await page.keyboard.press('Enter');await page.keyboard.type('/note');await page.getByRole('option',{name:'Another note',exact:true}).click();const slashPicker=page.getByRole('dialog',{name:'Link picker'});await slashPicker.getByRole('option',{name:/Renamed actual target/}).click();await expect(slashPicker).toHaveCount(0);await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();await expect(page.locator('.editor-pane .note-reference-chip')).toHaveCount(2); +await page.getByRole('button',{name:'Version history',exact:true}).click();const history=page.getByRole('dialog',{name:'Version history',exact:true});await expect(history).toBeVisible();await expect(history.getByRole('button',{name:'Note reference: Renamed actual target',exact:true})).toHaveCount(2);for(const ref of await history.getByRole('button',{name:'Note reference: Renamed actual target',exact:true}).all())await expect(ref).toBeDisabled();await expect(history.getByText(/This content isn’t supported/)).toHaveCount(0); +await page.evaluate(()=>window.__noteLinkFixture.erase());await expect(history).toHaveCount(0);await expect(page.getByRole('button',{name:'New note',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:/Open note:/})).toHaveCount(0); +if(errors.length)throw Error(errors.join('\n'));await writeFile(output+'/result.json',JSON.stringify({qualified:'Synthetic account; actual Notes writer, service, editor, WASM and IndexedDB; no account backend/native entry',checks:['actual [[ insertion','real outgoing/backlinks','guarded internal opening','rename projection without source save','encrypted reload','authorized Markdown+structuredJSON export','Trash safe label and exactIDrestore','Linked incoming+outgoing membership','Light/Dark/Dim and390px reduced picker/panel','erase removes held projections','actual slash Anothernote insertion','chipfocus CtrlZ+ShiftZ actualPM history','chip native Enter and Space activation','history typedrefs currentauthorizedlabels inertpreview','erase dismisses visible history'],pageErrors:errors},null,2));console.log('Note links browser passed'); +}catch(error){await page.screenshot({path:output+'/failure.png'});await writeFile(output+'/failure.txt',String(error)+'\n'+await page.locator('body').innerText());throw error}finally{await browser.close()} diff --git a/web/tests/integration/note-links-browser.tsx b/web/tests/integration/note-links-browser.tsx new file mode 100644 index 0000000..1c65a05 --- /dev/null +++ b/web/tests/integration/note-links-browser.tsx @@ -0,0 +1,73 @@ +// Test-only entry. Never imported by the production application. +import { createRoot } from "react-dom/client"; +import { Workspace } from "../../src/ui/Workspace"; +import { applyInitialPreferences } from "../../src/ui/primitives"; +import { openNotesStore } from "../../src/data"; +import { establish } from "../../src/account/session"; +import { loadEngine, DeviceKeys } from "../../src/data/engine"; +import { + newWrappingKey, + wrappingSecret, + readRecord, + mutateRecord, +} from "../../src/data/database"; +import type { DeviceRecord } from "../../src/data/database"; +import "../../src/styles/app.css"; +import "@fontsource/geist/400.css"; +import "@fontsource/geist/500.css"; + +const identity = { + accountId: "88888888-8888-4888-8888-888888888888", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + email: "isolated-note-links-fixture@example.test", + deviceState: "trusted" as const, +}; +applyInitialPreferences(); +async function start() { + await loadEngine(); + if (!(await readRecord("devices", identity.email))) { + const wrapKey = await newWrappingKey(), + secret = await wrappingSecret(wrapKey), + keys = DeviceKeys.create(); + try { + await mutateRecord("devices", identity.email, () => ({ + ...identity, + wrapKey, + sealedKeys: keys.export(secret), + generation: 0, + })); + } finally { + secret.fill(0); + keys.free(); + } + } + establish(identity); + const notes = await openNotesStore(identity); + Object.assign(window, { + __noteLinkFixture: { + metadata: async () => ({ + notes: (await notes.list()).map((note) => ({ + id: note.id, + title: note.title, + revision: note.revision, + })), + }), + erase: () => notes.erase(), + }, + }); + createRoot(document.getElementById("root")!).render( + { + notes.close(); + }} + />, + ); +} +void start().catch((error) => { + document.getElementById("root")!.textContent = + error instanceof Error ? error.message : "Fixture failed"; +}); diff --git a/web/tests/ui/import-boundary.test.tsx b/web/tests/ui/import-boundary.test.tsx new file mode 100644 index 0000000..d3ab468 --- /dev/null +++ b/web/tests/ui/import-boundary.test.tsx @@ -0,0 +1,23 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { Workspace } from "../../src/ui/Workspace"; +import type { NotesStore } from "../../src/domain/contracts"; + +beforeEach(() => { + vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })); +}); + +it.each([ + { name: "oversized file", files: [{ name: "large.md", size: 2_000_001 }], message: "Choose Markdown files up to 2 MB each." }, + { name: "oversized batch", files: Array.from({ length: 6 }, (_, i) => ({ name: `${i}.md`, size: 2_000_000 })), message: "Import batches up to 10 MB." }, + { name: "too many files", files: Array.from({ length: 101 }, (_, i) => ({ name: `${i}.md`, size: 1 })), message: "Import at most 100 files at once." }, +])("rejects $name before reading content", async ({ files, message }) => { + const read = vi.fn().mockResolvedValue("synthetic"); + const store = { list: vi.fn().mockResolvedValue([]), subscribe: () => () => {}, create: vi.fn() } as unknown as NotesStore; + const { container } = render( {}} />); + await screen.findByRole("button", { name: /All notes/ }); + fireEvent.change(container.querySelector('input[type="file"]')!, { target: { files: files.map(file => ({ ...file, text: read })) } }); + await waitFor(() => expect(screen.getByText(message)).toBeVisible()); + expect(read).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); +}); diff --git a/web/tests/ui/library.test.tsx b/web/tests/ui/library.test.tsx new file mode 100644 index 0000000..04cc390 --- /dev/null +++ b/web/tests/ui/library.test.tsx @@ -0,0 +1,573 @@ +import { + act, + fireEvent, + render, + renderHook, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import type { Note, NotesStore } from "../../src/domain/contracts"; +import type { + LibraryMutationResult, + LibrarySnapshot, + NotesLibrary, + TrashUndo, +} from "../../src/library/contracts"; +import { LibraryConflictError } from "../../src/library/contracts"; +import { checkClose } from "../../src/desktop/closeGuard"; +import { useLibraryAction } from "../../src/ui/libraryAction"; +import { TagsView } from "../../src/ui/TagsView"; +import { VersionHistory } from "../../src/ui/VersionHistory"; +import { NoteEditor } from "../../src/ui/NoteEditor"; +import { Workspace } from "../../src/ui/Workspace"; +vi.mock("../../src/ui/RichEditor", () => ({ + RichEditor: ({ + readOnly, + document, + }: { + readOnly: boolean; + document: unknown; + }) => ( +
+ {JSON.stringify(document)} +
+ ), +})); +beforeEach(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); +}); +afterEach(() => vi.unstubAllGlobals()); +const note: Note = { + id: "note-1", + title: "A note", + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Current writing" }], + }, + ], + }, + tags: ["Trip"], + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-11-11T00:00:00Z", + trashedAt: null, + revision: 2, +}; +const snapshot: LibrarySnapshot = { + token: { epoch: "epoch-1", revision: 2 }, + notes: [note], + tags: [ + { + id: "tag-1", + name: "Trip", + color: "#3b82f6", + revision: 1, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + state: "active", + mergedInto: null, + }, + ], + metadata: [ + { noteId: note.id, pinned: false, tagIds: ["tag-1"], revision: 1 }, + ], + preferences: { sort: "edited" }, +}; +const result: LibraryMutationResult = { + snapshot, + note: null, + tag: null, + undo: null, + removedCount: 0, + replayed: false, +}; +function library(overrides: Partial = {}): NotesLibrary { + return { + replaceDocument: vi.fn(async () => { + throw new Error("This fixture has no replacement editor."); + }), + updateWithSourceSteps: vi.fn(async () => { + throw new Error("This fixture has no source-aware editor."); + }), + read: vi.fn(async () => ({ mode: "ready" as const, snapshot })), + setPinned: vi.fn(async () => result), + setSort: vi.fn(async () => result), + createTag: vi.fn(async () => result), + renameTag: vi.fn(async () => result), + recolorTag: vi.fn(async () => result), + mergeTag: vi.fn(async () => result), + deleteTag: vi.fn(async () => result), + history: vi.fn(async () => []), + version: vi.fn(async () => null), + restoreVersion: vi.fn(async () => result), + changeTrash: vi.fn(async () => result), + undoTrash: vi.fn(async () => result), + emptyTrash: vi.fn(async () => result), + eraseLegacy: vi.fn(async () => result), + ...overrides, + }; +} +function store(): NotesStore { + return { + list: vi.fn(async () => [note]), + get: vi.fn(async () => note), + create: vi.fn(), + update: vi.fn(), + trash: vi.fn(), + restore: vi.fn(), + erase: vi.fn(), + subscribe: () => () => {}, + persistence: vi.fn(), + close: vi.fn(), + }; +} +function workspace(api: NotesLibrary) { + return render( + , + ); +} +describe("library mutation recovery", () => { + it("reuses an immutable operation ID only after explicit reload", async () => { + const effect = vi + .fn() + .mockRejectedValueOnce(new Error("Result unknown")) + .mockResolvedValue(result), + done = vi.fn(); + const { result: hook } = renderHook(() => useLibraryAction("epoch-1")); + act(() => hook.current.start(effect, done)); + await waitFor(() => expect(hook.current.error).toBe("Result unknown")); + expect(checkClose()).toBe("draft"); + act(() => hook.current.retry()); + expect(effect).toHaveBeenCalledTimes(1); + await act(() => hook.current.reload(async () => snapshot)); + act(() => hook.current.retry()); + await waitFor(() => expect(done).toHaveBeenCalledTimes(1)); + expect(effect.mock.calls[1]?.[0]).toBe(effect.mock.calls[0]?.[0]); + expect(checkClose()).toBe("allowed"); + }); + it("never retries a CAS conflict on a newer snapshot", async () => { + const effect = vi + .fn() + .mockRejectedValue(new LibraryConflictError(snapshot)); + const { result: hook } = renderHook(() => useLibraryAction()); + act(() => hook.current.start(effect, vi.fn())); + await waitFor(() => expect(hook.current.conflict).toBe(true)); + await act(() => hook.current.reload(async () => snapshot)); + act(() => hook.current.retry()); + expect(effect).toHaveBeenCalledTimes(1); + act(() => hook.current.dismiss()); + expect(checkClose()).toBe("allowed"); + }); + it("fences a response from the erased epoch", async () => { + let finish!: (value: LibraryMutationResult) => void; + const pending = new Promise((yes) => { + finish = yes; + }), + done = vi.fn(); + const view = renderHook(({ epoch }) => useLibraryAction(epoch), { + initialProps: { epoch: "old" }, + }); + act(() => view.result.current.start(() => pending, done)); + view.rerender({ epoch: "replacement" }); + await act(async () => finish(result)); + expect(done).not.toHaveBeenCalled(); + expect(checkClose()).toBe("allowed"); + }); +}); +it("keeps a failed New tag form guarded and retries the same creation", async () => { + const createTag = vi + .fn() + .mockRejectedValueOnce(new Error("Quota unavailable")) + .mockResolvedValue(result); + render( + snapshot} + onOpenTag={vi.fn()} + />, + ); + fireEvent.click(screen.getByText("New tag")); + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText("Name"), { + target: { value: "New tag draft" }, + }); + expect(checkClose()).toBe("draft"); + fireEvent.click(within(dialog).getByText("Create tag")); + await within(dialog).findByText("Quota unavailable"); + expect(within(dialog).getByLabelText("Name")).toHaveValue("New tag draft"); + fireEvent.click(within(dialog).getByText("Reload saved library")); + await waitFor(() => + expect(within(dialog).getByText("Retry same action")).toBeEnabled(), + ); + fireEvent.click(within(dialog).getByText("Retry same action")); + await waitFor(() => expect(createTag).toHaveBeenCalledTimes(2)); + expect(createTag.mock.calls[1]).toEqual(createTag.mock.calls[0]); +}); +it("previews captured tags read-only and confirms title/document-only restore", async () => { + const version = { + id: "v1", + noteId: note.id, + noteRevision: 1, + capturedAt: "2026-11-10T10:00:00Z", + reason: "edit" as const, + title: "Old title", + document: { + type: "doc" as const, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Old writing" }] }, + ], + }, + tagsAtCapture: [ + { id: "old-tag", name: "Deleted historical tag", color: null }, + ], + }; + const api = library({ history: vi.fn(async () => [version]) }); + render( + snapshot} + />, + ); + fireEvent.click(await screen.findByRole("button", { name: /Previous edit/ })); + expect(screen.getByText("Deleted historical tag")).toBeInTheDocument(); + expect(screen.getByLabelText("Preview boundary")).toHaveAttribute( + "data-readonly", + "true", + ); + fireEvent.click(screen.getByText("Restore this version")); + expect(api.restoreVersion).not.toHaveBeenCalled(); + const confirm = screen.getByRole("dialog", { name: "Restore this version?" }); + expect( + within(confirm).getByText(/Current tags and pin stay unchanged/), + ).toBeInTheDocument(); + fireEvent.click(within(confirm).getByText("Restore version")); + await waitFor(() => + expect(api.restoreVersion).toHaveBeenCalledWith( + note.id, + "v1", + 2, + expect.any(String), + ), + ); +}); +it("uses semantic Undo for restoring Trash, retaining the original trash clock", async () => { + const trashed = { ...note, trashedAt: "2026-11-01T10:00:00Z" }; + const receipt: TrashUndo = { + epoch: "epoch-1", + operationId: "original", + noteId: note.id, + resultRevision: 3, + previousTrashedAt: trashed.trashedAt, + resultTrashedAt: null, + expiresAt: new Date(Date.now() + 5000).toISOString(), + }; + const api = library({ + read: vi.fn(async () => ({ + mode: "ready" as const, + snapshot: { ...snapshot, notes: [trashed] }, + })), + changeTrash: vi.fn(async () => ({ ...result, note, undo: receipt })), + }); + workspace(api); + fireEvent.click(await screen.findByRole("button", { name: /Trash1/ })); + fireEvent.click(screen.getByText("Restore")); + await screen.findByText("Note restored"); + fireEvent.click(screen.getByText("Undo")); + await waitFor(() => + expect(api.undoTrash).toHaveBeenCalledWith(receipt, expect.any(String)), + ); + expect(api.changeTrash).toHaveBeenCalledWith( + note.id, + "restore", + 2, + expect.any(String), + ); +}); +it("requires explicit Empty Trash confirmation and reports the actual removed count", async () => { + const trashed = { ...note, trashedAt: "2026-11-01T10:00:00Z" }; + const api = library({ + read: vi.fn(async () => ({ + mode: "ready" as const, + snapshot: { ...snapshot, notes: [trashed] }, + })), + emptyTrash: vi.fn(async () => ({ ...result, removedCount: 1 })), + }); + workspace(api); + fireEvent.click(await screen.findByRole("button", { name: /Trash1/ })); + fireEvent.click(screen.getByText("Empty Trash")); + expect(api.emptyTrash).not.toHaveBeenCalled(); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Empty Trash?" })).getByText( + "Empty Trash", + ), + ); + await screen.findByText("Removed 1 notes permanently"); + expect(api.emptyTrash).toHaveBeenCalledWith( + snapshot.token, + expect.any(String), + ); +}); +it("keeps legacy read/export available but rejects editing without a fabricated token", async () => { + const api = library({ + read: vi.fn(async () => ({ + mode: "legacy-read-only" as const, + notes: [note], + reason: "metadata-limit" as const, + recovery: { + kind: "export-or-confirm-erase" as const, + deviceGeneration: 47, + }, + })), + }); + workspace(api); + await screen.findByText(/Library metadata exceeds/); + expect(screen.getByText("New note")).toBeDisabled(); + expect(screen.getByText("Manage tags")).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: /A note/ })); + expect(screen.getByLabelText("Note title")).toHaveAttribute("readonly"); + fireEvent.click(screen.getByRole("button", { name: "Note menu" })); + expect(screen.getByRole("menuitem", { name: /Duplicate/ })).toBeDisabled(); + expect( + screen.getByRole("menuitem", { name: /Export Markdown/ }), + ).toBeEnabled(); + expect(api.setPinned).not.toHaveBeenCalled(); +}); + +it("persists the chosen sort and maps Pinned to real metadata", async () => { + const pinned = { + ...snapshot, + metadata: [{ ...snapshot.metadata[0]!, pinned: true }], + }; + const api = library({ + setPinned: vi.fn(async () => ({ ...result, snapshot: pinned })), + setSort: vi.fn(async () => ({ + ...result, + snapshot: { ...pinned, preferences: { sort: "title" as const } }, + })), + }); + workspace(api); + await screen.findByRole("heading", { name: "All notes" }); + fireEvent.click(screen.getByRole("button", { name: "Pin A note" })); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Unpin A note" }), + ).toHaveAttribute("aria-pressed", "true"), + ); + fireEvent.click(screen.getByRole("button", { name: /^Pinned/ })); + expect(screen.getByRole("heading", { name: "Pinned" })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Sort notes"), { + target: { value: "title" }, + }); + await waitFor(() => + expect(api.setSort).toHaveBeenCalledWith( + "title", + snapshot.token, + expect.any(String), + ), + ); +}); +it("passes the actual read-only generation only after typed erase confirmation", async () => { + const api = library({ + read: vi.fn(async () => ({ + mode: "legacy-read-only" as const, + notes: [note], + reason: "metadata-limit" as const, + recovery: { + kind: "export-or-confirm-erase" as const, + deviceGeneration: 47, + }, + })), + }); + workspace(api); + await screen.findByText(/Library metadata exceeds/); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: /Data/ })); + fireEvent.click(screen.getByRole("button", { name: "Erase this browser" })); + const dialog = screen.getByRole("dialog", { + name: "Erase notes from this browser?", + }); + expect(api.eraseLegacy).not.toHaveBeenCalled(); + fireEvent.change(within(dialog).getByLabelText("Type ERASE to confirm"), { + target: { value: "ERASE" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Erase notes" })); + await waitFor(() => + expect(api.eraseLegacy).toHaveBeenCalledWith( + { deviceGeneration: 47, confirmation: "ERASE" }, + expect.any(String), + ), + ); +}); + +it.each(["ready", "legacy-read-only"] as const)( + "names the complete irreversible erase scope and export exclusions in %s mode", + async (mode) => { + const api = library( + mode === "ready" + ? {} + : { + read: vi.fn(async () => ({ + mode: "legacy-read-only" as const, + notes: [note], + reason: "metadata-limit" as const, + recovery: { + kind: "export-or-confirm-erase" as const, + deviceGeneration: 47, + }, + })), + }, + ); + workspace(api); + await screen.findByRole("heading", { name: "All notes" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Data and export" })); + fireEvent.click(screen.getByRole("button", { name: "Erase this browser" })); + const dialog = screen.getByRole("dialog", { + name: "Erase notes from this browser?", + }); + expect(dialog).toHaveTextContent("tags, pins and version history"); + expect(dialog).toHaveTextContent("Study decks, cards and reviews"); + expect(dialog).toHaveTextContent( + "protected Journal entries, habits and recovery drafts", + ); + expect(dialog).toHaveTextContent("Your account stays signed in"); + expect(dialog).toHaveTextContent( + "Notes Markdown export does not include Study, Journal, Clips, tag colors, pins or version history", + ); + expect( + within(dialog).getByRole("button", { name: "Open Journal to export" }), + ).toBeEnabled(); + expect( + within(dialog).getByRole("button", { name: "Erase notes" }), + ).toBeDisabled(); + expect(api.eraseLegacy).not.toHaveBeenCalled(); + }, +); +it("preserves exact comma/case/Unicode tags while adding, saving and reopening", async () => { + const initial = { ...note, tags: ["Trip", "trip", "café", "cafe\u0301"] }; + let saved = initial; + const update = vi + .fn() + .mockImplementation(async (_id, patch, revision) => { + saved = { ...saved, ...patch, revision: revision + 1 }; + return saved; + }); + const catalog = [ + { ...snapshot.tags[0]!, id: "comma-tag", name: "work, play" }, + ]; + const props = { + store: { ...store(), update }, + onSaved: vi.fn(), + onBack: vi.fn(), + onTrash: vi.fn(), + onExport: vi.fn(), + tagCatalog: catalog, + }; + const view = render(); + fireEvent.change(screen.getByLabelText("Add existing tag"), { + target: { value: "comma-tag" }, + }); + fireEvent.change(screen.getByLabelText("New tag"), { + target: { value: "Another tag" }, + }); + expect(checkClose()).toBe("draft"); + fireEvent.click(screen.getByRole("button", { name: "Add tag" })); + await waitFor(() => + expect(saved.tags).toEqual([...initial.tags, "work, play", "Another tag"]), + ); + view.unmount(); + render(); + for (const name of [...initial.tags, "work, play", "Another tag"]) + expect( + screen.getByRole("button", { name: `Remove tag ${name}` }), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Remove tag Another tag" }), + ); + await waitFor(() => + expect(saved.tags).toEqual([...initial.tags, "work, play"]), + ); +}); + +it("keeps an unsubmitted single tag guarded without rewriting saved tags", async () => { + const update = vi.fn(); + const back = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("New tag"), { + target: { value: "Unsubmitted" }, + }); + expect(checkClose()).toBe("draft"); + fireEvent.click(screen.getByRole("button", { name: "← Back to notes" })); + expect(back).not.toHaveBeenCalled(); + expect(screen.getByLabelText("New tag")).toHaveValue("Unsubmitted"); + expect(update).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("Clear tag input")); + expect(checkClose()).toBe("allowed"); + expect( + screen.getByRole("button", { name: "Remove tag work, play" }), + ).toBeInTheDocument(); +}); +it("ignores old reload completion after replacement or cleared action", async () => { + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const effect = vi.fn().mockRejectedValue(new Error("Unknown result")); + const view = renderHook(({ scope }) => useLibraryAction(scope), { + initialProps: { scope: "old" }, + }); + act(() => view.result.current.start(effect, vi.fn())); + await waitFor(() => expect(view.result.current.error).toBe("Unknown result")); + let reload!: Promise; + act(() => { + reload = view.result.current.reload(() => pending); + }); + view.rerender({ scope: "new" }); + await act(async () => { + finish(); + await reload; + }); + expect(view.result.current.reloaded).toBe(false); + expect(view.result.current.error).toBe(""); +}); diff --git a/web/tests/ui/note-link-entry.test.tsx b/web/tests/ui/note-link-entry.test.tsx new file mode 100644 index 0000000..52c88b9 --- /dev/null +++ b/web/tests/ui/note-link-entry.test.tsx @@ -0,0 +1,178 @@ +import { StrictMode, useEffect } from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { AccountSession } from "../../src/domain/contracts"; +import type { + NoteLinkCopyFormatter, + NoteLinkNavigatorRegistration, +} from "../../src/links/navigation"; +import { encodeNoteReference } from "../../src/links/reference"; + +const fixture = vi.hoisted(() => ({ + session: null as AccountSession | null, + lock: null as (() => void) | null, + navigate: vi.fn(), + format: undefined as NoteLinkCopyFormatter | undefined, +})); +const session: AccountSession = { + accountId: "11111111-1111-4111-8111-111111111111", + deviceId: "device", + email: "fixture@example.invalid", + deviceState: "trusted", +}; +const target = { + kind: "note" as const, + accountId: session.accountId, + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "one", +}; +function setHash(hash: string) { + window.history.replaceState(null, "", "/?invite=not-for-clipboard" + hash); + window.dispatchEvent(new HashChangeEvent("hashchange")); +} +const hash = () => "#note/v1/" + encodeNoteReference(target).split("/v1/")[1]; +vi.mock("../../src/account", () => ({ + createAccountService: () => ({ + current: async () => fixture.session, + signOut: async () => { + fixture.lock?.(); + }, + }), + subscribeLock: (listener: () => void) => { + fixture.lock = listener; + return () => { + fixture.lock = null; + }; + }, +})); +vi.mock("../../src/data", () => ({ + openNotesStore: async () => ({ close: vi.fn() }), +})); +vi.mock("../../src/ui/Workspace", () => ({ + Workspace: ({ + onNoteLinkNavigatorReady, + formatNoteLink, + }: { + onNoteLinkNavigatorReady: NoteLinkNavigatorRegistration; + formatNoteLink: NoteLinkCopyFormatter; + }) => { + fixture.format = formatNoteLink; + useEffect(() => { + onNoteLinkNavigatorReady(fixture.navigate); + return () => onNoteLinkNavigatorReady(null); + }, [onNoteLinkNavigatorReady]); + return

Workspace fixture

; + }, +})); +vi.mock("../../src/ui/AccountView", () => ({ + AccountView: ({ + onSession, + }: { + onSession: (session: AccountSession) => void; + }) => ( + + ), + PendingDevice: () =>

Pending fixture

, +})); +import { App } from "../../src/App"; +beforeEach(() => { + fixture.session = null; + fixture.navigate.mockReset().mockResolvedValue({ status: "opened" }); + fixture.format = undefined; + vi.stubEnv("VITE_ENGINE_URL", "http://127.0.0.1:8089"); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + window.history.replaceState(null, "", "/"); +}); +it("reports an invalid incoming link before sign-in without attempting private navigation", async () => { + setHash("#note/v2/not-valid"); + render(); + expect( + await screen.findByRole("button", { name: "Fixture sign in" }), + ).toBeVisible(); + expect(await screen.findByText("This note link isn’t valid.")).toBeVisible(); + expect(fixture.navigate).not.toHaveBeenCalled(); +}); + +it("holds the initial URL through actual App sign-in wiring and supplies a query-free web link", async () => { + setHash(hash()); + render( + + + , + ); + fireEvent.click( + await screen.findByRole("button", { name: "Fixture sign in" }), + ); + await screen.findByText("Workspace fixture"); + await waitFor(() => + expect(fixture.navigate).toHaveBeenCalledExactlyOnceWith( + target, + expect.any(AbortSignal), + ), + ); + expect(fixture.format?.(target)).toEqual({ + text: window.location.origin + "/" + hash(), + kind: "web-link", + }); + await waitFor(() => + expect( + screen.queryByLabelText("Incoming note link"), + ).not.toBeInTheDocument(), + ); +}); + +it("requires explicit retry after guarded blocking and revokes the old URL on lock", async () => { + fixture.session = session; + fixture.navigate + .mockResolvedValueOnce({ status: "blocked" }) + .mockResolvedValue({ status: "opened" }); + setHash(hash()); + render(); + await screen.findByText("Finish your current work before opening this note."); + expect(fixture.navigate).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(fixture.navigate).toHaveBeenCalledTimes(2)); + act(() => fixture.lock?.()); + expect(screen.queryByText("Workspace fixture")).not.toBeInTheDocument(); + expect(window.location.hash).toBe(""); + fireEvent.click(screen.getByRole("button", { name: "Fixture sign in" })); + await screen.findByText("Workspace fixture"); + expect(fixture.navigate).toHaveBeenCalledTimes(2); +}); + +it("aborts an in-flight intent when the hash changes and never puts private identity into notices", async () => { + fixture.session = session; + let admitted: AbortSignal | undefined; + fixture.navigate.mockImplementation((_target, signal) => { + admitted = signal; + return new Promise(() => {}); + }); + setHash(hash()); + render(); + await waitFor(() => expect(fixture.navigate).toHaveBeenCalledTimes(1)); + act(() => setHash("#note/v9/not-valid")); + expect(admitted?.aborted).toBe(true); + expect(await screen.findByText("This note link isn’t valid.")).toBeVisible(); + expect(screen.getByLabelText("Incoming note link")).not.toHaveTextContent( + target.accountId, + ); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(window.location.hash).toBe(""); +}); diff --git a/web/tests/ui/note-links-routing.test.tsx b/web/tests/ui/note-links-routing.test.tsx new file mode 100644 index 0000000..d422983 --- /dev/null +++ b/web/tests/ui/note-links-routing.test.tsx @@ -0,0 +1,398 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Note, NotesStore } from "../../src/domain/contracts"; +import type { + NotesLibrary, + NotesLibraryStore, + LibrarySnapshot, +} from "../../src/library/contracts"; +import type { NoteLinkService, NoteTarget } from "../../src/links/contracts"; +import type { NoteLinkNavigator } from "../../src/links/navigation"; +import { Workspace } from "../../src/ui/Workspace"; +import { registerCloseGuard } from "../../src/desktop/closeGuard"; +import type { NoteExportIntent } from "../../src/links/contracts"; +import { buildDownload } from "../../src/ui/download"; +const model = vi.hoisted(() => ({ + service: null as NoteLinkService | null, + linkBoot: true, + sourceBoot: true, + invalidate: null as ((reason: "epoch" | "closed") => void) | null, +})); +vi.mock("../../src/ui/noteLinkOwner", () => ({ + useNoteLinkOwner: ( + _owner: unknown, + onInvalidate: (reason: "epoch" | "closed") => void, + ) => { + model.invalidate = onInvalidate; + return { + service: model.service, + error: "", + bootSettled: model.linkBoot, + retry: vi.fn(), + }; + }, +})); +vi.mock("../../src/ui/studySourceOwners", () => ({ + useStudySourceOwners: () => ({ + service: null, + collections: null, + error: "", + bootSettled: model.sourceBoot, + retry: vi.fn(), + }), +})); +vi.mock("../../src/ui/download", () => ({ + buildDownload: vi.fn(() => ({ + blob: new Blob(["legacy"]), + name: "legacy.md", + })), +})); +vi.mock("../../src/ui/NoteEditor", () => ({ + NoteEditor: ( + props: Parameters[0], + ) => ( +
+

Editor {props.note.title}

+
+ +
+ ), +})); +const epoch = "22222222-2222-4222-8222-222222222222", + account = "11111111-1111-4111-8111-111111111111"; +const note = (id: string): Note => ({ + id, + title: id, + revision: 1, + tags: [], + createdAt: "2026-11-11T00:00:00Z", + updatedAt: "2026-11-11T00:00:00Z", + trashedAt: null, + document: { type: "doc", content: [{ type: "paragraph" }] }, +}); +const a = note("A"), + b = note("B"), + snapshot: LibrarySnapshot = { + token: { epoch, revision: 1 }, + notes: [a, b], + metadata: [], + tags: [], + preferences: { sort: "edited" }, + }; +const target = (noteId: string): NoteTarget => ({ + kind: "note", + accountId: account, + libraryEpoch: epoch, + noteId, +}); +function held() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +function fixture() { + const library: NotesLibrary = { + read: vi.fn(async () => ({ mode: "ready" as const, snapshot })), + updateWithSourceSteps: vi.fn(), + replaceDocument: vi.fn(), + setPinned: vi.fn(), + setSort: vi.fn(), + createTag: vi.fn(), + renameTag: vi.fn(), + recolorTag: vi.fn(), + mergeTag: vi.fn(), + deleteTag: vi.fn(), + history: vi.fn(), + version: vi.fn(), + restoreVersion: vi.fn(), + changeTrash: vi.fn(), + undoTrash: vi.fn(), + emptyTrash: vi.fn(), + eraseLegacy: vi.fn(), + }; + const base: NotesStore = { + list: vi.fn(async () => [a, b]), + get: vi.fn(async (id) => (id === "A" ? a : b)), + create: vi.fn(), + update: vi.fn(), + trash: vi.fn(), + restore: vi.fn(), + erase: vi.fn(), + subscribe: () => () => {}, + persistence: () => ({ status: "ready" }), + close: vi.fn(), + }; + const owner: NotesLibraryStore = { ...base, library }; + const service: NoteLinkService = { + admission: vi.fn(async () => ({ + mode: "current" as const, + token: snapshot.token, + graph: "ready" as const, + })), + currentToken: vi.fn(), + search: vi.fn(), + reference: vi.fn(), + resolve: vi.fn(async (ref) => ({ + status: "available" as const, + target: ref, + token: snapshot.token, + noteRevision: 1, + title: ref.noteId, + })), + outgoing: vi.fn(), + backlinks: vi.fn(), + linkedNoteIds: vi.fn(async () => ({ + status: "ready" as const, + token: snapshot.token, + items: [], + })), + verifyGraph: vi.fn(), + cancelVerification: vi.fn(), + prepareRepair: vi.fn(), + commitRepair: vi.fn(), + prepareExport: vi.fn(), + exportFiles: vi.fn(), + assertFreshExport: vi.fn(), + subscribe: () => () => {}, + onInvalidate: () => () => {}, + close: vi.fn(), + }; + model.service = service; + let navigator: NoteLinkNavigator | null = null; + const register = vi.fn((value: NoteLinkNavigator | null) => { + navigator = value; + }); + const props = { + store: base, + library, + sourceOwner: owner, + session: { + accountId: account, + deviceId: "33333333-3333-4333-8333-333333333333", + email: "explicit-routing-boundary@example.test", + deviceState: "trusted" as const, + }, + onSignOut: vi.fn(async () => {}), + onNoteLinkNavigatorReady: register, + onNoteLinkScopeRevoked: vi.fn(), + }; + const view = render(); + return { base, service, register, view, props, nav: () => navigator }; +} +beforeEach(() => { + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + model.linkBoot = true; + model.sourceBoot = true; + model.service = null; + vi.mocked(buildDownload).mockClear(); + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; + URL.createObjectURL = vi.fn(() => "blob:synthetic"); + URL.revokeObjectURL = vi.fn(); +}); +it("registers the first navigator only after actual initial boot boundaries settle, without retrying or revoking on later work", async () => { + model.linkBoot = false; + model.sourceBoot = false; + const f = fixture(); + await screen.findByRole("heading", { name: "All notes" }); + expect(f.nav()).toBeNull(); + model.linkBoot = true; + f.view.rerender(); + expect(f.nav()).toBeNull(); + model.sourceBoot = true; + f.view.rerender(); + await waitFor(() => expect(f.nav()).toBeTypeOf("function")); + const count = f.register.mock.calls.length; + model.linkBoot = false; + model.sourceBoot = false; + f.view.rerender(); + expect(f.register).toHaveBeenCalledTimes(count); + expect(f.service.resolve).not.toHaveBeenCalled(); +}); +it("forwards and obeys cancellation before work and after held resolve", async () => { + const f = fixture(); + await waitFor(() => expect(f.nav()).toBeTypeOf("function")); + const first = new AbortController(); + first.abort(); + expect(await f.nav()!(target("B"), first.signal)).toEqual({ + status: "unavailable", + }); + expect(f.service.resolve).not.toHaveBeenCalled(); + const delayed = held>>(); + vi.mocked(f.service.resolve).mockReturnValueOnce(delayed.promise); + const controller = new AbortController(); + let result!: ReturnType; + act(() => { + result = f.nav()!(target("B"), controller.signal); + }); + controller.abort(); + await act(async () => { + delayed.resolve({ + status: "available", + target: target("B"), + token: snapshot.token, + noteRevision: 1, + title: "B", + }); + await result; + }); + expect(f.base.get).not.toHaveBeenCalled(); + expect( + screen.queryByRole("heading", { name: "Editor B" }), + ).not.toBeInTheDocument(); +}); +it("does not admit a selected note after cancellation during held get", async () => { + const f = fixture(); + await waitFor(() => expect(f.nav()).toBeTypeOf("function")); + const delayed = held(); + vi.mocked(f.base.get).mockReturnValueOnce(delayed.promise); + const controller = new AbortController(); + let result!: ReturnType; + act(() => { + result = f.nav()!(target("B"), controller.signal); + }); + await waitFor(() => expect(f.base.get).toHaveBeenCalledWith("B")); + controller.abort(); + await act(async () => { + delayed.resolve(b); + await result; + }); + expect(await result).toEqual({ status: "unavailable" }); + expect( + screen.queryByRole("heading", { name: "Editor B" }), + ).not.toBeInTheDocument(); +}); +it("never falls back to the legacy renderer when a genuine owner is present but its link facade is not ready", async () => { + const f = fixture(); + model.service = null; + f.view.rerender(); + fireEvent.click(await screen.findByRole("button", { name: /^A / })); + fireEvent.click(screen.getByRole("button", { name: "Export this note" })); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Export Markdown" })).getByRole( + "button", + { name: "Export" }, + ), + ); + await screen.findByText( + "Note links aren’t ready. Wait for them to open, then try exporting again.", + ); + expect(buildDownload).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Export Markdown" })).toBeVisible(); +}); + +afterEach(() => vi.unstubAllGlobals()); + +it("clears its prior navigation guard notice after explicit allowed retry", async () => { + const f = fixture(); + await waitFor(() => expect(f.nav()).toBeTypeOf("function")); + const stop = registerCloseGuard(() => false); + let blocked; + try { + await act(async () => { + blocked = await f.nav()!(target("B")); + }); + expect(blocked).toEqual({ status: "blocked" }); + await screen.findByText( + "Finish or discard your unsaved changes before leaving.", + ); + } finally { + stop(); + } + await act(async () => { + await f.nav()!(target("B")); + }); + await screen.findByRole("heading", { name: "Editor B" }); + expect( + screen.queryByText( + "Finish or discard your unsaved changes before leaving.", + ), + ).not.toBeInTheDocument(); +}); +it("preserves an unrelated prepared-export notice when a later navigation clears its guard ownership", async () => { + const f = fixture(); + await waitFor(() => expect(f.nav()).toBeTypeOf("function")); + const stop = registerCloseGuard(() => false); + try { + await act(async () => { + await f.nav()!(target("B")); + }); + } finally { + stop(); + } + fireEvent.click(screen.getByRole("button", { name: /^A / })); + vi.mocked(f.service.prepareExport).mockResolvedValue({} as NoteExportIntent); + vi.mocked(f.service.exportFiles).mockResolvedValue({ + token: snapshot.token, + files: [{ filename: "A.md", content: "Approved boundary fixture" }], + warnings: [], + }); + vi.mocked(f.service.assertFreshExport).mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Export this note" })); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Export Markdown" })).getByRole( + "button", + { name: "Export" }, + ), + ); + await screen.findByText("Export prepared"); + await act(async () => { + await f.nav()!(target("B")); + }); + await screen.findByRole("heading", { name: "Editor B" }); + expect(screen.getByText("Export prepared")).toBeVisible(); +}); + +it.each(["epoch", "closed"] as const)( + "notifies external scope retirement synchronously before clearing %s projections", + async (reason) => { + const f = fixture(); + fireEvent.click(await screen.findByRole("button", { name: /^A / })); + await screen.findByRole("heading", { name: "Editor A" }); + const order: string[] = []; + f.props.onNoteLinkScopeRevoked.mockImplementation(() => { + order.push( + screen.queryByRole("heading", { name: "Editor A" }) + ? "retired-before-clear" + : "too-late", + ); + }); + expect(f.props.onNoteLinkScopeRevoked).not.toHaveBeenCalled(); + model.service = null; + act(() => model.invalidate!(reason)); + expect(f.props.onNoteLinkScopeRevoked).toHaveBeenCalledTimes(1); + expect(order).toEqual(["retired-before-clear"]); + expect( + screen.queryByRole("heading", { name: "Editor A" }), + ).not.toBeInTheDocument(); + }, +); +it("does not retire scope for initial service-null or pending boot registration", async () => { + model.linkBoot = false; + model.sourceBoot = false; + const f = fixture(); + model.service = null; + f.view.rerender(); + await screen.findByRole("heading", { name: "All notes" }); + expect(f.nav()).toBeNull(); + expect(f.props.onNoteLinkScopeRevoked).not.toHaveBeenCalled(); +});