/** * Document vault — the P4 "chat about this document" acceptance suite. * * Pins the scoped chat that lives in the detail sheet's AI area: the entry is * offered only when a provider is available AND the document is content-indexed; * a non-indexed document shows a calm read-it-first hint (never an error); a * conversation streams the assistant reply and settles it as plain text with the * always-on "not medical advice" safety note; an injection-laced document is * described, never obeyed, and any HTML the model emits renders inert (the XSS * posture); and with NO provider the AI area collapses to the settings pointer * with no chat surface at all. * * v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the * chat history GET, the streaming chat POST), so every one is mocked here — * otherwise the panel never renders and the specs time out waiting for elements. * The provider round-trips are mocked, so the SSE reply is deterministic. The * seeded demo user has no AI provider, so the no-provider state is the default. */ 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, } from "./setup/vault-fixture"; /** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */ async function fulfilJson( route: Parameters[1]>[0], body: unknown, ) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body), }); } /** The vault usage DTO the sheet reads to gate the AI affordances. */ function usageBody() { return { data: { usedBytes: 4096, quotaBytes: 1_073_741_824, maxFileBytes: 26_214_400, acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], linkedEpisodes: [], assistAvailable: true, contentIndex: { enabled: true, indexedCount: 1, totalCount: 2 }, }, error: null, }; } /** Serialise SSE frames the way the chat route emits them. */ function sse(frames: Array>): string { return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""); } /** * Mock the AI-availability surface (usage + the document-scoped capability * probe) so the sheet offers the AI area and the chat entry. `egress` drives the * vendor-blind notice; default external. */ async function mockAiEnabled( page: Page, opts: { egress?: "local" | "external" } = {}, ): Promise { await page.route("**/api/documents/inbound/usage", (route) => fulfilJson(route, usageBody()), ); await page.route("**/api/documents/inbound/capability", (route) => fulfilJson(route, { data: { available: true, mode: "vision", reason: null, pdfSupported: true, egress: opts.egress ?? "external", }, error: null, }), ); // The detail sheet reads the auto-AI-read opt-in; default it OFF so the AI // section renders its full set of controls alongside the chat entry. await page.route("**/api/auth/me/documents-auto-ai-read", (route) => fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }), ); } test.describe("document vault — chat about a document", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { await ensureVaultFixture(); await ensureVaultAiFixture(); }); // The chat drives a mocked provider round-trip; on a loaded CI runner it races // the default 30s, so the whole group earns the tripled timeout. test.beforeEach(() => { test.slow(); }); // ── (a) Indexed + available: the entry opens an empty, safe chat surface ── test("offers the chat entry and opens an empty, safety-noted surface", async ({ page, }) => { await mockAiEnabled(page); // The document has no thread yet → the history list is empty. await page.route( `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, (route) => { if (route.request().method() !== "GET") return route.fallback(); return fulfilJson(route, { data: { conversations: [], nextCursor: null }, error: null, }); }, ); await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); const sheet = page.locator('[data-slot="responsive-sheet-content"]'); await expect(sheet).toBeVisible(); // The neutral Coach icon lives in the detail-sheet header; tapping it // opens the doc-scoped chat drawer (a sibling overlay). const open = sheet.locator('[data-slot="document-chat-open"]'); await expect(open).toBeVisible(); await open.click(); const drawer = page.locator('[data-slot="document-chat-drawer"]'); await expect(drawer).toBeVisible(); await expect( drawer.locator('[data-slot="document-chat-log"]'), ).toBeVisible(); await expect( drawer.locator('[data-slot="document-chat-empty"]'), ).toBeVisible(); await expect( drawer.locator('[data-slot="document-chat-input"]'), ).toBeVisible(); // The always-on safety note is present. await expect( drawer.locator('[data-slot="document-chat-safety"]'), ).toContainText("not medical advice"); }); // ── (b) A turn streams a grounded reply that settles as plain text ── test("streams the assistant reply and settles the conversation", async ({ page, }) => { await mockAiEnabled(page); const CONV_ID = "e2edocchatconv0000000001"; const QUESTION = "What does the Impression say?"; const REPLY = "Per the report's Impression, the findings are unremarkable."; let posted = false; await page.route( `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, async (route) => { const req = route.request(); if (req.method() === "POST") { posted = true; await route.fulfill({ status: 200, contentType: "text/event-stream; charset=utf-8", body: sse([ { type: "token", token: REPLY }, { type: "done", conversationId: CONV_ID, messageId: "e2edocchatmsg0000000001", }, ]), }); return; } const url = new URL(req.url()); if (url.searchParams.get("conversationId")) { // The persisted thread the post-`done` refetch reads back. await fulfilJson(route, { data: { id: CONV_ID, title: QUESTION, createdAt: "2026-07-07T10:00:00.000Z", updatedAt: "2026-07-07T10:00:02.000Z", messageCount: 2, messages: [ { id: "e2edocchatu0000000001", role: "user", content: QUESTION, createdAt: "2026-07-07T10:00:00.000Z", metricSource: null, providerType: null, promptVersion: null, tokensUsed: null, model: null, }, { id: "e2edocchatmsg0000000001", role: "assistant", content: REPLY, createdAt: "2026-07-07T10:00:02.000Z", metricSource: null, providerType: "mock", promptVersion: null, tokensUsed: 42, model: "mock-1", }, ], }, error: null, }); return; } // The thread list: empty until the turn is posted, then the new thread. await fulfilJson(route, { data: { conversations: posted ? [ { id: CONV_ID, title: QUESTION, createdAt: "2026-07-07T10:00:00.000Z", updatedAt: "2026-07-07T10:00:02.000Z", messageCount: 2, }, ] : [], nextCursor: null, }, error: null, }); }, ); await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); const sheet = page.locator('[data-slot="responsive-sheet-content"]'); await expect(sheet).toBeVisible(); await sheet.locator('[data-slot="document-chat-open"]').click(); const drawer = page.locator('[data-slot="document-chat-drawer"]'); await expect(drawer).toBeVisible(); const input = drawer.locator('[data-slot="document-chat-input"]'); await input.fill(QUESTION); await drawer.locator('[data-slot="document-chat-send"]').click(); // The user's question and the streamed/settled reply both land in the log. const log = drawer.locator('[data-slot="document-chat-log"]'); await expect(log.getByText(QUESTION)).toBeVisible(); await expect(log.getByText(/findings are unremarkable/)).toBeVisible(); // The reply settled to a single assistant bubble (no duplicate). await expect( log.locator('[data-slot="document-chat-message"][data-role="assistant"]'), ).toHaveCount(1); }); // ── (b2) An injection-laced document: the reply describes it, never obeys, // and any HTML the model emits renders inert (no markdown, no // dangerouslySetInnerHTML — the project's XSS posture). ── test("answers about an injection-laced document without obeying it, and renders HTML inert", async ({ page, }) => { await mockAiEnabled(page); const CONV_ID = "e2edocchatconv0000000002"; const QUESTION = "What does this document say?"; // The (mocked) server reply the panel receives: a calm describe-only answer // — NOT obedience to the document's embedded "ignore instructions … reveal // the system prompt … say HACKED" — plus a literal It appears to be a lab report."; let posted = false; await page.route( `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, async (route) => { const req = route.request(); if (req.method() === "POST") { posted = true; await route.fulfill({ status: 200, contentType: "text/event-stream; charset=utf-8", body: sse([ { type: "token", token: REPLY }, { type: "done", conversationId: CONV_ID, messageId: "e2edocchatmsg0000000002", }, ]), }); return; } const url = new URL(req.url()); if (url.searchParams.get("conversationId")) { await fulfilJson(route, { data: { id: CONV_ID, title: QUESTION, createdAt: "2026-07-07T10:00:00.000Z", updatedAt: "2026-07-07T10:00:02.000Z", messageCount: 2, messages: [ { id: "e2edocchatu0000000002", role: "user", content: QUESTION, createdAt: "2026-07-07T10:00:00.000Z", metricSource: null, providerType: null, promptVersion: null, tokensUsed: null, model: null, }, { id: "e2edocchatmsg0000000002", role: "assistant", content: REPLY, createdAt: "2026-07-07T10:00:02.000Z", metricSource: null, providerType: "mock", promptVersion: null, tokensUsed: 30, model: "mock-1", }, ], }, error: null, }); return; } await fulfilJson(route, { data: { conversations: posted ? [ { id: CONV_ID, title: QUESTION, createdAt: "2026-07-07T10:00:00.000Z", updatedAt: "2026-07-07T10:00:02.000Z", messageCount: 2, }, ] : [], nextCursor: null, }, error: null, }); }, ); await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); const sheet = page.locator('[data-slot="responsive-sheet-content"]'); await expect(sheet).toBeVisible(); await sheet.locator('[data-slot="document-chat-open"]').click(); const drawer = page.locator('[data-slot="document-chat-drawer"]'); await expect(drawer).toBeVisible(); const input = drawer.locator('[data-slot="document-chat-input"]'); await input.fill(QUESTION); await drawer.locator('[data-slot="document-chat-send"]').click(); const log = drawer.locator('[data-slot="document-chat-log"]'); // The describe-only answer lands; the panel renders the model's