Files
HealthLog/e2e/documents-ai.spec.ts
T
Marc-André Bombeck e5de72cae2 feat(documents): hide manual AI actions under auto-read, trim suggestion chrome
When "read documents automatically with AI" is on, the detail sheet no longer
shows the manual per-document action row (Read / Suggest / Summarise) — reading
already happens on upload, so the buttons only nagged. The section header and
the searchable status pill stay, and content search keeps working. With the
setting off the manual controls render exactly as before.

Drop the "review before saving" heading and its grey sub-caption from the
suggestion panel; the reviewable fields and the apply / dismiss controls stay.
Remove the now-unused documents.assist.reviewTitle / reviewHint keys from every
locale.
2026-07-09 17:27:47 +02:00

437 lines
16 KiB
TypeScript

/**
* Document vault — the P2 AI-assist + content-search acceptance suite.
*
* Pins the review-first contract (a suggestion prefills an editable field and
* is only ever written on an explicit save — never auto-committed), the
* session-only summary panel (transient, carrying the "not saved · not a
* diagnosis" note), whole-word content search reaching a body-only word through
* the real blind-token GIN union, the corpus "index all" backfill CTA, the calm
* provider-unavailable pointer (no provider → no assist button, a settings
* link instead), and the text-mode image-only refusal that fires client-side
* before any OCR/upload work.
*
* The seeded demo user has NO AI provider, so the unavailable state is the
* default; the enabled states are driven by `page.route` mocks of `usage` +
* the OCR capability probe + the AI endpoints. Content search uses NO mock — it
* seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives
* the real list union.
*/
import { expect, test, type Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
AI_PROBE_DOC_ID,
CONTENT_DOC_ID,
CONTENT_BODY_WORD,
} from "./setup/vault-fixture";
/** The vault usage DTO the UI reads before offering upload + AI affordances. */
function usageBody(overrides: {
assistAvailable: boolean;
contentIndex: { enabled: boolean; indexedCount: number; totalCount: number };
}) {
return {
data: {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: 26_214_400,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: overrides.assistAvailable,
contentIndex: overrides.contentIndex,
},
error: null,
};
}
/** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */
async function fulfilJson(
route: Parameters<Parameters<Page["route"]>[1]>[0],
body: unknown,
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Mock the AI-availability surface: `usage` (assist + content-index flags) and
* the OCR capability probe (transport mode). Endpoints (suggest/summary/reindex)
* are mocked per-test on top of this.
*/
async function mockAiEnabled(
page: Page,
opts: {
mode: "vision" | "text";
pdfSupported?: boolean;
contentIndex?: {
enabled: boolean;
indexedCount: number;
totalCount: number;
};
},
): Promise<void> {
const contentIndex = opts.contentIndex ?? {
enabled: true,
indexedCount: 0,
totalCount: 1,
};
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, usageBody({ assistAvailable: true, contentIndex })),
);
await page.route("**/api/labs/ocr/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: opts.mode,
reason: null,
pdfSupported: opts.pdfSupported ?? opts.mode === "vision",
},
error: null,
}),
);
// v1.27.31 — the document AI section now probes the document-scoped
// capability endpoint (provider order + vendor-blind egress class), not the
// labs OCR probe. Mirror the same availability so the AI actions render;
// egress-notice-specific tests override this route afterwards.
await page.route("**/api/documents/inbound/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: opts.mode,
reason: null,
pdfSupported: opts.pdfSupported ?? opts.mode === "vision",
egress: "external",
},
error: null,
}),
);
// The detail sheet reads the auto-AI-read opt-in to decide whether to show
// the manual per-document action row. Default it OFF here so the manual
// Read / Suggest / Summarise actions render for these assertions; an unmocked
// fetch would leave the sheet reading a live value.
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
}
test.describe("document vault — AI assist + content search", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultAiFixture();
});
// These AI flows drive several mocked provider round-trips per test; on a
// loaded CI runner they race the default 30s, so the whole group earns the
// tripled timeout rather than flaking green paths.
test.beforeEach(() => {
test.slow();
});
// ── (a) Review-first assist: prefill → edit → save, nothing auto-committed ──
test("assist prefills an editable draft that only saves on commit", async ({
page,
}) => {
await mockAiEnabled(page, { mode: "vision" });
const suggestedTitle = `Suggested lab report ${Date.now()}`;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/suggest`,
(route) =>
fulfilJson(route, {
data: {
suggestions: {
title: suggestedTitle,
kind: "LAB_RESULT",
documentDate: "2025-03-03",
},
},
error: null,
}),
);
// Count PATCH writes to this document — a review-first draft must not fire
// one until the user commits.
let patchCount = 0;
page.on("request", (req) => {
if (
req.method() === "PATCH" &&
req.url().includes(`/api/documents/inbound/${AI_PROBE_DOC_ID}`)
) {
patchCount += 1;
}
});
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const suggest = sheet.locator('[data-slot="assist-suggest"]');
await expect(suggest).toBeEnabled();
await suggest.click();
const review = sheet.locator('[data-slot="assist-suggestion-review"]');
await expect(review).toBeVisible();
// Applying the title seeds the EDITABLE field only — no write yet.
await review.getByRole("button", { name: "Use title" }).click();
const titleInput = sheet.locator("#document-title-input");
await expect(titleInput).toHaveValue(suggestedTitle);
expect(patchCount).toBe(0);
// Commit (blur/Enter) → the write fires exactly once, carrying the title.
const saved = page.waitForRequest(
(req) =>
req.method() === "PATCH" &&
req.url().includes(`/api/documents/inbound/${AI_PROBE_DOC_ID}`),
);
await titleInput.press("Enter");
const savedReq = await saved;
expect(JSON.parse(savedReq.postData() ?? "{}").title).toBe(suggestedTitle);
expect(patchCount).toBe(1);
});
// ── (b) Session-only summary: transient, note present, closing discards ──
test("summary panel is transient and discards on close", async ({ page }) => {
await mockAiEnabled(page, { mode: "vision" });
const summaryText = "A chest imaging report issued by a radiology clinic.";
let summaryCalls = 0;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/summary**`,
(route) => {
summaryCalls += 1;
return fulfilJson(route, {
data: { summary: summaryText },
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Summarise" }).click();
const panel = sheet.locator('[data-slot="document-summary-panel"]');
await expect(panel).toBeVisible();
await expect(panel.getByText(summaryText)).toBeVisible();
expect(summaryCalls).toBe(1);
// Closing discards — the panel is gone and nothing lingers.
await panel.getByRole("button", { name: "Hide" }).click();
await expect(panel).not.toBeVisible();
});
// ── (c) Content search reaches a BODY-ONLY word through the token union ──
test("content search finds a word that lives only in the document body", async ({
page,
}) => {
await page.goto("/documents");
await expect(
page.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// The word is absent from every title/filename — the only route is the
// real blind-token union over the seeded content index.
await page
.getByRole("searchbox", { name: "Search documents" })
.fill(CONTENT_BODY_WORD);
const card = page.getByRole("button", { name: "Open Radiology note" });
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`));
// The matched card advertises its searchable body.
const searchable = page
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
.locator('[data-slot="document-searchable"]');
await expect(searchable).toBeVisible();
// Sanity: the word really is absent from the short fields (server-side
// confirmation the union — not an ILIKE — did the work).
const viaApi = await page.request.get(
`/api/documents/inbound?q=${CONTENT_BODY_WORD}&limit=10`,
);
const body = await viaApi.json();
const hit = body.data.documents.find(
(d: { id: string }) => d.id === CONTENT_DOC_ID,
);
expect(hit).toBeTruthy();
expect(`${hit.title} ${hit.filename}`.toLowerCase()).not.toContain(
CONTENT_BODY_WORD,
);
});
// ── (d) Corpus "index all" backfill CTA ──────────────────────────────────
test("the index-all CTA enqueues the corpus backfill", async ({ page }) => {
await mockAiEnabled(page, {
mode: "vision",
contentIndex: { enabled: true, indexedCount: 1, totalCount: 10 },
});
let reindexCalls = 0;
await page.route("**/api/documents/inbound/reindex", (route) => {
reindexCalls += 1;
return fulfilJson(route, { data: { enqueued: 5 }, error: null });
});
await page.goto("/documents");
const hint = page.locator('[data-slot="content-search-hint"]');
await expect(hint).toBeVisible();
const indexAll = page.locator('[data-slot="content-index-all"]');
await expect(indexAll).toBeVisible();
await indexAll.click();
await expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible();
expect(reindexCalls).toBe(1);
});
// ── (e) No-provider degradation: calm pointer, no assist button ──────────
test("with no provider the detail sheet shows a calm settings pointer", async ({
page,
}) => {
// No mocks — the seeded demo user has no AI provider configured.
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await expect(
sheet.locator('[data-slot="assist-unavailable"]'),
).toBeVisible();
await expect(
sheet.getByRole("link", { name: "Open AI settings" }),
).toBeVisible();
// The action button is absent — the endpoint would 422, so the UI never
// offers it.
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
// ── (g) "Read with AI": the prominent action maps to the index endpoint ──
test('the "Read with AI" action reads a document and confirms', async ({
page,
}) => {
await mockAiEnabled(page, { mode: "vision" });
// AI_PROBE_DOC_ID carries no content index → the action reads "Read with AI"
// (not "Read again") and the status pill is the calm "not searchable yet".
let indexCalls = 0;
let indexHadJsonBody = false;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/index`,
(route) => {
indexCalls += 1;
// VISION mode posts NO JSON body — the endpoint 422s a text body without
// the local-OCR opt-in, so the UI must never send one on this path.
indexHadJsonBody = Boolean(route.request().postData());
return fulfilJson(route, {
data: { documentId: AI_PROBE_DOC_ID, indexed: true, tokenCount: 12 },
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The prominent action is present and labelled for a first read.
const read = sheet.locator('[data-slot="document-read-ai"]');
await expect(read).toBeVisible();
await expect(read).toHaveText(/Read with AI/);
// The status pill starts at the calm "not searchable yet".
const status = sheet.locator('[data-slot="content-search-status"]');
await expect(status).toHaveAttribute("data-state", "none");
await read.click();
await expect(page.getByText("The AI read your document.")).toBeVisible();
expect(indexCalls).toBe(1);
expect(indexHadJsonBody).toBe(false);
});
// ── (h) Provenance: a provider-read document is marked "Read by AI" ──────
test("a vision-indexed document surfaces the AI-read provenance", async ({
page,
}, testInfo) => {
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
// No mocks: the real list/detail GET threads the provenance through.
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The detail status pill reflects the AI-read source — asserted on every
// project (this is the provenance contract; it holds on desktop + mobile).
const status = sheet.locator('[data-slot="content-search-status"]');
await expect(status).toHaveAttribute("data-state", "ai-read");
await expect(status).toHaveText(/Read by AI/);
// The timeline card carries the same AI-read marker — desktop only. The
// vault timeline is virtualized; on the narrow mobile viewport the seeded
// card renders outside the initial virtual window (not in the DOM), so a
// card-level attribute read there tests the virtualizer, not the marker.
// The marker's render logic is viewport-independent and covered by the SSR
// card test + this desktop assertion; mobile provenance is already proven
// by the status pill above.
if (testInfo.project.name !== "chromium-mobile") {
await page.keyboard.press("Escape");
const marker = page
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
.locator('[data-slot="document-searchable"]')
.first();
await expect(marker).toHaveAttribute("data-source", "ai-read", {
timeout: 20_000,
});
}
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
test("text-mode assist refuses a PDF client-side before any request", async ({
page,
}) => {
await mockAiEnabled(page, { mode: "text", pdfSupported: false });
let suggestCalls = 0;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/suggest`,
(route) => {
suggestCalls += 1;
return fulfilJson(route, {
data: {
suggestions: { title: null, kind: null, documentDate: null },
},
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const suggest = sheet.locator('[data-slot="assist-suggest"]');
await expect(suggest).toBeEnabled();
await suggest.click();
// The PDF is refused locally (tesseract reads images only) — the error
// shows and the server suggest route is never called.
await expect(
sheet.getByText("Local OCR reads images only — not this file."),
).toBeVisible();
await page.waitForTimeout(300);
expect(suggestCalls).toBe(0);
});
});