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
This commit is contained in:
Amal 2026-07-17 01:12:24 -07:00 committed by QA Runner
parent dafac6b0a4
commit 94987e6bd7
13 changed files with 2340 additions and 0 deletions

5
.gitignore vendored
View file

@ -17,3 +17,8 @@ next-env.d.ts
.DS_Store
.vercel
coverage
# Playwright artifacts and the bootstrapped e2e session (contains auth tokens)
test-results/
playwright-report/
e2e/.auth/

174
e2e/auth-flows.spec.ts Normal file
View file

@ -0,0 +1,174 @@
/**
* Authentication flow E2E tests:
* 1. Login: invalid credentials show an error message
* 2. Login: valid credentials redirect to /assistant
* 3. Logout redirects to /login
* 4. All protected routes redirect unauthenticated users to /login
*
* Tests 1, 2, and 4 run in a fresh browser context (no stored session).
* Test 3 inherits the authenticated storageState from the Playwright project
* config (e2e/.auth/user.json), so auth.setup.ts must run first.
*/
import { test, expect } from "@playwright/test";
/* ─── Unauthenticated tests ───────────────────────────────────────────────── */
/* describe-scoped test.use so only these tests run without a stored session.
File-level test.use would wipe the storageState for the authenticated
logout test below. */
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
/* ── Test 1: invalid credentials show error ──────────────────────────── */
test("login with invalid credentials shows error message", async ({
page,
}) => {
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", "e2e@mike.local");
await page.fill("#password", "definitely-wrong-password");
await page.click('button[type="submit"]');
/* Wait for the client-side async signIn call to complete and for React
to set the `error` state and re-render the error element. */
await page.waitForLoadState("networkidle");
/* The login page conditionally renders:
<div className="text-red-600 text-sm bg-red-50 p-3 rounded">
{error}
</div>
when the `error` state is non-null after a failed signInWithPassword.
REGRESSION: fails if the error <div className="bg-red-50"> is removed
from the login form or if the catch block stops setting `error`. */
await expect(page.locator("div.bg-red-50.text-red-600")).toBeVisible({
timeout: 10_000,
});
});
/* ── Test 2: valid credentials redirect to /assistant ─────────────────── */
test("login with valid credentials redirects to /assistant", async ({
page,
}) => {
/* Use the SAME credentials auth.setup.ts bootstrapped the shared user
with. Both read process.env.E2E_EMAIL / E2E_PASSWORD (falling back to
the local defaults). CI overrides E2E_PASSWORD to a value DIFFERENT
from the old hardcoded "E2eTestPass1!", so hardcoding it here typed a
password the user was never created with signInWithPassword failed,
the error banner rendered, and the /assistant redirect never fired.
Reading the env keeps the typed password in lock-step with the
bootstrapped one in every environment. */
const email = process.env.E2E_EMAIL ?? "e2e@mike.local";
const password = process.env.E2E_PASSWORD ?? "E2eTestPass1!";
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", email);
await page.fill("#password", password);
await page.click('button[type="submit"]');
/* REGRESSION: fails if `router.push("/assistant")` is removed from
the handleLogin success branch in frontend/src/app/login/page.tsx. */
await expect(page).toHaveURL(/\/assistant/, { timeout: 15_000 });
});
/* ── Test 4: all protected routes redirect to /login ─────────────────── */
test("all protected routes redirect unauthenticated users to /login", async ({
page,
}) => {
/* Every route under the (pages) route group is protected by the layout
auth guard:
if (!authLoading && !isAuthenticated) { router.push("/login"); }
in frontend/src/app/(pages)/layout.tsx.
REGRESSION: fails if that router.push("/login") is removed from the
layout, or if any of these routes is moved outside the (pages) group
without adding its own auth guard. */
const protectedRoutes = [
"/projects",
"/tabular-reviews",
"/workflows",
"/account",
];
for (const route of protectedRoutes) {
await page.goto(route);
/* 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: 10_000 });
}
});
});
/* ─── Authenticated tests ─────────────────────────────────────────────────── */
/* ── Test 3: logout redirects to /login ─────────────────────────────────── */
/* The logout flow calls supabase.auth.signOut(), which defaults to GLOBAL
scope and revokes the user's session server-side. If this ran against the
shared `e2e@mike.local` user it would 401 every other parallel worker
("Invalid or expired token"). So this test starts from a clean session and
logs in as a DEDICATED user (created in auth.setup.ts) whose session can be
safely destroyed without affecting any other test. */
test.describe("logout (isolated user)", () => {
test.use({ storageState: { cookies: [], origins: [] } });
const logoutEmail =
process.env.E2E_LOGOUT_EMAIL ?? "e2e-logout@mike.local";
const logoutPassword =
process.env.E2E_LOGOUT_PASSWORD ?? "E2eLogoutPass1!";
test("logout from account settings redirects to /login", async ({
page,
}) => {
/* Log in fresh as the dedicated logout user. */
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", logoutEmail);
await page.fill("#password", logoutPassword);
await page.click('button[type="submit"]');
await page.waitForURL(/\/assistant/, { timeout: 15_000 });
await page.waitForLoadState("networkidle");
/* The AppSidebar renders a user-profile toggle button at the very bottom
of the sidebar. The button wraps a circular div that shows the user's
initial:
<div className="h-7 w-7 ... rounded-full bg-gray-700 ...">
{getUserInitials(user.email)}
</div>
Locate the button by the presence of that inner div. */
const userMenuButton = page.locator("button").filter({
has: page.locator("div.rounded-full.bg-gray-700"),
});
await expect(userMenuButton).toBeVisible({ timeout: 10_000 });
await userMenuButton.click();
/* The dropdown that appears contains an "Account Settings" button which
navigates to /account via router.push("/account"). */
const accountSettingsItem = page.getByRole("button", {
name: "Account Settings",
});
await expect(accountSettingsItem).toBeVisible({ timeout: 5_000 });
await accountSettingsItem.click();
await expect(page).toHaveURL(/\/account/, { timeout: 10_000 });
await page.waitForLoadState("networkidle");
/* The /account page has a "Sign Out" button that calls:
await signOut();
router.push("/");
The root "/" page redirects to "/assistant", and the (pages) layout auth
guard then redirects the now-unauthenticated user to "/login".
REGRESSION: fails if signOut() is removed from handleLogout in
frontend/src/app/(pages)/account/page.tsx. */
const signOutButton = page.getByRole("button", { name: "Sign Out" });
await expect(signOutButton).toBeVisible({ timeout: 5_000 });
await signOutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
});
});

110
e2e/auth.setup.ts Normal file
View file

@ -0,0 +1,110 @@
import { test as setup, expect } from "@playwright/test";
import path from "path";
import fs from "fs";
const authFile = path.join(__dirname, ".auth/user.json");
/**
* Read a key out of backend/.env so the setup can reach Supabase with the
* service-role key without requiring the operator to export it manually.
*/
function readApiEnv(key: string): string | undefined {
if (process.env[key]) return process.env[key];
const envPath = path.join(__dirname, "..", "backend", ".env");
try {
const contents = fs.readFileSync(envPath, "utf8");
// dotenv semantics: a later assignment wins over an earlier one. CI
// does `cp .env.example .env` (which ships a PLACEHOLDER SUPABASE_URL)
// and then APPENDS the real values, so returning the FIRST match would
// hand back the placeholder (getaddrinfo ENOTFOUND your-project...).
// Iterate every line and keep the LAST matching value, mirroring how
// the API's dotenv loader resolves the file.
let value: string | undefined;
for (const line of contents.split("\n")) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (m && m[1] === key) value = m[2].trim();
}
return value;
} catch {
/* .env not present — fall through to undefined */
}
return undefined;
}
/**
* Idempotently create a confirmed Supabase user via the admin API. If the user
* already exists the admin endpoint returns a 422 which we treat as success.
*/
async function ensureUser(email: string, password: string) {
const supabaseUrl =
readApiEnv("SUPABASE_URL") ?? "http://127.0.0.1:54321";
const serviceKey = readApiEnv("SUPABASE_SECRET_KEY");
if (!serviceKey) {
throw new Error(
"SUPABASE_SECRET_KEY not found (checked env and backend/.env); " +
"cannot bootstrap E2E users",
);
}
const res = await fetch(`${supabaseUrl}/auth/v1/admin/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
},
body: JSON.stringify({
email,
password,
email_confirm: true,
}),
});
if (!res.ok && res.status !== 422) {
const body = await res.text();
// 422 == user already registered, which is fine for an idempotent setup.
if (!body.includes("already been registered")) {
throw new Error(
`Failed to create user ${email}: ${res.status} ${body}`,
);
}
}
}
/**
* The main authenticated session shared by every non-destructive test.
* Stored to e2e/.auth/user.json and loaded via the chromium project config.
*/
setup("authenticate", async ({ page }) => {
// Default to the credentials the spec files use (the specs log in with
// e2e@mike.local / E2eTestPass1!), so the suite runs out-of-the-box against
// a local stack with no env juggling. The bootstrapped user MUST match the
// password the specs type, or the valid-login tests fail; keeping the
// default here is the single source of truth. Override via env in CI.
const email = process.env.E2E_EMAIL ?? "e2e@mike.local";
const password = process.env.E2E_PASSWORD ?? "E2eTestPass1!";
/* Bootstrap the shared user plus a dedicated user for destructive auth
tests (logout / account deletion). The logout test calls Supabase
signOut() which uses GLOBAL scope and revokes the user's session
server-side; running it against the shared user would 401 every other
parallel worker. Isolating it onto its own user keeps the suite stable. */
await ensureUser(email, password);
await ensureUser(
process.env.E2E_LOGOUT_EMAIL ?? "e2e-logout@mike.local",
process.env.E2E_LOGOUT_PASSWORD ?? "E2eLogoutPass1!",
);
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", email);
await page.fill("#password", password);
await page.click('button[type="submit"]');
/* After login the app redirects to /assistant */
await page.waitForURL(/\/assistant/, { timeout: 15_000 });
/* Save the authenticated session for all subsequent tests */
await page.context().storageState({ path: authFile });
});

498
e2e/chat-management.spec.ts Normal file
View file

@ -0,0 +1,498 @@
/**
* 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 });
});

209
e2e/critical-path.spec.ts Normal file
View file

@ -0,0 +1,209 @@
/**
* 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 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,
}) => {
/* 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 "+ Create New" affordance renders. */
const projectUrl = page.url().split("?")[0];
const createNew = page.getByText("+ Create New");
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(
"Ask a question about your documents...",
);
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 });
});
});

17
e2e/fixtures/test.pdf Normal file
View file

@ -0,0 +1,17 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj 4 0 obj<</Length 44>>stream
BT /F1 12 Tf 100 700 Td (Test PDF Document) Tj ET
endstream
endobj 5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000062 00000 n
0000000119 00000 n
0000000273 00000 n
0000000370 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
441
%%EOF

View file

@ -0,0 +1,416 @@
/**
* Project management E2E tests:
* 1. Rename a project inline
* 2. Delete a project
* 3. Create a folder inside a project
* 4. File upload type validation (wrong type rejected)
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json.
* All tests run with the authenticated storageState configured in playwright.config.ts
* no test.use override is needed here.
*
* Each test creates its own uniquely-named project so tests are fully isolated.
*/
import { test, expect } from "@playwright/test";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
// ─── Shared helper ────────────────────────────────────────────────────────────
/**
* Creates a new project via the "New project" modal and waits until
* NewProjectModal's onCreated handler redirects to /projects/<id>.
*
* Pass `filePath` to also upload a document during creation. This matters for
* the folder test: ProjectPage only renders the document tree (and therefore
* the root "Add Subfolder" input) when the project is NOT empty an empty
* project shows the "Drop PDF or DOCX files here" placeholder instead, which
* has no folder input.
*/
async function createProject(
page: import("@playwright/test").Page,
projectName: string,
filePath?: string,
) {
/* These tests are throttled by the local Supabase stack, which the app
hammers on every modal open (see the settle note below). The per-test
`{ timeout }` option passed to test() is silently ignored by Playwright
(that object only accepts tag/annotation), so the tests would otherwise
run at the 30s default too tight for the directory storm. Raise it
here, where the slow work happens, so every caller gets the budget.
The storm scales with the number of projects the test user has
accumulated, and the modal now mounts FileDirectory on its second
("Add Documents") step, so creation alone can consume most of a 120s
budget and starve whatever the caller does afterwards. */
test.setTimeout(180_000);
await page.goto("/projects");
await expect(page).toHaveURL(/\/projects/, { timeout: 10_000 });
/* 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();
const nameInput = page.getByPlaceholder("Project name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.fill(projectName);
/* NewProjectModal is a two-step wizard: "Details" (name / CM number /
practice / colleagues) then "Add Documents". Only the second step has a
submit button the first step's primary action is a plain "Next". */
await page.getByRole("button", { name: "Next", exact: true }).click();
if (filePath) {
/* On the documents step the footer "Upload" button opens a hidden file
input, and its label gains a "(n)" count once files are attached. */
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: /^Upload/ }).click();
(await fileChooserPromise).setFiles(filePath);
await expect(
page.getByRole("button", { name: /^Upload \(1\)/ }),
).toBeVisible({ timeout: 5_000 });
}
/* The modal mounts a FileDirectory whose useDirectoryData hook fires several
Supabase-backed requests (listProjects + listStandaloneDocuments, plus a
getProject per project) the moment the modal opens. Submitting while those
are still in flight makes the local Supabase gateway (Kong) buckle under
the concurrent load and return a 502 "An invalid response was received
from the upstream server" which the API surfaces as a 500 and the modal
shows inline (text-red-500). Letting the modal's requests settle first
makes creation reliable (measured 6/6 vs 3/6 without this wait).
useDirectoryData fires getProject() for EVERY existing project, so this
storm grows with the project count and can take ~20s once many test
projects have accumulated. Bound the wait so we never hang on it; if it
doesn't fully settle, the submit retry below absorbs any residual 502. */
await page
.waitForLoadState("networkidle", { timeout: 45_000 })
.catch(() => {});
/* Submit NewProjectModal's onCreated calls router.push(`/projects/${id}`).
The PDF upload runs (awaited) inside handleSubmit before onCreated fires,
so allow extra time for navigation when a file is attached.
Even with the settle above, a transient upstream 502 can still slip
through occasionally. NewProjectModal surfaces it inline (text-red-500)
while keeping the modal open and the form state intact, so re-clicking
submit retries safely (the failed request created nothing). Race the
navigation against that inline error so a failure is detected immediately
and retried, rather than burning the full navigation timeout each time. */
const navTimeout = filePath ? 30_000 : 15_000;
const inlineError = page.locator("form p.text-red-500");
for (let attempt = 1; attempt <= 5; attempt++) {
/* The documents step's primary action submits the form (its label flips
to "Creating…" while in flight, so match on the submit role instead). */
await page.locator('button[type="submit"]').click();
const outcome = await Promise.race([
page
.waitForURL(/\/projects\/.+/, { timeout: navTimeout })
.then(() => "nav" as const)
.catch(() => "timeout" as const),
inlineError
.waitFor({ state: "visible", timeout: navTimeout })
.then(() => "error" as const)
.catch(() => "timeout" as const),
]);
if (outcome === "nav") return;
if (attempt === 5) {
throw new Error(
`createProject: never navigated to /projects/<id> (last outcome: ${outcome})`,
);
}
// transient upstream error (or stall) → wait for the inline message to
// clear so the next iteration's race can't latch onto the stale one,
// then resubmit.
await inlineError
.waitFor({ state: "hidden", timeout: 2_000 })
.catch(() => {});
}
}
/**
* Navigate to the projects list and return the table row for `projectName`,
* re-navigating (which refetches) if the list doesn't render it in time.
* ProjectsOverview gates its table on listProjects(); under the local-Supabase
* load that call can be slow or transiently 502 (the page then shows "Could not
* load projects."), so a single goto isn't reliable. Rows are <div class="group">;
* the sidebar "Recent Projects" renders the same name as a <button>, so scoping
* to div.group avoids a strict-mode double match.
*/
async function gotoProjectRow(
page: import("@playwright/test").Page,
projectName: string,
) {
const row = page.locator("div.group").filter({ hasText: projectName });
for (let attempt = 1; attempt <= 5; attempt++) {
await page.goto("/projects");
const shown = await row
.first()
.waitFor({ state: "visible", timeout: 12_000 })
.then(() => true)
.catch(() => false);
if (shown) return row;
}
await expect(row.first()).toBeVisible({ timeout: 12_000 });
return row;
}
/**
* After creating a project we land on /projects/<id>, whose ProjectPage fetches
* getProject() once on mount and does NOT retry. Under the local-Supabase load
* that request can transiently 502 (rendering a permanent "Project not found")
* or simply be slow (leaving the loading skeleton up). Reload until `anchor`
* a control that only renders once the project has loaded becomes visible.
*/
async function waitForProjectLoaded(
page: import("@playwright/test").Page,
anchor: import("@playwright/test").Locator,
) {
for (let attempt = 1; attempt <= 6; attempt++) {
const shown = await anchor
.waitFor({ state: "visible", timeout: 8_000 })
.then(() => true)
.catch(() => false);
if (shown) return;
if (attempt === 6) break;
await page.reload();
await page.waitForLoadState("domcontentloaded").catch(() => {});
}
await expect(anchor).toBeVisible({ timeout: 8_000 });
}
// ─── Test 1: Rename a project inline ─────────────────────────────────────────
test("rename a project via Edit details", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/* Navigate to the projects list (where the rename UI lives) and grab the
row. Each project row is a <div class="group">; gotoProjectRow refetches
if the list is slow/errors under load. The sidebar "Recent Projects" list
renders the same name as a <button> (not div.group), so scoping to
div.group keeps the lookup to the table row. */
const row = await gotoProjectRow(page, projectName);
/* The ··· button (middle-dot U+00B7 × 3) is inside the last cell of the row */
const ellipsisBtn = row.locator("button").filter({ hasText: "···" });
await ellipsisBtn.click();
/*
* The row menu (RowActions) offers "Edit details" and "Delete" the old
* inline "Rename" affordance is gone; renaming now happens in
* ProjectDetailsModal, which also carries the CM number and practice fields.
*/
await page.getByRole("button", { name: "Edit details", exact: true }).click();
/* ProjectDetailsModal's name field is pre-filled with the current name;
fill() clears it first, platform-independently. */
const newName = `E2E Proj Renamed ${Date.now()}`;
const renameInput = page.locator("#project-details-name");
await expect(renameInput).toBeVisible({ timeout: 10_000 });
await renameInput.fill(newName);
// REGRESSION: fails if ProjectDetailsModal's onSave (updateProject) is removed
await page.getByRole("button", { name: "Update", exact: true }).click();
/* handleRenameSubmit optimistically updates the projects list state.
Scope to table rows (div.group) so the sidebar's stale copy of the old
name does not interfere with the negative assertion below. */
// REGRESSION: fails if rename input or submit handler is removed
await expect(
page.locator("div.group").filter({ hasText: newName }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.locator("div.group").filter({ hasText: projectName }),
).toHaveCount(0);
});
// ─── Test 2: Delete a project ─────────────────────────────────────────────────
test("delete a project", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/*
* Back to the projects list (refetching if it's slow/errors under load).
* Rows are scoped to div.group the sidebar "Recent Projects" list also
* shows the name as a <button>, so an unscoped getByText would match two
* elements.
*
* The row checkbox is wrapped in a div with onClick={e.stopPropagation()}
* to prevent accidental row navigation. Clicking the checkbox alone is safe.
*/
const row = await gotoProjectRow(page, projectName);
const checkbox = row.locator('input[type="checkbox"]');
await checkbox.click();
/*
* The "Actions" button is conditionally rendered only when selectedIds.length > 0.
* It opens a small dropdown containing a "Delete" option.
*/
const actionsBtn = page.getByRole("button", { name: /^Actions/ });
await expect(actionsBtn).toBeVisible({ timeout: 3_000 });
await actionsBtn.click();
/* exact:true so the substring match can't pick up any other button whose
accessible name merely contains "Delete". */
const deleteBtn = page.getByRole("button", { name: "Delete", exact: true });
await expect(deleteBtn).toBeVisible({ timeout: 3_000 });
// REGRESSION: fails if `handleDeleteSelected` is removed
await deleteBtn.click();
/* handleDeleteSelected removes the project from local state immediately.
Scope to table rows so a stale sidebar entry can't keep this truthy. */
await expect(
page.locator("div.group").filter({ hasText: projectName }),
).toHaveCount(0, { timeout: 10_000 });
});
// ─── Test 3: Create a folder inside a project ────────────────────────────────
test("create a folder inside a project", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
/* Create WITH a document so the project isn't empty. The "Add Subfolder"
button always sits in the documents toolbar, but the root folder INPUT
only renders inside the document tree (ProjectPage.renderLevel), which is
shown only for a non-empty project an empty project shows the "Drop PDF
or DOCX files here" placeholder instead, with no folder input. */
await createProject(page, projectName, PDF_FIXTURE);
/*
* After createProject we are on the new project page (Documents tab). The
* page fetches the project via getProject() on mount and does NOT retry; if
* that request transiently 502s under the local-Supabase load it renders a
* permanent "Project not found", and a slow getProject just leaves the page
* on its loading skeleton for a while. The documents toolbar (which hosts
* "Add Subfolder") only appears once the project has loaded, so reload until
* it does.
*/
const addSubfolderBtn = page.getByRole("button", { name: "Add Subfolder" });
await waitForProjectLoaded(page, addSubfolderBtn);
/* Confirm the uploaded document rendered, i.e. the project is non-empty and
the document tree (and therefore the root folder input) will render. */
await expect(page.getByText("test.pdf").first()).toBeVisible({
timeout: 10_000,
});
/* Clicking "Add Subfolder" sets creatingFolderIn = null (root level). */
await addSubfolderBtn.click();
/*
* renderFolderInput renders an <input> with placeholder "Folder name"
* that is autoFocused. Pressing Enter calls handleCreateFolder(null)
* which creates a folder at the root level.
*/
const folderInput = page.getByPlaceholder("Folder name");
await expect(folderInput).toBeVisible({ timeout: 3_000 });
const folderName = `Test Folder ${Date.now()}`;
await folderInput.fill(folderName);
await folderInput.press("Enter");
/*
* handleCreateFolder optimistically inserts the folder into local state,
* then replaces the temp entry with the real folder from the API.
*/
// REGRESSION: fails if folder creation button or API call is removed
await expect(page.getByText(folderName)).toBeVisible({ timeout: 10_000 });
});
// ─── Test 4: File upload type validation (wrong type rejected) ────────────────
test("file upload type validation — .txt file is rejected", async ({ page }) => {
const projectName = `E2E Proj ${Date.now()}`;
await createProject(page, projectName);
/*
* After createProject we are on the project's Documents tab.
* The "Add Documents" button opens AddDocumentsModal which has a
* hidden file input with accept=".pdf,.docx,.doc".
*
* Validation is now two layers, and this test covers both:
* (a) UI: AddDocumentsModal filters unsupported files client-side
* (partitionSupportedDocumentFiles) and shows a visible warning
* no request is sent, so we assert the warning + absence of the file.
* (b) Server: the upload endpoint must still 400 unsupported extensions
* (defense in depth for API/SDK callers that bypass the web UI).
* The UI never emits that request anymore, so we exercise the
* endpoint directly with the browser session's bearer token.
*/
/* Open the Add Documents modal. The "Add Documents" button only renders once
ProjectPage has loaded the project; getProject() can transiently 502 (
"Project not found") or be slow under load, so reload-guard the page. */
const addDocsBtn = page.getByRole("button", { name: "Add Documents" });
await waitForProjectLoaded(page, addDocsBtn);
/* (b) Server-side rejection REGRESSION: fails if type validation is
removed from the upload handler. */
const projectId = page.url().match(/\/projects\/([0-9a-f-]{36})/)?.[1];
expect(projectId, "expected to be on a /projects/<id> page").toBeTruthy();
const accessToken = await page.evaluate(() => {
const item = Object.entries(localStorage).find(([k]) =>
k.includes("auth-token"),
);
if (!item) return null;
try {
return JSON.parse(item[1]).access_token ?? null;
} catch {
return null;
}
});
expect(accessToken, "expected a Supabase session in localStorage").toBeTruthy();
const apiBase = process.env.MIKE_API_BASE_URL ?? "http://localhost:3001";
const uploadResponse = await page.request.post(
`${apiBase}/projects/${projectId}/documents`,
{
headers: { Authorization: `Bearer ${accessToken}` },
multipart: {
file: {
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from(
"This is a plain text file that should be rejected.",
),
},
},
},
);
expect(uploadResponse.status()).toBe(400);
/* (a) UI-side filtering with a visible warning. */
await addDocsBtn.click();
const fileChooserPromise = page.waitForEvent("filechooser");
/* The Upload button label is "Upload" (not "Uploading…") when idle */
await page.getByRole("button", { name: "Upload" }).first().click();
const fileChooser = await fileChooserPromise;
/*
* Playwright's setFiles accepts an in-memory file descriptor, bypassing
* the browser's accept-attribute filter so the client-side partition
* logic (not the accept attribute) is what's under test.
*/
await fileChooser.setFiles({
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from("This is a plain text file that should be rejected."),
});
// REGRESSION: fails if the visible unsupported-type warning is removed
// (UNSUPPORTED_DOCUMENT_WARNING_MESSAGE in documentUploadValidation.ts).
await expect(
page.getByText(
"Unsupported file type. Only PDF, Word, Excel, and PowerPoint files can be uploaded.",
),
).toBeVisible({ timeout: 10_000 });
/* The .txt file must not appear in the modal's document list */
await expect(page.getByText("test.txt")).not.toBeVisible();
});

315
e2e/tabular-reviews.spec.ts Normal file
View file

@ -0,0 +1,315 @@
/**
* Tabular Review E2E tests:
* 1. Navigate to /tabular-reviews the list page loads correctly
* 2. Create a new tabular review modal flow, API call, redirect to detail
* 3. Review detail page table structure and toolbar controls render
* 4. Add a document upload via AddDocumentsModal, row appears in table
*
* Prerequisite: auth.setup.ts has already saved the session to e2e/.auth/user.json
* Test user: e2e@mike.local / E2eTestPass1! (storageState inherited from playwright.config.ts)
*/
import { test, expect } from "@playwright/test";
import path from "path";
const PDF_FIXTURE = path.join(__dirname, "fixtures/test.pdf");
// Run these tests sequentially in a single worker. The global config sets
// fullyParallel:true, which would otherwise run the create/detail tests
// concurrently — and they each trigger the first on-demand `next dev` compile
// of the dynamic /tabular-reviews/[id] route at the same time. Under that
// compiler contention the dev server drops in-flight client navigations, so
// router.push() lands but the URL never changes. Serial mode lets the first
// test warm (compile) the route, after which the rest navigate near-instantly.
test.describe.configure({ mode: "serial" });
/* ─── Helpers ────────────────────────────────────────────────────────────── */
/**
* Click the "New review" Plus icon button in the Tabular Reviews list-page header.
*
* The button has no aria-label or visible text it is an icon-only button
* rendered immediately after the HeaderSearchBtn (magnifier icon) in the same
* flex container that is the sibling of the <h1>Tabular Reviews</h1>.
*
* DOM structure:
* <div class="… justify-between …">
* <h1>Tabular Reviews</h1>
* <div class="flex items-center gap-2"> xpath=../div[1] from h1
* <div><button>{SearchIcon}</button></div> HeaderSearchBtn
* <button>{PlusIcon}</button> new-review button (.last())
* </div>
* </div>
*
* TODO: once aria-label="New review" is added to that button, replace with:
* page.getByRole("button", { name: "New review" })
*/
async function clickNewReviewBtn(page: import("@playwright/test").Page) {
// Walk from the h1 to the parent div, then select its first div child
// (the actions container); the last button within it is the Plus icon.
const actionsDiv = page
.getByRole("heading", { name: "Tabular Reviews" })
.locator("xpath=../div[1]"); // TODO: verify selector
await actionsDiv.getByRole("button").last().click();
}
/** Predicate matching the create-review request: POST /tabular-review (exact). */
const isCreateReviewPost = (r: import("@playwright/test").Response) =>
/\/tabular-review\/?$/.test(new URL(r.url()).pathname) &&
r.request().method() === "POST";
/**
* Open the AddNewTRModal (assumes /tabular-reviews is already loaded) and return
* its "Review name" input once visible.
*/
async function openNewReviewModal(page: import("@playwright/test").Page) {
await clickNewReviewBtn(page);
const titleInput = page.getByPlaceholder("Review name");
await expect(titleInput).toBeVisible({ timeout: 10_000 });
return titleInput;
}
/**
* Create a tabular review through the real modal flow and land on its detail
* page. Returns the title that was entered so callers can assert on it.
*
* Two dev-environment flakes are tolerated here so the *behaviour under test*
* (create detail page) is what's exercised, not infrastructure noise:
*
* 1. The local Supabase gateway (Kong) intermittently returns 500
* "An invalid response was received from the upstream server" / "fetch
* failed" when its PostgREST upstream is momentarily unavailable observed
* on ~30% of create calls, and far more often while the modal's per-project
* fetch burst is hammering Supabase concurrently. We wait for that burst to
* settle before submitting, and retry the POST on transient 5xx. A genuinely
* broken create flow fails *every* attempt, so regressions are still caught.
* 2. `next dev` compiles the dynamic /tabular-reviews/[id] route on first
* navigation (15-30s cold); under that latency the dev server can drop the
* in-flight client navigation, so we fall back to an explicit goto.
*
* @param onFirstOpen optional assertion run against the modal on the first open
* (used by Test 2 to verify the workflow-template default renders).
*/
async function createReview(
page: import("@playwright/test").Page,
label = "E2E Review",
onFirstOpen?: () => Promise<void>,
): Promise<string> {
await page.goto("/tabular-reviews");
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
const reviewName = `${label} ${Date.now()}`;
let review: { id: string } | null = null;
for (let attempt = 0; attempt < 10 && !review; attempt++) {
if (attempt > 0) {
// The modal closes itself on submit; reopen for the retry.
await page.goto("/tabular-reviews");
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
}
const titleInput = await openNewReviewModal(page);
if (attempt === 0 && onFirstOpen) await onFirstOpen();
await titleInput.fill(reviewName);
// NewTRModal is a two-step wizard ("Details" → "Add Documents"); the
// "Create" submit button only exists on the second step, and "Next" only
// enables once the review has a name.
await page.getByRole("button", { name: "Next", exact: true }).click();
// Let the modal's project-fetch burst settle so the create POST doesn't
// compete with it on the flaky local Supabase (best-effort; the HMR
// socket means networkidle may not fully settle, so it's time-boxed).
await page
.waitForLoadState("networkidle", { timeout: 8_000 })
.catch(() => {});
// exact: true hits the modal's submit button only — the list page's
// empty-state "+ Create New" CTA and the "Create under a project" toggle
// also contain the word "Create".
const respP = page
.waitForResponse(isCreateReviewPost, { timeout: 30_000 })
.catch(() => null);
await page.getByRole("button", { name: "Create", exact: true }).click();
const resp = await respP;
if (resp && resp.ok()) {
review = (await resp.json()) as { id: string };
}
// else: transient upstream 5xx — loop reopens the modal and retries.
}
expect(
review,
"POST /tabular-review never returned 2xx after retries — create flow broken?",
).not.toBeNull();
// createTabularReview() → router.push("/tabular-reviews/<id>").
await page
.waitForURL(`**/tabular-reviews/${review!.id}`, { timeout: 60_000 })
.catch(() => page.goto(`/tabular-reviews/${review!.id}`));
await expect(page).toHaveURL(
new RegExp(`/tabular-reviews/${review!.id}`),
{ timeout: 60_000 },
);
return reviewName;
}
/* ─── Test 1: list page loads ─────────────────────────────────────────────── */
test("navigates to /tabular-reviews and the list page renders", async ({
page,
}) => {
// REGRESSION: fails if the /tabular-reviews route is removed or broken
await page.goto("/tabular-reviews");
await expect(page).toHaveURL(/\/tabular-reviews/);
// The page renders an h1 heading with the section title
await expect(
page.getByRole("heading", { name: "Tabular Reviews" }),
).toBeVisible({ timeout: 10_000 });
// The ToolbarTabs bar renders the "All" tab
// TODO: verify selector if ToolbarTabs uses role="tab" instead of role="button"
await expect(page.getByText("All")).toBeVisible({ timeout: 5_000 });
});
/* ─── Test 2: create a new tabular review ─────────────────────────────────── */
test("creates a new tabular review and is redirected to the detail page", async ({
page,
}) => {
// Headroom for the create-POST retries (flaky local Supabase) plus the
// on-demand `next dev` compile of the dynamic /tabular-reviews/[id] route.
test.setTimeout(180_000);
// REGRESSION: fails if createTabularReview() API call is removed or the
// /tabular-reviews POST route is broken (every retry attempt then fails,
// so `review` stays null and createReview's not-null assertion trips).
//
// createReview opens the modal, verifies the workflow-template default
// renders, submits, and lands on the new review's detail page.
const reviewName = await createReview(page, "E2E Review", async () => {
// The workflow template control defaults to "No template - start from
// scratch" once the templates request resolves (it shows "Loading
// templates…" until then), so allow time for that listWorkflows() fetch.
// NewTRModal renders it as a button, with a hyphen — not an em dash.
await expect(
page.getByRole("button", { name: "No template - start from scratch" }),
).toBeVisible({ timeout: 15_000 });
});
// The new review's title appears in the page breadcrumb header
await expect(page.getByText(reviewName)).toBeVisible({ timeout: 10_000 });
});
/* ─── Test 3: review detail page table structure ─────────────────────────── */
test("review detail page renders the table structure and toolbar controls", async ({
page,
}) => {
// Headroom for create-POST retries (flaky local Supabase) plus the
// on-demand `next dev` compile of the dynamic detail route.
test.setTimeout(180_000);
// REGRESSION: fails if the /tabular-reviews/[id] route, TRView, or TRTable
// component is broken
const reviewName = await createReview(page, "E2E Table Review");
// The breadcrumb header shows the review title via RenameableTitle
await expect(page.getByText(reviewName)).toBeVisible({ timeout: 10_000 });
// The breadcrumb also contains a "Tabular Reviews" back-nav button. Scope to
// the <main> landmark: the left sidebar nav also has a "Tabular Reviews"
// button, so an unscoped role query is a strict-mode violation. exact:true
// avoids also matching the mobile-only "Back to Tabular Reviews" control.
await expect(
page
.getByRole("main")
.getByRole("button", { name: "Tabular Reviews", exact: true }),
).toBeVisible({ timeout: 5_000 });
// TRTable always renders a "Document" column header, even when the review
// is empty. This is visible in both the empty-state and populated states.
await expect(
page.getByText("Document", { exact: true }),
).toBeVisible({ timeout: 10_000 });
// The toolbar renders "Add Columns" and "Add Documents" once loading is done.
// Both may also appear in TRTable's empty-state CTA, so .first() is used.
await expect(
page.getByRole("button", { name: /Add Columns/ }).first(),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /Add Documents/ }).first(),
).toBeVisible({ timeout: 5_000 });
});
/* ─── Test 4: add a document to a review ─────────────────────────────────── */
test("adds a document to a tabular review and the row appears in the table", async ({
page,
}) => {
// Headroom for create-POST retries, the detail-route compile, and the
// upload + document-link round-trips.
test.setTimeout(180_000);
// REGRESSION: fails if the document-to-review linking
// (PATCH /tabular-reviews/:id with document_ids) or the upload endpoint breaks
const reviewName = await createReview(page, "E2E Doc Review");
// reviewName is already confirmed visible on the detail page by createReview
// Add the document and assert the row appears. The upload endpoint and the
// document-link PATCH both go through the same flaky local Supabase that
// intermittently 500s (see createReview), so the whole open→upload→confirm
// round-trip is retried until the row renders. The behaviour under test —
// a successful upload + link surfacing the row — is unchanged; a genuine
// break in the upload or link path fails *every* attempt (the modal already
// re-deletes nothing, so each retry uploads a fresh copy).
const row = page.getByText("test.pdf").first();
const confirmBtn = page.getByRole("button", { name: "Confirm" });
for (let attempt = 0; attempt < 6; attempt++) {
// Open AddDocumentsModal (standalone path → AddDocumentsModal, not
// AddProjectDocsModal). first() handles both toolbar & empty-state CTA.
const addDocsBtn = page
.getByRole("button", { name: /Add Documents/ })
.first();
await expect(addDocsBtn).toBeVisible({ timeout: 10_000 });
await addDocsBtn.click();
// The footer's "Upload" button programmatically clicks a hidden
// <input type="file"> — Playwright intercepts it as a file-chooser event.
const uploadBtn = page.getByRole("button", { name: "Upload" });
await expect(uploadBtn).toBeVisible({ timeout: 5_000 });
const fileChooserPromise = page.waitForEvent("filechooser");
await uploadBtn.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(PDF_FIXTURE);
// After a successful upload the server document is auto-selected and the
// "Confirm" button transitions disabled → enabled. A 5xx upload leaves it
// disabled — close the modal and retry.
const becameEnabled = await expect(confirmBtn)
.toBeEnabled({ timeout: 20_000 })
.then(() => true)
.catch(() => false);
if (!becameEnabled) {
await page.getByRole("button", { name: "Cancel" }).click();
continue; // upload 5xx'd — retry
}
// Confirm → onSelect() → handleAddDocuments() → updateTabularReview()
// PATCH → setDocuments() → TRTable renders the new row with doc.filename.
await confirmBtn.click();
const appeared = await row
.waitFor({ state: "visible", timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (appeared) break;
// The link PATCH may have 5xx'd (modal already closed) — loop and retry.
}
await expect(row).toBeVisible({ timeout: 15_000 });
});

View file

@ -0,0 +1,386 @@
/**
* E2E tests for Workflows and Account Settings features.
*
* Test user: e2e@mike.local / E2eTestPass1! (session loaded from e2e/.auth/user.json)
*
* Key source facts used by these selectors:
* - WorkflowList.tsx: h1 "Workflows"; Plus icon button (no aria-label) opens NewWorkflowModal
* - NewWorkflowModal.tsx: placeholder "Workflow name"; submit button text "Create workflow"
* - systemWorkflows.ts (generated): built-in id "builtin-cp-checklist", title "Draft CP Checklist"
* - WorkflowDetailPage ([id]/page.tsx): readOnly badge renders <span>Read-only</span>;
* WorkflowPromptEditor passes editable:!readOnly to Tiptap contenteditable="false" when readOnly
* - WorkflowPromptEditor.tsx: editorProps class = "workflow-editor-content" on the ProseMirror div
* - WorkflowDetailPage save status: text "Saving…" "Saved" rendered in a plain <span>
* - account/page.tsx: h2 "Profile"; Input placeholder "Enter your name"; Button "Save" / "Saved"
* - account/layout.tsx: h1 "Settings" in layout header
* - account/models/page.tsx: h2 "API Keys"; label texts include "Anthropic (Claude) API Key" etc.
*/
import { test, expect, type Page } from "@playwright/test";
/**
* Create a workflow from an already-open NewWorkflowModal and wait for the
* post-create navigation to /workflows/<id>.
*
* The local Supabase/Kong gateway intermittently returns 502 on POST /workflows
* under load. On a failed create the modal stays open with the entered name
* retained NewWorkflowModal.handleSubmit only calls onCreated()/onClose() on
* success so a transient failure is recovered by re-submitting the form.
*
* This retries ONLY transient failures: a genuine create regression (persistent
* 5xx) never navigates on any attempt, so the final assertion still fails and the
* regression is preserved.
*/
async function createWorkflowAndOpenDetail(page: Page, title: string) {
const nameInput = page.getByPlaceholder("Workflow name");
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.fill(title);
// Match the submit button in BOTH states: its label is "Create workflow" when idle
// and "Creating…" while a request is in flight. Matching only "Create workflow" would
// make the button "not found" mid-submit and break the retry loop.
const createBtn = page.getByRole("button", {
name: /create workflow|creating/i,
});
const MAX_ATTEMPTS = 4;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
// Already navigated (success on a prior attempt)? Done.
if (/\/workflows\/.+/.test(page.url())) return;
// Wait until the button is idle/enabled ("Create workflow"), then re-submit.
// toBeEnabled rides out a slow in-flight "Creating…" from the previous attempt.
await expect(createBtn).toBeEnabled({ timeout: 10_000 });
await createBtn.click();
try {
await expect(page).toHaveURL(/\/workflows\/.+/, { timeout: 10_000 });
return;
} catch {
// Transient gateway 5xx — modal stays open with the name retained; retry.
}
}
// Final assertion: surfaces a persistent (non-transient) create failure clearly.
await expect(page).toHaveURL(/\/workflows\/.+/, { timeout: 10_000 });
}
/*
WORKFLOWS
*/
test.describe("Workflows", () => {
/* ── Test 1: list page loads and shows built-in workflows ──────────────── */
test("workflow list page loads and shows built-in workflows", async ({
page,
}) => {
await page.goto("/workflows");
// REGRESSION: fails if the /workflows route or page component is broken
await expect(page).toHaveURL(/\/workflows/, { timeout: 10_000 });
// The WorkflowList renders an h1 heading
await expect(
page.getByRole("heading", { name: "Workflows" }),
).toBeVisible({ timeout: 10_000 });
// System workflows are generated into backend/src/lib/systemWorkflows.ts —
// "Draft CP Checklist" (id: builtin-cp-checklist) is always present
// is always present; its title appears as a row in the table.
// REGRESSION: fails if the workflow list page or built-in workflow rendering is broken
await expect(page.getByText("Draft CP Checklist")).toBeVisible({
timeout: 10_000,
});
});
/* ── Test 2: create a custom workflow ──────────────────────────────────── */
test("create a custom assistant workflow and navigate to its detail page", async ({
page,
}) => {
await page.goto("/workflows");
await expect(
page.getByRole("heading", { name: "Workflows" }),
).toBeVisible({ timeout: 10_000 });
// The Plus icon button (no aria-label) is the last button inside the div
// that directly contains the h1 "Workflows" heading. The only other button
// in that container is the HeaderSearchBtn search toggle, which comes first.
// TODO: verify selector if the page header layout changes
const newWorkflowBtn = page
.locator("div:has(> h1:has-text('Workflows')) button")
.last();
await expect(newWorkflowBtn).toBeVisible({ timeout: 5_000 });
await newWorkflowBtn.click();
// The NewWorkflowModal opens — its breadcrumb reads "New workflow"
await expect(page.getByText("New workflow")).toBeVisible({
timeout: 5_000,
});
// Fill the title, submit, and wait for the post-create router.push to
// /workflows/<id>. Type defaults to "Assistant" — no change needed.
// The helper re-submits on transient gateway 502s (see top of file).
// REGRESSION: a broken workflow-create API never navigates on any attempt,
// so the helper's final toHaveURL assertion still fails.
const workflowTitle = `E2E Workflow ${Date.now()}`;
await createWorkflowAndOpenDetail(page, workflowTitle);
// The detail page shows the newly created workflow's title
await expect(page.getByText(workflowTitle)).toBeVisible({
timeout: 10_000,
});
});
/* ── Test 3: built-in workflow detail page is read-only ────────────────── */
test("built-in workflow detail page shows Read-only badge and non-editable prompt", async ({
page,
}) => {
// Navigate directly to the known built-in ID; this avoids having to click
// through the DisplayWorkflowModal "View Page" button.
await page.goto("/workflows/builtin-cp-checklist");
// The page loads and shows the built-in workflow title
await expect(page.getByText("Draft CP Checklist")).toBeVisible({
timeout: 15_000,
});
// WorkflowDetailPage renders a "Read-only" badge for built-in (is_system) workflows
// REGRESSION: fails if built-in read-only enforcement is removed from the detail page
await expect(page.getByText("Read-only")).toBeVisible({
timeout: 10_000,
});
// WorkflowPromptEditor is dynamically imported (SSR: false); wait for it to mount.
// When readOnly=true, Tiptap sets editable:false which renders contenteditable="false"
// on the ProseMirror content div (given class "workflow-editor-content" via editorProps).
// REGRESSION: fails if the readOnly prop is no longer passed to WorkflowPromptEditor
const editorDiv = page.locator(".ProseMirror");
await expect(editorDiv).toBeVisible({ timeout: 15_000 });
await expect(editorDiv).toHaveAttribute("contenteditable", "false", {
timeout: 5_000,
});
});
/* ── Test 4: custom workflow prompt auto-saves on change ───────────────── */
test("editing a custom workflow prompt triggers auto-save", async ({
page,
}) => {
/* Step 1: create a fresh custom workflow to edit */
await page.goto("/workflows");
await expect(
page.getByRole("heading", { name: "Workflows" }),
).toBeVisible({ timeout: 10_000 });
// TODO: verify selector if the page header layout changes
const newWorkflowBtn = page
.locator("div:has(> h1:has-text('Workflows')) button")
.last();
await newWorkflowBtn.click();
const workflowTitle = `E2E Edit Workflow ${Date.now()}`;
// Resilient create: the inline workflow-create here intermittently hit a
// transient gateway 502 (→ no navigation, test stuck on /workflows). The
// helper re-submits on transient 5xx and waits for the /workflows/<id>
// detail navigation. A genuine create regression still fails all attempts.
await createWorkflowAndOpenDetail(page, workflowTitle);
await page.waitForLoadState("networkidle");
/* Step 2: type into the WorkflowPromptEditor */
// The editor is dynamically imported; wait until it is ready.
// When readOnly=false (custom workflow), contenteditable="true".
const editorDiv = page.locator(".ProseMirror");
await expect(editorDiv).toBeVisible({ timeout: 15_000 });
await expect(editorDiv).toHaveAttribute("contenteditable", "true", {
timeout: 5_000,
});
await editorDiv.click();
await page.keyboard.type("This is an E2E test prompt.");
/* Step 3: the debounced auto-save (800 ms) fires and the save-status
span transitions: "" "Saving…" "Saved".
save() (workflows/[id]/page.tsx:122-138) sets "Saving…" synchronously on
every edit, then PATCHes prompt_md and sets "Saved" (which auto-reverts to
idle after ~2 s). Under load the PATCH can transiently 502 the catch
sets status back to "idle" so "Saved" never lands. Each keystroke re-fires
the debounced save, so we re-trigger until the PATCH succeeds.
REGRESSION: a removed/broken update API or save wiring shows NEITHER
"Saving…" (guard #1, save() never fires) NOR "Saved" (guard #2, PATCH
never resolves) on any attempt, so this still fails for a genuine break. */
const SAVE_ATTEMPTS = 4;
let saveConfirmed = false;
for (let attempt = 0; attempt < SAVE_ATTEMPTS && !saveConfirmed; attempt++) {
if (attempt > 0) {
// Re-fire the debounced save after a transient PATCH failure.
await page.keyboard.type(".");
}
// Guard #1: the save() handler must run (sets "Saving…" synchronously).
// PageHeader renders its actions twice — a desktop inline copy and a
// portal-mounted mobile copy — so an unscoped text locator resolves to
// two nodes and trips strict mode. Filter to the visible instance.
await expect(
page
.getByText(/^(Saving…|Saved)$/)
.filter({ visible: true })
.first(),
).toBeVisible({ timeout: 10_000 });
// Guard #2: the PATCH must resolve to "Saved" (transient 502s retried).
saveConfirmed = await page
.getByText("Saved")
.filter({ visible: true })
.first()
.waitFor({ state: "visible", timeout: 8_000 })
.then(() => true)
.catch(() => false);
}
expect(
saveConfirmed,
"workflow prompt auto-save never reached the 'Saved' state",
).toBe(true);
});
});
/*
ACCOUNT SETTINGS
*/
test.describe("Account Settings", () => {
/* ── Test 5: account page loads with user info ────────────────────────── */
test("account settings page loads and shows user email", async ({
page,
}) => {
await page.goto("/account");
// The account layout renders a "Settings" h1
// REGRESSION: fails if the account page or its layout is broken
await expect(
page.getByRole("heading", { name: "Settings" }),
).toBeVisible({ timeout: 10_000 });
// The Profile section has its own h2
await expect(
page.getByRole("heading", { name: "Profile" }),
).toBeVisible({ timeout: 10_000 });
// The email is rendered in the (editable) Email input, so assert its
// value rather than page text.
// REGRESSION: fails if user auth context is not propagated to the account page
await expect(page.getByPlaceholder("Enter your email")).toHaveValue(
"e2e@mike.local",
{ timeout: 10_000 },
);
});
/* ── Test 6: update display name ─────────────────────────────────────── */
test("updating display name saves and persists across navigation", async ({
page,
}) => {
// This test bounds-retries its mutation + persistence steps to ride out the
// intermittent gateway 502s, so give it more headroom than the 30 s default.
test.setTimeout(120_000);
await page.goto("/account");
await expect(
page.getByRole("heading", { name: "Settings" }),
).toBeVisible({ timeout: 10_000 });
// The Display Name Input has placeholder "Enter your name"
const nameInput = page.getByPlaceholder("Enter your name");
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const newName = `E2E Test User ${Date.now()}`;
// The Save button is the sibling of the input in the same "flex gap-2" row.
// Scope it to that row so it is the Display-Name button, not the Organisation one.
// TODO: verify selector if the Profile section layout changes
const saveBtn = nameInput
.locator("xpath=parent::div")
.getByRole("button", { name: /save/i });
// Robustly save the new name and verify it persists. Two real hazards are folded
// into one converging retry:
//
// 1) Async hydration race. The account page hydrates this input from a profile
// fetch (UserProfileContext → `if (profile?.displayName) setDisplayName(...)`).
// Under cold-start the auth state can settle late and trigger a SECOND profile
// fetch that overwrites the field AFTER we type — so the stale stored name is
// what handleSaveDisplayName persists (observed: a *previous* run's name was
// saved). We therefore (re)fill immediately before saving and re-verify the
// persisted value; if a late overwrite slipped a stale value in, the persist
// check fails and the block re-runs (auth has settled by then, so it converges).
// 2) Transient gateway 502 on the PATCH or the post-reload GET — also retried here.
//
// On success the label flips Save → "Saved" for ~2 s (Display-Name button only; the
// Organisation button stays "Save").
//
// REGRESSION: a broken profile PATCH / save handler never reaches "Saved" and never
// persists newName, so every attempt fails and toPass exhausts → the test fails.
await expect(async () => {
// Reload at the START of each attempt so a transient 502 on the profile GET
// (which leaves the input empty via the null-displayName fallback, with no
// client-side refetch) is retried with a fresh fetch rather than looping on a
// permanently-empty page.
//
// Hydration signal: wait for the profile GET itself, not for a non-empty
// input. A fresh e2e user (fresh database) has displayName=null, so
// "input pre-filled with the stored name" can never happen on the
// first-ever run — the old not.toHaveValue("") wait deadlocked there.
const profileLoaded = page.waitForResponse(
(resp) =>
resp.url().endsWith("/user/profile") &&
resp.request().method() === "GET" &&
resp.ok(),
{ timeout: 10_000 },
);
await page.goto("/account");
await profileLoaded;
await nameInput.fill(newName);
await expect(nameInput).toHaveValue(newName, { timeout: 2_000 });
await expect(saveBtn).toBeEnabled({ timeout: 5_000 });
await saveBtn.click();
await expect(saveBtn).toHaveText(/saved/i, { timeout: 8_000 });
// Navigate away and back; the freshly fetched profile must show newName.
await page.goto("/assistant");
await page.goto("/account");
await expect(nameInput).toHaveValue(newName, { timeout: 8_000 });
}).toPass({ timeout: 90_000 });
});
/* ── Test 7: API keys page loads and shows all three provider sections ── */
test("API keys page loads and shows Anthropic, Google, and OpenAI sections", async ({
page,
}) => {
// API keys were split out of /account/models into their own settings
// page (the "API Keys" sidebar entry) — /account/models now holds only
// model preferences.
await page.goto("/account/api-keys");
// The shared account layout still renders "Settings"
await expect(
page.getByRole("heading", { name: "Settings" }),
).toBeVisible({ timeout: 10_000 });
// The h2 "API Keys" section is present
// REGRESSION: fails if the /account/api-keys page is broken or the API Keys section is removed
await expect(
page.getByRole("heading", { name: "API Keys" }),
).toBeVisible({ timeout: 10_000 });
// All three provider label texts (from MODEL_API_KEY_FIELDS in api-keys/page.tsx) must appear
// REGRESSION: fails if any provider section is removed from the API keys page
await expect(
page.getByText("Anthropic (Claude) API Key"),
).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Google (Gemini) API Key")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("OpenAI API Key")).toBeVisible({
timeout: 10_000,
});
});
});

113
package-lock.json generated Normal file
View file

@ -0,0 +1,113 @@
{
"name": "mike",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mike",
"license": "AGPL-3.0-only",
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "mike",
"private": true,
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22"
},
"license": "AGPL-3.0-only"
}

65
playwright.config.ts Normal file
View file

@ -0,0 +1,65 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Run `npx playwright install` to download the browsers.
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* These E2E tests run against a single shared backend and a single shared
test user (e2e@mike.local). Running them concurrently causes data races
on shared list views (projects/chats/workflows) and on the user's
session, producing flaky pass/fail that can't be trusted for regression
detection. So we run strictly one test at a time. */
fullyParallel: false,
workers: 1,
/* Fail the build on CI if you accidentally left test.only in the source */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter */
reporter: process.env.CI ? "github" : "list",
/* Shared settings for all the projects below */
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
/* Run the auth setup before all other tests */
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/user.json",
},
dependencies: ["setup"],
},
],
/* Start the backend and the Next.js dev server when running locally */
webServer: process.env.CI
? undefined
: [
{
command: "npm run dev",
cwd: "backend",
url: "http://localhost:3001/health",
reuseExistingServer: true,
timeout: 120_000,
},
{
command: "npm run dev",
cwd: "frontend",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 120_000,
},
],
});

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["node"]
},
"include": ["e2e/**/*.ts", "playwright.config.ts"]
}