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 harnessTest 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();
+});