/** * AI Software Factory — E2E: Pages & Page Engine (Phase 3) * * Fully written (MVP core flow): create page, nested pages, rename, * icon/cover, move via context menu, trash + restore + purge, favorites, * version snapshot visibility after edit. * * Selector convention (tests/README.md): * data-testid="sidebar-page-{id}" sidebar page node * data-testid="sidebar-new-page" "New page" button in the sidebar * data-testid="page-title" page title (editable text) * data-testid="page-icon-button" icon picker button * data-testid="page-cover-picker" cover picker * data-testid="page-context-menu" right-click context menu on a page * data-testid="context-menu-move" "Move to…" context menu item * data-testid="context-menu-trash" "Delete (move to trash)" item * data-testid="context-menu-favorite" "Add to favorites" item * data-testid="trash-section" sidebar Trash node * data-testid="trash-item-{id}" item in the trash view * data-testid="trash-restore-{id}" restore button * data-testid="trash-purge-{id}" purge button * data-testid="favorite-item-{id}" favorites section item * data-testid="page-versions" version history panel */ import { test, expect } from "../fixtures"; import { createPage } from "../utils/api"; test.describe("Pages — Phase 3", () => { test("create page from sidebar", async ({ authPage, freshWorkspace }) => { const ws = await freshWorkspace("Pages Space"); await authPage.goto(`/w/${ws.id}`); await expect(authPage.getByTestId("sidebar")).toBeVisible(); await authPage.getByTestId("sidebar-new-page").click(); // A new untitled page opens; typing in the title commits it. const title = authPage.getByTestId("page-title"); await expect(title).toBeVisible({ timeout: 10_000 }); await title.click(); await title.press("ControlOrMeta+a").catch(() => {}); await title.type("First Real Page"); // Blur to commit (or the app auto-commits on blur). await authPage.keyboard.press("Escape"); // The page now appears in the sidebar with its title. await expect(authPage.getByTestId("sidebar").getByText("First Real Page")).toBeVisible({ timeout: 10_000, }); }); test("nested page: create a page inside a page", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Nesting Space"); const parent = await createPage(api, ws.id, { title: "Parent Page" }); await authPage.goto(`/w/${ws.id}/page/${parent.id}`); await expect(authPage.getByTestId("page-title")).toContainText("Parent Page"); // "Add a sub-page" creates the child + soft-navigates to it (the sidebar // `factory:new-page` handler does `router.push` AFTER the create resolves). // That client-side transition is RACY: the in-page title is a persistent // contenteditable that keeps the parent's text until the child's `page` // query lands, and typing then commits "Child Page" onto the PARENT id // ("ParentChild Page Page", child ""). T-192: don't act on the flaky // post-click SPA state. The server commit is the source of truth — poll // for the child row (created before the push), then HARD-navigate to it // (`page.goto` = a full document load, always fresh), and only then edit // the title so the commit unambiguously targets the child. await authPage.getByTestId("subpage-create").click(); // Poll the server until the child row exists (captured into `childId` via // the closure — expect.poll resolves once the predicate is truthy). let childId: string | null = null; await expect .poll( async () => { const body = await ( await api.get( `/api/v1/workspaces/${ws.id}/pages?parentId=${parent.id}`, ) ).json(); const kids = (body.data ?? body) as { id: string }[]; childId = kids.length > 0 ? kids[0].id : null; return childId !== null; }, { timeout: 10_000 }, ) .toBeTruthy(); // Full navigation to the child (a fresh document load — the only // deterministic way to leave the post-`router.push` soft-nav state). await authPage.goto(`/w/${ws.id}/page/${childId}`); // Gate on the canvas actually serving the CHILD's page: the title is a // persistent contenteditable that lags a client-side route change, and it // only commits to the page id it currently holds (`data-page-id`, added in // T-192). A text-based gate ("title !== parent title") is unreliable // because the field can transiently show stale or empty text. const childTitle = authPage.getByTestId("page-title"); await expect(childTitle).toHaveAttribute("data-page-id", childId, { timeout: 10_000, }); await childTitle.click(); await childTitle.type("Child Page"); await authPage.keyboard.press("Escape"); // The child appears under the parent in the sidebar tree. Stable // `sidebar-page-{id}` node testid (tests/README contract) — NOT a title // substring: the per-keystroke commit fires a burst of page-tree // invalidations and the row transiently shows intermediate titles ("Chil") // while the refetches settle (T-192). await expect( authPage.getByTestId(`sidebar-page-${childId}`), ).toBeVisible({ timeout: 10_000 }); // Server-side: the child's parentId is the parent's id and the title // commit has settled. The title commits per keystroke (10 rapid PATCHes), // so the final value can land after an earlier refetch — poll the // endpoint until the row's title is the final one. There is no // `/pages/:id/children` route; the documented sibling-list endpoint is // `GET /workspaces/:id/pages?parentId=` (`{ data: Page[] }`; T-192: the // old `/children` call 404'd and the assertion crashed on the error body). let childRow: { id: string; title: string; parentId: string } | null = null; await expect .poll( async () => { const body = await ( await api.get( `/api/v1/workspaces/${ws.id}/pages?parentId=${parent.id}`, ) ).json(); const kids = (body.data ?? body) as { id: string; title: string; parentId: string; }[]; childRow = kids.find((c) => c.id === childId) ?? null; return childRow?.title === "Child Page"; }, { timeout: 10_000 }, ) .toBeTruthy(); expect(childRow, "child page exists under the parent").toBeTruthy(); expect(childRow?.parentId).toBe(parent.id); }); test("rename page", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Rename Page Space"); const page = await createPage(api, ws.id, { title: "Before Rename" }); await authPage.goto(`/w/${ws.id}/page/${page.id}`); const title = authPage.getByTestId("page-title"); await title.click(); await title.press("ControlOrMeta+a").catch(() => {}); await title.type("After Rename"); await authPage.keyboard.press("Escape"); await expect(title).toContainText("After Rename", { timeout: 10_000 }); }); test("icon + cover", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Style Space"); const page = await createPage(api, ws.id, { title: "Styled Page" }); await authPage.goto(`/w/${ws.id}/page/${page.id}`); // Icon: open the picker, choose an emoji. await authPage.getByTestId("page-icon-button").click(); const iconPicker = authPage.getByTestId("icon-picker"); await expect(iconPicker).toBeVisible(); await iconPicker.getByText("🚀").click(); // Icon applied: the page header shows the emoji. await expect(authPage.getByTestId("page-icon")).toContainText("🚀", { timeout: 10_000, }); // Cover: pick a solid color from the cover picker. await authPage.getByTestId("page-cover-picker").click(); const coverPicker = authPage.getByTestId("cover-picker"); await expect(coverPicker).toBeVisible(); await coverPicker.getByTestId("cover-color-0").click(); await expect(authPage.getByTestId("page-cover")).toBeVisible(); }); test("move page via context menu", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Move Space"); const page = await createPage(api, ws.id, { title: "Mover" }); const destination = await createPage(api, ws.id, { title: "Destination" }); await authPage.goto(`/w/${ws.id}`); await authPage.getByTestId("sidebar").getByText("Mover").click(); // Context menu on the sidebar node → "Move to…" → pick destination. await authPage .getByTestId("sidebar-page-" + page.id) .click({ button: "right" }); await authPage.getByTestId("page-context-menu").getByTestId("context-menu-move").click(); const moveDialog = authPage.getByTestId("move-dialog"); await expect(moveDialog).toBeVisible(); await moveDialog.getByText("Destination").click(); await moveDialog.getByTestId("move-dialog-confirm").click(); // The dialog closes only on PATCH success (onSuccess), so waiting for it // to close gates on the server commit — the green-suite convention (wait // for a UI side-effect before asserting server state) instead of racing // the fire-and-forget mutation with an immediate GET (T-192). await expect(moveDialog).not.toBeVisible({ timeout: 10_000 }); // Server-side: Mover's parentId is now Destination's id. const moved = await api.get(`/api/v1/pages/${page.id}`); const body = await moved.json(); const mover = body.page ?? body; expect(mover.parentId).toBe(destination.id); }); test("trash → restore → purge round-trip", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Trash Space"); const page = await createPage(api, ws.id, { title: "Trash Candidate" }); // A block inside so restore has content to bring back. await api.post(`/api/v1/pages/${page.id}/blocks`, { data: { type: "PARAGRAPH", content: { text: "precious content" } }, }); // 1) Move to trash via the context menu. await authPage.goto(`/w/${ws.id}`); await authPage .getByTestId("sidebar-page-" + page.id) .click({ button: "right" }); await authPage.getByTestId("page-context-menu").getByTestId("context-menu-trash").click(); await expect( authPage.getByTestId("sidebar").getByText("Trash Candidate"), ).toHaveCount(0); // Server-side: trashedAt is set, deletedAt is not. let body = await (await api.get(`/api/v1/pages/${page.id}`)).json(); let p = body.page ?? body; expect(p.trashedAt).toBeTruthy(); expect(p.deletedAt).toBeFalsy(); // 2) Restore from the Trash view. await authPage.getByTestId("trash-section").click(); const item = authPage.getByTestId(`trash-item-${page.id}`); await expect(item).toBeVisible({ timeout: 10_000 }); await authPage.getByTestId(`trash-restore-${page.id}`).click(); // The trash row disappears only after the restore succeeds (onSuccess // invalidates the trash list), so wait for that UI side-effect before the // server assertion — the green-suite convention (T-192). await expect(item).toHaveCount(0, { timeout: 10_000 }); body = await (await api.get(`/api/v1/pages/${page.id}`)).json(); p = body.page ?? body; expect(p.trashedAt).toBeFalsy(); expect(p.restoredAt).toBeTruthy(); // Block survived the round-trip. The block list is a `{ data }` envelope // (block.service `list` — see pageApi.blocks); unwrap `data` (T-192: the // old `blocks.blocks ?? blocks` unwrap saw the envelope object and read // `length` = undefined). Assert the CONTENT block's text round-tripped — // not a count — because page creation seeds a title block (parentId null), // so the top-level list carries title + paragraph and a fixed count of 1 // was never correct. const blocks = await ( await api.get(`/api/v1/pages/${page.id}/blocks`) ).json(); const blockRows = (blocks.data ?? blocks.blocks ?? blocks) as Array<{ content?: { text?: string }; }>; expect(blockRows.some((b) => b.content?.text === "precious content")).toBe(true); // 3) Trash again, then PURGE (hard delete). We are on the trash route // already, so re-clicking the nav triggers no refetch — wait for the // re-trashed row to (re)appear in the list before purging (the // green-suite convention; T-192). await authPage .getByTestId("sidebar-page-" + page.id) .click({ button: "right" }); await authPage.getByTestId("page-context-menu").getByTestId("context-menu-trash").click(); await authPage.getByTestId("trash-section").click(); const itemAgain = authPage.getByTestId(`trash-item-${page.id}`); await expect(itemAgain).toBeVisible({ timeout: 10_000 }); await authPage.getByTestId(`trash-purge-${page.id}`).click(); // The row disappears only after the hard delete succeeds. await expect(itemAgain).toHaveCount(0, { timeout: 10_000 }); const gone = await api.get(`/api/v1/pages/${page.id}`); expect(gone.status()).toBe(404); }); test("favorite page appears in favorites section", async ({ authPage, api, freshWorkspace, }) => { // The fixture must NOT contain the word "Favorites" — the test navigates // via `getByText("Favorites")` (a case-insensitive substring match), and a // workspace named "Favorites Space" collides with the sidebar "Favorites" // nav label (strict-mode: 2 elements). Renamed in T-192. const ws = await freshWorkspace("Star Space"); const page = await createPage(api, ws.id, { title: "Starred Page" }); await authPage.goto(`/w/${ws.id}`); await authPage .getByTestId("sidebar-page-" + page.id) .click({ button: "right" }); await authPage .getByTestId("page-context-menu") .getByTestId("context-menu-favorite") .click(); await authPage.getByTestId("sidebar").getByText("Favorites").click(); await expect( authPage.getByTestId(`favorite-item-${page.id}`), ).toBeVisible({ timeout: 10_000 }); }); test("version snapshot visible after edit", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Versions Space"); const page = await createPage(api, ws.id, { title: "Versioned Page" }); // Content for the snapshots to capture. await api.post(`/api/v1/pages/${page.id}/blocks`, { data: { type: "PARAGRAPH", content: { text: "v1 content" } }, }); await authPage.goto(`/w/${ws.id}/page/${page.id}`); // Open the version history panel (⋯ menu → History). await authPage.getByTestId("page-more-menu").click(); await authPage.getByTestId("page-more-history").click(); const versions = authPage.getByTestId("page-versions"); await expect(versions).toBeVisible({ timeout: 10_000 }); // T-192 (recorded stale test): the old expectation ("two block edits → // ≥2 versions") assumed auto-snapshot on block edits. TASK-0043 ships // MANUAL snapshots in Phase 3; the auto-snapshot (100-op counter) is // explicitly deferred to Phase 4. Phase 3's only version path is the // "Snapshot now" button (TASK-0043 criterion 5) — drive it twice, which // is exactly the spec's Flow 7 "version snapshot visible in the UI" gate. const entries = versions.getByTestId("page-version-entry"); await versions.getByTestId("version-snapshot-button").click(); await expect(entries).toHaveCount(1, { timeout: 10_000 }); await versions.getByTestId("version-snapshot-button").click(); await expect(entries).toHaveCount(2, { timeout: 10_000 }); }); });