/** * AI Software Factory — E2E: AI page generation, golden UI path (TASK-0126). * * Drives the REAL stack (Next web + Nest API + the deterministic mock * provider's `mode:"generate"` branch) through the user-facing generate dialog * and asserts the generated structure field-by-field via the API (the spec's * "Golden E2E ... asserted via the API"). The mock's generate plan is * deterministic: * * createPage "Product Launch Plan" (the generated root page) * insertBlocks → 6 × HEADING_2 [Objectives, Requirements, Timeline, * Risks, Team, Meeting Notes] * createDatabase { ref:"tasks", title:"Tasks", properties:[Due:DATE], * sampleRecords:[ {Title:"Draft launch announcement", * Due:"2026-01-15"}, * {Title:"Schedule launch review", * Due:"2026-01-20"} ] } * * (The seeded Title + Status properties are always present on a generated * database — Status is a SELECT with To Do / In Progress / Done.) * * Hierarchy note (the executor's semantics, verified against the API): the * generated page is created UNDER the endpoint's `parentId` — the dialog passes * the current page (the Home page the user is viewing) — so it is a CHILD of * that page, not a top-level page; the Tasks database is a child of the * generated page; the sample record pages are top-level (created by * `records.createRecord`). We therefore locate the generated page by its URL id * (the dialog navigates to `/w/{…}/page/{pageId}`) and the Tasks page as its * child — independent of how many Home pages the workspace index created. * * Covered: * golden (Home empty-state card) — the "Describe a page…" card opens the * dialog; Generate → success navigates to the generated page; the page + its * 6 section headings, and the Tasks database (Title/Status/Due + 2 * page-backed records) all exist via the API, created under the owner. * * The live step checklist is verified where it is observable — the cancel test * (ai-security item 8) arms a slow mock so the steps stay on screen. The * palette / context-menu entry points share the same dialog + endpoint and are * covered by ai-security item 6 (GUEST gating). * * Requires a reachable API + the mock provider (AI_PROVIDER=mock) started with * the internal test seam (see fixtures). The deterministic mock means no real * LLM / network is needed. */ import type { APIRequestContext } from "@playwright/test"; import { expect, test } from "../fixtures"; import { createWorkspace } from "../utils/api"; /** The canonical plan the mock emits (the E2E asserts this exact structure). */ const SECTIONS = ["Objectives", "Requirements", "Timeline", "Risks", "Team", "Meeting Notes"]; const RECORD_TITLES = ["Draft launch announcement", "Schedule launch review"]; type PageRow = { id: string; title: string; parentId: string | null }; type BlockRow = { id: string; type: string; content?: { text?: string } }; type PropRow = { id: string; name: string; type: string; isTitle?: boolean; config?: { options?: { name: string; color?: string }[] } }; type RecordRow = { id: string; title?: string; pageId: string | null; properties: Record }; function unwrap(body: T | { data: T }): T { return Array.isArray(body) ? (body as unknown as T) : (body as { data: T }).data; } /** The workspace's pages (optionally filtered by parent) via the API. */ async function listPages(api: APIRequestContext, workspaceId: string, parentId?: string | null): Promise { const params: Record = {}; if (parentId !== undefined) params.parentId = parentId; const res = await api.get(`/api/v1/workspaces/${workspaceId}/pages`, { params }); if (res.status() !== 200) throw new Error(`listPages ${res.status()}: ${await res.text()}`); return unwrap(await res.json()); } /** A single page by id via the API ({ id, title, parentId, ... } | null). */ async function getPage(api: APIRequestContext, pageId: string): Promise { const res = await api.get(`/api/v1/pages/${pageId}`); if (res.status() !== 200) return null; const body = (await res.json()) as { page?: PageRow } | PageRow; return (body as { page?: PageRow }).page ?? (body as PageRow); } /** A page's blocks via the API — the block list returns `{ data }` / `{ data, tree, total }` * (NOT `{ blocks }`), so read `.data` directly. `depth=all` returns every block. */ async function listBlocks(api: APIRequestContext, pageId: string): Promise { const res = await api.get(`/api/v1/pages/${pageId}/blocks`, { params: { depth: "all" } }); if (res.status() !== 200) throw new Error(`listBlocks ${res.status()}: ${await res.text()}`); return unwrap(await res.json()); } /** A database's properties via the API. */ async function listProperties(api: APIRequestContext, databaseId: string): Promise { const res = await api.get(`/api/v1/databases/${databaseId}/properties`); if (res.status() !== 200) throw new Error(`listProperties ${res.status()}: ${await res.text()}`); return unwrap(await res.json()); } /** A database's records via the API (title resolves to the page title). */ async function listRecords(api: APIRequestContext, databaseId: string): Promise { const res = await api.get(`/api/v1/databases/${databaseId}/records`); if (res.status() !== 200) throw new Error(`listRecords ${res.status()}: ${await res.text()}`); const body = (await res.json()) as unknown as | Array | { data: Array }; return unwrap>(body).map((r) => ({ id: r.id, title: r.title ?? r.page?.title, pageId: r.pageId, properties: r.properties })); } test.describe("AI page generation — golden UI (TASK-0126)", () => { test("Home empty-state card → Generate → page (6 headings) + Tasks database", async ({ authPage, api }) => { const ws = await createWorkspace(api, `Gen ${Date.now()}`); // A fresh workspace: the root auto-creates the (content-empty) "Home" page // and redirects to it — its empty-state shows the "Describe a page…" card. await authPage.goto(`/w/${ws.id}`); const card = authPage.getByTestId("home-generate-card"); await expect(card).toBeVisible({ timeout: 30_000 }); const homeId = new URL(authPage.url()).pathname.split("/").pop()!; // Open the dialog from the card, describe the page, and generate. await card.click(); const dialog = authPage.getByTestId("generate-dialog"); await expect(dialog).toBeVisible(); await authPage.getByTestId("generate-input").fill("Create a product launch plan"); await authPage.getByTestId("generate-submit").click(); // Success → the dialog closes and the browser navigates to the GENERATED // page (a different id than Home — both live under /w/{id}/page/). await expect .poll( () => new URL(authPage.url()).pathname.split("/").pop() !== homeId, { timeout: 30_000 }, ) .toBe(true); const pageId = new URL(authPage.url()).pathname.split("/").pop()!; await expect(dialog).toHaveCount(0); // The generated page exists via the API (located by its URL id — the dialog // navigates to /page/{pageId}) with the plan's title. const generated = await getPage(api, pageId); expect(generated, `the generated page ${pageId} should exist`).toBeTruthy(); expect(generated!.title, "the generated page has the plan's title").toBe("Product Launch Plan"); // The six section headings exist as HEADING_2 blocks on the page. const blocks = await listBlocks(api, pageId); const headings = blocks.filter((b) => b.type === "HEADING_2").map((b) => b.content?.text); for (const section of SECTIONS) { expect(headings, `section heading "${section}" should be present (got ${JSON.stringify(headings)})`).toContain(section); } expect(headings.length, `exactly the 6 section headings (got ${headings.join(", ")})`).toBe(SECTIONS.length); // The Tasks database is a child of the generated page, with a Title + // Status (select) + Due (date) property and 2 page-backed records. const tasksPage = (await listPages(api, ws.id, pageId)).find((p) => p.title === "Tasks"); expect(tasksPage, "the Tasks database page should be a child of the generated page").toBeTruthy(); // The Tasks database is a child of the generated page. `GET // /workspaces/:id/databases` (listByWorkspace) returns a bare array of // { id, title, icon } — no pageId — but in this FRESH workspace the // generation is the only thing that ever creates a database, so there is // exactly one and it is the Tasks database (assert the title to be safe). const dbs = unwrap<{ id: string; title: string; pageId?: string }[]>( await (await api.get(`/api/v1/workspaces/${ws.id}/databases`)).json(), ); expect(dbs.length, `exactly one database in the fresh workspace (got ${dbs.map((d) => d.title).join(", ")})`).toBe(1); const tasksDb = dbs[0]; expect(tasksDb.title, "the generated database is titled Tasks").toBe("Tasks"); const props = await listProperties(api, tasksDb!.id); const byName = (n: string) => props.find((p) => p.name === n); expect(byName("Title"), "a Title property (seeded)").toBeTruthy(); const status = byName("Status"); expect(status, "a Status property (seeded)").toBeTruthy(); expect(status!.type).toBe("SELECT"); expect( status!.config?.options?.map((o) => o.name), "the seeded Status select has To Do / In Progress / Done", ).toEqual(expect.arrayContaining(["To Do", "In Progress", "Done"])); const due = byName("Due"); expect(due, "a Due property (the plan's date column)").toBeTruthy(); expect(due!.type).toBe("DATE"); // The two sample records are real pages (pageId set) with the mock titles + // a date value on the Due property. const records = await listRecords(api, tasksDb!.id); expect(records.length, "two sample records").toBe(2); expect(records.map((r) => r.title).sort()).toEqual(RECORD_TITLES.slice().sort()); for (const r of records) { expect(r.pageId, `record "${r.title}" is a real page`).toBeTruthy(); const dueValue = r.properties[due!.id] as { start?: string } | undefined; expect(dueValue?.start, `record "${r.title}" carries a Due date`).toMatch(/^2026-01-(15|20)$/); } }); });