mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
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:
parent
dafac6b0a4
commit
94987e6bd7
13 changed files with 2340 additions and 0 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
174
e2e/auth-flows.spec.ts
Normal 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
110
e2e/auth.setup.ts
Normal 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
498
e2e/chat-management.spec.ts
Normal 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
209
e2e/critical-path.spec.ts
Normal 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
17
e2e/fixtures/test.pdf
Normal 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
|
||||
416
e2e/project-management.spec.ts
Normal file
416
e2e/project-management.spec.ts
Normal 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
315
e2e/tabular-reviews.spec.ts
Normal 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 });
|
||||
});
|
||||
386
e2e/workflows-account.spec.ts
Normal file
386
e2e/workflows-account.spec.ts
Normal 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
113
package-lock.json
generated
Normal 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
18
package.json
Normal 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
65
playwright.config.ts
Normal 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
14
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue