diff --git a/web/tests/integration/notes-source-motion-browser.mjs b/web/tests/integration/notes-source-motion-browser.mjs new file mode 100644 index 0000000..4ae3342 --- /dev/null +++ b/web/tests/integration/notes-source-motion-browser.mjs @@ -0,0 +1,574 @@ +// Genuine encrypted Notes source motion/placement regression driver; synthetic disposable contexts only. +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve, join } from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +const here = dirname(fileURLToPath(import.meta.url)); +const web = resolve(here, "../.."); +const require = createRequire(join(web, "package.json")); +const { chromium, expect } = require("@playwright/test"); +const focusOnly = process.argv.includes("--focus-only"); +const base = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"; +const output = + process.env.NOTES_UI_OUTPUT ?? + resolve(web, "test-results/notes-source-motion-browser"); +await mkdir(output, { recursive: true }); +const sources = [ + "src/ui/RichEditor.tsx", + "src/ui/SourceCardComposer.tsx", + "src/ui/NoteEditor.tsx", + "src/styles/study-source.css", + "src/ui/primitives.tsx", + "src/styles/app.css", + "tests/integration/study-source-browser.tsx", +]; +async function hashes() { + return Promise.all( + sources.map(async (path) => ({ + path, + sha256: createHash("sha256") + .update(await readFile(join(web, path))) + .digest("hex"), + })), + ); +} +const before = await hashes(), + runs = []; +const browser = await chromium.launch({ channel: "chrome", headless: true }); +let activePage; +async function stable(locator) { + await locator.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => {})), + ); + }); + await expect(locator).toHaveCSS("opacity", "1"); +} +async function sample(page, selector) { + return page + .locator(selector) + .first() + .evaluateAll((elements) => { + const element = elements[0]; + if (!element) return { present: false }; + const css = getComputedStyle(element), + rect = element.getBoundingClientRect(); + return { + present: true, + classes: element.className, + inert: element.inert, + ariaHidden: element.getAttribute("aria-hidden"), + opacity: css.opacity, + transform: css.transform, + animation: css.animationName, + duration: css.animationDuration, + easing: css.animationTimingFunction, + pointerEvents: css.pointerEvents, + borderColor: css.borderBottomColor, + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + activeInside: element.contains(document.activeElement), + focusable: [ + ...element.querySelectorAll("button,textarea,select,input"), + ].filter((control) => !control.disabled).length, + }; + }); +} +async function mark(page, value) { + await page.evaluate((value) => (window.__sourceAudit.phase = value), value); +} +async function selection(page) { + const editor = page.getByLabel("Note content", { exact: true }); + await editor.click(); + await editor.press("Control+Home"); + for (let i = 0; i < 7; i++) await editor.press("ArrowRight"); + for (let i = 0; i < 14; i++) await editor.press("Shift+ArrowRight"); + await expect( + page.getByRole("toolbar", { name: "Selection tools", exact: true }), + ).toBeVisible(); +} +async function open(page) { + await selection(page); + await page + .getByRole("toolbar", { name: "Selection tools", exact: true }) + .getByRole("button", { name: "Make a card", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { + name: "Make a card", + exact: true, + includeHidden: true, + }); + await expect(dialog).toBeVisible(); + return dialog; +} +function row(events, name, phase) { + return events.filter( + (event) => + event.kind === "animationend" && + event.name === name && + (!phase || event.phase === phase), + ); +} +try { + for (const mode of [ + { id: "normal", os: "no-preference", motion: "system" }, + { id: "os-reduced", os: "reduce", motion: "system" }, + { id: "explicit-on-os-reduced", os: "reduce", motion: "on" }, + { id: "explicit-reduced", os: "no-preference", motion: "reduced" }, + ].slice(0, focusOnly ? 1 : 4)) { + const reduced = + mode.motion === "reduced" || + (mode.os === "reduce" && mode.motion !== "on"); + const context = await browser.newContext({ + viewport: { width: 1160, height: 850 }, + reducedMotion: mode.os, + }); + await context.addInitScript( + (motion) => localStorage.setItem("notes.motion", motion), + mode.motion, + ); + const page = await context.newPage(); + activePage = page; + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`${base}/tests/integration/study-source-browser.html`); + await page.waitForFunction(() => window.__studySourceFixture); + const setup = page.getByRole("dialog", { + name: "Set up Orbyn Notes", + exact: true, + }); + 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).toHaveCount(0); + } + await expect + .poll(() => + page.evaluate(async () => + (await import("/src/desktop/closeGuard.ts")).checkClose(), + ), + ) + .toBe("allowed"); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await page + .getByRole("button", { name: "New deck", exact: true }) + .first() + .click(); + let dialog = page.getByRole("dialog", { name: "New deck", exact: true }); + await dialog.getByLabel("Name").fill("Motion audit deck"); + await dialog + .getByRole("button", { name: "Create deck", exact: true }) + .click(); + await expect(dialog).toHaveCount(0); + await page.getByRole("button", { name: /^All notes/ }).click(); + await page + .getByRole("button", { name: "New note", exact: true }) + .first() + .click(); + await page.getByLabel("Note title").fill("Synthetic source motion audit"); + const editor = page.getByLabel("Note content", { exact: true }); + await editor.click(); + await page.keyboard.type("Before NADH and FADH2 after."); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + await page.evaluate(() => { + const audit = (window.__sourceAudit = { + phase: "ready", + events: [], + frames: [], + dom: [], + }), + ids = new WeakMap(); + let next = 0; + const selector = + ".source-card-popover,.source-card-notice,.study-source-annotation,.study-selection-toolbar"; + const id = (element) => { + if (!ids.has(element)) ids.set(element, ++next); + return ids.get(element); + }; + const snapshot = (element) => { + const css = getComputedStyle(element); + return { + id: id(element), + at: performance.now(), + phase: audit.phase, + classes: element.className, + connected: element.isConnected, + opacity: css.opacity, + transform: css.transform, + borderColor: css.borderBottomColor, + animation: css.animationName, + duration: css.animationDuration, + easing: css.animationTimingFunction, + pointerEvents: css.pointerEvents, + inert: element.inert, + ariaHidden: element.getAttribute("aria-hidden"), + }; + }; + for (const kind of [ + "animationstart", + "animationend", + "animationcancel", + ]) { + document.addEventListener( + kind, + (event) => { + const element = event.target; + if (!(element instanceof Element) || !element.matches(selector)) + return; + audit.events.push({ + ...snapshot(element), + kind, + name: event.animationName, + elapsed: event.elapsedTime, + keyframes: element.getAnimations().flatMap((animation) => + animation.effect + .getKeyframes() + .map(({ opacity, transform, borderBottomColor, offset }) => ({ + opacity, + transform, + borderBottomColor, + offset, + })), + ), + }); + if (kind === "animationstart") { + const deadline = performance.now() + 450; + let frames = 0; + const frame = () => { + if ( + element.isConnected && + performance.now() < deadline && + frames++ < 40 + ) { + audit.frames.push(snapshot(element)); + requestAnimationFrame(frame); + } + }; + requestAnimationFrame(frame); + } + }, + true, + ); + } + new MutationObserver((records) => { + for (const record of records) { + if (record.type === "attributes" && record.target.matches(selector)) + audit.dom.push({ ...snapshot(record.target), kind: "attribute" }); + for (const [nodes, kind] of [ + [record.addedNodes, "added"], + [record.removedNodes, "removed"], + ]) + for (const node of nodes) { + if (!(node instanceof Element)) continue; + for (const element of [node, ...node.querySelectorAll(selector)]) + if (element.matches(selector)) + audit.dom.push({ ...snapshot(element), kind }); + } + } + }).observe(document.getElementById("root"), { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["class", "inert", "aria-hidden"], + }); + }); + const observations = { + mode, + policy: await page.evaluate(() => ({ + effective: document.documentElement.dataset.motion, + os: matchMedia("(prefers-reduced-motion: reduce)").matches, + })), + }; + await editor.evaluate((node) => { + window.__sourceEditorBefore = node; + window.__sourceHTMLBefore = node.innerHTML; + }); + await mark(page, "clean-escape"); + dialog = await open(page); + observations.enter = await sample(page, ".source-card-popover"); + await stable(dialog); + await expect( + dialog.getByRole("textbox", { name: "Front", exact: true }), + "Opening Front receives focus after visible placement", + ).toBeFocused(); + await page.screenshot({ path: join(output, `${mode.id}-popover.png`) }); + if (focusOnly) { + runs.push({ mode, openingFrontFocused: true, pageErrors: errors }); + await context.close(); + break; + } + await dialog + .getByRole("textbox", { name: "Front", exact: true }) + .press("Escape"); + observations.escapeClosing = await sample(page, ".source-card-popover"); + expect(observations.escapeClosing).toMatchObject({ + inert: true, + ariaHidden: "true", + pointerEvents: "none", + }); + await expect(dialog).toHaveCount(0); + observations.escapeFocusImmediate = await page.evaluate(() => ({ + label: document.activeElement?.getAttribute("aria-label"), + selectedPassage: window.getSelection()?.toString() === "NADH and FADH2", + nativeSelection: window.getSelection()?.toString(), + activeTag: document.activeElement?.tagName, + })); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + observations.escapeFocusSettled = await page.evaluate(() => ({ + label: document.activeElement?.getAttribute("aria-label"), + selectedPassage: window.getSelection()?.toString() === "NADH and FADH2", + nativeSelection: window.getSelection()?.toString(), + activeTag: document.activeElement?.tagName, + })); + await writeFile( + join(output, `${mode.id}-escape-state.json`), + JSON.stringify(observations.escapeFocusSettled, null, 2), + ); + await expect(editor).toBeFocused(); + expect(observations.escapeFocusSettled.selectedPassage).toBe(true); + observations.editorIdentity = await editor.evaluate((node) => ({ + sameDOM: node === window.__sourceEditorBefore, + sameHTML: node.innerHTML === window.__sourceHTMLBefore, + pmSelection: node.pmViewDesc?.view?.state?.selection + ? { + from: node.pmViewDesc.view.state.selection.from, + to: node.pmViewDesc.view.state.selection.to, + } + : null, + })); + expect(observations.editorIdentity.sameDOM).toBe(true); + expect(observations.editorIdentity.sameHTML).toBe(true); + await mark(page, "clean-outside"); + dialog = await open(page); + await stable(dialog); + await page.getByRole("button", { name: "Study", exact: true }).click(); + observations.outsideAfterClick = await sample(page, ".source-card-popover"); + expect(observations.outsideAfterClick).toMatchObject({ + present: true, + inert: true, + ariaHidden: "true", + }); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(editor).toBeVisible(); + await expect( + page.getByRole("status").filter({ + hasText: "Finish or cancel this card draft before leaving.", + }), + ).toHaveCount(0); + await expect( + page + .getByRole("status") + .filter({ hasText: "Your latest changes are only in this window." }), + ).toHaveCount(0); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Motion audit deck", exact: true }), + ) + .toBeVisible() + .catch(async () => { + await expect( + page.getByRole("button", { name: /Motion audit deck/ }), + ).toBeVisible(); + }); + await page.getByRole("button", { name: /^All notes/ }).click(); + await page + .getByRole("button", { name: /Synthetic source motion audit/ }) + .first() + .click(); + await expect(editor).toBeVisible(); + observations.freshNavigation = true; + await mark(page, "dirty-escape"); + dialog = await open(page); + await stable(dialog); + await dialog + .getByRole("textbox", { name: "Front", exact: true }) + .fill("User-authored audit question"); + await dialog + .getByRole("textbox", { name: "Front", exact: true }) + .press("Escape"); + const discard = page.getByRole("dialog", { + name: "Discard card draft?", + exact: true, + }); + await expect(discard).toBeVisible(); + await discard + .getByRole("button", { name: "Keep editing", exact: true }) + .click(); + await expect( + dialog.getByRole("textbox", { name: "Front", exact: true }), + ).toHaveValue("User-authored audit question"); + observations.dirtyKept = true; + await mark(page, "commit"); + await dialog.getByRole("button", { name: "Add card", exact: true }).click(); + observations.commitClosing = await sample(page, ".source-card-popover"); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".study-source-annotation")).toHaveCount(1); + const meta = await page.evaluate(() => + window.__studySourceFixture.metadata(), + ); + observations.actualCounts = { + entries: meta.entries.length, + cards: meta.cards.length, + notes: meta.notes.length, + }; + expect(observations.actualCounts.entries).toBe(1); + expect(observations.actualCounts.cards).toBe(1); + await expect(page.locator(".source-card-notice")).toContainText( + "Card added to Motion audit deck", + ); + observations.annotation = await sample(page, ".study-source-annotation"); + await stable(page.locator(".source-card-notice")); + await page.screenshot({ path: join(output, `${mode.id}-added.png`) }); + await mark(page, "toast-dismiss"); + await page + .getByRole("button", { name: "Dismiss card notification", exact: true }) + .click(); + observations.toastClosing = await sample(page, ".source-card-notice"); + await expect(page.locator(".source-card-notice")).toHaveCount(0); + await mark(page, "narrow"); + await page.setViewportSize({ width: 390, height: 844 }); + dialog = await open(page); + await stable(dialog); + observations.narrow = await sample(page, ".source-card-popover"); + await dialog + .getByRole("button", { name: "Add card", exact: true }) + .scrollIntoViewIfNeeded(); + observations.narrowActions = { + cancel: await dialog + .getByRole("button", { name: "Cancel", exact: true }) + .isVisible(), + add: await dialog + .getByRole("button", { name: "Add card", exact: true }) + .isVisible(), + overflow: await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + }; + await page.screenshot({ path: join(output, `${mode.id}-narrow.png`) }); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(dialog).toHaveCount(0); + const trace = await page.evaluate(() => window.__sourceAudit); + observations.expected = { + reduced, + popEnterMs: reduced ? 150 : 200, + popExitMs: reduced ? 150 : 140, + underlineMs: reduced ? 0 : 150, + }; + observations.measured = { + enterEnds: trace.events.filter( + (event) => + event.phase === "clean-escape" && + event.kind === "animationend" && + !event.classes.includes("source-closing"), + ), + escapeExitEnds: trace.events.filter( + (event) => + event.phase === "clean-escape" && + event.kind === "animationend" && + event.classes.includes("source-closing"), + ), + outsideExitEnds: trace.events.filter( + (event) => + event.phase === "clean-outside" && + event.kind === "animationend" && + event.classes.includes("source-closing"), + ), + underlineEnds: row(trace.events, "source-mark-in"), + toastExitEnds: trace.events.filter( + (event) => + event.phase === "toast-dismiss" && event.kind === "animationend", + ), + }; + const enter = observations.measured.enterEnds.find( + (event) => event.name === (reduced ? "source-fade" : "source-pop-in"), + ); + expect(enter).toBeTruthy(); + expect(enter.elapsed).toBeCloseTo(reduced ? 0.15 : 0.2, 3); + const exit = observations.measured.escapeExitEnds.find( + (event) => + event.name === (reduced ? "source-fade-out" : "source-pop-out"), + ); + expect(exit).toBeTruthy(); + expect(exit.elapsed).toBeCloseTo(reduced ? 0.15 : 0.14, 3); + const outsideExit = observations.measured.outsideExitEnds.find( + (event) => + event.name === (reduced ? "source-fade-out" : "source-pop-out"), + ); + expect(outsideExit).toBeTruthy(); + expect(outsideExit.elapsed).toBeCloseTo(reduced ? 0.15 : 0.14, 3); + expect(observations.annotation.animation).toBe( + reduced ? "none" : "source-mark-in", + ); + if (!reduced) + expect(observations.measured.underlineEnds[0].elapsed).toBeCloseTo( + 0.15, + 3, + ); + expect(observations.narrowActions.overflow).toBe(false); + expect(errors).toEqual([]); + runs.push({ ...observations, trace, pageErrors: errors }); + await writeFile( + join(output, `${mode.id}.json`), + JSON.stringify(runs.at(-1), null, 2), + ); + await context.close(); + } + const after = await hashes(); + const drift = before.filter( + (item, index) => item.sha256 !== after[index].sha256, + ); + await writeFile( + join(output, "result.json"), + JSON.stringify( + { + qualification: + "Disposable synthetic identity; actual production Workspace/editor/source writer/WASM/IDB; no backend login/native input claim", + before, + after, + drift, + runs: runs.map(({ trace, ...run }) => run), + }, + null, + 2, + ), + ); + if (drift.length) + throw Error("Source drift: evidence is not a single candidate"); + console.log( + `Source motion audit captured ${runs.length} policy contexts in ${output}`, + ); +} catch (error) { + if (activePage && !activePage.isClosed()) { + await activePage.screenshot({ path: join(output, "failure.png") }); + await writeFile( + join(output, "failure-state.json"), + JSON.stringify( + await activePage.evaluate(() => ({ + trace: window.__sourceAudit, + body: document.body.innerText, + })), + null, + 2, + ), + ); + } + await writeFile(join(output, "failure.txt"), String(error)); + await writeFile( + join(output, "failure-hashes.json"), + JSON.stringify({ before, after: await hashes() }, null, 2), + ); + throw error; +} finally { + await browser.close(); +} diff --git a/web/tests/integration/source-opening-browser.mjs b/web/tests/integration/source-opening-browser.mjs new file mode 100644 index 0000000..a978493 --- /dev/null +++ b/web/tests/integration/source-opening-browser.mjs @@ -0,0 +1,79 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +const base = new URL(process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"); +if (!["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)) + throw new Error("Source-opening proof requires a loopback preview."); +const source = new URL("/tests/integration/source-proof.html", base).href; +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/source-opening-browser"; +const browser = await chromium.launch({ channel: "chrome", headless: true }); +try { + await mkdir(output, { recursive: true }); + const context = await browser.newContext(); + const requests = [], + errors = []; + context.on("request", (request) => { + if (request.url() === source) requests.push(request.url()); + }); + await context.addInitScript(() => localStorage.setItem("notes.editor.v1", JSON.stringify({textSize:"large",lineWidth:"wide",markdown:true,spellcheck:true}))); + const page = await context.newPage(); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(new URL("/tests/integration/clips-browser.html", base).href); + await page.getByRole("button", { name: "Clips", exact: true }).click(); + await page + .getByRole("button", { name: "Save a link", exact: true }) + .first() + .click(); + const dialog = page.getByRole("dialog", { name: "Save a link", exact: true }); + await dialog.getByLabel("Clip URL").fill(source); + await dialog.getByLabel("Title (optional)").fill("Controlled saved source"); + await dialog.getByLabel("Selected text (optional)").fill("Controlled pasted text for reader preferences."); + await dialog.getByRole("button", { name: "Save link", exact: true }).click(); + const notice = page.getByRole("status").filter({ hasText: "Text clip saved" }); + await expect(notice).toBeVisible(); + await notice.getByRole("button", { name: "Open clip", exact: true }).click(); + expect(requests).toHaveLength(0); + await expect(page.locator(".clip-text")).toHaveCSS("font-size", "16px"); + await expect(page.locator(".clip-reader-content")).toHaveCSS("max-width", "880px"); + const original = page.getByRole("link", { + name: "Open original", + exact: true, + }); + await expect(original).toHaveAttribute("rel", "noopener noreferrer"); + const pending = page.waitForEvent("popup"); + await original.click(); + const popup = await pending; + await expect( + popup.getByRole("heading", { name: "Controlled clip source", exact: true }), + ).toBeVisible(); + expect(popup.url()).toBe(source); + expect(await popup.evaluate(() => window.opener === null)).toBe(true); + expect(requests).toHaveLength(1); + await expect( + page.getByRole("button", { name: "Mark read", exact: true }), + ).toBeVisible(); + expect(errors).toEqual([]); + await popup.close(); + await writeFile( + join(output, "result.json"), + JSON.stringify( + { + qualified: + "Actual browser source opening from a real-WASM/IndexedDB Clip under a synthetic account session. No native OS handoff or account-server qualification.", + noFetchOnSave: true, + oneExplicitLoopbackHandoff: true, + openerNull: true, + stillUnread: true, + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log( + "Controlled browser source opening passed; no prefetch, opener null, clip remains unread.", + ); +} finally { + await browser.close(); +} diff --git a/web/tests/integration/source-proof.html b/web/tests/integration/source-proof.html new file mode 100644 index 0000000..1ba60fc --- /dev/null +++ b/web/tests/integration/source-proof.html @@ -0,0 +1,11 @@ + + + + + Controlled clip source + + +

