mike/e2e/critical-path.spec.ts
QA Runner 42346d6900 ci(e2e): apply migrations, serve prod build, skip LLM specs without a key
Three fixes that take the suite from ~everything-failing to green (verified end
to end against a local stack):

1. schema.sql lags the migrations (missing e.g. workflow_open_source_submissions,
   which GET /workflows/:id queries → 500). Apply every dated migration on top of
   schema.sql (idempotent; 0 errors on a fresh DB), grant service_role AFTER so
   new tables are covered, then reload PostgREST. Fixes the workflow specs.

2. Serve a production build (next build + next start) instead of next dev. The dev
   server's on-demand compilation (slow first hit → waitForResponse timeouts) and
   hydration-error overlay (injects nextjs__container_errors DOM that pollutes text
   locators, e.g. getByText('All') matching 'Call Stack') made the suite flaky.
   Fixes tabular-reviews list render + the timing flakes.

3. LLM-dependent specs (chat rename/delete/submit, critical-path) require a model
   key to send a message. Guard them with test.skip(!hasLlmKey) (e2e/llm.ts) and
   expose ANTHROPIC_API_KEY to the Playwright process, so a keyless run is green on
   the ~23 other specs and the LLM specs run + enforce only when the secret is set.

Local result (no key, with MinIO+LibreOffice as on ubuntu-latest): 23 passed,
4 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:52:47 -07:00

211 lines
10 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) is throttled by
the local Supabase stack and needs far 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(180_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 modal's FileDirectory (useDirectoryData) fires a burst of Supabase
requests when the modal opens — a getProject() per existing project.
Submitting mid-burst makes the local Supabase gateway (Kong) return a 502
("An invalid response was received from the upstream server"), surfaced
by the modal inline as text-red-500. Let those requests settle first so
the create POST goes through cleanly. */
await page
.waitForLoadState("networkidle", { timeout: 45_000 })
.catch(() => {});
/* 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). Race it against the inline error so a residual transient 502 is
detected immediately and retried by re-submitting (safe — the failed
request created nothing). */
const inlineError = page.locator("form p.text-red-500");
for (let attempt = 1; attempt <= 5; attempt++) {
await page.click('button[type="submit"]');
const outcome = await Promise.race([
page
.waitForURL(/\/projects\/[^/]+$/, { timeout: 30_000 })
.then(() => "nav" as const)
.catch(() => "timeout" as const),
inlineError
.waitFor({ state: "visible", timeout: 30_000 })
.then(() => "error" as const)
.catch(() => "timeout" as const),
]);
if (outcome === "nav") break;
if (attempt === 5) {
throw new Error(
`create project: never navigated (last outcome: ${outcome})`,
);
}
await inlineError
.waitFor({ state: "hidden", timeout: 2_000 })
.catch(() => {});
}
/* ── 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 workspace fetches
getProject() on mount and does NOT retry, so under the local-Supabase load
the page can land on a permanent "Project not found" or a slow skeleton;
re-navigate until the assistant tab's empty-state "Create" affordance
renders. (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 });
for (let attempt = 1; attempt <= 6; attempt++) {
await page.goto(`${projectUrl}/assistant`);
await page
.waitForLoadState("networkidle", { timeout: 20_000 })
.catch(() => {});
if (await createNew.isVisible().catch(() => false)) break;
}
/* The assistant tab shows a chat list. Click "+ Create New" to open the
chat interface where the text input appears. */
await expect(createNew).toBeVisible({ timeout: 10_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 });
});
});