Files
HealthLog/e2e/documents.spec.ts
Marc-André Bombeck 2e14b2d8b5 fix(documents): wait out the upload route's busy answer instead of failing
The inbound route admits two uploads per person (four per process) and
answers the next with 429 `uploadBusy` and `Retry-After: 1`. The vault
queue sent three at once, so a multi-file drop provoked that answer
itself, and the card read it as the hourly limit: "Try again in a few
minutes".

The queue now runs at `DOCUMENT_UPLOAD_PER_USER_CONCURRENCY`, moved to
`inbound-constants.ts` so client and route share one number. A busy
answer is classified on its own, carries its Retry-After, and is retried
for the same file (Retry-After clamped to 1-10 s plus jitter, at most six
attempts within 30 s) while the row stays in its uploading state. Only a
spent budget shows an error, with copy that says the server was busy.
Unmounting aborts any pending wait. The Coach's new-file attach uses the
same retry. The hourly-limit copy now names the hour.
2026-11-06 12:35:25 +01:00

948 lines
37 KiB
TypeScript

/**
* 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<Page["locator"]>,
maxSteps = 40,
): Promise<void> {
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<void> {
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<</Type/Catalog>>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<void> {
const fulfil = (
route: Parameters<Parameters<Page["route"]>[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);
});
});