Controlled clip source

+

This local test page is opened only by the explicit source action.

+ + diff --git a/web/tests/integration/study-browser.mjs b/web/tests/integration/study-browser.mjs new file mode 100644 index 0000000..4177091 --- /dev/null +++ b/web/tests/integration/study-browser.mjs @@ -0,0 +1,45 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +// Run with the local Vite server. Auth is a synthetic session; encryption/storage +// are the actual production adapters. This does not qualify account-server entry. +const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome", headless: true }); +const output = process.env.NOTES_UI_OUTPUT ?? "test-results/study-browser"; +try { + const page = await browser.newPage({ viewport: { width: 1160, height: 760 } }); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(`${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/collections-browser.html`); + await expect(page.getByText("No decks yet", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "New deck", exact: true }).first().click(); + await page.getByRole("dialog", { name: "New deck" }).getByLabel("Name").fill("Encrypted browser study"); + await page.getByRole("button", { name: "Create deck", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Encrypted browser study" })).toBeVisible(); + await page.getByRole("button", { name: "Add card", exact: true }).first().click(); + const dialog = page.getByRole("dialog", { name: "Add card" }); + await dialog.getByLabel("Front", { exact: true }).fill("Synthetic question"); + await dialog.getByLabel("Back", { exact: true }).fill("Synthetic answer"); + await dialog.getByRole("button", { name: "Add card", exact: true }).click(); + await expect(dialog).toHaveCount(0); + const before = await page.evaluate(() => window.__collectionsFixture.read()); + if (before.study.cards.length !== 1) throw new Error("Card not committed"); + await page.reload(); + await expect(page.getByRole("button", { name: "Encrypted browser study", exact: true })).toBeVisible(); + await page.getByRole("button", { name: /^Study 1 card$/ }).click(); + await page.getByRole("button", { name: "Show answer", exact: true }).click(); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect(page.getByRole("heading", { name: "Done for today" })).toBeVisible(); + const after = await page.evaluate(() => window.__collectionsFixture.read()); + if (after.study.events.length !== 1 || after.study.cards[0].schedule.state !== "review") throw new Error("Grade not persisted"); + await page.reload(); + await expect(page.getByRole("button", { name: "Encrypted browser study", exact: true })).toBeVisible(); + const persisted = await page.evaluate(() => window.__collectionsFixture.read()); + if (persisted.study.events.length !== 1) throw new Error("Review lost on reopen"); + await page.evaluate(() => window.__collectionsFixture.erase()); + await expect(page.getByText("No decks yet", { exact: true })).toBeVisible(); + if (errors.length) throw new Error(errors.join("\n")); + await mkdir(output, { recursive: true }); + await writeFile(join(output, "result.json"), JSON.stringify({ harness: "Synthetic account session; real WASM + IndexedDB; no account-server qualification", passed: ["create deck", "create card", "reopen persisted card", "grade changes schedule", "reopen review event", "erase invalidates view"], errors }, null, 2)); + console.log("Encrypted Study browser journey passed: create, reopen, grade, reopen, erase."); +} finally { await browser.close(); } diff --git a/web/tests/integration/study-motion-audit.mjs b/web/tests/integration/study-motion-audit.mjs new file mode 100644 index 0000000..44eec8e --- /dev/null +++ b/web/tests/integration/study-motion-audit.mjs @@ -0,0 +1,206 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +const output = process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-audit"; +const preview = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"; +await mkdir(output, { recursive: true }); +const sourcePaths = ["src/ui/StudyView.tsx", "src/styles/study.css"]; +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const sourceBefore = await Promise.all( + sourcePaths.map(async (path) => ({ + path, + sha256: hash(await readFile(path)), + })), +); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +const results = []; +try { + for (const reduced of [false, true]) { + const context = await browser.newContext({ + reducedMotion: reduced ? "reduce" : "no-preference", + viewport: { width: 1160, height: 850 }, + }); + try { + const page = await context.newPage(), + errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto( + `${preview}/tests/integration/study-settings-browser.html`, + ); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(async () => + (await import("/src/desktop/closeGuard.ts")).checkClose(), + ), + ) + .toBe("allowed"); + await page.evaluate(() => { + window.__studyMotionAudit = []; + const observe = (element, phase) => { + if (!(element instanceof Element)) return; + const css = getComputedStyle(element); + window.__studyMotionAudit.push({ + phase, + at: performance.now(), + kind: element.classList.contains("study-toast") ? "toast" : "card", + label: element.getAttribute("aria-label"), + duration: css.animationDuration, + animation: css.animationName, + frames: element.getAnimations().flatMap((animation) => + animation.effect + .getKeyframes() + .map(({ opacity, transform, offset }) => ({ + opacity, + transform, + offset, + })), + ), + }); + }; + new MutationObserver((records) => { + for (const record of records) { + for (const [nodes, phase] of [ + [record.addedNodes, "added"], + [record.removedNodes, "removed"], + ]) { + for (const node of nodes) { + if (!(node instanceof Element)) continue; + if (node.matches(".study-flash,.study-toast")) + observe(node, phase); + for (const element of node.querySelectorAll( + ".study-flash,.study-toast", + )) + observe(element, phase); + } + } + } + }).observe(document.getElementById("root"), { + childList: true, + subtree: true, + }); + }); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await page.getByRole("button", { name: /^Study 4 cards$/ }).click(); + await expect( + page.getByRole("article", { name: "Card question" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Show answer", exact: true }) + .click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + const good = page.getByRole("button", { name: /^Good/ }); + await good.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + const gradeStyle = await good.evaluate((element) => { + const css = getComputedStyle(element); + return { + transitionDuration: css.transitionDuration, + transitionProperty: css.transitionProperty, + transitionTimingFunction: css.transitionTimingFunction, + outlineWidth: css.outlineWidth, + outlineStyle: css.outlineStyle, + }; + }); + expect(gradeStyle.outlineStyle).toBe("solid"); + expect(gradeStyle.outlineWidth).toBe("2px"); + const box = await good.boundingBox(); + expect(box).toBeTruthy(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await good.evaluate((element) => + Promise.all( + element + .getAnimations() + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const pressedTransform = await good.evaluate( + (element) => getComputedStyle(element).transform, + ); + expect(pressedTransform).toBe( + reduced ? "none" : "matrix(0.97, 0, 0, 0.97, 0, 0)", + ); + await page.mouse.up(); + await expect( + page.getByRole("article", { name: "Card question" }), + ).toBeVisible(); + await expect(page.locator(".study-toast")).toBeVisible(); + // Wait on the actual UI disappearance, rather than using a guessed sleep. + await expect(page.locator(".study-toast")).toHaveCount(0, { + timeout: 7000, + }); + const timeline = await page.evaluate(() => window.__studyMotionAudit); + const entered = timeline.filter( + (item) => item.phase === "added" && item.kind === "card", + ); + expect(entered.length).toBeGreaterThanOrEqual(3); + expect(entered.some((item) => item.label === "Card answer")).toBe(true); + if (!reduced) + expect( + entered.every( + (item) => + item.animation === "study-answer-in" && item.duration === "0.15s", + ), + ).toBe(true); + else + expect( + entered.every((item) => + item.frames.every( + (frame) => !frame.transform || frame.transform === "none", + ), + ), + ).toBe(true); + const toastEnter = timeline.find( + (item) => item.phase === "added" && item.kind === "toast", + ), + toastExit = timeline.find( + (item) => item.phase === "removed" && item.kind === "toast", + ); + expect(toastEnter).toBeTruthy(); + expect(toastExit.at - toastEnter.at).toBeGreaterThanOrEqual(4900); + expect(errors).toEqual([]); + results.push({ + reduced, + gradeStyle, + pressedTransform, + timeline, + toastLifetimeMs: toastExit.at - toastEnter.at, + errors, + }); + } finally { + await context.close(); + } + } + const sourceAfter = await Promise.all( + sourcePaths.map(async (path) => ({ + path, + sha256: hash(await readFile(path)), + })), + ); + expect(sourceAfter).toEqual(sourceBefore); + await writeFile( + `${output}/result.json`, + JSON.stringify( + { + qualified: + "Disposable synthetic account; genuine App/WASM/IndexedDB. Browser presentation observations only; no native input, backend authentication or whole-app frame-rate claim.", + source: sourceBefore, + results, + }, + null, + 2, + ), + ); + console.log( + "Study motion baseline passed: keyed card/answer entry, grade feedback styles, actual toast expiry and reduced-motion paths observed.", + ); +} finally { + await browser.close(); +} diff --git a/web/tests/integration/study-motion-browser.mjs b/web/tests/integration/study-motion-browser.mjs new file mode 100644 index 0000000..870a299 --- /dev/null +++ b/web/tests/integration/study-motion-browser.mjs @@ -0,0 +1,430 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-browser"; +const base = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +const results = [], + errors = []; +let activePage; +async function pageFor(context, path = "study-motion-component.html") { + const page = await context.newPage(); + activePage = page; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`${base}/tests/integration/${path}`); + await page.waitForFunction( + () => window.__studyMotionFixture || window.__studySettingsFixture, + ); + await page.evaluate(() => { + window.__motionEvents = []; + for (const kind of ["animationstart", "animationend"]) { + document.addEventListener( + kind, + (event) => { + if (!event.animationName.startsWith("study-")) return; + const element = event.target, + css = getComputedStyle(element); + window.__motionEvents.push({ + kind, + name: event.animationName, + elapsed: event.elapsedTime, + at: performance.now(), + phase: element.dataset.studyMotion, + duration: css.animationDuration, + easing: css.animationTimingFunction, + frames: element + .getAnimations() + .flatMap((a) => + a.effect + .getKeyframes() + .map(({ opacity, transform }) => ({ opacity, transform })), + ), + }); + }, + true, + ); + } + }); + return page; +} +async function settle(page, selector = ".study-flash") { + await page.locator(selector).evaluate(async (element) => { + await Promise.all( + element.getAnimations().map((a) => a.finished.catch(() => {})), + ); + }); +} +async function start(page) { + await page + .getByRole("button", { name: "Study 4 cards", exact: true }) + .click(); + await expect( + page.getByRole("article", { name: "Card question" }), + ).toBeVisible(); + await settle(page); +} +async function reveal(page) { + await page.getByRole("button", { name: "Show answer", exact: true }).click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + await settle(page); +} +function timing(events, name, seconds) { + const ends = events.filter( + (event) => event.name === name && event.kind === "animationend", + ); + expect(ends.length, `${name} actual completion`).toBeGreaterThan(0); + for (const event of ends) expect(event.elapsed).toBeCloseTo(seconds, 3); +} +try { + { + const context = await browser.newContext(); + const page = await pageFor(context); + await start(page); + await page.evaluate(() => { + const early = (event) => { + if ( + event.animationName !== "study-answer-in" || + event.target.getAttribute("aria-label") !== "Card answer" + ) + return; + document.removeEventListener("animationstart", early, true); + const good = [ + ...document.querySelectorAll(".study-grades button"), + ].find((button) => button.textContent.startsWith("Good")); + window.__earlyGradeAttempt = { + disabled: good.disabled, + phase: event.target.dataset.studyMotion, + preview: good.querySelector("time").getAttribute("datetime"), + }; + good.click(); + event.target.dispatchEvent( + new KeyboardEvent("keydown", { key: "1", bubbles: true }), + ); + }; + document.addEventListener("animationstart", early, true); + }); + await reveal(page); + const attempted = await page.evaluate(() => window.__earlyGradeAttempt); + expect(attempted).toMatchObject({ disabled: true, phase: "answer-enter" }); + await expect(page.getByRole("button", { name: /^Good/ })).toBeEnabled(); + expect( + (await page.evaluate(() => window.__studyMotionFixture.metadata())).study + .events, + ).toHaveLength(0); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + expect( + (await page.evaluate(() => window.__studyMotionFixture.metadata())).study + .events, + ).toHaveLength(0); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect + .poll( + async () => + (await page.evaluate(() => window.__studyMotionFixture.metadata())) + .study.events.length, + ) + .toBe(1); + expect( + (await page.evaluate(() => window.__studyMotionFixture.metadata())).study + .events[0].after.schedule.due, + ).toBe(attempted.preview); + results.push({ stableAnswerAdmission: attempted, noQueuedGrade: true }); + await context.close(); + } + for (const reduced of [false, true]) { + const context = await browser.newContext({ + viewport: { width: 1160, height: 850 }, + reducedMotion: reduced ? "reduce" : "no-preference", + }); + const page = await pageFor(context); + await start(page); + await reveal(page); + const before = await page.evaluate(() => + window.__studyMotionFixture.metadata(), + ); + for (const theme of ["light", "dark", "dim"]) { + await page.evaluate( + (value) => (document.documentElement.dataset.theme = value), + theme, + ); + await page.screenshot({ + path: `${output}/answer-${theme}-${reduced ? "reduced" : "normal"}.png`, + }); + } + await page.setViewportSize({ width: 390, height: 780 }); + await page.getByRole("button", { name: /^Easy/ }).scrollIntoViewIfNeeded(); + await page.screenshot({ + path: `${output}/answer-narrow-${reduced ? "reduced" : "normal"}.png`, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.setViewportSize({ width: 1160, height: 850 }); + const preview = await page + .locator(".study-grades time") + .evaluateAll((elements) => + elements.map((el) => el.getAttribute("datetime")), + ); + await page.evaluate(() => window.__studyMotionFixture.holdNextCommit()); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect + .poll(() => + page.evaluate(() => window.__studyMotionFixture.gates().commitReached), + ) + .toBe(true); + expect( + await page.locator('[data-study-motion="confirmed-exit"]').count(), + ).toBe(0); + expect( + (await page.evaluate(() => window.__studyMotionFixture.metadata())).study + .events, + ).toEqual(before.study.events); + await page.evaluate(() => window.__studyMotionFixture.releaseCommit()); + await expect( + page.getByRole("article", { name: "Card question" }), + ).toBeVisible(); + await settle(page); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + await settle(page); + expect( + (await page.evaluate(() => window.__studyMotionFixture.metadata())).study + .events[0].undoneAt, + ).not.toBeNull(); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect( + page.getByRole("article", { name: "Card question" }), + ).toBeVisible(); + await settle(page); + await expect(page.locator(".study-toast")).toHaveCount(0, { + timeout: 7000, + }); + for (let i = 0; i < 3; i++) { + await reveal(page); + await page.getByRole("button", { name: /^Easy/ }).click(); + if (i < 2) { + await expect( + page.getByRole("article", { name: "Card question" }), + ).toBeVisible(); + await settle(page); + } + } + await expect( + page.getByRole("heading", { name: "Done for today", exact: true }), + ).toBeVisible(); + await settle(page, ".study-done-label"); + const events = await page.evaluate(() => window.__motionEvents); + timing(events, "study-reveal-out", 0.105); + timing(events, "study-answer-in", 0.15); + timing(events, "study-card-out", 0.14); + timing(events, "study-next-in", 0.2); + timing(events, "study-toast-out", 0.14); + if (!reduced) timing(events, "study-fade", 0.15); + const next = events.find( + (event) => + event.name === "study-next-in" && event.kind === "animationstart", + ); + expect(next.easing).toBe("cubic-bezier(0.2, 0.8, 0.2, 1)"); + expect( + next.frames.some( + (frame) => frame.transform && frame.transform !== "none", + ), + ).toBe(!reduced); + if (reduced) + expect( + await page + .locator(".study-done-label") + .evaluate((el) => getComputedStyle(el).animationName), + ).toBe("none"); + for (const theme of ["light", "dark", "dim"]) { + await page.evaluate( + (value) => (document.documentElement.dataset.theme = value), + theme, + ); + await page.screenshot({ + path: `${output}/done-${theme}-${reduced ? "reduced" : "normal"}.png`, + }); + } + await page.setViewportSize({ width: 390, height: 780 }); + await page.screenshot({ + path: `${output}/done-narrow-${reduced ? "reduced" : "normal"}.png`, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + results.push({ + reduced, + events, + preview, + checks: [ + "real precommit hold has no departure/event", + "real confirmation and Undo", + "actual105/140/150/200 completions", + "toast140exit", + "Done actual/reduced", + "themes/narrow", + ], + }); + await context.close(); + } + // Cold initial read acknowledgement remains genuine; only its delivery is held. + for (const reduced of [false, true]) { + const context = await browser.newContext({ + reducedMotion: reduced ? "reduce" : "no-preference", + }); + const page = await pageFor( + context, + "study-motion-component.html?holdRead=1", + ); + await expect + .poll(() => + page.evaluate(() => window.__studyMotionFixture.gates().readReached), + ) + .toBe(true); + await expect(page.getByLabel("Loading decks")).toBeVisible(); + const ring = await page.locator(".study-loading-ring").evaluate((el) => ({ + name: getComputedStyle(el).animationName, + duration: getComputedStyle(el).animationDuration, + })); + expect(ring).toEqual( + reduced + ? { name: "none", duration: "0s" } + : { name: "study-ring", duration: "0.9s" }, + ); + await page.evaluate(() => window.__studyMotionFixture.releaseRead()); + await expect( + page.getByRole("button", { name: "Study 4 cards", exact: true }), + ).toBeVisible(); + await expect(page.locator(".study-loader-exit")).toHaveCount(0); + const coldEvents = await page.evaluate(() => window.__motionEvents); + timing(coldEvents, "study-fade", 0.15); + timing(coldEvents, "study-loader-out", 0.15); + results.push({ + coldReduced: reduced, + ring, + events: coldEvents, + }); + await context.close(); + } + // Synchronous privacy boundaries in fresh isolated owners, including held commit. + { + const context = await browser.newContext(); + const page = await pageFor(context); + await start(page); + const edit = await page.evaluate(async () => { + const front = document.querySelector(".study-question").textContent; + const state = await window.__studyMotionFixture.metadata(); + const entry = state.study.entries.find((entry) => entry.front === front); + document.querySelector(".study-flash .primary").click(); + await Promise.resolve(); + const admitted = + document.querySelector(".study-flash").dataset.studyMotion; + await window.__studyMotionFixture.editEntry( + entry.id, + "Genuine compatible edit", + ); + return { admitted }; + }); + expect(edit.admitted).toBe("reveal-exit"); + await expect(page.locator('[data-study-motion="reveal-exit"]')).toHaveCount( + 0, + ); + await expect(page.locator(".study-question")).toHaveText( + "Genuine compatible edit", + ); + results.push({ sameIdCompatibleEdit: edit }); + await context.close(); + } + for (const action of [ + "closeAndInspect", + "lockAndInspect", + "reauthenticateAndInspect", + "eraseAndObserve", + ]) { + const context = await browser.newContext(); + const page = await pageFor(context); + await start(page); + await reveal(page); + if (action === "closeAndInspect") { + await page.evaluate(() => window.__studyMotionFixture.holdNextCommit()); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect + .poll(() => + page.evaluate( + () => window.__studyMotionFixture.gates().commitReached, + ), + ) + .toBe(true); + } + if (action === "eraseAndObserve") + await page.evaluate(() => window.__studyMotionFixture.watchEpoch()); + const result = await page.evaluate( + (action) => window.__studyMotionFixture[action](), + action, + ); + expect(result.noCardDom).toBe(true); + if (result.epochSamples) + expect(result.epochSamples.every(Boolean)).toBe(true); + if (action === "closeAndInspect") { + expect( + await page.evaluate(async () => + (await import("/src/desktop/closeGuard.ts")).checkClose(), + ), + ).toBe("working"); + await page.evaluate(() => window.__studyMotionFixture.releaseCommit()); + await expect( + page.getByText("Study is no longer available.", { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Try again", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Study 4 cards", exact: true }), + ).toBeVisible(); + } + results.push({ privacyAction: action, result }); + await context.close(); + } + expect(errors).toEqual([]); + await writeFile( + `${output}/result.json`, + JSON.stringify({ results, pageErrors: errors }, null, 2), + ); + console.log("Study motion genuine encrypted browser passed"); +} catch (error) { + if (activePage && !activePage.isClosed()) { + await activePage.screenshot({ path: `${output}/failure.png` }); + await writeFile( + `${output}/failure-events.json`, + JSON.stringify( + await activePage.evaluate(() => ({ + events: window.__motionEvents, + body: document.body.innerText, + })), + null, + 2, + ), + ); + } + await writeFile( + `${output}/failure.txt`, + String(error) + "\n" + JSON.stringify({ results, errors }, null, 2), + ); + throw error; +} finally { + await browser.close(); +} diff --git a/web/tests/integration/study-motion-component.html b/web/tests/integration/study-motion-component.html new file mode 100644 index 0000000..13c00d3 --- /dev/null +++ b/web/tests/integration/study-motion-component.html @@ -0,0 +1,26 @@ + + + + + + + +

