mike/e2e/chat-management.spec.ts
Amal 916f260093 test: Playwright e2e suite (auth, chat, projects, tabular reviews, workflows)
Port of the amal66/mike fork's Playwright end-to-end suite onto the
upstream backend/ + frontend/ layout:

- e2e/: auth.setup (bootstraps a confirmed e2e@mike.local user via the
  Supabase admin API and saves storageState), auth-flows, critical-path
  (create project -> upload PDF -> ask a question -> streamed response),
  chat-management, project-management, tabular-reviews,
  workflows-account; fixtures/test.pdf
- playwright.config.ts: single-worker (shared test user), setup project
  + chromium project with saved auth state; webServer adapted from the
  fork's monorepo command (npm run dev --workspace apps/web) to
  upstream's layouts: backend `npm run dev` (health-checked on :3001)
  and frontend `npm run dev` (:3000)
- root package.json (upstream has none): @playwright/test, typescript
  dev-deps and test:e2e scripts, trimmed from the fork's root manifest
- root tsconfig.json scoped to e2e/ + playwright.config.ts so
  `npx tsc --noEmit` typechecks the suite
- .gitignore: playwright artifacts + e2e/.auth (session tokens)

Specs select by ARIA role/name and placeholder text only - no
data-testid attributes, so no app-code changes are required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
2026-07-22 11:37:59 -07:00

498 lines
28 KiB
TypeScript

