From 5440ac90700b93bf50d22d8ef3566fc000f8d236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 11:24:22 +0200 Subject: [PATCH] test(e2e): repoint document-chat spec to the Coach hand-off The bespoke document-chat drawer was removed with Design A, so the old e2e asserting the drawer overlay no longer applies. The spec now pins the documents-side contract that replaced it: with a provider available the Coach affordance hands off to the document-scoped Coach (/coach?doc=), and with no provider the AI area shows only the settings pointer and no Coach entry. The Coach-side scope banner (the Document badge, "Chatting about", and the not-indexed hint) moves to a coach-conversation component test, replacing the drawer's not-indexed/render coverage; the fenced-send guarantee stays pinned by the coach-send-target unit test. --- e2e/documents-chat.spec.ts | 370 ++---------------- .../__tests__/coach-doc-scope-banner.test.tsx | 100 +++++ 2 files changed, 131 insertions(+), 339 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/coach-doc-scope-banner.test.tsx diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index ce76987aa..4d2cd7c14 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -1,20 +1,24 @@ /** - * Document vault — the P4 "chat about this document" acceptance suite. + * Document vault — the "chat about a document" entry point (v1.28.51, R3). * - * 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. + * The bespoke in-sheet chat drawer is gone (Documents R3, Design A). Asking the + * Coach about a document now hands off to the REAL Coach, scoped to that + * document: the detail sheet's Coach affordance navigates to `/coach?doc=`, + * where the conversation runs on the same hardened fenced endpoint but inside + * the Coach's own surface and history. * - * 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. + * This suite pins the documents-side contract: the affordance is offered only + * when a provider is available, and it navigates to the doc-scoped Coach; with + * NO provider the AI area collapses to the settings pointer and no Coach entry + * renders at all. The Coach-side rendering (the "Chatting about" scope banner + + * the not-indexed hint) is covered by the coach-conversation component test; the + * fenced-send guarantee (a document turn never reaches the tool route) by the + * coach-send-target + persistence-scope unit tests. + * + * v1.27.31 lesson kept: the sheet's AI area depends on the capability probe + + * usage endpoints, so both are mocked — otherwise the AI area never renders and + * the specs time out. The seeded demo user has no AI provider, so the + * no-provider state is the default. */ import { expect, test, type Page } from "@playwright/test"; @@ -22,7 +26,6 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { ensureVaultFixture, ensureVaultAiFixture, - AI_PROBE_DOC_ID, CONTENT_DOC_ID, } from "./setup/vault-fixture"; @@ -54,20 +57,11 @@ function usageBody() { }; } -/** 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. + * probe) so the sheet offers the AI area and the Coach entry. */ -async function mockAiEnabled( - page: Page, - opts: { egress?: "local" | "external" } = {}, -): Promise { +async function mockAiEnabled(page: Page): Promise { await page.route("**/api/documents/inbound/usage", (route) => fulfilJson(route, usageBody()), ); @@ -78,13 +72,11 @@ async function mockAiEnabled( mode: "vision", reason: null, pdfSupported: true, - egress: opts.egress ?? "external", + 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 }), ); @@ -98,334 +90,39 @@ test.describe("document vault — chat about a document", () => { 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 ── + // ── (a) Provider available: the Coach entry hands off to the doc-scoped Coach ── - test("offers the chat entry and opens an empty, safety-noted surface", async ({ + test("offers the Coach entry and hands off to the document-scoped Coach", 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). + // The neutral Coach affordance lives in the detail-sheet AI area; tapping it + // navigates to the real Coach seeded with this document. 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"); + // The documents-side contract: the URL hands off to the doc-scoped Coach. + await page.waitForURL(new RegExp(`/coach\\?doc=${CONTENT_DOC_ID}`)); + expect(new URL(page.url()).searchParams.get("doc")).toBe(CONTENT_DOC_ID); }); - // ── (b) A turn streams a grounded reply that settles as plain text ── + // ── (b) No provider: the calm settings pointer, no Coach entry at all ── - 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