+ Synthetic identity · genuine Study owner/WASM/IndexedDB · controlled read + acknowledgement and pre-commit gates · no account backend or App-shell + qualification +

+
+ + + diff --git a/web/tests/integration/study-motion-component.tsx b/web/tests/integration/study-motion-component.tsx new file mode 100644 index 0000000..34a037c --- /dev/null +++ b/web/tests/integration/study-motion-component.tsx @@ -0,0 +1,263 @@ +// Test-only direct production component with genuine account/store authority. +// Gates delay actual read acknowledgements or admission to the real writer; +// they never fabricate state, authority, commits or successful grades. +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { StudyView } from "../../src/ui/StudyView"; +import { applyInitialPreferences } from "../../src/ui/primitives"; +import { establish, lock } from "../../src/account/session"; +import { openNotesStore } from "../../src/data"; +import { loadEngine, DeviceKeys } from "../../src/data/engine"; +import { + newWrappingKey, + wrappingSecret, + readRecord, + mutateRecord, + type DeviceRecord, +} from "../../src/data/database"; +import { + openCollectionsStore, + onCollectionsEpochChange, +} from "../../src/collections/store"; +import type { CollectionsStore } from "../../src/collections/contracts"; +import { createDeck, saveEntry } from "../../src/study/model"; +import "../../src/styles/app.css"; +import "@fontsource/geist/400.css"; +import "@fontsource/geist/500.css"; + +const identity = { + accountId: "c1111111-1111-4111-8111-111111111111", + deviceId: "c2222222-2222-4222-8222-222222222222", + email: "isolated-study-motion@example.test", + deviceState: "trusted" as const, +}; +type Gate = { + claimed: boolean; + reached: boolean; + released: boolean; + all: boolean; + wait: Promise; + release: () => void; +}; +function gate(all = false): Gate { + let resolveWait!: () => void; + const wait = new Promise((resolve) => { + resolveWait = resolve; + }); + const value: Gate = { + claimed: false, + reached: false, + released: false, + all, + wait, + release: () => { + value.released = true; + resolveWait(); + }, + }; + return value; +} +applyInitialPreferences(); +async function start() { + await loadEngine(); + const fresh = !(await readRecord("devices", identity.email)); + if (fresh) { + 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, + verifiedTrust: true, + })); + } finally { + secret.fill(0); + keys.free(); + } + } + establish(identity); + let notes = await openNotesStore(identity), + current = await openCollectionsStore(identity); + if (fresh) { + await notes.create({ + title: "Motion fixture note", + tags: [], + document: { type: "doc", content: [{ type: "paragraph" }] }, + }); + const initial = await current.read(), + at = new Date("2026-01-01T12:00:00Z"); + await current.update(initial, (state) => { + let study = createDeck(state.study, "Motion continuity", at); + for (let i = 1; i <= 4; i++) + study = saveEntry( + study, + { + id: crypto.randomUUID(), + deckId: study.decks[0]!.id, + revision: 1, + tags: [], + source: null, + type: "basic", + front: `Motion question ${i}`, + back: `Motion answer ${i}`, + }, + at, + ); + return { ...state, study }; + }); + } + let readGate: Gate | null = new URL(location.href).searchParams.has( + "holdRead", + ) + ? gate(true) + : null, + commitGate: Gate | null = null, + actualRead: CollectionsStore["read"], + covered = false; + const epochSamples: boolean[] = []; + function wrap(store: CollectionsStore) { + const read = store.read.bind(store), + update = store.update.bind(store); + actualRead = read; + store.read = async () => { + const held = + readGate && !readGate.released && (readGate.all || !readGate.claimed) + ? readGate + : null; + if (held) held.claimed = true; + const result = await read(); + if (held) { + held.reached = true; + await held.wait; + } + return result; + }; + store.update = async (expected, change) => { + const held = + commitGate && !commitGate.released && !commitGate.claimed + ? commitGate + : null; + if (held) { + held.claimed = true; + held.reached = true; + await held.wait; + } + return update(expected, change); + }; + return store; + } + current = wrap(current); + const root = createRoot(document.getElementById("root")!); + const noCardDom = () => document.querySelector(".study-flash") === null; + function render() { + root.render( + + void reopen().catch(fail)} + /> + , + ); + } + async function reopen() { + const next = await openCollectionsStore(identity), + nextNotes = await openNotesStore(identity); + current.close(); + notes.close(); + current = wrap(next); + notes = nextNotes; + render(); + } + Object.assign(window, { + __studyMotionFixture: { + metadata: async () => { + const state = await actualRead(); + return { + epoch: state.epoch, + revision: state.revision, + study: state.study, + }; + }, + holdNextRead: () => { + if (readGate && !readGate.released) + throw Error("A read gate is already pending."); + readGate = gate(); + }, + releaseRead: () => readGate?.release(), + holdNextCommit: () => { + if (commitGate && !commitGate.released) + throw Error("A commit gate is already pending."); + commitGate = gate(); + }, + releaseCommit: () => commitGate?.release(), + gates: () => ({ + readReached: readGate?.reached ?? false, + commitReached: commitGate?.reached ?? false, + }), + closeAndInspect: () => { + current.close(); + return { noCardDom: noCardDom() }; + }, + lockAndInspect: () => { + lock(false); + return { noCardDom: noCardDom() }; + }, + reauthenticateAndInspect: () => { + establish(identity); + return { noCardDom: noCardDom() }; + }, + watchEpoch: () => + onCollectionsEpochChange(current, () => epochSamples.push(noCardDom())), + eraseAndObserve: async () => { + await notes.erase(); + await actualRead(); + return { noCardDom: noCardDom(), epochSamples: [...epochSamples] }; + }, + setCovered: (value: boolean) => { + covered = value; + render(); + }, + toggleStreak: async () => { + const state = await actualRead(); + await current.update(state, (latest) => ({ + ...latest, + study: { + ...latest.study, + preferences: { + ...latest.study.preferences, + showStreak: !latest.study.preferences.showStreak, + }, + }, + })); + }, + editEntry: async (id: string, front: string) => { + const state = await actualRead(), + entry = state.study.entries.find((item) => item.id === id); + if (!entry || entry.type !== "basic") + throw Error("Choose a genuine basic fixture entry."); + await current.update(state, (latest) => ({ + ...latest, + study: saveEntry( + latest.study, + { ...entry, revision: entry.revision + 1, front }, + new Date(), + ), + })); + }, + reopen, + }, + }); + render(); +} +function fail(error: unknown) { + const root = document.getElementById("root")!; + root.dataset.fixtureFailed = "true"; + root.textContent = + error instanceof Error ? error.message : "Motion fixture failed"; +} +void start().catch(fail); diff --git a/web/tests/integration/study-motion-fixture-smoke.mjs b/web/tests/integration/study-motion-fixture-smoke.mjs new file mode 100644 index 0000000..b7ee482 --- /dev/null +++ b/web/tests/integration/study-motion-fixture-smoke.mjs @@ -0,0 +1,182 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-fixture-smoke"; +const base = process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"; +await mkdir(output, { recursive: true }); +const sourcePaths = [ + "src/ui/StudyView.tsx", + "src/ui/useStudyMotion.ts", + "src/styles/study.css", +]; +const fingerprint = () => + Promise.all( + sourcePaths.map(async (path) => ({ + path, + sha256: createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + })), + ); +const before = await fingerprint(), + results = []; +const browser = await chromium.launch({ channel: "chrome", headless: true }); +let stage = "boot"; +try { + for (const mode of [ + "cold-and-reopen", + "lock", + "same-identity", + "epoch", + "held-commit", + "held-read", + ]) { + const context = await browser.newContext({ + viewport: { width: 1160, height: 850 }, + }); + try { + const page = await context.newPage(), + errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + stage = mode + ":boot"; + await page.goto( + `${base}/tests/integration/study-motion-component.html${mode === "cold-and-reopen" ? "?holdRead=1" : ""}`, + ); + await page.waitForFunction( + () => + !!window.__studyMotionFixture || + document.getElementById("root")?.dataset.fixtureFailed === "true", + ); + if (await page.locator("#root").getAttribute("data-fixture-failed")) + throw Error(await page.locator("#root").innerText()); + if (mode === "cold-and-reopen") { + await page.waitForFunction( + () => window.__studyMotionFixture.gates().readReached, + ); + await expect(page.locator(".study-skeleton")).toBeVisible(); + await page.evaluate(() => window.__studyMotionFixture.releaseRead()); + } + await page.getByRole("button", { name: /^Study 4 cards$/ }).click(); + await page + .getByRole("button", { name: "Show answer", exact: true }) + .click(); + await expect( + page.getByRole("article", { name: "Card answer", exact: true }), + ).toBeVisible(); + stage = mode + ":revoke"; + let observation; + if (mode === "cold-and-reopen") { + observation = await page.evaluate(() => + window.__studyMotionFixture.closeAndInspect(), + ); + expect(observation.noCardDom).toBe(true); + await expect( + page.getByText("Study is no longer available.", { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Try again", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: /^Study 4 cards$/ }), + ).toBeVisible(); + } else if (mode === "lock") { + observation = await page.evaluate(() => + window.__studyMotionFixture.lockAndInspect(), + ); + expect(observation.noCardDom).toBe(true); + } else if (mode === "same-identity") { + observation = await page.evaluate(() => + window.__studyMotionFixture.reauthenticateAndInspect(), + ); + expect(observation.noCardDom).toBe(true); + } else if (mode === "epoch") { + await page.evaluate(() => window.__studyMotionFixture.watchEpoch()); + observation = await page.evaluate(() => + window.__studyMotionFixture.eraseAndObserve(), + ); + expect(observation.noCardDom).toBe(true); + expect(observation.epochSamples.length).toBeGreaterThan(0); + expect(observation.epochSamples.every(Boolean)).toBe(true); + await expect( + page.getByText("No decks yet", { exact: true }), + ).toBeVisible(); + } else if (mode === "held-commit") { + await page.evaluate(() => window.__studyMotionFixture.holdNextCommit()); + await page.getByRole("button", { name: /^Good/ }).click(); + await page.waitForFunction( + () => window.__studyMotionFixture.gates().commitReached, + ); + const pending = await page.evaluate(() => + window.__studyMotionFixture.metadata(), + ); + expect(pending.study.events).toHaveLength(0); + await expect( + page.getByRole("article", { name: "Card answer", exact: true }), + ).toBeVisible(); + observation = await page.evaluate(() => + window.__studyMotionFixture.closeAndInspect(), + ); + expect(observation.noCardDom).toBe(true); + await page.evaluate(() => window.__studyMotionFixture.releaseCommit()); + } else { + await page.evaluate(async () => { + window.__studyMotionFixture.holdNextRead(); + await window.__studyMotionFixture.toggleStreak(); + }); + await page.waitForFunction( + () => window.__studyMotionFixture.gates().readReached, + ); + observation = await page.evaluate(() => + window.__studyMotionFixture.closeAndInspect(), + ); + expect(observation.noCardDom).toBe(true); + await page.evaluate(() => window.__studyMotionFixture.releaseRead()); + } + if (!["cold-and-reopen", "epoch"].includes(mode)) { + await expect( + page.getByText("Study is no longer available.", { exact: true }), + ).toBeVisible(); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + await expect(page.locator(".study-flash")).toHaveCount(0); + } + expect(errors).toEqual([]); + results.push({ mode, observation, errors }); + } finally { + await context.close(); + } + } + const after = await fingerprint(); + await writeFile( + `${output}/result.json`, + JSON.stringify( + { + qualified: + "Disposable synthetic identity; direct production StudyView with genuine owner/WASM/IndexedDB, explicit test-only read-ack/precommit gates. No App-shell/backend/native proof. WIP source identities recorded; final freeze needs a rerun.", + before, + after, + sourceStable: JSON.stringify(before) === JSON.stringify(after), + results, + }, + null, + 2, + ), + ); + console.log( + "PASS: genuine cold/read/commit gates, synchronous close/lock/reauth/observed-epoch clearing, real owner reopen and stale-result suppression.", + ); +} catch (error) { + await writeFile( + `${output}/failure.json`, + JSON.stringify({ stage, error: String(error), before, results }, null, 2), + ); + throw error; +} finally { + await browser.close(); +} diff --git a/web/tests/integration/study-motion-policy.mjs b/web/tests/integration/study-motion-policy.mjs new file mode 100644 index 0000000..9d6afcc --- /dev/null +++ b/web/tests/integration/study-motion-policy.mjs @@ -0,0 +1,142 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/study-motion-policy"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ channel: "chrome", headless: true }), + results = []; +try { + for (const policy of [ + { os: "reduce", preference: "on", reduced: false }, + { os: "no-preference", preference: "reduced", reduced: true }, + ]) { + const context = await browser.newContext({ + reducedMotion: policy.os, + viewport: { width: 1160, height: 850 }, + }); + try { + await context.addInitScript( + (preference) => localStorage.setItem("notes.motion", preference), + policy.preference, + ); + const page = await context.newPage(), + errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-motion-component.html`, + ); + await expect( + page.getByRole("button", { name: "Study 4 cards", exact: true }), + ).toBeVisible(); + await page.evaluate(() => { + window.__motionPolicyEvents = []; + for (const kind of ["animationstart", "animationend"]) + document.addEventListener( + kind, + (event) => { + if (!event.target.matches(".study-flash")) return; + window.__motionPolicyEvents.push({ + kind, + name: event.animationName, + elapsed: event.elapsedTime, + frames: event.target + .getAnimations() + .flatMap((a) => + a.effect + .getKeyframes() + .map(({ transform, opacity }) => ({ + transform, + opacity, + })), + ), + }); + }, + true, + ); + }); + const settle = async () => + page + .locator(".study-flash") + .evaluate((element) => + Promise.all( + element.getAnimations().map((a) => a.finished.catch(() => {})), + ), + ); + await page + .getByRole("button", { name: "Study 4 cards", exact: true }) + .click(); + await expect( + page.getByRole("article", { name: "Card question", exact: true }), + ).toBeVisible(); + await settle(); + await page + .getByRole("button", { name: "Show answer", exact: true }) + .click(); + await expect( + page.getByRole("article", { name: "Card answer", exact: true }), + ).toBeVisible(); + await settle(); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect( + page.getByRole("article", { name: "Card question", exact: true }), + ).toBeVisible(); + await settle(); + const events = await page.evaluate(() => window.__motionPolicyEvents); + for (const [name, seconds] of [ + ["study-reveal-out", 0.105], + ["study-answer-in", 0.15], + ["study-card-out", 0.14], + ["study-next-in", 0.2], + ]) { + const completed = events.filter( + (e) => e.kind === "animationend" && e.name === name, + ); + expect( + completed.length, + `${policy.preference} actual ${name} completion`, + ).toBeGreaterThan(0); + for (const event of completed) + expect(event.elapsed).toBeCloseTo(seconds, 3); + } + const frames = events + .filter( + (e) => + e.kind === "animationstart" && + ["study-answer-in", "study-next-in"].includes(e.name), + ) + .flatMap((e) => e.frames); + expect(frames.length).toBeGreaterThan(0); + if (policy.reduced) + expect( + frames.every( + (frame) => !frame.transform || frame.transform === "none", + ), + ).toBe(true); + else + expect( + frames.some((frame) => frame.transform && frame.transform !== "none"), + ).toBe(true); + expect(errors).toEqual([]); + results.push({ policy, events, errors }); + } finally { + await context.close(); + } + } + await writeFile( + `${output}/result.json`, + JSON.stringify( + { + qualified: + "Genuine encrypted direct Study component; saved preference overrides OS policy. Actual CSS event timings; no App-shell/native/FPS claim.", + results, + }, + null, + 2, + ), + ); + console.log( + "PASS: explicit On overrides OS reduction; explicit Reduced overrides ordinary OS policy, preserving actual phase durations.", + ); +} finally { + await browser.close(); +} diff --git a/web/tests/integration/study-settings-browser.html b/web/tests/integration/study-settings-browser.html new file mode 100644 index 0000000..6558bd2 --- /dev/null +++ b/web/tests/integration/study-settings-browser.html @@ -0,0 +1,25 @@ + + + + + + + +

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

