/** * Document vault — the release acceptance suite. * * Pins the doctor-flow script (three independent routes to one document: * type chip, condition chip, text search), the URL deep-link contract * (`?kind` / `?episode` / `?year` / `?doc`), delete→undo, the bulk flows * (50-item kind change, partial failure, the >100 client-side chunking), * keyboard-only navigation, page-wide drop + clipboard paste intake, the * duplicate-upload contract, the admin per-file cap (commit-on-Enter + * rejection copy naming the limit), and axe scans over the timeline, the * open detail sheet, and bulk mode. * * Fixture: `e2e/setup/vault-fixture.ts` seeds the 60-document corpus (plus * the "Knie" episode and the linked MRT trio) straight through Postgres — * idempotent, so the desktop and mobile projects can both (re-)run it. * Mutating tests operate on their own seeded namespaces or on documents * they create, never on the shared doctor-flow corpus. */ import AxeBuilder from "@axe-core/playwright"; import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { E2E_VAULT, VAULT_STORAGE_STATE_PATH } from "./setup/global-setup"; import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, seedNamespaceDocs, vaultFixtureIds, } from "./setup/vault-fixture"; // The vault on an account of its own (`E2E_VAULT`): its layout is account // state, and the month-grid tests read the default. const { knieEpisodeId: KNIE_EPISODE_ID, mrtDocId: MRT_DOC_ID } = vaultFixtureIds(E2E_VAULT.username); const DEFAULT_MAX_FILE_BYTES = 26_214_400; /** Scroll the shell's scroll container until `locator` is visible (the * timeline is virtualized — off-window rows are not in the DOM at all). */ async function scrollUntilVisible( page: Page, locator: ReturnType, maxSteps = 40, ): Promise { for (let i = 0; i < maxSteps; i++) { if (await locator.isVisible().catch(() => false)) return; await page .locator("#main-content") .evaluate((el) => el.scrollBy(0, el.clientHeight)); await page.waitForTimeout(150); } await expect(locator).toBeVisible(); } /** The invisible whole-card open button for a document titled `title`. */ function openButton(page: Page, title: string) { return page.getByRole("button", { name: `Open ${title}` }).first(); } /** The card checkbox for a document titled `title`. */ function selectBox(page: Page, title: string) { return page.getByRole("checkbox", { name: `Select ${title}` }).first(); } /** Upload `bytes` through the picker input and return when queued. */ async function uploadViaPicker( page: Page, name: string, bytes: Buffer, mimeType = "application/pdf", ): Promise { await page .locator('[data-slot="document-upload-zone"] input[type="file"]') .setInputFiles([{ name, mimeType, buffer: bytes }]); } function uniquePdf(marker: string): Buffer { return Buffer.from( `%PDF-1.4\n%${marker}-${Date.now()}-${Math.random()}\n1 0 obj<>endobj\n%%EOF\n`, "utf8", ); } async function runAxe(page: Page) { const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); const blocking = results.violations.filter( (v) => v.impact === "serious" || v.impact === "critical", ); if (blocking.length > 0) { // Pretty-print so failures are actionable in CI logs. console.log( "axe violations:\n" + blocking .map( (v) => ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes .map((n) => n.target.join(" ")) .join("\n ")}`, ) .join("\n"), ); } return blocking; } async function mockDocumentCoach(page: Page): Promise { const fulfil = ( route: Parameters[1]>[0], data: unknown, ) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data, error: null }), }); await page.route("**/api/documents/inbound/usage", (route) => fulfil(route, { usedBytes: 4096, quotaBytes: 1_073_741_824, maxFileBytes: DEFAULT_MAX_FILE_BYTES, acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], linkedEpisodes: [], assistAvailable: true, contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 }, }), ); await page.route("**/api/documents/inbound/capability", (route) => fulfil(route, { available: true, mode: "vision", reason: null, pdfSupported: true, egress: "external", }), ); await page.route("**/api/auth/me/documents-auto-ai-read", (route) => fulfil(route, { documentsAutoAiRead: false }), ); } test.describe("document vault", () => { // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite // has no reachable provider; every capability reads available here so the // mocked AI routes below are what the surfaces talk to. test.beforeEach(async ({ page }) => { await serveAiBlock(page, aiBlockAvailable()); }); test.use({ storageState: VAULT_STORAGE_STATE_PATH }); test.beforeAll(async () => { await ensureVaultFixture(E2E_VAULT.username); }); // ── v1.42 — the archive as a month grid of preview tiles ────────────── for (const [width, columns] of [ [390, 2], [1440, 4], ] as const) { test(`the archive is a month grid of preview tiles, ${columns} across at ${width} px`, async ({ page, }) => { await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); await page.goto("/documents"); const firstRow = page.locator('[data-slot="document-month-row"]').first(); await expect(firstRow).toBeVisible({ timeout: 15_000 }); // The months run side by side by default: a month is marked above // its first document instead of taking a heading row of its own. await expect( page.locator('[data-slot="document-timeline"]'), ).toHaveAttribute("data-arrangement", "flow"); await expect( page.locator('[data-slot="document-flow-month"]').first(), ).toBeVisible(); // Tiles with the preview on top, and at most `columns` side by side: // the fullest mounted row holds exactly that many, on one line. const tiles = page.locator( '[data-slot="document-month-row"] [data-slot="document-card"]', ); await expect(tiles.first()).toHaveAttribute("data-variant", "tile"); await expect( tiles.first().locator('[data-slot="document-preview"]'), ).toBeVisible(); const rows = await page .locator('[data-slot="document-month-row"]') .evaluateAll((els) => els.map((row) => Array.from(row.querySelectorAll('[data-slot="document-card"]')).map( (el) => Math.round(el.getBoundingClientRect().top), ), ), ); const fullest = rows.reduce((a, b) => (b.length > a.length ? b : a), []); expect(fullest.length).toBe(columns); expect(new Set(fullest).size).toBe(1); // Nothing on the page scrolls sideways. const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(overflow).toBeLessThanOrEqual(0); }); } // ── §1A — the 30-second doctor flow, three independent routes ───────── test("doctor flow A: the type filter reaches the MRT report", async ({ page, }) => { await page.goto("/documents"); await expect( page.getByRole("heading", { name: "Documents" }), ).toBeVisible(); // Open the type dropdown, tick Imaging (multi-select stays open), then // dismiss the menu. await page.getByRole("button", { name: "All types" }).click(); await page.getByRole("menuitemcheckbox", { name: "Imaging" }).click(); await page.keyboard.press("Escape"); // Autumn 2025 sits below the newest sections — scroll the window down. await scrollUntilVisible(page, openButton(page, "MRT Knie")); await openButton(page, "MRT Knie").click(); const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); await expect(sheet.getByText("MRT Knie").first()).toBeVisible(); // Inline preview (Class A PDF) mounts against the serve route. await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); }); test("a PDF opens in a new browser tab from the fetched bytes", async ({ page, }) => { // Headless Chromium has no PDF viewer and turns a PDF tab into a // download, so the tab itself is not observable here. Record what the // control hands to `window.open` instead, and read the bytes behind it. await page.addInitScript(() => { const calls: unknown[][] = []; (window as unknown as { __opened: unknown[][] }).__opened = calls; window.open = (...args: unknown[]) => { calls.push(args); return null; }; }); await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); await openButton(page, "MRT Knie").click(); const sheet = page.getByRole("dialog", { name: "MRT Knie" }); await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); const open = sheet.locator('[data-slot="document-open-in-tab"]'); // Enabled once the preview's bytes are in hand. await expect(open).toBeEnabled(); if (process.env.E2E_SHOT_DIR) { await page.screenshot({ path: `${process.env.E2E_SHOT_DIR}/open-in-tab-${test.info().project.name}.png`, }); } await open.click(); const opened = await page.evaluate( () => (window as unknown as { __opened: string[][] }).__opened, ); // The object URL the preview already shows, not the serve route: a bare // navigation would carry no record-session assertion. const previewSrc = await sheet.locator("iframe").getAttribute("src"); expect(previewSrc).toMatch(/^blob:/); expect(opened).toEqual([[previewSrc, "_blank", "noopener"]]); // The sheet stays where it was. await expect(sheet).toBeVisible(); }); test("doctor flow B: the condition filter reaches the MRT report", async ({ page, }) => { await page.goto("/documents"); // Open the condition dropdown and pick the linked episode (single-select // → the menu closes on choice). await page.locator('[data-slot="document-condition-filter"]').click(); await page .getByRole("menuitemcheckbox", { name: "Knie", exact: true }) .click(); // The Knie filter narrows the corpus to the linked MRT trio. await expect(openButton(page, "MRT Knie")).toBeVisible(); await expect(openButton(page, "MRT Aufnahme 1")).toBeVisible(); await openButton(page, "MRT Knie").click(); await expect(page.getByRole("dialog")).toBeVisible(); }); test("doctor flow C: text search reaches the MRT report", async ({ page, }) => { await page.goto("/documents"); await page.getByRole("searchbox", { name: "Search documents" }).fill("MRT"); // 200 ms debounce → URL → refetch. await expect(openButton(page, "MRT Knie")).toBeVisible(); await openButton(page, "MRT Knie").click(); await expect(page.getByRole("dialog")).toBeVisible(); await expect(page).toHaveURL(/q=MRT/); }); // ── URL deep links ───────────────────────────────────────────────────── test("deep links land filtered: ?kind, ?year, ?episode, ?doc", async ({ page, }) => { await page.goto("/documents?kind=IMAGING&year=2025"); // A deep-linked facet surfaces on its dropdown trigger label. await expect( page.locator('[data-slot="document-type-filter"]'), ).toContainText("Imaging"); await expect( page.locator('[data-slot="document-year-filter"]'), ).toContainText("2025"); await scrollUntilVisible(page, openButton(page, "MRT Knie")); await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`); await expect(openButton(page, "MRT Knie")).toBeVisible(); await expect(openButton(page, "MRT Aufnahme 2")).toBeVisible(); // `?doc=` opens the detail sheet; closing it strips the param. await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&doc=${MRT_DOC_ID}`); const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); await expect(sheet.getByText("MRT Knie").first()).toBeVisible(); await page.keyboard.press("Escape"); await expect(sheet).not.toBeVisible(); await expect(page).not.toHaveURL(/doc=/); }); test("ordinary card selection owns doc history and preserves unrelated query state", async ({ page, }) => { await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); await expect(openButton(page, "MRT Knie")).toBeVisible(); await openButton(page, "MRT Knie").click(); const detail = page.getByRole("dialog", { name: "MRT Knie" }); // The sheet is CLOSED is a different claim from the sheet is showing this // document, and only one of them can be made by a name-matched locator. // // The sheet takes its accessible name from the document title and falls // back to "Untitled document" while the detail read is pending, so // `expect(detail).not.toBeVisible()` also passes on an OPEN sheet that has // gone back to its skeleton — measured, not theorised: a slow detail read // reports the named dialog invisible and the unnamed one visible at the // same instant. That resolves immediately instead of waiting, which is the // wait this test needs after a history traversal, and the assertion that // depended on it then reads a URL the app has not finished with. The // deep-link test above already uses the unnamed role for this reason. const sheet = page.getByRole("dialog"); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBe(MRT_DOC_ID); expect(new URL(page.url()).searchParams.get("episode")).toBe( KNIE_EPISODE_ID, ); expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); await page.goBack(); await expect(sheet).not.toBeVisible(); expect(new URL(page.url()).searchParams.get("doc")).toBeNull(); expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); await page.goForward(); // Both: the sheet is open AND it is this document, so Escape is pressed on // a settled sheet rather than on one still resolving its title. await expect(sheet).toBeVisible(); await expect(detail).toBeVisible(); await page.keyboard.press("Escape"); await expect(sheet).not.toBeVisible(); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBeNull(); expect(new URL(page.url()).searchParams.get("episode")).toBe( KNIE_EPISODE_ID, ); expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); }); test("Coach close and overlay dismissal consume the document handoff", async ({ page, }) => { await mockDocumentCoach(page); await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); await expect(openButton(page, "MRT Knie")).toBeVisible(); await openButton(page, "MRT Knie").click(); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBe(MRT_DOC_ID); const detail = page.getByRole("dialog", { name: "MRT Knie" }); await detail.locator('[data-slot="document-chat-open"]').click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible(); await expect(detail).not.toBeVisible(); await drawer.locator('[data-slot="coach-drawer-close"]').click(); await expect(drawer).not.toBeVisible(); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBeNull(); expect(new URL(page.url()).searchParams.get("episode")).toBe( KNIE_EPISODE_ID, ); expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); await expect(detail).not.toBeVisible(); await page.reload(); // A reload replaces the document, and the protected shell holds every // child until `/api/auth/me` resolves, so "the sheet is not visible" // right here would be a statement about the hold, not about the handoff // having been consumed. The list row is the first thing that exists only // once the page really rendered, so it goes first. await expect(openButton(page, "MRT Knie")).toBeVisible(); await expect(detail).not.toBeVisible(); expect(new URL(page.url()).searchParams.get("doc")).toBeNull(); await openButton(page, "MRT Knie").click(); await expect(detail).toBeVisible(); await detail.locator('[data-slot="document-chat-open"]').click(); await expect(drawer).toBeVisible(); await page .locator('[data-slot="sheet-overlay"]') .last() .click({ position: { x: 2, y: 2 } }); await expect(drawer).not.toBeVisible(); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBeNull(); await expect(detail).not.toBeVisible(); }); test("Ask Coach maximize then browser back restores the selected document", async ({ page, }) => { await mockDocumentCoach(page); await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); await expect(openButton(page, "MRT Knie")).toBeVisible(); await openButton(page, "MRT Knie").click(); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBe(MRT_DOC_ID); const detail = page.getByRole("dialog", { name: "MRT Knie" }); await detail.locator('[data-slot="document-chat-open"]').click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible(); // The drawer being on screen is not the same event as the vault having // recorded the hand-off. The page books `?doc=` as the way back only once // the detail sheet is actually closed with the drawer owning the same // document; maximize before that and the close path takes the other branch // — `history.back()` — which races the push to `/coach` and lands back on // `/documents` with nothing raised. The sheet's own close control leaves // the tree with the sheet, so its absence is that hand-off, observed. await expect( page.locator('[data-slot="document-detail-close"]'), ).toHaveCount(0); await drawer.locator('[data-slot="coach-drawer-maximize"]').click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/coach"); expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID); await page.goBack(); await expect.poll(() => new URL(page.url()).pathname).toBe("/documents"); await expect(detail).toBeVisible(); const restoredUrl = new URL(page.url()); expect(restoredUrl.searchParams.get("doc")).toBe(MRT_DOC_ID); expect(restoredUrl.searchParams.get("episode")).toBe(KNIE_EPISODE_ID); expect(restoredUrl.searchParams.get("view")).toBe("compact"); expect(restoredUrl.searchParams.get("q")).toBe("MRT"); }); test("the condition page links into the vault and back", async ({ page }) => { await page.goto(`/illness/${KNIE_EPISODE_ID}`); const card = page.locator('[data-slot="episode-documents-card"]'); await expect(card).toBeVisible(); await card.getByRole("link", { name: /MRT Knie/ }).click(); // Lands on the vault, episode-filtered, with the detail sheet open. await expect(page).toHaveURL(/\/documents\?episode=/); await expect(page.getByRole("dialog")).toBeVisible(); }); // ── Delete → undo ────────────────────────────────────────────────────── test("delete then undo restores the document", async ({ page }, testInfo) => { // Unique per run — a stable name would leave a same-named twin from a // previous run in the corpus and break the disappearance assertion. const title = `undo-probe-${testInfo.project.name}-${Date.now()}`; await page.goto("/documents"); await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title)); await page.getByRole("searchbox", { name: "Search documents" }).fill(title); // The uploaded card is optimistic and can paint before the 200 ms search // draft is committed to the URL. Opening it earlier races that pending // replace against the detail's `?doc=` history entry and closes the sheet. await expect .poll(() => new URL(page.url()).searchParams.get("q")) .toBe(title); await expect(openButton(page, `${title}.pdf`)).toBeVisible(); await openButton(page, `${title}.pdf`).click(); const sheet = page.getByRole("dialog", { name: `${title}.pdf` }); await expect(sheet).toBeVisible(); await sheet.getByRole("button", { name: "Delete" }).click(); // v1.42 — the sheet's Delete asks first, like every vault delete. await page.locator('[data-slot="document-detail-delete-confirm"]').click(); // Undo toast → restore → the card returns. // // Addressed to the toast's own attributes, not to its copy. The copy is // i18n-driven and the literal here has already been wrong once — a trailing // full stop moved and the assertion spent its whole timeout looking for a // sentence that no longer existed, on every attempt, on both projects. The // success type is what the delete claims and the action button is what the // undo needs; both are structural. const undo = page.locator( '[data-sonner-toast][data-type="success"] [data-button]', ); await expect(undo).toBeVisible(); await expect(openButton(page, `${title}.pdf`)).not.toBeVisible(); await undo.click(); await expect(openButton(page, `${title}.pdf`)).toBeVisible(); }); // ── Bulk flows (desktop-only: shift-click ranges are a pointer flow) ── test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => { test.skip(isMobile, "range selection is a desktop pointer flow"); test.setTimeout(90_000); await seedNamespaceDocs("bulkfifty", 50, E2E_VAULT.username); // Shared filing date → the namespace orders by id desc: "050" first, // "001" last. Anchor on the first card, shift-click the last. await page.goto("/documents?q=bulkfifty"); await expect(openButton(page, "bulkfifty 050")).toBeVisible({ timeout: 15_000, }); await selectBox(page, "bulkfifty 050").click(); await scrollUntilVisible(page, openButton(page, "bulkfifty 001")); await selectBox(page, "bulkfifty 001").click({ modifiers: ["Shift"] }); const bar = page.locator('[data-slot="document-bulk-bar"]'); await expect(bar).toBeVisible(); await expect(bar.getByText("50 selected")).toBeVisible(); await bar.getByRole("button", { name: "Change type" }).click(); await page.getByRole("menuitem", { name: "Referral" }).click(); await expect(page.getByText("50 document(s) updated")).toBeVisible({ timeout: 15_000, }); // Server state: all 50 now carry the new kind. const res = await page.request.get( "/api/documents/inbound?q=bulkfifty&kind=REFERRAL&limit=100", ); expect(res.status()).toBe(200); const body = await res.json(); expect(body.data.documents).toHaveLength(50); }); test("bulk: a partial failure surfaces per-id results", async ({ page, isMobile, }) => { test.skip(isMobile, "bulk selection is a desktop pointer flow"); await seedNamespaceDocs("bulkpf", 5, E2E_VAULT.username); await page.goto("/documents?q=bulkpf"); for (let i = 1; i <= 5; i++) { const box = selectBox(page, `bulkpf 00${i}`); await scrollUntilVisible(page, box); await box.click(); } await expect(page.getByText("5 selected")).toBeVisible(); // Kill one of the five behind the selection's back (tombstone), so the // bulk call reports exactly one per-id failure. const list = await page.request.get( "/api/documents/inbound?q=bulkpf&limit=10", ); const victim = (await list.json()).data.documents.find( (d: { title: string }) => d.title === "bulkpf 003", ); const del = await page.request.delete( `/api/documents/inbound/${victim.id}`, ); expect(del.status()).toBe(200); const bar = page.locator('[data-slot="document-bulk-bar"]'); await bar.getByRole("button", { name: "Change type" }).click(); await page.getByRole("menuitem", { name: "Insurance" }).click(); await expect(page.getByText("1 of 5 could not be updated")).toBeVisible(); }); test("bulk: a selection above 100 chunks into two requests", async ({ page, isMobile, }) => { test.skip(isMobile, "range selection is a desktop pointer flow"); test.setTimeout(120_000); await seedNamespaceDocs("bulkchunk", 101, E2E_VAULT.username); // Newest-first by id tiebreak: "101" renders first, "001" last. Walk // the infinite query to the end (pages of 50) so the range can span // all 101, then anchor + shift-click. await page.goto("/documents?q=bulkchunk"); await expect(openButton(page, "bulkchunk 101")).toBeVisible(); await selectBox(page, "bulkchunk 101").click(); await scrollUntilVisible(page, openButton(page, "bulkchunk 001"), 80); await selectBox(page, "bulkchunk 001").click({ modifiers: ["Shift"] }); await expect(page.getByText("101 selected")).toBeVisible(); let bulkPosts = 0; page.on("request", (req) => { if ( req.method() === "POST" && req.url().includes("/api/documents/inbound/bulk") ) { bulkPosts += 1; } }); const bar = page.locator('[data-slot="document-bulk-bar"]'); await bar.getByRole("button", { name: "Change type" }).click(); await page.getByRole("menuitem", { name: "Vaccination" }).click(); await expect(page.getByText("101 document(s) updated")).toBeVisible({ timeout: 15_000, }); expect(bulkPosts).toBe(2); }); // ── Keyboard-only doctor flow ────────────────────────────────────────── test("keyboard-only: search, roving grid, Enter opens, Escape closes", async ({ page, isMobile, }) => { test.skip(isMobile, "keyboard navigation is a desktop flow"); await page.goto("/documents"); await expect( page.getByRole("heading", { name: "Documents" }), ).toBeVisible(); // `/` focuses the search from anywhere. await page.keyboard.press("/"); await expect( page.getByRole("searchbox", { name: "Search documents" }), ).toBeFocused(); await page.keyboard.type("MRT"); await expect(openButton(page, "MRT Knie")).toBeVisible(); // Tab from the search into the grid's single roving slot (bounded walk // across the filter dropdowns), then arrows move the active card. let reachedGrid = false; for (let i = 0; i < 40; i++) { await page.keyboard.press("Tab"); const slot = await page.evaluate( () => document.activeElement?.getAttribute("data-slot") ?? "", ); if (slot === "document-open") { reachedGrid = true; break; } } expect(reachedGrid).toBe(true); // Arrow keys move the roving slot; the focus lands asynchronously // (the target row may mount on the next virtualizer paint) — poll. const activeLabel = () => page.evaluate( () => document.activeElement?.getAttribute("aria-label") ?? "", ); const first = await activeLabel(); await page.keyboard.press("ArrowRight"); await expect.poll(activeLabel).not.toBe(first); await page.keyboard.press("ArrowLeft"); await expect.poll(activeLabel).toBe(first); await page.keyboard.press("Enter"); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // Wait for the preview to finish loading — the sheet hands focus back // to the dialog after Chromium's PDF viewer grabs it, so Escape works // regardless of when the user presses it. await expect(dialog.locator("iframe")).toBeVisible(); // True grace (C3): Chromium's PDF viewer steals focus from inside the // iframe and the sheet hands it back — both moves are invisible to the // outer document (activeElement is "the iframe" before AND after the // grab), so there is no condition to poll on. The window only needs to // outlive the focus dance. await page.waitForTimeout(250); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); }); // ── Touch: long-press selects (mobile project only) ──────────────────── test("long-press selects a card on touch", async ({ page, isMobile }) => { test.skip(!isMobile, "long-press is the touch selection gesture"); await page.goto("/documents?q=MRT"); const button = openButton(page, "MRT Knie"); await expect(button).toBeVisible(); await button.dispatchEvent("touchstart"); // Not a wait-for-condition sleep (C3): this IS the gesture — the finger // stays down past the long-press threshold before lifting. await page.waitForTimeout(700); await button.dispatchEvent("touchend"); await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible(); await expect(page.getByText("1 selected")).toBeVisible(); }); // ── Page-wide drop + clipboard paste intake ──────────────────────────── test("dropping a file anywhere on the page uploads it", async ({ page, isMobile, }) => { test.skip(isMobile, "OS file drag is a desktop gesture"); await page.goto("/documents"); await expect( page.getByRole("heading", { name: "Documents" }), ).toBeVisible(); const marker = `drop-probe-${Date.now()}`; const dataTransfer = await page.evaluateHandle( ([name, content]) => { const dt = new DataTransfer(); dt.items.add( new File([content], `${name}.pdf`, { type: "application/pdf" }), ); return dt; }, [marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const, ); await page.dispatchEvent("body", "dragenter", { dataTransfer }); await expect( page.locator('[data-slot="document-drop-overlay"]'), ).toBeVisible(); await page.dispatchEvent("body", "drop", { dataTransfer }); await expect( page.locator('[data-slot="document-drop-overlay"]'), ).not.toBeVisible(); // The stored card lands in the timeline once the upload settles. await page .getByRole("searchbox", { name: "Search documents" }) .fill(marker); await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ timeout: 10_000, }); }); test("pasting a file from the clipboard uploads it", async ({ page, isMobile, }) => { test.skip(isMobile, "clipboard file paste is a desktop gesture"); await page.goto("/documents"); await expect( page.getByRole("heading", { name: "Documents" }), ).toBeVisible(); const marker = `paste-probe-${Date.now()}`; await page.evaluate( ([name, content]) => { const dt = new DataTransfer(); dt.items.add( new File([content], `${name}.pdf`, { type: "application/pdf" }), ); const event = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true, }); window.dispatchEvent(event); }, [marker, `%PDF-1.4\n%${marker}\n%%EOF\n`] as const, ); await page .getByRole("searchbox", { name: "Search documents" }) .fill(marker); await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ timeout: 10_000, }); }); test("a duplicate upload highlights the existing row instead of storing twice", async ({ page, isMobile, }) => { test.skip(isMobile, "runs once on the desktop project (shared bucket)"); await page.goto("/documents"); const marker = `dup-probe-${Date.now()}`; const bytes = uniquePdf(marker); await uploadViaPicker(page, `${marker}.pdf`, bytes); await page .getByRole("searchbox", { name: "Search documents" }) .fill(marker); await expect(openButton(page, `${marker}.pdf`)).toBeVisible(); await uploadViaPicker(page, `${marker}-copy.pdf`, bytes); await expect( page.getByText("Already stored. The existing document is highlighted."), ).toBeVisible(); // One row, not two — the copy never landed. await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible(); }); test("a busy server is waited out and the file still lands", async ({ page, isMobile, }) => { test.skip(isMobile, "runs once on the desktop project (shared bucket)"); // The route answers the first POST the way a full upload slot does; the // queue must retry it after Retry-After instead of showing an error. let refused = 0; await page.route("**/api/documents/inbound", async (route) => { if (route.request().method() === "POST" && refused === 0) { refused += 1; await route.fulfill({ status: 429, headers: { "Retry-After": "1", "Content-Type": "application/json" }, body: JSON.stringify({ data: null, error: "Too many uploads are already in progress.", meta: { errorCode: "documents.inbound.uploadBusy", reason: "uploadBusy", }, }), }); return; } await route.fallback(); }); await page.goto("/documents"); const marker = `busy-probe-${Date.now()}`; await uploadViaPicker(page, `${marker}.pdf`, uniquePdf(marker)); await page .getByRole("searchbox", { name: "Search documents" }) .fill(marker); await expect(openButton(page, `${marker}.pdf`)).toBeVisible({ timeout: 15_000, }); expect(refused).toBe(1); await expect( page.locator('[data-slot="document-upload-card"]'), ).toHaveCount(0); }); // ── Admin cap: commit-on-Enter + rejection copy naming the limit ────── test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({ page, isMobile, }) => { test.skip(isMobile, "admin surface is a desktop flow"); try { await page.goto("/admin/general"); const capInput = page.getByRole("spinbutton", { name: "Document upload limit", }); await expect(capInput).toBeEnabled(); await capInput.fill("1"); const saved = page.waitForResponse( (res) => res.url().includes("/api/admin/settings") && res.request().method() === "PUT", ); await capInput.press("Enter"); expect((await saved).status()).toBe(200); await page.goto("/documents"); await expect( page.getByRole("heading", { name: "Documents" }), ).toBeVisible(); // ~1.2 MB — over the 1 MB cap, far under the default. const big = Buffer.concat([ Buffer.from("%PDF-1.4\n"), Buffer.alloc(1_200_000, 0x20), ]); await uploadViaPicker(page, "oversized-scan.pdf", big); await expect( page.getByText("This file is larger than the 1 MB limit."), ).toBeVisible(); } finally { const restore = await page.request.put("/api/admin/settings", { data: { documentMaxFileBytes: DEFAULT_MAX_FILE_BYTES }, }); expect(restore.status()).toBe(200); } }); // ── Accessibility ────────────────────────────────────────────────────── test("axe: the vault timeline has no serious or critical violations", async ({ page, }) => { await page.goto("/documents"); await expect( page.locator('[data-slot="document-card"]').first(), ).toBeVisible({ timeout: 15_000 }); await page.waitForLoadState("networkidle"); const blocking = await runAxe(page); expect(blocking).toHaveLength(0); }); test("axe: the open detail sheet has no serious or critical violations", async ({ page, isMobile, }) => { test.skip(isMobile, "scanned once on the desktop project"); await page.goto(`/documents?doc=${MRT_DOC_ID}`); await expect(page.getByRole("dialog")).toBeVisible(); await page.waitForLoadState("networkidle"); const blocking = await runAxe(page); expect(blocking).toHaveLength(0); }); test("axe: bulk mode has no serious or critical violations", async ({ page, isMobile, }) => { test.skip(isMobile, "scanned once on the desktop project"); await page.goto("/documents?q=MRT"); await expect(openButton(page, "MRT Knie")).toBeVisible(); await selectBox(page, "MRT Knie").click(); await expect(page.locator('[data-slot="document-bulk-bar"]')).toBeVisible(); const blocking = await runAxe(page); expect(blocking).toHaveLength(0); }); });