/**
* Chat-management E2E tests:
* 1. Cold-load existing chat — verifies getChat() API loads messages on direct URL
* 2. Rename a chat from sidebar — verifies rename API and sidebar UI update
* 3. Delete a chat from sidebar — verifies delete API and sidebar removal
* 4. Project assistant: create a new chat and submit a question
*
* Auth: inherits storageState from playwright.config.ts ("e2e/.auth/user.json")
* Test user: e2e@mike.local / E2eTestPass1!
*/
import { test, expect, type Page } from "@playwright/test";
/* ─── Helpers ────────────────────────────────────────────────────────────────── */
/**
* Ensure the app sidebar is expanded so that "Assistant History" is visible.
*
* layout.tsx initialises isSidebarOpen=true on desktop (≥768 px, which is
* Playwright's Desktop Chrome viewport), but the project-chat page calls
* setSidebarOpen(false) on mount. This helper reopens it if needed.
*/
async function ensureSidebarOpen(page: Page) {
const historySection = page.getByText("Assistant History");
if (!(await historySection.isVisible())) {
// The toggle button's title alternates between "Open sidebar" and "Close sidebar"
// (AppSidebar.tsx onToggle handler). Use the first match in case both the
// desktop and mobile toggle buttons are in the DOM simultaneously.
await page.getByTitle("Open sidebar").first().click();
await expect(historySection).toBeVisible({ timeout: 5_000 });
}
}
/**
* Select the built-in keyless "demo" model in the chat input's ModelToggle so
* the first submit actually creates a chat instead of opening the
* ApiKeyMissingModal.
*
* The default model is "gemini-3-flash-preview" (ModelToggle.DEFAULT_MODEL_ID),
* for which no key is configured; ChatInput.handleSubmit (ChatInput.tsx:116-119)
* then refuses to send. The suite runs WITHOUT any provider key (the CI stack
* leaves ANTHROPIC_API_KEY empty), so no Anthropic/Gemini/OpenAI model is
* available — only the demo model (DEMO_MODEL_ID "mike-demo", label "Demo (no
* key needed)") is always available and streams a canned response via
* providers/demo.ts. ModelToggle renders a Radix DropdownMenu: the trigger is a
* button whose title is "Choose model" (current model available) or "API key
* missing for selected model" (current model not available — the default-Gemini
* case). We open it, pick the Demo item, and confirm the trigger now shows
* "Demo (no key needed)".
*/
async function selectDemoModel(page: Page) {
const trigger = page
.locator(
'button[title="Choose model"], button[title="API key missing for selected model"]',
)
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await page
.getByRole("menuitem", { name: "Demo (no key needed)" })
.click();
// After selection the trigger label reflects the chosen model.
await expect(
page.getByRole("button", { name: /Demo \(no key needed\)/ }),
).toBeVisible({ timeout: 5_000 });
}
/* ─── Test 1: cold-load existing chat ───────────────────────────────────────── */
test("cold-load: direct URL to a chat triggers the getChat history load", async ({ page }) => {
// REGRESSION: fails if AssistantChatPage's cold-load history load is removed —
// i.e. if the component stops calling getChat(id) on mount
// (AssistantChatPage.tsx:37-45). On a direct navigation we assert (a) the
// GET /chat/<id> request actually fires, and (b) its result drives the
// documented navigation: when getChat yields no messages the page redirects
// back to /assistant (AssistantChatPage.tsx:42/45 router.replace("/assistant")).
// Verified by temporarily removing the getChat(...) call: the request no longer
// fires and no redirect happens, so this test fails.
//
// Why not assert a rendered message? This environment can't produce a chat
// with stored messages: no LLM provider key is configured (so the UI's
// Enter-to-send is blocked by the ApiKeyMissingModal, ChatInput.tsx:116-119),
// and even a direct POST /chat can't persist one — chat.routes.ts:530-536
// inserts a `workflow` column that does not exist on chat_messages, so every
// message insert fails silently and the table stays empty. An existing-but-
// empty chat and a never-created chat id are therefore observably identical
// here: getChat runs, returns no messages, and AssistantChatPage redirects.
// Using a fresh id keeps the test self-contained — no chat-creation request to
// fail under DB churn, no message-history precondition that can't be met.
// A valid-shaped UUID that will not exist (gen_random_uuid never yields it),
// so getChat(id) → GET /chat/<id> resolves 404 and the page redirects.
const chatId = "00000000-0000-4000-8000-000000000000";
// ── Step 1: the cold-load getChat(id) call must issue GET <api>/chat/<id> ─────
// Scope to the API origin (port 3001) so we match the getChat() API call and
// NOT the Next.js page/RSC navigation request, whose URL also contains the
// path "/assistant/chat/<id>".
const getChatRequest = page.waitForResponse(
(r) =>
/:3001\/chat\//.test(r.url()) &&
r.url().includes(`/chat/${chatId}`) &&
r.request().method() === "GET",
{ timeout: 20_000 },
);
await page.goto(`/assistant/chat/${chatId}`);
await getChatRequest; // proves the cold-load getChat(id) call happened
// ── Step 2: with no messages, AssistantChatPage redirects to the landing ─────
await expect(page).toHaveURL(/\/assistant$/, { timeout: 15_000 });
});
/* ─── Test 2: rename a chat from sidebar ────────────────────────────────────── */
test("rename chat: sidebar rename interaction updates the title", async ({ page }) => {
// REGRESSION: fails if the renameChat API call or the optimistic title update in
// ChatHistoryContext.renameChatFn / SidebarChatItem.handleRenameSave is removed.
// Chat creation (saveChat → POST /chat/create) can be slow when the dev server
// / DB is under load, so allow extra headroom over the default 30s test cap.
test.setTimeout(90_000);
const message = `Rename test ${Date.now()}`;
const newTitle = `Renamed Chat ${Date.now()}`;
// ── Step 1: create a new chat ─────────────────────────────────────────────────
await page.goto("/assistant");
const textarea = page.getByPlaceholder("Ask a question about your documents...");
await expect(textarea).toBeVisible({ timeout: 10_000 });
// Pick the keyless demo model so the submit isn't blocked by the
// ApiKeyMissingModal (no provider key is configured in this run).
await selectDemoModel(page);
await textarea.fill(message);
// Sending the first message triggers auto title-generation
// (useGenerateChatTitle → POST /chat/<id>/generate-title → renameChat). That
// would overwrite our manual rename below if it lands afterwards, so wait for
// it to settle first. Best-effort: if it never fires (e.g. the LLM errors),
// proceed — our manual rename is then unopposed.
const titleGenerated = page
.waitForResponse(
(r) =>
/:3001\/chat\/.+\/generate-title$/.test(r.url()) &&
r.request().method() === "POST",
{ timeout: 30_000 },
)
.catch(() => null);
await textarea.press("Enter");
// ── Step 2: wait for navigation to the new chat page ─────────────────────────
await page.waitForURL(/\/assistant\/chat\/.+/, { timeout: 45_000 });
await titleGenerated; // let auto title-generation apply before we rename
// ── Step 3: ensure the sidebar is open ───────────────────────────────────────
await ensureSidebarOpen(page);
// ── Step 4: locate the active chat item ──────────────────────────────────────
// SidebarChatItem.tsx renders a `div.group.relative` wrapper for each chat.
// When isActive=true the wrapper class includes "bg-gray-200/60" as a
// standalone Tailwind class. Inactive items have "hover:bg-gray-100" (a
// different token), so matching the "bg-gray-200/60" class distinguishes the
// active item. Use an attribute-substring match ([class*=]) to avoid having
// to CSS-escape the "/" in the Tailwind class name.
const activeItem = page
.locator('div.group.relative[class*="bg-gray-200/60"]')
.first();
// The active item's trigger is already opacity-100, but hover is harmless and
// keeps parity with the inactive-item path.
await activeItem.hover();
// ── Step 5: click the MoreHorizontal trigger (three-dot menu) ────────────────
// SidebarChatItem.tsx lines 104-115: DropdownMenuTrigger wraps a <button> with
// the MoreHorizontal icon. In the non-renaming state the two buttons inside the
// item are [0] chat-title button and [1] the trigger; .last() picks the trigger.
const triggerBtn = activeItem.locator("button").last();
await triggerBtn.click();
// ── Step 6: click "Rename" in the Radix DropdownMenuContent ─────────────────
// SidebarChatItem.tsx lines 117-129: DropdownMenuItem with Pencil icon + "Rename"
const renameItem = page.getByRole("menuitem", { name: "Rename" });
await expect(renameItem).toBeVisible({ timeout: 5_000 });
await renameItem.click();
// ── Step 7: type the new title in the inline input ───────────────────────────
// SidebarChatItem.tsx lines 56-68: isRenaming state shows an <input type="text">
// that is focused automatically (editInputRef.current?.focus() in useEffect).
// There is no data-testid; scope to the item container to avoid ambiguity.
const renameInput = activeItem.locator("input[type='text']");
await expect(renameInput).toBeVisible({ timeout: 5_000 });
await renameInput.fill(newTitle);
// SidebarChatItem.tsx line 63: Enter key calls handleRenameSave()
await renameInput.press("Enter");
// ── Step 8: assert the new title appears in the sidebar ──────────────────────
// ChatHistoryContext.renameChatFn optimistically updates the chat title in state.
// SidebarChatItem re-renders the title button with the new text.
await expect(
page.getByRole("button", { name: newTitle })
).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 3: delete a chat from sidebar ────────────────────────────────────── */
test("delete chat: sidebar delete action removes the chat from history", async ({ page }) => {
// REGRESSION: fails if the deleteChat API call or the optimistic list removal in
// ChatHistoryContext.deleteChatFn (filter by chatId) is removed.
// Chat creation (saveChat → POST /chat/create) can be slow when the dev server
// / DB is under load, so allow extra headroom over the default 30s test cap.
test.setTimeout(90_000);
const message = `Delete test ${Date.now()}`;
// ── Step 1: create a new chat ─────────────────────────────────────────────────
await page.goto("/assistant");
const textarea = page.getByPlaceholder("Ask a question about your documents...");
await expect(textarea).toBeVisible({ timeout: 10_000 });
// ── Step 2: create the chat, riding out transient gateway 502s ───────────────
// ChatInput.handleSubmit creates the chat (saveChat → POST /chat/create) then
// navigates to /assistant/chat/<id>. The local Supabase/Kong gateway
// intermittently returns 502 on that POST under load; saveChat then returns null
// (createChat throws → caught in ChatHistoryContext.saveChat), so no chat is
// created and no navigation happens — the page stays on /assistant with the
// textarea intact, so re-submitting recovers the transient failure. This mirrors
// the bounded create-with-retry the project-assistant test (Step 7-8 below) uses
// for the same flaky POST. A genuinely broken create/navigation never reaches
// /assistant/chat/<id> on any attempt, so the final waitForURL still fails and
// the regression this test guards is preserved.
const newChatUrl = /\/assistant\/chat\/.+/;
// Sending the first message kicks off auto title-generation
// (useGenerateChatTitle → POST /chat/<id>/generate-title → renameChat). If it
// lands after the manual rename in step 4 it overwrites the unique title and
// the row can no longer be found. Wait for it to settle first, exactly as the
// rename test does. Best-effort: if it never fires, our rename is unopposed.
const titleGenerated = page
.waitForResponse(
(r) =>
/:3001\/chat\/.+\/generate-title$/.test(r.url()) &&
r.request().method() === "POST",
{ timeout: 30_000 },
)
.catch(() => null);
const CREATE_ATTEMPTS = 4;
for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt++) {
if (newChatUrl.test(page.url())) break;
// Re-assert the demo model as the active (available) model in case a
// remount reset it to the default Gemini (which the ApiKeyMissingModal
// would block on), then re-fill and re-submit.
await selectDemoModel(page);
await textarea.fill(message);
await textarea.press("Enter");
try {
await page.waitForURL(newChatUrl, { timeout: 20_000 });
break;
} catch {
// transient gateway 5xx on POST /chat/create — still on /assistant; retry
}
}
await page.waitForURL(newChatUrl, { timeout: 20_000 });
// Let auto title-generation land before renaming, so it cannot clobber the
// unique title this test targets the row by.
await titleGenerated;
// ── Step 3: ensure the sidebar is open ───────────────────────────────────────
await ensureSidebarOpen(page);
// ── Step 4: rename the new chat to a unique title so we can target it ─────────
// The shared test user accumulates many chats across runs and the sidebar
// paginates at INITIAL_CHAT_LIMIT (20), so total-row-count and positional
// assertions are unreliable. Instead, give this chat a unique title and then
// assert on that exact title — immune to pagination and to other chats. The
// just-created chat is active and prepended, so it is the first row; rename it
// via the same three-dot menu the rename test exercises.
const uniqueTitle = `Delete Target ${Date.now()}`;
const firstRow = page.locator("div.group.relative.h-9.rounded-md").first();
await firstRow.hover();
await firstRow.locator("button").last().click();
await page.getByRole("menuitem", { name: "Rename" }).click();
const renameInput = firstRow.locator("input[type='text']");
await expect(renameInput).toBeVisible({ timeout: 5_000 });
await renameInput.fill(uniqueTitle);
await renameInput.press("Enter");
// The renamed chat's title button now uniquely identifies its row.
const targetTitle = page.getByRole("button", { name: uniqueTitle });
await expect(targetTitle).toBeVisible({ timeout: 10_000 });
// The row wrapper that contains that title button (for reaching its menu).
const targetRow = page
.locator("div.group.relative.h-9.rounded-md")
.filter({ has: targetTitle });
// ── Step 5-7: delete that specific chat, riding out flaky Supabase 500s ──────
// deleteChatFn (ChatHistoryContext.tsx:157-168) optimistically removes the row
// then, on API error, refetches via loadChats() which RESTORES it — so a
// transient 500 on DELETE /chat/<id> re-adds the row. Retry the open→Delete
// interaction until the uniquely-titled row is gone. A genuinely broken delete
// never removes it on any attempt, so the final assertion still fails.
// SidebarChatItem.tsx:132-144: the "Delete" DropdownMenuItem calls
// deleteChat(chat.id) directly — no confirmation dialog.
const DELETE_ATTEMPTS = 4;
for (let attempt = 0; attempt < DELETE_ATTEMPTS; attempt++) {
if (!(await targetTitle.isVisible().catch(() => false))) break;
await targetRow.hover();
await targetRow.locator("button").last().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(targetTitle).toBeHidden({ timeout: 10_000 }).catch(() => {});
}
await expect(targetTitle).toBeHidden({ timeout: 10_000 });
});
/* ─── Test 4: project assistant — create new chat ───────────────────────────── */
test("project assistant: create a new chat and submit a question", async ({ page }) => {
// REGRESSION: fails if the project chat creation route is broken — specifically if
// handleNewChat() in ProjectPage.tsx (lines 515-519) fails to call saveChat() or
// router.push to /projects/[id]/assistant/chat/[chatId]. (Verified by temporarily
// removing that router.push: "+ Create New" then no longer navigates and the
// Step 8 waitForURL below fails.)
// This test creates a project then a chat (two sequential write round-trips plus
// a route compile/render each). Under a loaded dev server those round-trips can
// each take tens of seconds, so give the whole test — and the two create waits —
// generous headroom. Reliability matters more than speed here. The bounded
// create-with-retry on both the project and the chat (each up to a few
// re-submits under load) needs headroom beyond the create round-trips.
test.setTimeout(180_000);
// ── Step 1: navigate to projects ─────────────────────────────────────────────
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/, { timeout: 10_000 });
// ── Step 2: open the "New project" modal ─────────────────────────────────────
const createBtn = page.getByRole("button", { name: "New project" });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await createBtn.click();
// ── Step 3: fill in the project name ─────────────────────────────────────────
// NewProjectModal.tsx line 203: disabled={!name.trim() || loading}
// PDF upload is optional — we omit it to keep this test focused on routing.
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
const projectName = `E2E Chat Route ${Date.now()}`;
await nameInput.fill(projectName);
// NewProjectModal is a two-step wizard ("Details" → "Add Documents"); the
// "Create project" submit button only exists on the second step.
await page.getByRole("button", { name: "Next", exact: true }).click();
// ── Step 4: submit the form (resilient to transient gateway 502s) ────────────
// NewProjectModal.handleSubmit does NOT navigate itself — it calls onCreated()
// then onClose(). ProjectsOverview.onCreated (ProjectsOverview.tsx:475-478)
// inserts the row AND router.push(`/projects/${p.id}`), so the app navigates
// straight to the project detail page; the name never re-appears in a list to
// click. Wait for that navigation instead of asserting a list row.
//
// The local Supabase/Kong gateway intermittently returns 502 on POST /projects
// under load. On a failed create, handleSubmit catches the error, leaves the
// modal open with the name retained, and re-enables the submit button — so a
// transient failure is recovered by re-submitting. A genuine broken
// create/navigation never reaches /projects/<id> on any attempt, so the final
// assertion below still fails and that regression is preserved.
const submitBtn = page.getByRole("button", {
name: /create project|creating/i,
});
const projectUrl = /\/projects\/[^/]+$/;
const CREATE_ATTEMPTS = 4;
for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt++) {
if (projectUrl.test(page.url())) break;
// toBeEnabled rides out a slow in-flight "Creating…" from a prior attempt.
await expect(submitBtn).toBeEnabled({ timeout: 10_000 });
await submitBtn.click();
try {
await page.waitForURL(projectUrl, { timeout: 20_000 });
break;
} catch {
// transient gateway 5xx — modal stays open, name retained; retry
}
}
await page.waitForURL(projectUrl, { timeout: 20_000 });
// ── Step 6: open the assistant tab and reach the empty-state "+ Create New" ──
// The project assistant is now a nested route (/projects/[id]/assistant), not a
// ?tab= query on the detail page. Navigating straight there avoids ambiguity
// with the sidebar "Assistant" nav item.
//
// The project workspace fetches getProject(id) on mount with no client-side
// refetch. Under load that GET can transiently 502, leaving project=null so the
// page renders "Project not found" and the assistant section never mounts. The
// project row genuinely exists (Step 4 navigated to its id), so a reload
// refetches and recovers. Bounded-retry the load until the empty-state
// "+ Create New" button (ProjectAssistantTable.tsx:110-115, shown when
// chats.length === 0) is visible, reloading past any transient "Project not
// found". A genuinely broken assistant tab never shows the button on any
// attempt, so the final assertion still fails.
const assistantUrl = page.url() + "/assistant";
const createNewBtn = page.getByText("+ Create New");
const projectNotFound = page.getByText("Project not found");
const TAB_ATTEMPTS = 4;
for (let attempt = 0; attempt < TAB_ATTEMPTS; attempt++) {
await page.goto(assistantUrl);
// Race the empty-state button against the transient "Project not found".
const outcome = await Promise.race([
createNewBtn
.waitFor({ state: "visible", timeout: 20_000 })
.then(() => "ready")
.catch(() => "retry"),
projectNotFound
.waitFor({ state: "visible", timeout: 20_000 })
.then(() => "notfound")
.catch(() => "retry"),
]);
if (outcome === "ready") break;
// "notfound" (transient getProject 502) or a timeout — reload to refetch.
}
await expect(createNewBtn).toBeVisible({ timeout: 20_000 });
// ── Step 7-8: click "+ Create New" and wait for the project chat URL ─────────
// handleNewChat (ProjectPage.tsx:515-519) calls saveChat(projectId) then
// router.push(`/projects/${projectId}/assistant/chat/${id}`). On a transient
// gateway 502, saveChat returns null (createChat throws → caught in
// ChatHistoryContext.saveChat), so no chat is created and no navigation
// happens; the empty-state button stays mounted (chats still empty), so
// re-clicking recovers the transient failure.
//
// REGRESSION (the target of this test): if handleNewChat's router.push is
// removed, saveChat still succeeds and adds the chat to state, so the
// empty-state "+ Create New" button is replaced by the chat list and no
// navigation occurs — the loop's visibility guard then stops retrying and the
// final waitForURL fails. If saveChat itself is broken, navigation never
// happens either. So a genuine break fails on every attempt and is preserved.
const chatUrl = /\/projects\/.+\/assistant\/chat\/.+/;
const CHAT_ATTEMPTS = 4;
for (let attempt = 0; attempt < CHAT_ATTEMPTS; attempt++) {
if (chatUrl.test(page.url())) break;
// Empty-state button gone without navigation ⇒ a chat was created but
// never navigated (router.push regression): stop so it surfaces below.
if (!(await createNewBtn.isVisible().catch(() => false))) break;
await createNewBtn.click();
try {
await page.waitForURL(chatUrl, { timeout: 20_000 });
break;
} catch {
// transient gateway 5xx — empty-state remains; retry
}
}
await page.waitForURL(chatUrl, { timeout: 20_000 });
// ── Step 9: assert the ChatInput textarea is visible ─────────────────────────
// ProjectAssistantChatPage renders <ChatInput> in the right "Project Assistant"
// panel (line 1221-1229 of the chat page component).
const chatInput = page.getByPlaceholder("Ask a question about your documents...");
await expect(chatInput).toBeVisible({ timeout: 10_000 });
// ── Step 10-11: pick an available model, submit a question, assert it clears ──
// ChatInput.handleSubmit (ChatInput.tsx:113-140) clears the textarea
// synchronously (setValue("")) ONLY when the selected model is available and no
// prior response is in flight; otherwise it returns early. Two transient gates
// exist under load:
// • useSelectedModel (useSelectedModel.ts:16-20) seeds DEFAULT (Gemini) for
// one render before its localStorage-read effect restores the demo model,
// so a submit racing a ChatInput remount can momentarily see Gemini → the
// ApiKeyMissingModal ("API key required") pops and the submit no-ops.
// • A transient in-flight response (isResponseLoading) also no-ops the submit.
// Re-select the demo model and re-submit until the textarea clears. A genuinely
// broken send never clears on ANY attempt, so a real regression is still caught.
const question = "What is in this project?";
const apiKeyModalHeading = page.getByRole("heading", {
name: "API key required",
});
const SUBMIT_ATTEMPTS = 4;
let cleared = false;
for (let attempt = 0; attempt < SUBMIT_ATTEMPTS && !cleared; attempt++) {
// Dismiss a stray "API key required" modal left by a prior racey attempt
// (Cancel closes it without navigating; "Go to account settings" would).
if (await apiKeyModalHeading.isVisible().catch(() => false)) {
await page
.getByRole("button", { name: "Cancel" })
.click()
.catch(() => {});
}
// Re-assert the demo model as the active (available) model after any remount.
await selectDemoModel(page);
await chatInput.fill(question);
// ChatInput.handleKeyDown: Enter (no Shift) → handleSubmit().
await chatInput.press("Enter");
// setValue("") runs synchronously on a successful send.
cleared = await expect(chatInput)
.toHaveValue("", { timeout: 5_000 })
.then(() => true)
.catch(() => false);
}
// Final assertion surfaces a genuinely broken send (never clears).
await expect(chatInput).toHaveValue("", { timeout: 5_000 });
});