+
+ + + diff --git a/web/tests/integration/study-settings-browser.mjs b/web/tests/integration/study-settings-browser.mjs new file mode 100644 index 0000000..f4bff8e --- /dev/null +++ b/web/tests/integration/study-settings-browser.mjs @@ -0,0 +1,399 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/study-settings-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(), + errors = []; +page.on("pageerror", (e) => errors.push(e.message)); +async function open(expected = "10") { + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await dialog.getByRole("button", { name: "Study", exact: true }).click(); + await expect( + dialog.getByRole("textbox", { name: "New cards per day", exact: true }), + ).toHaveValue(expected); + return dialog; +} +try { + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-settings-browser.html`, + ); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(async () => { + const { checkClose } = await import("/src/desktop/closeGuard.ts"); + return checkClose(); + }), + ) + .toBe("allowed"); + const before = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + let dialog = await open(); + await dialog + .getByRole("textbox", { name: "New cards per day", exact: true }) + .fill("2"); + await dialog + .getByRole("textbox", { name: "Reviews per day", exact: true }) + .fill("20000"); + await dialog + .getByRole("combobox", { name: "Day starts at", exact: true }) + .selectOption("23"); + await dialog + .getByRole("switch", { + name: "Show next review on answer buttons", + exact: true, + }) + .uncheck(); + await dialog + .getByRole("switch", { name: "Show streak", exact: true }) + .uncheck(); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + dialog.getByText("Saved on this device.", { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByRole("textbox", { name: "New cards per day", exact: true }), + ).toBeEnabled(); + const saved = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + expect(saved.preferences).toMatchObject({ + newPerDay: 2, + reviewsPerDay: 20000, + dayStartHour: 23, + showNextReview: false, + showStreak: false, + }); + expect(saved.cards).toEqual(before.cards); + expect(saved.events).toEqual(before.events); + expect(saved.preferences.algorithm).toBe(before.preferences.algorithm); + expect(saved.preferences.timezone).toBe(before.preferences.timezone); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await page.reload(); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await dialog.getByRole("button", { name: "Study", exact: true }).click(); + await expect( + dialog.getByRole("textbox", { name: "New cards per day", exact: true }), + ).toHaveValue("2"); + const limit = () => + dialog.getByRole("textbox", { name: "New cards per day", exact: true }); + await limit().fill("1.5"); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect(limit()).toHaveValue("1.5"); + await expect(limit()).toHaveAttribute("aria-invalid", "true"); + expect( + (await page.evaluate(() => window.__studySettingsFixture.metadata())) + .preferences.newPerDay, + ).toBe(2); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + let confirm = page.getByRole("dialog", { + name: "Unsaved Study changes", + exact: true, + }); + await expect( + confirm.getByRole("button", { name: "Save and continue" }), + ).toBeDisabled(); + await confirm.getByRole("button", { name: "Keep editing" }).click(); + await page.keyboard.press("Escape"); + confirm = page.getByRole("dialog", { + name: "Unsaved Study changes", + exact: true, + }); + await confirm.getByRole("button", { name: "Keep editing" }).click(); + await dialog.getByRole("button", { name: "Editor", exact: true }).click(); + await page + .getByRole("dialog", { name: "Unsaved Study changes", exact: true }) + .getByRole("button", { name: "Discard and continue" }) + .click(); + await expect( + dialog.getByRole("heading", { name: "Editor", exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText( + "Planned · Opening backlinks by default isn’t available yet. Use Links and backlinks in a note.", + { exact: true }, + ), + ).toBeVisible(); + await dialog.getByRole("button", { name: "Study", exact: true }).click(); + await expect(limit()).toHaveValue("2"); + await limit().fill("3"); + await page.evaluate(() => + window.__studySettingsFixture.setPreferences({ + reviewsPerDay: 77, + showStreak: true, + }), + ); + await expect( + dialog.getByRole("button", { name: "Reload latest (keep edits)" }), + ).toBeVisible(); + await dialog + .getByRole("button", { name: "Reload latest (keep edits)" }) + .click(); + await expect(limit()).toHaveValue("3"); + await expect( + dialog.getByRole("textbox", { name: "Reviews per day", exact: true }), + ).toHaveValue("77"); + await expect( + dialog.getByRole("switch", { name: "Show streak", exact: true }), + ).toBeChecked(); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + dialog.getByText("Saved on this device.", { exact: true }), + ).toBeVisible(); + const conflictSaved = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + expect(conflictSaved.preferences).toMatchObject({ + newPerDay: 3, + reviewsPerDay: 77, + showStreak: true, + }); + expect(conflictSaved.cards).toEqual(before.cards); + expect(conflictSaved.events).toEqual(before.events); + await dialog + .getByRole("combobox", { name: "Day starts at", exact: true }) + .selectOption("2"); + await dialog + .getByRole("switch", { name: "Show streak", exact: true }) + .uncheck(); + await dialog + .getByRole("switch", { + name: "Show next review on answer buttons", + exact: true, + }) + .check(); + await page.evaluate(() => + window.__studySettingsFixture.setPreferences({ dayStartHour: 3 }), + ); + const actualConflict = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + await dialog + .getByRole("button", { name: "Reload latest (keep edits)" }) + .click(); + await expect( + dialog.getByText("Latest settings loaded. Your edits are still here.", { + exact: true, + }), + ).toBeVisible(); + await expect( + dialog.getByRole("combobox", { name: "Day starts at", exact: true }), + ).toHaveValue("2"); + await expect( + dialog.getByText("Saved value: 03:00", { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText("Saved value: On", { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByText("Saved value: Off", { exact: true }), + ).toBeVisible(); + expect( + (await page.evaluate(() => window.__studySettingsFixture.metadata())) + .revision, + ).toBe(actualConflict.revision); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await page + .getByRole("dialog", { name: "Discard Study changes?", exact: true }) + .getByRole("button", { name: "Discard changes", exact: true }) + .click(); + await expect( + dialog.getByRole("combobox", { name: "Day starts at", exact: true }), + ).toHaveValue("3"); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await page + .getByRole("button", { name: "Study 3 cards", exact: true }) + .click(); + await page.getByRole("button", { name: "Show answer", exact: true }).click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + const answer = await page + .getByRole("article", { name: "Card answer" }) + .innerText(); + expect(await page.locator(".study-grades time").count()).toBe(0); + dialog = await open("3"); + await limit().fill("0"); + await dialog + .getByRole("combobox", { name: "Day starts at", exact: true }) + .selectOption("0"); + await dialog + .getByRole("switch", { + name: "Show next review on answer buttons", + exact: true, + }) + .check(); + expect(await page.locator(".study-grades time").count()).toBe(0); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + dialog.getByText("Saved on this device.", { exact: true }), + ).toBeVisible(); + await expect(page.locator(".study-grades time")).toHaveCount(4); + expect( + await page + .getByRole("article", { name: "Card answer", includeHidden: true }) + .innerText(), + ).toBe(answer); + expect( + await page + .getByRole("progressbar", { + name: "Review progress", + includeHidden: true, + }) + .getAttribute("aria-valuemax"), + ).toBe("3"); + expect( + (await page.evaluate(() => window.__studySettingsFixture.metadata())) + .events, + ).toEqual(before.events); + for (const theme of ["light", "dark", "dim"]) { + await page.evaluate( + (value) => (document.documentElement.dataset.theme = value), + theme, + ); + await dialog.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => {})), + ); + }); + const computed = await limit().evaluate((element) => { + const css = getComputedStyle(element); + return { width: css.width, height: css.height, radius: css.borderRadius }; + }); + expect(computed).toEqual({ width: "100px", height: "32px", radius: "8px" }); + expect( + await dialog + .locator(".settings-heading h3") + .evaluate((element) => getComputedStyle(element).fontSize), + ).toBe("22px"); + expect( + await dialog + .getByRole("switch", { name: "Show streak", exact: true }) + .evaluate((element) => ({ + width: getComputedStyle(element).width, + height: getComputedStyle(element).height, + })), + ).toEqual({ width: "30px", height: "18px" }); + await page.screenshot({ path: output + `/study-settings-${theme}.png` }); + } + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 390, height: 844 }); + await dialog.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => {})), + ); + }); + await expect(limit()).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ + path: output + "/study-settings-narrow-dim-reduced.png", + }); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + expect( + await page.getByRole("article", { name: "Card answer" }).innerText(), + ).toBe(answer); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect + .poll( + async () => + (await page.evaluate(() => window.__studySettingsFixture.metadata())) + .events.length, + ) + .toBe(1); + await expect( + page.getByRole("button", { name: "Undo", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + await page.setViewportSize({ width: 1160, height: 850 }); + dialog = await open("0"); + await limit().fill("10000"); + await dialog + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + dialog.getByText("Saved on this device.", { exact: true }), + ).toBeVisible(); + expect( + (await page.evaluate(() => window.__studySettingsFixture.metadata())) + .preferences.newPerDay, + ).toBe(10000); + await limit().fill("123"); + await page.evaluate(() => window.__studySettingsFixture.erase()); + await expect( + page.getByRole("textbox", { name: "New cards per day", exact: true }), + ).toHaveCount(0); + expect(errors).toEqual([]); + await writeFile( + output + "/result.json", + JSON.stringify( + { + checks: [ + "actual owner before Study visit", + "five prefs commit/reopen encrypted", + "cards/events/timezone/algorithm unchanged", + "raw invalid input and Done/Escape/section guards", + "real independent CAS conflict keeps dirty keys", + "conflict hour and boolean saved-value disclosure without auto write", + "held revealed review survives caps/day/visibility save and real grade Undo", + "all three themes, narrow/reduced layout, full producer bounds, epoch clears draft", + ], + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log("Study settings encrypted browser journey passed"); +} catch (e) { + await page.screenshot({ path: output + "/failure.png", fullPage: true }); + await writeFile( + output + "/failure.txt", + String(e) + + "\nURL " + + page.url() + + "\n" + + (await page.locator("body").innerText()), + ); + throw e; +} finally { + await browser.close(); +} diff --git a/web/tests/integration/study-settings-browser.tsx b/web/tests/integration/study-settings-browser.tsx new file mode 100644 index 0000000..4faa8ef --- /dev/null +++ b/web/tests/integration/study-settings-browser.tsx @@ -0,0 +1,152 @@ +// Disposable integration fixture; never imported by the production entry. +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "../../src/App"; +import { applyInitialPreferences } from "../../src/ui/primitives"; +import { establish, lock } from "../../src/account/session"; +import { openNotesStore } from "../../src/data"; +import { loadEngine, DeviceKeys } from "../../src/data/engine"; +import { + newWrappingKey, + wrappingSecret, + readRecord, + mutateRecord, + type DeviceRecord, +} from "../../src/data/database"; +import { openCollectionsStore } from "../../src/collections/store"; +import type { StudyPreferences } from "../../src/collections/contracts"; +import { createDeck, saveEntry } from "../../src/study/model"; +import "../../src/styles/app.css"; +import "@fontsource/geist/400.css"; +import "@fontsource/geist/500.css"; + +const identity = { + accountId: "b1111111-1111-4111-8111-111111111111", + deviceId: "b2222222-2222-4222-8222-222222222222", + email: "isolated-study-settings@example.test", + deviceState: "trusted" as const, +}; +type Editable = Pick< + StudyPreferences, + | "newPerDay" + | "reviewsPerDay" + | "dayStartHour" + | "showNextReview" + | "showStreak" +>; +applyInitialPreferences(); +async function start() { + await loadEngine(); + const fresh = !(await readRecord("devices", identity.email)); + if (fresh) { + 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, + verifiedTrust: true, + })); + } finally { + secret.fill(0); + keys.free(); + } + } + establish(identity); + const notes = await openNotesStore(identity), + collections = await openCollectionsStore(identity); + if (fresh) { + await notes.create({ + title: "Study settings fixture note", + tags: [], + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "A real encrypted note keeps optional setup from taking over.", + }, + ], + }, + ], + }, + }); + const current = await collections.read(), + at = new Date("2026-01-01T12:00:00.000Z"); + await collections.update(current, (state) => { + let study = createDeck(state.study, "Settings continuity", at); + for (let i = 1; i <= 4; i++) { + study = saveEntry( + study, + { + id: crypto.randomUUID(), + deckId: study.decks[0]!.id, + revision: 1, + tags: [], + source: null, + type: "basic", + front: `Settings question ${i}`, + back: `Settings answer ${i}`, + }, + at, + ); + } + return { ...state, study }; + }); + } + Object.assign(window, { + __studySettingsFixture: { + metadata: async () => { + const state = await collections.read(); + return { + epoch: state.epoch, + revision: state.revision, + preferences: state.study.preferences, + decks: state.study.decks, + cards: state.study.cards, + events: state.study.events, + }; + }, + setPreferences: async (patch: Partial) => { + const allowed = new Set([ + "newPerDay", + "reviewsPerDay", + "dayStartHour", + "showNextReview", + "showStreak", + ]); + if (Object.keys(patch).some((key) => !allowed.has(key))) + throw new Error( + "Only the five editable preference keys are admitted by this fixture.", + ); + const state = await collections.read(); + await collections.update(state, (current) => ({ + ...current, + study: { + ...current.study, + preferences: { ...current.study.preferences, ...patch }, + }, + })); + }, + erase: () => notes.erase(), + lock: () => lock(false), + }, + }); + createRoot(document.getElementById("root")!).render( + + + , + ); +} +void start().catch((error) => { + const root = document.getElementById("root")!; + root.dataset.fixtureFailed = "true"; + root.textContent = + error instanceof Error ? error.message : "Study settings fixture failed"; +}); diff --git a/web/tests/integration/study-source-browser.html b/web/tests/integration/study-source-browser.html new file mode 100644 index 0000000..5f079cb --- /dev/null +++ b/web/tests/integration/study-source-browser.html @@ -0,0 +1 @@ +Encrypted Study source 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/study-source-browser.mjs b/web/tests/integration/study-source-browser.mjs new file mode 100644 index 0000000..444911e --- /dev/null +++ b/web/tests/integration/study-source-browser.mjs @@ -0,0 +1,26 @@ +import {chromium,expect} from '@playwright/test'; +import {mkdir,writeFile} from 'node:fs/promises'; +const output=process.env.NOTES_UI_OUTPUT??'test-results/study-source-browser';await mkdir(output,{recursive:true}); +const browser=await chromium.launch({channel:'chrome',headless:true});const page=await browser.newPage({viewport:{width:1160,height:850}});const errors=[];page.on('pageerror',error=>errors.push(error.message)); +async function stable(locator){await locator.evaluate(async element=>{await Promise.all(element.getAnimations({subtree:true}).map(animation=>animation.finished.catch(()=>{})));});await expect(locator).toHaveCSS('opacity','1');} +try{ +await page.goto(`${process.env.NOTES_PREVIEW_URL??'http://127.0.0.1:5179'}/tests/integration/study-source-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:'Study',exact:true}).click();await page.getByRole('button',{name:'New deck',exact:true}).first().click();let dialog=page.getByRole('dialog',{name:'New deck',exact:true});await dialog.getByLabel('Name').fill('Actual source deck');await dialog.getByRole('button',{name:'Create deck',exact:true}).click();await expect(dialog).toHaveCount(0); +await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:'New note',exact:true}).first().click();await page.getByLabel('Note title').fill('Source passage note');const editor=page.getByLabel('Note content',{exact:true});await editor.click();await page.keyboard.type('Before NADH and FADH2 after. NADH and FADH2 again.');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible(); +await editor.press('Home');await editor.press('ArrowRight',{delay:1});for(let i=1;i<7;i++)await editor.press('ArrowRight');for(let i=0;i<14;i++)await editor.press('Shift+ArrowRight'); +await page.getByRole('toolbar',{name:'Selection tools',exact:true}).getByRole('button',{name:'Make a card',exact:true}).click();dialog=page.getByRole('dialog',{name:'Make a card',exact:true});await expect(dialog.getByRole('textbox',{name:'Back',exact:true})).toHaveValue('NADH and FADH2');await expect(dialog.getByRole('textbox',{name:'Front',exact:true})).toHaveValue('');await dialog.getByRole('textbox',{name:'Front',exact:true}).fill('Which molecules carry electrons?');await expect(dialog.getByRole('button',{name:'Add card',exact:true})).toBeEnabled();await stable(dialog);await page.screenshot({path:output+'/basic-popover-light.png'});await dialog.getByRole('button',{name:'Add card',exact:true}).click();await expect(dialog).toHaveCount(0);await expect(page.locator('.study-source-annotation')).toHaveCount(1);const meta=await page.evaluate(()=>window.__studySourceFixture.metadata());if(meta.entries.length!==1||meta.cards.length!==1||meta.entries[0].source?.version!==2)throw Error('Real card/anchor not committed'); +await expect(page.locator('.source-card-notice')).toContainText('Card added to Actual source deck');await page.getByRole('button',{name:'Open card',exact:true}).click();dialog=page.getByRole('dialog',{name:'Edit card',exact:true});await expect(dialog.getByRole('textbox',{name:'Front',exact:true})).toHaveValue('Which molecules carry electrons?');await dialog.getByRole('button',{name:'Cancel',exact:true}).click();await page.getByRole('button',{name:/^Study 1 card/}).click();await page.getByRole('button',{name:'Show answer',exact:true}).click();await page.getByRole('button',{name:'Open in note',exact:true}).click();await expect(page.locator('.study-source-highlight')).toHaveText('NADH and FADH2');await stable(page.locator('.study-source-highlight'));await page.screenshot({path:output+'/jump-source-light.png'});await page.getByRole('button',{name:'Back to card',exact:true}).click();await expect(page.getByRole('article',{name:'Card answer'})).toBeVisible(); +await page.getByRole('button',{name:'End session',exact:true}).click();await page.getByRole('button',{name:/^All notes/}).click();await page.getByRole('button',{name:/Source passage note/}).first().click(); +async function selectPassage(prefix=7){const body=page.getByLabel('Note content',{exact:true});await body.click();await body.press('Home');for(let i=0;iwindow.__studySourceFixture.metadata());if(current.entries.length!==2||current.cards.length!==3)throw Error('Reversed did not create actual2cards');await page.getByRole('button',{name:'Undo card creation',exact:true}).click();await expect.poll(async()=>(await page.evaluate(()=>window.__studySourceFixture.metadata())).cards.length).toBe(1); +dialog=await selectPassage();await dialog.getByRole('button',{name:'Fill in the blank',exact:true}).click();await expect(dialog.getByRole('region',{name:'Card preview'})).toContainText('[...]');await expect(dialog.getByRole('button',{name:'Add card',exact:true})).toBeEnabled();await stable(dialog);await page.screenshot({path:output+'/cloze-popover-light.png'});await dialog.getByRole('button',{name:'Add card',exact:true}).click();await expect(dialog).toHaveCount(0);current=await page.evaluate(()=>window.__studySourceFixture.metadata());if(current.entries.length!==2||current.cards.length!==2||!current.entries.some(entry=>entry.type==='cloze'))throw Error('Cloze actual entry/card missing');await page.getByRole('button',{name:'Open card',exact:true}).click();dialog=page.getByRole('dialog',{name:'Edit card',exact:true});await dialog.getByRole('button',{name:'Cancel',exact:true}).click();await page.getByRole('row').filter({hasText:'Fill in the blank'}).getByRole('button',{name:'Open in note',exact:true}).click();await expect(page.locator('.study-source-highlight')).toHaveText('NADH and FADH2'); +const sourceNoteId=current.notes.find(note=>note.title==='Source passage note').id;const initialAnchors=await page.evaluate(id=>window.__studySourceFixture.annotations(id),sourceNoteId);if(initialAnchors.length!==2)throw Error('Missing attached source annotations');const from=initialAnchors[0].from; +await page.getByLabel('Note content',{exact:true}).press('Home');await page.keyboard.type('Nearby ');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();const mapped=await page.evaluate(id=>window.__studySourceFixture.annotations(id),sourceNoteId);if(mapped.length!==2||mapped.some(anchor=>anchor.from!==from+7))throw Error('Surrounding edit did not preserve mapped range'); +await page.getByLabel('Note content',{exact:true}).press('Home');for(let i=0;i<14;i++)await page.getByLabel('Note content',{exact:true}).press('ArrowRight');for(let i=0;i<14;i++)await page.getByLabel('Note content',{exact:true}).press('Shift+ArrowRight');await page.keyboard.type('NADH and FADH2');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();await expect.poll(async()=>(await page.evaluate(id=>window.__studySourceFixture.annotations(id),sourceNoteId)).length).toBe(0);await expect(page.locator('.study-source-annotation')).toHaveCount(0);await page.getByRole('button',{name:'Back to card',exact:true}).click();await page.getByRole('row').filter({hasText:'Fill in the blank'}).getByRole('button',{name:'Open in note',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'The source passage changed.'})).toBeVisible();await expect(page.locator('.study-source-highlight')).toHaveCount(0);await page.getByRole('button',{name:'Open note without passage',exact:true}).click();await expect(page.getByLabel('Note content',{exact:true})).toBeVisible(); +await page.getByLabel('Note content',{exact:true}).press('Control+End');await page.keyboard.type(' {{literal}}');await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();dialog=await selectPassage(14);await expect(dialog.getByRole('button',{name:'Fill in the blank',exact:true})).toBeDisabled();await expect(dialog).toContainText('Blank markers in this context prevent a new blank.');await dialog.getByRole('textbox',{name:'Front',exact:true}).fill('Draft kept during reselection'); +for(const theme of ['light','dark','dim']){await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;},theme);await stable(dialog);await page.screenshot({path:output+`/card-form-${theme}.png`});} +await page.setViewportSize({width:390,height:844});await page.emulateMedia({reducedMotion:'reduce'});await stable(dialog);await page.screenshot({path:output+'/card-sheet-narrow-dim.png'});if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Narrow source page overflow');const reduced=await dialog.evaluate(element=>({animation:getComputedStyle(element).animationDuration,transform:getComputedStyle(element).transform}));if(reduced.transform!=='none')throw Error('Reduced source sheet transform active'); +await dialog.getByRole('textbox',{name:'Front',exact:true}).press('End');await dialog.getByRole('textbox',{name:'Front',exact:true}).press('Enter');await dialog.getByRole('textbox',{name:'Front',exact:true}).type('Second line');await expect(dialog.getByRole('textbox',{name:'Front',exact:true})).toHaveValue('Draft kept during reselection\nSecond line');await dialog.getByRole('button',{name:'Basic',exact:true}).focus();await dialog.getByRole('button',{name:'Basic',exact:true}).press('ArrowRight');await expect(dialog.getByRole('button',{name:'Reversed',exact:true})).toBeFocused();await dialog.getByRole('button',{name:'Reversed',exact:true}).press('ArrowRight');await expect(dialog.getByRole('button',{name:'Basic',exact:true})).toBeFocused(); +await dialog.getByRole('button',{name:'Add card',exact:true}).scrollIntoViewIfNeeded();await expect(dialog.getByRole('button',{name:'Add card',exact:true})).toBeInViewport();await expect(dialog.getByRole('button',{name:'Cancel',exact:true})).toBeInViewport();await stable(dialog);await page.screenshot({path:output+'/card-sheet-actions-narrow-dim.png'}); +await page.evaluate(()=>window.__studySourceFixture.erase());await expect(page.getByRole('dialog',{name:'Make a card',exact:true})).toHaveCount(0);await expect(page.getByText('No decks yet',{exact:true})).toBeVisible();current=await page.evaluate(()=>window.__studySourceFixture.metadata());if(current.entries.length||current.cards.length)throw Error('Erase left source cards'); +if(errors.length)throw Error(errors.join('\n'));await writeFile(output+'/result.json',JSON.stringify({qualified:'Synthetic account session; real production editor/source service/WASM/IndexedDB, not account login/native input',checks:['Basic from actual keyboard selection','empty user Front/verbatim selected Back','atomic actual entry/card/anchor','Open actual card','validated source jump with duplicate quote elsewhere','return to revealed review card','Reversed actual2cards plus receiptUndo','Cloze actualcontext/answer/card','verified surrounding edit maps attached ranges','identical-text replacement makes anchors stale/no wrong jump','literal moustache fallback keeps Basic/Reversed','Light/Dark/Dim and390px reduced sheet','wholeerase clears carddraft/source/cards','scoped enabledtype arrow navigation and textarea newline','constrained sheet scroll keeps Cancel/Add reachable'],pageErrors:errors},null,2));console.log('Study source browser smoke 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/study-source-browser.tsx b/web/tests/integration/study-source-browser.tsx new file mode 100644 index 0000000..fd239fe --- /dev/null +++ b/web/tests/integration/study-source-browser.tsx @@ -0,0 +1,66 @@ +// 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: "99999999-9999-4999-8999-999999999999", + deviceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + email: "isolated-source-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 {openCollectionsStore}=await import("../../src/collections/store"); + const inspector=await openCollectionsStore(identity); + const {createStudySourceService}=await import("../../src/study/sourceStore"); + const sources=createStudySourceService(notes,inspector); + Object.assign(window,{__studySourceFixture:{metadata:async()=>{const state=await inspector.read();const list=await notes.list();return{entries:state.study.entries.map(entry=>({id:entry.id,type:entry.type,source:entry.source})),cards:state.study.cards.map(card=>({id:card.id,entryId:card.entryId})),notes:list.map(note=>({id:note.id,title:note.title,revision:note.revision}))};},annotations:(id:string)=>sources.annotations(id),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/integration/study-source-reopen-browser.mjs b/web/tests/integration/study-source-reopen-browser.mjs new file mode 100644 index 0000000..0ca4695 --- /dev/null +++ b/web/tests/integration/study-source-reopen-browser.mjs @@ -0,0 +1,193 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import assert from "node:assert/strict"; + +const output = + process.env.NOTES_UI_OUTPUT ?? "test-results/study-source-reopen"; +await mkdir(output, { recursive: true }); +const profile = await mkdtemp(join(tmpdir(), "orbyn-source-reopen-")); +const url = `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-source-browser.html`; +const errors = []; +const quote = "NADH and FADH2"; +const body = `Before ${quote} after. ${quote} again.`; +const selectedOffset = body.lastIndexOf(quote); +let context; +async function launch() { + context = await chromium.launchPersistentContext(profile, { + channel: process.env.PLAYWRIGHT_CHANNEL ?? "chrome", + headless: true, + viewport: { width: 1160, height: 850 }, + }); + // The Vite assets remain available. This deliberately proves local encrypted + // state with the account API unreachable, not offline frontend installation. + await context.route("http://127.0.0.1:8089/**", (route) => route.abort()); + const page = context.pages()[0] ?? (await context.newPage()); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await page.waitForFunction(() => Boolean(window.__studySourceFixture)); + return page; +} +const metadata = (page) => + page.evaluate(() => window.__studySourceFixture.metadata()); +async function exactDecoration(page, selector) { + const actual = await page + .getByLabel("Note content", { exact: true }) + .evaluate((element, selector) => { + const decoration = element.querySelector(selector); + if (!decoration) return null; + const prefix = element.ownerDocument.createRange(); + prefix.selectNodeContents(element); + prefix.setEndBefore(decoration); + return { prefix: prefix.toString(), text: decoration.textContent }; + }, selector); + assert.deepEqual(actual, { + prefix: body.slice(0, selectedOffset), + text: quote, + }); +} +async function openNote(page) { + await page.getByRole("button", { name: /^All notes/ }).click(); + await page + .getByRole("button") + .filter({ hasText: "Reload source note" }) + .click(); + await expect(page.getByLabel("Note title")).toHaveValue("Reload source note"); + await expect(page.locator(".study-source-annotation")).toHaveText( + "NADH and FADH2", + ); + await exactDecoration(page, ".study-source-annotation"); +} + +try { + let page = await launch(); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await page + .getByRole("button", { name: "New deck", exact: true }) + .first() + .click(); + let dialog = page.getByRole("dialog", { name: "New deck", exact: true }); + await dialog.getByLabel("Name").fill("Reload source deck"); + await dialog + .getByRole("button", { name: "Create deck", exact: true }) + .click(); + await expect(dialog).toHaveCount(0); + await page.getByRole("button", { name: /^All notes/ }).click(); + await page.getByRole("button", { name: "New note", exact: true }).click(); + await page.getByLabel("Note title").fill("Reload source note"); + const editor = page.getByLabel("Note content", { exact: true }); + await editor.click(); + await page.keyboard.type(body); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + await editor.press("Control+Home"); + for (let i = 0; i < selectedOffset; i++) await editor.press("ArrowRight"); + for (let i = 0; i < 14; i++) await editor.press("Shift+ArrowRight"); + await page + .getByRole("toolbar", { name: "Selection tools", exact: true }) + .getByRole("button", { name: "Make a card", exact: true }) + .click(); + dialog = page.getByRole("dialog", { name: "Make a card", exact: true }); + await expect( + dialog.getByRole("textbox", { name: "Back", exact: true }), + ).toHaveValue("NADH and FADH2"); + await dialog + .getByRole("textbox", { name: "Front", exact: true }) + .fill("Which carriers persist?"); + await dialog.getByRole("button", { name: "Add card", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".study-source-annotation")).toHaveText( + "NADH and FADH2", + ); + const before = await metadata(page); + assert.equal(before.entries.length, 1); + assert.equal(before.cards.length, 1); + assert.equal(before.entries[0].source.version, 2); + const originalAnchors = await page.evaluate( + (id) => window.__studySourceFixture.annotations(id), + before.notes[0].id, + ); + assert.equal(originalAnchors.length, 1); + assert.equal(originalAnchors[0].from, selectedOffset + 1); + assert.equal(originalAnchors[0].to, selectedOffset + quote.length + 1); + await exactDecoration(page, ".study-source-annotation"); + + await page.reload(); + await page.waitForFunction(() => Boolean(window.__studySourceFixture)); + assert.deepEqual(await metadata(page), before); + await openNote(page); + await page.screenshot({ + path: join(output, "underline-after-page-reload.png"), + }); + await context.close(); + context = undefined; + + // Relaunch a new Chromium process against the same task-only profile. No + // in-memory source service, key or editor can carry the old projection over. + page = await launch(); + assert.deepEqual(await metadata(page), before); + await openNote(page); + const noteId = before.notes[0].id; + const anchors = await page.evaluate( + (id) => window.__studySourceFixture.annotations(id), + noteId, + ); + assert.equal(anchors.length, 1); + assert.equal(anchors[0].entryId, before.entries[0].id); + assert.deepEqual(anchors, originalAnchors); + await page.getByRole("button", { name: /Back to notes$/ }).click(); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await page + .getByRole("button", { name: "Reload source deck", exact: true }) + .click(); + await page.getByRole("button", { name: /^Study 1 card$/ }).click(); + await page.getByRole("button", { name: "Show answer", exact: true }).click(); + await page.getByRole("button", { name: "Open in note", exact: true }).click(); + await expect(page.locator(".study-source-highlight")).toHaveText( + "NADH and FADH2", + ); + await exactDecoration(page, ".study-source-highlight"); + await page.screenshot({ + path: join(output, "verified-jump-after-browser-restart.png"), + }); + await page.getByRole("button", { name: "Back to card", exact: true }).click(); + await expect( + page.getByRole("article", { name: "Card answer" }), + ).toBeVisible(); + assert.deepEqual(errors, []); + await writeFile( + join(output, "result.json"), + JSON.stringify( + { + qualified: + "Disposable synthetic account session; actual production editor/source/WASM/IndexedDB and retained CryptoKey across Chromium restart. Vite assets stay online, account API blocked. Not account-server login, installed-offline frontend, or native input qualification.", + checks: [ + "actual second-occurrence selection and atomic card/anchor save", + "exact saved PM range matches second occurrence", + "page reload restores exact entry/card/note identities and underline DOM offset", + "new Chromium process restores encrypted state/retained key", + "verified source jump after restart matches saved second-occurrence PM range and exact DOM prefix, not first equal quote", + "revealed review retained on return after restart", + ], + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log("Study source encrypted page reload and browser restart passed"); +} catch (error) { + const page = context?.pages()[0]; + if (page) { + await page.screenshot({ path: join(output, "failure.png") }); + await writeFile( + join(output, "failure.txt"), + `${String(error)}\n${await page.locator("body").innerText()}`, + ); + } + throw error; +} finally { + await context?.close(); +} diff --git a/web/tests/integration/study-toast-pause-browser.mjs b/web/tests/integration/study-toast-pause-browser.mjs new file mode 100644 index 0000000..0aab545 --- /dev/null +++ b/web/tests/integration/study-toast-pause-browser.mjs @@ -0,0 +1,127 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; + +const output = process.env.NOTES_UI_OUTPUT ?? "test-results/study-toast-pause"; +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(), + errors = []; +let stage = "boot"; +page.on("pageerror", (error) => errors.push(error.message)); +try { + const now = new Date(); + await page.clock.install({ time: now }); + await page.goto( + `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/study-settings-browser.html`, + ); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(async () => + (await import("/src/desktop/closeGuard.ts")).checkClose(), + ), + ) + .toBe("allowed"); + await page.getByRole("button", { name: "Study", exact: true }).click(); + await page.getByRole("button", { name: /^Study 4 cards$/ }).click(); + await page.getByRole("button", { name: "Show answer", exact: true }).click(); + // Bootstrap needs its actual queued work. Freeze after the real answer exists, + // before creating the toast whose presentation lifetime is under test. + const beforeGrade = await page.evaluate(() => Date.now()); + await page.clock.pauseAt(new Date(beforeGrade + 1000)); + await page.getByRole("button", { name: /^Good/ }).click(); + const toast = page.locator(".study-toast"), + undo = toast.getByRole("button", { name: "Undo", exact: true }); + await expect(toast).toBeVisible(); + await page.clock.fastForward(1000); + await toast.hover(); + await undo.focus(); + await page.mouse.move(1140, 5); + await expect(undo).toBeFocused(); + stage = "focus-remains-after-pointer-leave"; + await page.clock.fastForward(6000); + await expect(toast).toBeVisible(); + await expect(undo).toBeFocused(); + const held = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + expect(held.events).toHaveLength(1); + stage = "resume-remaining-time"; + await page.getByRole("button", { name: "End session", exact: true }).focus(); + await page.clock.fastForward(3999); + await expect(toast).toBeVisible(); + await page.clock.fastForward(501); + await expect(toast).toHaveCount(0); + const after = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + expect(after.events).toEqual(held.events); + expect(after.cards).toEqual(held.cards); + stage = "settings-cover-pause"; + await page.getByRole("button", { name: "Show answer", exact: true }).click(); + await page.getByRole("button", { name: /^Good/ }).click(); + await expect(toast).toBeVisible(); + await page.clock.fastForward(1000); + await expect + .poll(() => + page.evaluate(async () => + (await import("/src/desktop/closeGuard.ts")).checkClose(), + ), + ) + .toBe("allowed"); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const settings = page.getByRole("dialog", { name: "Settings", exact: true }); + await expect(settings).toBeVisible(); + await page.clock.fastForward(6000); + await expect(toast).toHaveCount(1); + const covered = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + expect(covered.events).toHaveLength(2); + await settings.getByRole("button", { name: "Done", exact: true }).click(); + await expect(settings).toHaveCount(0); + await page.clock.fastForward(3999); + await expect(toast).toBeVisible(); + await page.clock.fastForward(501); + await expect(toast).toHaveCount(0); + const uncovered = await page.evaluate(() => + window.__studySettingsFixture.metadata(), + ); + expect(uncovered.events).toEqual(covered.events); + expect(uncovered.cards).toEqual(covered.cards); + expect(errors).toEqual([]); + stage = "passed"; + await writeFile( + `${output}/result.json`, + JSON.stringify( + { + qualified: + "Disposable synthetic account; actual App/WASM/IndexedDB with browser-controlled presentation clock. No native input, account backend or real-time frame-rate claim.", + stage, + eventCount: uncovered.events.length, + cardsAndEventsUnchangedByToastExpiry: true, + settingsCoverPause: true, + errors, + }, + null, + 2, + ), + ); + console.log( + "PASS: actual focused toast survives pointer leave and resumes only its remaining dwell; expiry does not change grades.", + ); +} catch (error) { + await writeFile( + `${output}/result.json`, + JSON.stringify({ stage, error: String(error), errors }, null, 2), + ); + throw error; +} finally { + await context.close(); + await browser.close(); +} diff --git a/web/tests/ui/study-preview.html b/web/tests/ui/study-preview.html new file mode 100644 index 0000000..99345f3 --- /dev/null +++ b/web/tests/ui/study-preview.html @@ -0,0 +1,22 @@ + + + + + + Study UI test fixture + + +
+ Study UI test fixture · memory only · not encrypted storage +
+
+ + + diff --git a/web/tests/ui/study-preview.tsx b/web/tests/ui/study-preview.tsx new file mode 100644 index 0000000..486dc93 --- /dev/null +++ b/web/tests/ui/study-preview.tsx @@ -0,0 +1,47 @@ +import { createRoot } from "react-dom/client"; +import { StudyView } from "../../src/ui/StudyView"; +import type { + CollectionsState, + CollectionsStore, +} from "../../src/collections/contracts"; +import { CollectionsRevisionConflictError } from "../../src/collections/contracts"; +import { + emptyStudy, + validateCollections, +} from "../../src/collections/validation"; +import "../../src/styles/app.css"; + +// Test-only memory store. Production CollectionsHost always opens the real sealed store. +let state: CollectionsState = { + version: 3, + epoch: crypto.randomUUID(), + revision: 0, + study: emptyStudy(Intl.DateTimeFormat().resolvedOptions().timeZone), + journal: null, + clips: { items: [], receipts: [] }, +}; +const listeners = new Set<() => void>(); +const store: CollectionsStore = { + read: async () => structuredClone(state), + update: async (expected, change) => { + if (expected.epoch !== state.epoch || expected.revision !== state.revision) + throw new CollectionsRevisionConflictError(structuredClone(state)); + state = validateCollections({ + ...change(structuredClone(state)), + revision: state.revision + 1, + }); + listeners.forEach((listener) => listener()); + return structuredClone(state); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + persistence: () => ({ status: "ready" }), + close: () => listeners.clear(), +}; +createRoot(document.getElementById("root")!).render( + , +); diff --git a/web/tests/ui/study-source-routing.test.tsx b/web/tests/ui/study-source-routing.test.tsx new file mode 100644 index 0000000..e646444 --- /dev/null +++ b/web/tests/ui/study-source-routing.test.tsx @@ -0,0 +1,270 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Workspace } from "../../src/ui/Workspace"; +import type { Note, NotesStore } from "../../src/domain/contracts"; +import type { NotesLibrary } from "../../src/library/contracts"; +import type { + CollectionsStore, + CollectionsState, +} from "../../src/collections/contracts"; +import type { + StudySourceService, + SourceResolution, +} from "../../src/study/sourceContracts"; +import { emptyStudy } from "../../src/collections/validation"; +const model = vi.hoisted(() => ({ + owners: null as { + collections: CollectionsStore; + service: StudySourceService; + error: string; + retry: () => void; + } | null, +})); +vi.mock("../../src/ui/studySourceOwners", () => ({ + useStudySourceOwners: () => model.owners, +})); +vi.mock("../../src/ui/CollectionsHost", () => ({ + CollectionsHost: ({ + onOpenSource, + }: { + onOpenSource?: (request: { entryId: string }) => void; + }) => ( +
+ +
+ ), +})); +vi.mock("../../src/ui/NoteEditor", () => ({ + NoteEditor: ( + props: Parameters[0], + ) => ( +
+

Source reader

+ + +
+ ), +})); +const epoch = "11111111-1111-4111-8111-111111111111", + entryId = "33333333-3333-4333-8333-333333333333"; +const note: Note = { + id: "source-note", + revision: 1, + title: "Source", + tags: [], + document: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "NADH" }] }], + }, + createdAt: "2026-11-11T00:00:00.000Z", + updatedAt: "2026-11-11T00:00:00.000Z", + trashedAt: null, +}; +const attached: SourceResolution = { + status: "attached", + entryId, + anchorId: "44444444-4444-4444-8444-444444444444", + anchorRevision: 1, + noteId: note.id, + noteRevision: 1, + documentDigest: "a".repeat(64), + from: 1, + to: 5, + quote: "NADH", +}; +// Explicit routing/projection boundary doubles; encrypted passage authority is covered by the real browser fixture. +function fixture() { + const collection: CollectionsState = { + version: 3, + epoch, + revision: 1, + study: { + ...emptyStudy(), + entries: [ + { + id: entryId, + revision: 1, + deckId: "22222222-2222-4222-8222-222222222222", + type: "basic", + front: "Question", + back: "NADH", + tags: [], + source: { + version: 2, + noteId: note.id, + noteRevision: 1, + quote: "NADH", + libraryEpoch: epoch, + anchorId: attached.status === "attached" ? attached.anchorId : "", + }, + }, + ], + }, + journal: null, + clips: { items: [], receipts: [] }, + }; + const collections: CollectionsStore = { + read: vi.fn(async () => collection), + update: vi.fn(), + subscribe: () => () => {}, + persistence: () => ({ status: "ready" }), + close: vi.fn(), + }; + const service: StudySourceService = { + prepareCreate: vi.fn(), + prepareUndo: vi.fn(), + commit: vi.fn(), + resolve: vi.fn(async () => attached), + annotations: vi.fn(async () => []), + subscribe: () => () => {}, + persistence: () => ({ status: "ready" }), + onInvalidate: () => () => {}, + close: vi.fn(), + }; + const library: NotesLibrary = { + read: vi.fn(async () => ({ + mode: "ready" as const, + snapshot: { + token: { epoch, revision: 1 }, + notes: [note], + tags: [], + metadata: [], + preferences: { sort: "edited" as const }, + }, + })), + 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 store: NotesStore = { + 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: () => ({ status: "ready" }), + close: vi.fn(), + }; + model.owners = { collections, service, error: "", retry: vi.fn() }; + const view = render( + , + ); + return { view, service }; +} +beforeEach(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; + vi.stubGlobal("matchMedia", () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + URL.createObjectURL = vi.fn(() => "blob:unrelated-export"); + URL.revokeObjectURL = vi.fn(); +}); +afterEach(() => vi.unstubAllGlobals()); +async function readerWithExport() { + fireEvent.click(await screen.findByRole("button", { name: "Study" })); + fireEvent.click(await screen.findByText("Open saved source")); + await screen.findByRole("heading", { name: "Source reader" }); + fireEvent.click(screen.getByText("Export this note")); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Export Markdown" })).getByRole( + "button", + { name: "Export" }, + ), + ); +} +it("clears stale projection feedback on a fresh source jump without deleting an unrelated export notice", async () => { + const f = fixture(); + await readerWithExport(); + fireEvent.click(screen.getByText("Report stale projection")); + await screen.findByText(/This saved passage target changed/); + expect(f.view.container.querySelector(".notice")).toHaveTextContent( + "Export prepared", + ); + fireEvent.click(screen.getByRole("button", { name: "Back to card" })); + fireEvent.click(screen.getByText("Open saved source")); + await screen.findByRole("heading", { name: "Source reader" }); + expect( + screen.queryByText(/This saved passage target changed/), + ).not.toBeInTheDocument(); + expect(f.view.container.querySelector(".notice")).toHaveTextContent( + "Export prepared", + ); + expect(f.service.resolve).toHaveBeenCalledTimes(2); +}); +it("shows one stale fallback warning and preserves the unrelated notice when ordinary Open note succeeds", async () => { + const f = fixture(); + await readerWithExport(); + fireEvent.click(screen.getByText("Report stale projection")); + await screen.findByText(/This saved passage target changed/); + fireEvent.click(screen.getByRole("button", { name: "Back to card" })); + vi.mocked(f.service.resolve).mockResolvedValue({ + status: "unavailable", + reason: "stale", + }); + fireEvent.click(screen.getByText("Open saved source")); + await screen.findByText("The source passage changed."); + fireEvent.click( + screen.getByRole("button", { name: "Open note without passage" }), + ); + await screen.findByRole("heading", { name: "Source reader" }); + expect(screen.getAllByText("The source passage changed.")).toHaveLength(1); + expect( + screen.queryByText(/This saved passage target changed/), + ).not.toBeInTheDocument(); + expect(f.view.container.querySelector(".notice")).toHaveTextContent( + "Export prepared", + ); + await waitFor(() => expect(f.service.resolve).toHaveBeenCalledTimes(3)); +}); diff --git a/web/tests/ui/study.test.tsx b/web/tests/ui/study.test.tsx new file mode 100644 index 0000000..1e6999b --- /dev/null +++ b/web/tests/ui/study.test.tsx @@ -0,0 +1,710 @@ +import { onLock } from "../../src/account/session"; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StudyView } from "../../src/ui/StudyView"; +import type { + CollectionsState, + CollectionsStore, + StudyEntry, +} from "../../src/collections/contracts"; +import { CollectionsRevisionConflictError } from "../../src/collections/contracts"; +import { + emptyStudy, + validateCollections, +} from "../../src/collections/validation"; +import { createDeck, saveEntry } from "../../src/study/model"; +import { checkClose } from "../../src/desktop/closeGuard"; +import { Workspace } from "../../src/ui/Workspace"; +import { openCollectionsStore } from "../../src/collections/store"; +import type { + AccountSession, + Note, + NotesStore, +} from "../../src/domain/contracts"; + +// Labelled component-boundary actors only; production still requires its genuine owner registry. +const boundaryActors = vi.hoisted( + () => + new WeakMap< + object, + { + closed: boolean; + epochs: Set<(epoch: string) => void>; + closes: Set<() => void>; + } + >(), +); +vi.mock("../../src/collections/store", () => { + const access = (store: object) => { + const actor = boundaryActors.get(store); + if (!actor || actor.closed) + throw new Error("locked: Test Collections boundary is closed."); + return actor; + }; + return { + openCollectionsStore: vi.fn(), + assertCollectionsAccess: (store: object) => { + access(store); + }, + onCollectionsClose: (store: object, listener: () => void) => { + const actor = access(store); + actor.closes.add(listener); + return () => actor.closes.delete(listener); + }, + onCollectionsEpochChange: ( + store: object, + listener: (epoch: string) => void, + ) => { + const actor = access(store); + actor.epochs.add(listener); + return () => actor.epochs.delete(listener); + }, + }; +}); +const fixtureStores = new Set(); +vi.mock("../../src/ui/RichEditor", () => ({ + RichEditor: () =>
Editor test boundary
, +})); + +beforeEach(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); +afterEach(() => { + for (const store of fixtureStores) store.close(); + fixtureStores.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function memoryStore(populated = false, sourceNoteId: string | null = null) { + const at = new Date(Date.now() - 60_000); + let study = emptyStudy("UTC"); + if (populated) { + study = createDeck(study, "Biology", at); + const entry: StudyEntry = { + id: crypto.randomUUID(), + deckId: study.decks[0]!.id, + type: "basic", + front: "Where does glycolysis take place?", + back: "In the cytoplasm", + tags: [], + source: sourceNoteId + ? { + noteId: sourceNoteId, + noteRevision: 1, + quote: "Glycolysis takes place in the cytoplasm.", + } + : null, + revision: 1, + }; + study = saveEntry(study, entry, at); + } + let state: CollectionsState = { + version: 3, + epoch: crypto.randomUUID(), + revision: 0, + study, + journal: null, + clips: { items: [], receipts: [] }, + }; + const listeners = new Set<() => void>(); + const actor = { + closed: false, + epochs: new Set<(epoch: string) => void>(), + closes: new Set<() => void>(), + }; + const assertOpen = () => { + if (actor.closed) + throw new Error("locked: Test Collections boundary is closed."); + }; + let stopLock = () => {}; + let unknownResult = false; + const update = vi.fn(async (expected, change) => { + assertOpen(); + if (expected.epoch !== state.epoch || expected.revision !== state.revision) + throw new CollectionsRevisionConflictError(structuredClone(state)); + state = validateCollections({ + ...change(structuredClone(state)), + revision: state.revision + 1, + }); + if (unknownResult) { + unknownResult = false; + throw new Error("Commit response lost"); + } + listeners.forEach((listener) => listener()); + return structuredClone(state); + }); + const store: CollectionsStore = { + read: vi.fn(async () => { + assertOpen(); + return structuredClone(state); + }), + update, + subscribe: (listener) => { + assertOpen(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + persistence: () => ({ status: "ready" }), + close: vi.fn(() => { + if (actor.closed) return; + actor.closed = true; + for (const callback of actor.closes) callback(); + actor.closes.clear(); + actor.epochs.clear(); + listeners.clear(); + stopLock(); + }), + }; + boundaryActors.set(store, actor); + fixtureStores.add(store); + stopLock = onLock(() => store.close()); + return { + store, + update, + state: () => structuredClone(state), + loseNextCommitResponse: () => { + unknownResult = true; + }, + erase: () => { + state = { + ...state, + epoch: crypto.randomUUID(), + revision: state.revision + 1, + study: emptyStudy("UTC"), + journal: null, + }; + assertOpen(); + actor.epochs.forEach((listener) => listener(state.epoch)); + listeners.forEach((listener) => listener()); + }, + }; +} +async function finishMotion(phase: string, animationName: string) { + const article = document.querySelector( + `.study-flash[data-study-motion="${phase}"]`, + ); + expect(article).not.toBeNull(); + const event = new Event("animationend", { bubbles: true }); + Object.defineProperty(event, "animationName", { value: animationName }); + await act(async () => { + fireEvent(article!, event); + // This consumer import graph can make React choose WebKit's jsdom event + // alias. Both dispatch through the real guarded handler on the same node; + // neither calls the controller or advances domain state directly. + const prefixed = new Event("webkitAnimationEnd", { bubbles: true }); + Object.defineProperty(prefixed, "animationName", { value: animationName }); + fireEvent(article!, prefixed); + }); +} +async function finishReveal() { + // Exiting content deliberately has no accessible name while aria-hidden. + const outgoing = document.querySelector( + 'article.study-flash[aria-label="Card question"][data-study-motion="reveal-exit"]', + ); + expect(outgoing).not.toBeNull(); + expect(outgoing).toHaveAttribute("data-study-motion", "reveal-exit"); + expect(outgoing).toHaveAttribute("inert"); + expect(outgoing).toHaveAttribute("aria-hidden", "true"); + await finishMotion("reveal-exit", "study-reveal-out"); + expect(screen.getByRole("article", { name: "Card answer" })).toHaveAttribute( + "data-study-motion", + "answer-enter", + ); + await finishMotion("answer-enter", "study-answer-in"); +} +async function revealAnswer() { + fireEvent.click(screen.getByRole("button", { name: "Show answer" })); + await finishReveal(); +} +async function openDeck() { + fireEvent.click(await screen.findByRole("button", { name: "Biology" })); +} +function closeState() { + let state: ReturnType = "allowed"; + act(() => { + state = checkClose(); + }); + return state; +} + +describe("local Study", () => { + it("clears the held source editor when a remote erase replaces the collections epoch", async () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("min-width"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + const id = crypto.randomUUID(); + const { store, erase } = memoryStore(true, id); + vi.mocked(openCollectionsStore).mockResolvedValue(store); + const note: Note = { + id, + title: "Erased source", + document: { type: "doc", content: [] }, + tags: [], + createdAt: "2026-11-11T00:00:00Z", + updatedAt: "2026-11-11T00:00:00Z", + trashedAt: null, + revision: 1, + }; + const notes: NotesStore = { + 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: () => ({ status: "ready" }), + close: vi.fn(), + }; + const session: AccountSession = { + accountId: crypto.randomUUID(), + deviceId: crypto.randomUUID(), + deviceState: "trusted", + email: "study-fixture@example.test", + }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Study" })); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + fireEvent.click(screen.getByRole("button", { name: "Open in note" })); + await screen.findByDisplayValue("Erased source"); + await act(async () => erase()); + expect(screen.queryByLabelText("Note title")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Back to card" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("No decks yet")).toBeVisible(); + }); + it("does not claim done for the day when Again is due soon, and refreshes the home queue", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-11-11T08:00:00Z")); + const { store } = memoryStore(true); + await act(async () => { + render(); + }); + fireEvent.click(screen.getByRole("button", { name: "Study 1 card" })); + await revealAnswer(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Again/ })); + }); + await finishMotion("confirmed-exit", "study-card-out"); + expect( + screen.getByRole("heading", { name: "Session complete" }), + ).toBeVisible(); + expect( + screen.queryByText("That is everything due. Nothing is waiting for you."), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^Done$/ })); + expect( + screen.queryByRole("button", { name: "Study 1 card" }), + ).not.toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_010); + }); + expect(screen.getByRole("button", { name: "Study 1 card" })).toBeVisible(); + }); + + it("fences a late old-epoch grade response after erase", async () => { + const { store, update, state, erase } = memoryStore(true); + let finish!: (state: CollectionsState) => void; + let oldResult!: CollectionsState; + update.mockImplementationOnce((_expected, change) => { + oldResult = validateCollections({ ...change(state()), revision: 1 }); + return new Promise((resolve) => { + finish = resolve; + }); + }); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + fireEvent.click(screen.getByRole("button", { name: /^Good/ })); + await act(async () => erase()); + await screen.findByText("No decks yet"); + await act(async () => finish(oldResult)); + expect(screen.queryByLabelText("Card answer")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Undo" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("No decks yet")).toBeVisible(); + expect(closeState()).toBe("allowed"); + }); + it("preserves the revealed review and store through the guarded source-note round trip", async () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("min-width"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + const id = crypto.randomUUID(); + const { store } = memoryStore(true, id); + vi.mocked(openCollectionsStore).mockResolvedValue(store); + const note: Note = { + id, + title: "Biology source", + document: { type: "doc", content: [{ type: "paragraph" }] }, + tags: [], + createdAt: "2026-11-11T00:00:00Z", + updatedAt: "2026-11-11T00:00:00Z", + trashedAt: null, + revision: 1, + }; + const notes: NotesStore = { + 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: () => ({ status: "ready" }), + close: vi.fn(), + }; + const session: AccountSession = { + accountId: crypto.randomUUID(), + deviceId: crypto.randomUUID(), + deviceState: "trusted", + email: "study-fixture@example.test", + }; + render(); + await screen.findByRole("button", { name: "Study" }); + expect(openCollectionsStore).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Study" })); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + fireEvent.click(screen.getByRole("button", { name: "Open in note" })); + await screen.findByDisplayValue("Biology source"); + expect(screen.getByLabelText("Card answer")).not.toBeVisible(); + expect(store.close).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Note title"), { + target: { value: "Unsaved source" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Back to card" })); + expect(screen.getByDisplayValue("Unsaved source")).toBeVisible(); + // Returning to the review must not discard the source note's dirty draft. + expect( + screen.getByText( + "Your latest changes are only in this window. Save or copy your text before leaving.", + ), + ).toBeVisible(); + }); + + it("restores the same answer after opening and returning from a clean source note", async () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("min-width"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + const id = crypto.randomUUID(); + const { store } = memoryStore(true, id); + vi.mocked(openCollectionsStore).mockResolvedValue(store); + const note: Note = { + id, + title: "Source", + document: { type: "doc", content: [] }, + tags: [], + createdAt: "2026-11-11T00:00:00Z", + updatedAt: "2026-11-11T00:00:00Z", + trashedAt: null, + revision: 1, + }; + const notes: NotesStore = { + 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: () => ({ status: "ready" }), + close: vi.fn(), + }; + const session: AccountSession = { + accountId: crypto.randomUUID(), + deviceId: crypto.randomUUID(), + deviceState: "trusted", + email: "study-fixture@example.test", + }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Study" })); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + fireEvent.click(screen.getByRole("button", { name: "Open in note" })); + await screen.findByDisplayValue("Source"); + fireEvent.click(screen.getByRole("button", { name: "Back to card" })); + expect(screen.getByLabelText("Card answer")).toBeVisible(); + expect(screen.getByText("1 of 1")).toBeVisible(); + expect(openCollectionsStore).toHaveBeenCalledTimes(1); + expect(store.close).not.toHaveBeenCalled(); + }); + + it("uses the revealed preview instant after time passes and focus refreshes", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-11T08:00:00Z")); + const { store, state } = memoryStore(true); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + const expectedDue = screen + .getByRole("button", { name: /^Good/ }) + .querySelector("time")!.dateTime; + vi.setSystemTime(new Date("2026-11-11T09:00:00Z")); + fireEvent.focus(window); + fireEvent.click(screen.getByRole("button", { name: /^Good/ })); + await screen.findByRole("heading", { name: "Done for today" }); + expect(state().study.events[0]!.at).toBe("2026-11-11T08:00:00.000Z"); + expect(state().study.cards[0]!.schedule.due).toBe(expectedDue); + }); + + it("does not intercept Space on the focused End session button", async () => { + const { store } = memoryStore(true); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + const end = screen.getByRole("button", { name: "End session" }); + end.focus(); + expect(fireEvent.keyDown(end, { key: " " })).toBe(true); + expect(screen.queryByLabelText("Card answer")).not.toBeInTheDocument(); + fireEvent.click(end); + expect(screen.queryByLabelText("Card question")).not.toBeInTheDocument(); + }); + it("reconciles a committed grade after a lost response without grading twice", async () => { + const { store, update, state, loseNextCommitResponse } = memoryStore(true); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + loseNextCommitResponse(); + fireEvent.click(screen.getByRole("button", { name: /^Good/ })); + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(state().study.events).toHaveLength(1); + fireEvent.click(await screen.findByRole("button", { name: "Reload" })); + await screen.findByRole("heading", { name: "Done for today" }); + expect(update).toHaveBeenCalledTimes(1); + expect(state().study.events).toHaveLength(1); + expect(state().study.cards[0]!.schedule.repetitions).toBe(1); + }); + + it("clears erased drafts and their close guard when a replacement epoch arrives", async () => { + const { store, erase } = memoryStore(true); + render(); + await openDeck(); + fireEvent.click(screen.getByRole("button", { name: "Add card" })); + fireEvent.change(screen.getByLabelText("Front"), { + target: { value: "Private erased draft" }, + }); + expect(closeState()).toBe("draft"); + act(() => { + erase(); + // Assert inside the observer turn, before any async read can settle. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect( + screen.queryByDisplayValue("Private erased draft"), + ).not.toBeInTheDocument(); + expect(checkClose()).toBe("allowed"); + }); + await screen.findByText("No decks yet"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect( + screen.queryByDisplayValue("Private erased draft"), + ).not.toBeInTheDocument(); + expect(closeState()).toBe("allowed"); + }); + it("creates actual decks and reversed cards, retaining a failed draft and close guard", async () => { + const { store, update, state } = memoryStore(); + render(); + await screen.findByText("No decks yet"); + fireEvent.click(screen.getAllByRole("button", { name: "New deck" })[0]!); + const deckDialog = screen.getByRole("dialog", { name: "New deck" }); + fireEvent.change(within(deckDialog).getByLabelText("Name"), { + target: { value: "My deck" }, + }); + expect(closeState()).toBe("draft"); + fireEvent.click( + within(deckDialog).getByRole("button", { name: "Create deck" }), + ); + await screen.findByRole("heading", { name: "My deck" }); + expect(state().study.decks).toHaveLength(1); + expect(closeState()).toBe("allowed"); + fireEvent.click(screen.getAllByRole("button", { name: "Add card" })[0]!); + const dialog = screen.getByRole("dialog", { name: "Add card" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Reversed" })); + fireEvent.change(within(dialog).getByLabelText("Front"), { + target: { value: "Glycolysis" }, + }); + fireEvent.change(within(dialog).getByLabelText("Back"), { + target: { value: "Breaks glucose into pyruvate" }, + }); + update.mockRejectedValueOnce(new Error("Storage full")); + fireEvent.click(within(dialog).getByRole("button", { name: "Add card" })); + await within(dialog).findByText( + "Couldn’t save this card. Your draft is still here. Try again.", + ); + expect(within(dialog).getByLabelText("Front")).toHaveValue("Glycolysis"); + expect(state().study.cards).toHaveLength(0); + expect(closeState()).toBe("draft"); + fireEvent.click(within(dialog).getByRole("button", { name: "Add card" })); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + expect(state().study.cards).toHaveLength(2); + expect(screen.getByText("2 cards · 2 due")).toBeVisible(); + expect(closeState()).toBe("allowed"); + }); + + it("reveals, grades once, persists its schedule, and undoes the actual review", async () => { + const { store, update, state } = memoryStore(true); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + expect(screen.queryByText("In the cytoplasm")).not.toBeInTheDocument(); + const view = screen.getByRole("region", { name: "Study" }); + fireEvent.keyDown(view, { key: "1" }); + expect(update).not.toHaveBeenCalled(); + fireEvent.keyDown(view, { key: " " }); + await finishReveal(); + expect(screen.getByText("In the cytoplasm")).toBeVisible(); + fireEvent.keyDown(view, { key: "3" }); + fireEvent.keyDown(view, { key: "3", repeat: true }); + await screen.findByRole("heading", { name: "Done for today" }); + expect(update).toHaveBeenCalledTimes(1); + expect(state().study.events).toHaveLength(1); + expect(state().study.cards[0]!.schedule.state).toBe("review"); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + await screen.findByLabelText("Card answer"); + expect(state().study.events[0]!.undoneAt).not.toBeNull(); + expect(state().study.cards[0]!.schedule.state).toBe("new"); + expect(screen.getByText("1 of 1")).toBeVisible(); + }); + + it("keeps a revealed answer and queue position when grading fails", async () => { + const { store, update, state } = memoryStore(true); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Study 1 card" }), + ); + await revealAnswer(); + update.mockRejectedValueOnce(new Error("Write failed")); + fireEvent.click(screen.getByRole("button", { name: /^Good/ })); + await screen.findByText( + "Couldn’t confirm this review. Reload to check whether it was saved.", + ); + expect(screen.getByLabelText("Card answer")).toBeVisible(); + expect(screen.getByText("1 of 1")).toBeVisible(); + expect(state().study.events).toHaveLength(0); + fireEvent.click(screen.getByRole("button", { name: "Reload" })); + await waitFor(() => + expect(screen.getByRole("button", { name: /^Good/ })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole("button", { name: /^Good/ })); + await screen.findByRole("heading", { name: "Done for today" }); + expect(state().study.events).toHaveLength(1); + }); + + it("requires an explicit reload after CAS conflict without replaying the draft", async () => { + const { store, update, state } = memoryStore(true); + render(); + await openDeck(); + fireEvent.click(screen.getByRole("button", { name: "Add card" })); + const dialog = screen.getByRole("dialog", { name: "Add card" }); + fireEvent.change(within(dialog).getByLabelText("Front"), { + target: { value: "My question" }, + }); + fireEvent.change(within(dialog).getByLabelText("Back"), { + target: { value: "My answer" }, + }); + update.mockRejectedValueOnce(new CollectionsRevisionConflictError(state())); + fireEvent.click(within(dialog).getByRole("button", { name: "Add card" })); + await within(dialog).findByText( + "Study changed in another window. Your draft is still here. Reload before trying again.", + ); + expect(within(dialog).getByLabelText("Front")).toHaveValue("My question"); + fireEvent.click(within(dialog).getByRole("button", { name: "Reload" })); + await waitFor(() => + expect( + within(dialog).queryByRole("button", { name: "Reload" }), + ).not.toBeInTheDocument(), + ); + expect(update).toHaveBeenCalledTimes(1); + expect(state().study.entries).toHaveLength(1); + fireEvent.click(within(dialog).getByRole("button", { name: "Add card" })); + await waitFor(() => expect(state().study.entries).toHaveLength(2)); + }); + + it("validates cloze blanks and derives actual question and answer cards", async () => { + const { store, state } = memoryStore(true); + render(); + await openDeck(); + fireEvent.click(screen.getByRole("button", { name: "Add card" })); + const dialog = screen.getByRole("dialog", { name: "Add card" }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Fill in the blank" }), + ); + fireEvent.change(within(dialog).getByLabelText("Text"), { + target: { value: "The capital is {{capital}}." }, + }); + fireEvent.change(within(dialog).getByLabelText("Answer 1"), { + target: { value: "Paris" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Add card" })); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Give each blank a unique ID", + ); + fireEvent.change(within(dialog).getByLabelText("Blank ID 1"), { + target: { value: "capital" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Add card" })); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + expect(state().study.cards).toHaveLength(2); + expect( + screen.getByRole("button", { name: "The capital is [...]." }), + ).toBeVisible(); + }); + + it("loads progress from actual reviews and persists hiding the streak", async () => { + const { store, state } = memoryStore(true); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Progress" })); + expect(screen.getByText("Streak 0 days")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Hide streak" })); + await screen.findByRole("button", { name: "Show streak" }); + expect(state().study.preferences.showStreak).toBe(false); + expect(screen.queryByText("Streak 0 days")).not.toBeInTheDocument(); + }); +});