/** * 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-draft-cp-checklist", title "Draft CP Checklist" * - WorkflowDetailPage ([id]/page.tsx): readOnly badge renders Read-only; * 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 * - 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/. */ 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 the request is in flight. const createBtn = page.getByRole("button", { name: /create workflow|creating/i, }); await expect(createBtn).toBeEnabled({ timeout: 10_000 }); await createBtn.click(); await expect(page).toHaveURL(/\/workflows\/.+/, { timeout: 15_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-draft-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/. Type defaults to "Assistant" — no change needed. // REGRESSION: a broken workflow-create API never navigates, so the // helper's toHaveURL assertion 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. builtin-draft-cp-checklist // is an assistant-type workflow, so use the typed detail route // (/workflows/assistant/[id]) that the app itself links to via // workflowDetailPath — the flat /workflows/[id] path does not exist here. await page.goto("/workflows/assistant/builtin-draft-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()}`; 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). REGRESSION: a removed/broken update API or save wiring shows NEITHER "Saving…" (guard #1, save() never fires) NOR "Saved" (guard #2, the PATCH never resolves), so this fails for a genuine break. */ // 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". await expect( page.getByText("Saved").filter({ visible: true }).first(), ).toBeVisible({ timeout: 10_000 }); }); }); /* ───────────────────────────────────────────────────────────────────────────── 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 converge // past a real client-side hydration race (see below), 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. This retry exists // for a real client-side hazard, independent of infrastructure: // // 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). // // 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 failed or stale 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, }); }); });