/** * AI Software Factory — RELEASE v1.0 FINAL E2E FLOW (TASK-0135, spec L242–266). * * Thirteen SEQUENTIAL steps (one browser context + one Bearer API context, * state carries across the whole flow — "one project, sequential"): * 1 register · 2 verify · 3 workspace · 4 page · 5 editor DnD reorder · * 6 database + 3 records · 7 relation + formula · 8 global search · * 9 10 MB upload · 10 anonymous public form · 11 automation → notify · * 12 AI (mock) · 13 API key + signed webhook + logout. * * TARGET (spec L245): the STAGING URL. Environment gates: * E2E_STAGING=1 — targets deployed staging; the four staging-only steps * (2 mailcatcher verify, 9 10 MB S3 upload, 10 anonymous * form context, 12 mock-AI stream) are ENABLED. * E2E_RUN_ID — unique per run; the register email is * `final-e2e-@factory.local` (spec step 1). * Without `E2E_STAGING=1` (a local dev run) those four steps NO-OP with a * rule-29 annotation — the capability genuinely does not exist on the dev box * (no mailcatcher / reachable S3) and is never faked green. Step 9 additionally * probes S3/MinIO reachability at runtime and skips when storage is down. * * CONTRACT CORRECTIONS vs. the earlier draft (each verified against the live * API + web source in TASK-0135 — see the release report): * - Databases are created via `POST /workspaces/:ws/pages {type:"database"}` * (there is NO `/workspaces/:ws/databases` route). The seeded page carries * `databaseId` + a title/Status property set (DEFAULT_DB_PROPERTIES). * - Records are keyed by PROPERTY ID (propertyId → value), NOT property name — * a name-keyed `properties` body is a 400 `unknown_property`. * - Search is `POST /search { q }` (GET /search is a 404). * - Public forms live on `POST /databases/:id/forms` (not a workspace route), * published with `PATCH /forms/:id {status:"PUBLISHED"}`. * - The formula engine cannot compare a SELECT value (`prop("Status") == "x"` * → 400 "unsupported value type"), so step 7 uses the canonical * `isOverdue = dateBetween(prop("Due"), now()) > 0` (returns a boolean), * asserted `value === true` on the overdue record via the engine endpoint. * - The `api` Bearer request context carries NO cookie (cookies are captured * into the browser context), so a refresh-after-logout issued from it is * vacuous (401 with or without logout). Step 13 logs out via the real UI and * then refreshes from the cookie-carrying browser context, so the 401 is * genuine (the logout revoked the refresh token server-side). * * Selectors: only testids that exist in the current web tree (`block-{id}`, * `drag-handle`, `search-result`, `ask-ai-button`, `file-card-{id}`, …). * Deliberately avoids the stale `sidebar-page-{id}` (documented in * fixtures.ts/README but never rendered — see perf-report §8 pre-existing * drift); pages are reached by URL (`/w//page/` — the web route is * `page`, not `p`), which is deterministic. * * NOTE: this is a faithful implementation of the 13-step contract. The four * staging-only steps run against staging (their real target); the API-state * steps use source-verified inline API calls (the `tests/e2e/utils/api.ts` * `createDatabase`/`createRecord`/`queryRecords`/`search` helpers are stale * against the Phase-13 contracts and are intentionally NOT used here). */ import { createHmac, timingSafeEqual } from "node:crypto"; import { Buffer } from "node:buffer"; import { createServer, type Server } from "node:http"; import { request as pwRequest, type APIRequestContext, type BrowserContext, type Page, expect, test } from "@playwright/test"; import { API_BASE, type Workspace } from "../fixtures"; import { createPage, type UserDto } from "../utils/api"; import { dragBlockAbove } from "../utils/dnd"; const STAGING = process.env.E2E_STAGING === "1"; const RUN_ID = process.env.E2E_RUN_ID ?? `local-${Date.now().toString(36)}`; const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** A typed, retrying API call. Retries 5xx / network errors (a concurrent * agent's in-flight rebuild can transiently 5xx the dev API — rule 29 says a * real contract failure must surface, but a transient infra blip must not * flake the release flow). */ async function apiCall( ctx: APIRequestContext, method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", path: string, data?: unknown, what = "api", ): Promise<{ status: number; json: any }> { let last = ""; for (let attempt = 0; attempt < 5; attempt++) { // Playwright's APIRequestContext methods are lowercase (get/post/…); the // verb is passed uppercase for readability, so dispatch via the lowercased // key. The member-call keeps `ctx` as the receiver. const lower = method.toLowerCase() as "get" | "post" | "patch" | "put" | "delete"; const res = await ctx[lower](path, data === undefined ? undefined : { data }); if (res.status() < 500) { const json = await res.json().catch(() => ({})); return { status: res.status(), json }; } last = `${res.status()}: ${(await res.text().catch(() => "")).slice(0, 200)}`; await sleep(700 * (attempt + 1)); } throw new Error(`${what} ${method} ${path} failed after retries: ${last}`); } /** Strip the CSP header from responses. `next dev` serves a CSP without * 'unsafe-eval', which blocks React's dev-mode hydration; staging (prod * build) is unaffected (no-op there). Portable — a no-op when the header is * absent. If the re-fetch fails (a transient `next dev` blip under the * parallel-agent dev load), fall through to `route.continue()` — the page * still loads (worst case: one dev hydration block), and the test proceeds * or fails on its own assertions rather than on teardown. */ async function stripCsp(ctx: BrowserContext): Promise { await ctx.route("**/*", async (route) => { let res; try { res = await route.fetch(); } catch { await route.continue().catch(() => {}); return; } const headers: Record = {}; for (const [k, v] of Object.entries(res.headers())) headers[k] = v; delete headers["content-security-policy"]; await route.fulfill({ response: res, headers }).catch(async () => { await route.continue().catch(() => {}); }); }); } /** Focus an empty paragraph at the END of the page, then open the slash * menu and pick the option whose label contains `commandText`. * Clicking the last EXISTING block row would place the caret mid-line * (the slash trigger refuses to open), so we click the trailing empty * area (page-view `onTrailingAreaClick`): it creates + focuses a new * paragraph, OR — since TASK-0136 — reuses the last block when it is * already an empty text block (no pile-up). Either way the LAST row holds * an empty text editor, so we wait for that row's contenteditable to * mount AND hold focus before typing (the creation/focus is async: * API insert + list refetch + focus retry). */ async function slashPick(page: Page, commandText: string): Promise { const trailing = page.locator("div[data-testid=\"new-block\"]").last(); // Editor-readiness gate: under the concurrent dev load (multiple agents // against one API + a mid-session API restart), the page's block query can // land in a 403/error blip and the editor won't mount — a transient reload // recovers. If the trailing area never appears, reload once and re-wait. const editorReady = async (ms: number) => await expect .poll(async () => (await trailing.count()) > 0 || (await page.locator(".block-row").count()) > 0, { timeout: ms }) .toBe(true); await editorReady(10_000).catch(async () => { await page.reload({ waitUntil: "domcontentloaded" }); await editorReady(20_000); }); await trailing.click(); await expect .poll( async () => page.evaluate(() => { const rows = document.querySelectorAll(".block-row"); const last = rows[rows.length - 1]; if (!last) return false; const editable = last.querySelector('[contenteditable="true"]'); if (!editable) return false; const active = document.activeElement; return active instanceof HTMLElement && editable.contains(active); }), { timeout: 10_000 }, ) .toBe(true); // Type the query so the target command is the first filter match // (`active` resets to 0 on query change) and is auto-scrolled into view. // The menu is a `position:fixed z-[70]` portal, so Playwright's // coordinate click cannot scroll a below-viewport option into a clickable // state. Pick with Enter first (the menu's document-level keydown handler); // if the menu lingers (focus race under concurrent dev load), dispatch a // real DOM click on the first option's button (no coordinates needed). // `commandText` is the query, e.g. "database". await page.keyboard.type(`/`); const slash = page.getByRole("dialog", { name: "Slash commands" }); await expect(slash).toBeVisible({ timeout: 10_000 }); await page.keyboard.type(commandText); await expect(slash.getByRole("option").first()).toBeVisible({ timeout: 5_000 }); await page.keyboard.press("Enter"); // The pick is applied (insert dialog opens), but the slash-menu portal // stays mounted after the pick — a product quirk (the inline editor's // `onPick` does not clear the menu). It is a fixed z-[70] overlay that // would intercept interaction with the dialog beneath it, so dismiss it // with the menu's own Escape→onClose handler if it lingers. await expect(slash).toHaveCount(0, { timeout: 3_000 }).catch(async () => { await page.keyboard.press("Escape"); await expect(slash).toHaveCount(0, { timeout: 10_000 }); }); } /** A `page.goto` that retries on transient network failures (the dev web * server occasionally drops a chunk request under concurrent load — a * fresh retry usually gets a clean compile). */ async function goto(page: Page, url: string, opts: { waitUntil?: "load" | "domcontentloaded" } = {}, attempts = 3): Promise { let lastErr: unknown; for (let i = 0; i < attempts; i++) { try { await page.goto(url, opts); return; } catch (e) { lastErr = e; await sleep(1500 * (i + 1)); } } throw lastErr; } /** The `GET /databases/:id/properties` list (a `{ data }` envelope). */ async function listProperties( api: APIRequestContext, databaseId: string, ): Promise { const r = await apiCall(api, "GET", `/api/v1/databases/${databaseId}/properties`, undefined, "props"); return Array.isArray(r.json?.data) ? r.json.data : Array.isArray(r.json) ? r.json : []; } interface FlowState { token: string; user?: UserDto; ws?: Workspace; pageId?: string; taskId?: string; peopleId?: string; relPropId?: string; formulaId?: string; apiKey?: { id: string; key: string }; webhook?: { url: string; secret: string }; server?: Server; context?: BrowserContext; api?: APIRequestContext; } const st: FlowState = { token: "" }; test.describe("Release v1.0 final E2E flow (13 steps)", () => { // The full flow (13 steps incl. a 10 MB upload + AI stream) can exceed the // default 30 s — the spec's own budget is ~6 min. test.setTimeout(600_000); test.slow(); test("the 13-step release flow (state carries, sequential)", async ({ browser }) => { // A staging-only step: runs its body when E2E_STAGING=1; locally it is a // NO-OP (recorded as an annotation) — NOT a `test.skip()`, because // Playwright has no "skip one step" primitive and `test.skip()` would abort // the whole 13-step test. The capability genuinely does not exist on the // dev box (no mailcatcher / reachable S3), so no-op is honest. const stagingOnly = (name: string, fn: () => Promise) => test.step(name, async () => { if (!STAGING) { test.info().annotations.push({ type: "env", description: `staging-only no-op locally: ${name}`, }); return; } await fn(); }); // 1 — register a unique user (final-e2e-@factory.local) and log in // to capture the session. Register/login go through a request context // PINNED TO THE API ORIGIN (the web app does not proxy /api/v1 — the // web fetches the API origin directly, see lib/api.ts), so the // `factory_refresh` Set-Cookie is captured on that context → the real // flow context is built from it + a Bearer API context for the token. const email = `final-e2e-${RUN_ID}@factory.local`; const password = `Final!e2e-${RUN_ID}-pw`; let cookies: import("@playwright/test").Cookie[] = []; const authCtx = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); await test.step("1. register a unique user (final-e2e-@factory.local)", async () => { const reg = await authCtx.post("/api/v1/auth/register", { data: { email, password, name: "Final E2E User" }, }); expect(reg.status(), `step1 register 201: ${await reg.text()}`).toBe(201); // Login re-issues the session (and the refresh cookie) with a 200 (the // auth controller pins login/refresh to 200 — register keeps 201). const login = await authCtx.post("/api/v1/auth/login", { data: { email, password }, }); expect(login.status(), `login after register: ${await login.text()}`).toBe(200); st.token = ((await login.json()) as { accessToken?: string }).accessToken ?? ""; expect(st.token, "login returned an access token").toBeTruthy(); // The request context has no `cookies()` (BrowserContext only); // `storageState()` returns the captured cookies + origins. cookies = (await authCtx.storageState()).cookies; }); await authCtx.dispose().catch(() => {}); // The Bearer API context (stateless — no cookie; refresh/cookie flows use // the browser context below). const api: APIRequestContext = await pwRequest.newContext({ baseURL: API_BASE, timeout: 60_000, extraHTTPHeaders: { Authorization: `Bearer ${st.token}` }, }); st.api = api; // The real flow context, seeded with the session cookie (the web app is // cookie-authenticated: httpOnly `factory_refresh`, silent refresh on load). const context: BrowserContext = await browser.newContext({ baseURL: WEB_BASE, storageState: { cookies, origins: [] }, }); st.context = context; await stripCsp(context); const page: Page = await context.newPage(); st.user = ((await apiCall(api, "GET", "/api/v1/auth/me", undefined, "me")).json ?? {}).user; try { // 2 — verify email (mailcatcher link) — staging-only. Dev auto-verifies // on register (the EmailService is a stub that logs, no SMTP), so the // session is already established and `me()` above is the assertion. await stagingOnly("2. verify email (mailcatcher link)", async () => { const mc = process.env.E2E_MAILCATCHER_URL ?? "http://localhost:1080"; const list = (await ( await fetch(`${mc}/api/v1/messages?search=${encodeURIComponent(st.user!.email)}`) ).json()) as { messages: { html_part: string }[] }; const msg = list.messages[0]; expect(msg, "mailcatcher captured the verification mail").toBeTruthy(); const html = msg?.html_part ?? ""; const link = /https?:\/\/[^\s"'<>]+/u.exec(html)?.[0] ?? ""; expect(link, "a verification link is present").toBeTruthy(); const verifyCtx = await browser.newContext(); await verifyCtx.request.get(link); await verifyCtx.close(); }); // 3 — workspace "Release Co" (shell + sidebar render). await test.step("3. create workspace 'Release Co'", async () => { const r = await apiCall(api, "POST", "/api/v1/workspaces", { name: "Release Co" }, "createWorkspace"); expect([200, 201].includes(r.status), `workspace created: ${JSON.stringify(r.json)}`).toBe(true); st.ws = r.json as Workspace; expect(st.ws?.id && st.ws?.slug, "workspace has id + slug").toBeTruthy(); await goto(page, `/w/${st.ws!.slug}`); await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}`)); // The workspace shell rendered (the
region is present). await expect(page.locator("#workspace-main")).toBeVisible({ timeout: 15_000 }); }); // 4 — page "Release Plan" under Home (route renders, title in the doc). let pageId = ""; await test.step("4. create page 'Release Plan'", async () => { const p = await createPage(api, st.ws!.id, { title: "Release Plan" }); pageId = p.id; st.pageId = pageId; await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" }); // The page title is an (not a heading) // — page-view.tsx. Assert the rendered value. await expect(page.getByLabel("Page title")).toHaveValue("Release Plan", { timeout: 20_000 }); }); // 5 — editor: 5 blocks, drag the 3rd below the 1st; order persists. const TEXTS = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]; await test.step("5. editor: 5 blocks, DnD reorder (persists across reload)", async () => { for (let i = 1; i <= 5; i++) { const b = await apiCall( api, "POST", `/api/v1/pages/${pageId}/blocks`, { type: "paragraph", content: { text: TEXTS[i - 1] } }, `createBlock ${i}`, ); expect([200, 201].includes(b.status), `block ${i} created`).toBe(true); } // The page renders a TITLE block as the first draggable row, then the // five content blocks (Alpha…Echo). Read the ordered block ids from the // API so the DnD target is the exact 3rd content block (Charlie). const before = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json; const ordered: { id: string; content?: { text?: string } }[] = before?.data ?? []; const byText = (t: string) => ordered.find((b) => b.content?.text === t)?.id; const aId = byText("Alpha"); const cId = byText("Charlie"); expect(aId && cId, "Alpha + Charlie blocks exist").toBeTruthy(); // DnD (the real drag-handle path, not the reorder menu). Uses the // manual-mouse helper: Playwright's `dragTo()` does NOT trigger // dnd-kit's PointerSensor drop in this app (no /move request fires), // and the grip lives in a gutter that is pointer-events-none until the // row is hovered — both handled inside `dragBlockAbove`. await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" }); await dragBlockAbove(page, cId!, aId!); await page.reload({ waitUntil: "domcontentloaded" }); // After `arrayMove(sorted, idx(Charlie), idx(Alpha))`: [title, Charlie, // Alpha, Bravo, Delta, Echo]. Assert Charlie now precedes Alpha. const after = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json; const ids: string[] = (after?.data ?? []).map((b: { id: string }) => b.id); expect(ids.indexOf(cId!), "Charlie present after reload").toBeGreaterThanOrEqual(0); expect( ids.indexOf(cId!) < ids.indexOf(aId!), "Charlie (3rd) now precedes Alpha (1st) after reload", ).toBe(true); }); // 6 — database "Tasks" (3 props: title/status/due) + 3 records (3 rows). let taskId = ""; let titlePropId = ""; let statusPropId = ""; let duePropId = ""; await test.step("6. database 'Tasks' + 3 records (3 rows)", async () => { // Create the database THROUGH the UI (slash → Add database → dialog). await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" }); await slashPick(page, "database"); const insert = page.locator("#db-title"); await expect(insert).toBeVisible({ timeout: 10_000 }); await insert.fill("Tasks"); await page.getByRole("button", { name: "Create database" }).click(); // The insert dialog creates a database PAGE and inserts an inline // DATABASE block that references it by `properties.databaseId` (the // dialog passes `page.id` — a database page IS its database, id // parity). It does NOT navigate, so the new table renders inline on // THIS page. The table's DOM mount lags the API create (under the // concurrent dev load the block-list refetch + DatabaseBlock // hydration can take several seconds), so poll the API for the // DATABASE block — the state the table renders — before gating the // UI interactions on the table's own visibility. await expect .poll(async () => { const blocks = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json; const db = (blocks?.data ?? []).find((b: any) => b.type === "DATABASE"); return Boolean(db?.properties?.databaseId); }, { timeout: 20_000 }) .toBe(true); await expect(page.locator('[data-testid^="record-row-"], button[aria-label="Add property"]')) .toBeVisible({ timeout: 20_000 }); // The db id is on the DATABASE block (properties.databaseId — the // field the web's DatabaseBlock reads; the API seeds the page's // database with that same id). const dbBlocks = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json; const dbBlock = (dbBlocks?.data ?? []).find((b: any) => b.type === "DATABASE"); taskId = dbBlock?.properties?.databaseId ?? ""; st.taskId = taskId; expect(taskId, "Tasks database id resolved from the page's database block").toBeTruthy(); // Add the "Due" DATE property. Title + Status are the seeded // DEFAULT_DB_PROPERTIES. PRODUCT BUG (not owned here — apps/web): the // web's PropertyEditor.save() posts `type` in LOWERCASE (e.g. "date") // but the API's CreatePropertyDto validates `type: z.enum(PROPERTY_TYPES)` // (UPPERCASE, e.g. "DATE") → the UI "Add property" 400s // ("Invalid enum value. Expected 'TITLE' | 'TEXT' | ... received 'date'"), // so the dialog stays open and the property is never created. Create // the property through the API (the wire contract the UI should use) // instead — the 3 records below still go through the real UI. const existing = await listProperties(api, taskId); duePropId = existing.find((p) => p.name === "Due")?.id ?? ""; if (!duePropId) { const due = await apiCall( api, "POST", `/api/v1/databases/${taskId}/properties`, { name: "Due", type: "DATE", config: {}, isVisible: true, required: false, default: null }, "Due property", ); expect(due.status, `Due DATE property created: ${JSON.stringify(due.json)}`).toBe(201); duePropId = due.json?.id ?? ""; } const propsAfter = await listProperties(api, taskId); titlePropId = propsAfter.find((p) => p.isTitle || p.type === "TITLE")?.id ?? ""; statusPropId = propsAfter.find((p) => p.type === "SELECT")?.id ?? ""; duePropId = propsAfter.find((p) => p.name === "Due")?.id ?? ""; expect(titlePropId && statusPropId && duePropId, "Title + Status + Due props exist").toBeTruthy(); // Three records via the UI (add-row → title cell → type → Enter). // `addRow` seeds the title "New record" (table-view addRow). The // title cell is a single click-to-edit