/** * 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"; import { hasLlmKey, LLM_SKIP_REASON } from "./llm"; /* ─── 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/ 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/ resolves 404 and the page redirects. const chatId = "00000000-0000-4000-8000-000000000000"; // ── Step 1: the cold-load getChat(id) call must issue GET /chat/ ───── // 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/". 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 }) => { test.skip(!hasLlmKey, LLM_SKIP_REASON); // 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("How can I help?"); 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//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 carries APP_SURFACE_ACTIVE_CLASS // ("bg-app-surface-active"); inactive items carry APP_SURFACE_HOVER_CLASS // ("hover:bg-app-surface-hover", a different token), so matching // "bg-app-surface-active" distinguishes the active item. (The olp liquid- // surface refresh renamed the old "bg-gray-200/60" active token.) const activeItem = page .locator('div.group.relative[class*="bg-app-surface-active"]') .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