mike/e2e/critical-path.spec.ts
QA Runner af38a45067 test(e2e): drop transient-5xx retry scaffolding now that the fetch storm is fixed
With the directory fetch storm eliminated (previous commit: batched
listProjects?include=documents instead of a getProject burst per modal
open), the local Supabase gateway no longer collapses under modal-open
load, so the specs' 5xx-oriented scaffolding is removed:

- create-with-retry loops (project create x2, chat create x2, workflow
  create, tabular-review create x10-attempt, review add-document x6)
- reload-guards for transient "Project not found" (waitForProjectLoaded,
  gotoProjectRow, project-assistant tab loop)
- networkidle settle-waits whose purpose was to let the per-project fetch
  burst drain before submitting (NewProjectModal / NewTRModal)
- oversized per-test timeouts justified by the storm (180s -> 60-120s)

Kept, deliberately:
- the title-generation settle logic in chat rename/delete
- chat submit's model-seeding retry loop (guards a documented
  useSelectedModel localStorage hydration race, unrelated to the gateway)
- account display-name toPass block (guards a documented profile
  hydration race, unrelated to the gateway)
- generic short networkidle settles after login/logout navigation
- Playwright's own retries: 2 on CI

Verified: with the fix, 3 consecutive full-suite runs (plus 2 more before
de-retry) were green with zero Playwright retries, running against an
account seeded with ~40 projects — a larger directory fan-out than CI's
fresh database ever produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:44:00 -07:00

169 lines
8.5 KiB
TypeScript

/**
* Critical path E2E tests:
* 1. Authenticated landing — /assistant loads correctly
* 2. Projects — create a project, upload a PDF, open the project assistant,
* send a message and verify a response begins streaming
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json
*/
import { test, expect, type Page } from "@playwright/test";
import { hasLlmKey, LLM_SKIP_REASON } from "./llm";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
/**
* Select the built-in keyless "demo" model in the chat input's ModelToggle.
*
* The default model (Gemini) has no key configured in this environment, so a
* submit would be blocked by the ApiKeyMissingModal. The demo model
* (ModelToggle DEMO_MODEL_ID) is always available and streams a canned response
* via providers/demo.ts, letting the "receive a response" assertion run
* deterministically without any provider key. The Radix DropdownMenu trigger's
* title is "Choose model" (current model available) or "API key missing for
* selected model" (default-Gemini case).
*/
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: authenticated landing ─────────────────────────────────────── */
test("authenticated user lands on the assistant page", async ({ page }) => {
await page.goto("/assistant");
await expect(page).toHaveURL(/\/assistant/);
/* The InitialView renders a greeting heading */
await expect(page.locator("h1, h2").first()).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 2: create project → upload PDF → chat ─────────────────────────── */
test("create project, upload PDF, ask a question and receive a response", async ({
page,
}) => {
test.skip(!hasLlmKey, LLM_SKIP_REASON);
/* This end-to-end flow (create + upload + navigate + chat) needs more than
the 30s default. The per-test `{ timeout }` option that test() accepts is
silently ignored by Playwright (that object only takes tag/annotation),
so set it here. */
test.setTimeout(120_000);
/* ── Step 1: navigate to projects ─────────────────────────────────────── */
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/);
/* ── Step 2: open the "New project" modal ────────────────────────────── */
/* The Plus icon button in the header has aria-label="New project" */
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 ─────────────────────────────────── */
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
const projectName = `E2E Test Project ${Date.now()}`;
await nameInput.fill(projectName);
/* ── Step 4: advance to "Add Documents" and upload a PDF ──────────────── */
/* NewProjectModal is a two-step wizard; the details step's primary action is
a plain "Next" and only the documents step carries the file input. */
await page.getByRole("button", { name: "Next", exact: true }).click();
const uploadBtn = page.getByRole("button", { name: /^Upload/ });
/* We need to trigger the hidden file input; intercept the chooser */
const fileChooserPromise = page.waitForEvent("filechooser");
await uploadBtn.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(PDF_FIXTURE);
/* The button label should update to reflect the queued file */
await expect(
page.getByRole("button", { name: /^Upload \(1\)/ }),
).toBeVisible({ timeout: 5_000 });
/* ── Step 5: submit the form ──────────────────────────────────────────── */
/* The PDF upload runs inside NewProjectModal.handleSubmit
(await Promise.all([uploadProjectDocument(...)])) BEFORE onCreated fires,
so the "Creating…" button state can persist for many seconds while the
file uploads. ProjectsOverview.onCreated then router.push()es to the new
project page, so wait for that navigation (generously, to cover the
upload). */
await page.click('button[type="submit"]');
await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 30_000 });
/* ── Step 6: open the project assistant ───────────────────────────────── */
/* We're already on /projects/[id] (Documents tab by default). The project
assistant is now a nested route, /projects/[id]/assistant. Navigate there
directly rather than clicking through the tab bar to avoid ambiguity with
the "Assistant" item in the sidebar nav. (The olp UI replaced the old
"+ Create New" text link with a PillButton reading "Create" —
ProjectAssistantTable empty state.) */
const projectUrl = page.url().split("?")[0];
const createNew = page.getByRole("button", { name: "Create", exact: true });
await page.goto(`${projectUrl}/assistant`);
/* The assistant tab shows a chat list. Click "Create" to open the chat
interface where the text input appears. */
await expect(createNew).toBeVisible({ timeout: 20_000 });
await createNew.click();
/* Navigates to /projects/{id}/assistant (new chat UI) */
await page.waitForURL(/\/projects\/.+\/assistant/, { timeout: 10_000 });
await page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => {});
/* ── Step 7: select the keyless demo model, type a question, submit ───── */
const chatInput = page.getByPlaceholder("How can I help?");
await expect(chatInput).toBeVisible({ timeout: 10_000 });
/* The default Gemini model has no key configured, so submitting it would be
blocked by the ApiKeyMissingModal. Select the keyless demo model so the
request actually streams a response. */
await selectDemoModel(page);
await chatInput.fill("What is this document about?");
/* This ChatInput submits on Enter (Shift+Enter inserts a newline). */
await chatInput.press("Enter");
/* ── Step 8: verify the assistant streams a response ─────────────────── */
/* The demo provider always opens its reply with "Demo mode …"
(providers/demo.ts buildDemoAnswer). Its appearance proves the message
was sent, streamed, and rendered end-to-end — deterministically and
without any provider key.
The reply is preceded by a POST that persists the chat and a client-side
route change to /assistant/chat/<id>; under the local-Supabase load that
round-trip alone can outlast a 30s budget, so allow the same headroom the
rest of this flow gets. */
await expect(page.getByText("Demo mode").first()).toBeVisible({
timeout: 60_000,
});
});
/* ─── Test 3: login-page redirect for unauthenticated users ──────────────── */
/* describe-scoped test.use so only this test runs without a stored session.
File-level test.use would wipe the storageState for all tests in this file. */
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("unauthenticated request to /assistant redirects to login", async ({
page,
}) => {
await page.goto("/assistant");
/* Auth check is client-side (Supabase getSession) — allow time for the
async check to resolve and for Next.js router.push to fire. */
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
});
});