/** * AI Software Factory — E2E: Block Editor (Phase 4) * * The canonical editor interaction suite. Slash commands, block types, the * ⋮ context menu ("turn into" / delete), copy/paste, and reload persistence. * * T-194 — rewritten against the REAL editor surface (the pre-rewrite spec * asserted a surface that never existed: an `

` tag for headings, a * `drag-handle-{id}` testid, a `context-menu-turn-heading1` id, a * `toggle-handle-{id}` caret, `slash-menu-item-heading1`, block-level * Backspace demotion, and block-level undo/redo — none of which the app * implements, so the suite was red across the board). This rewrite drives * the surface the app actually ships and asserts type/content changes * SERVER-side (the read API is the source of truth — headings and quotes * render as styled Tiptap contenteditables, not semantic tags), per AGENTS * rule 29. * * Fresh-page typing surface (T-194 design decision — recorded conflict, * rule 29): a freshly created page carries exactly ONE block, the seeded * title (`properties.isTitle`). The render loop does NOT draw the title in * the body (the header's `page-title` contenteditable is the canonical * title and self-syncs). So the body of a fresh page is an empty canvas + * the `new-block` trailing area — clicking it creates + focuses the first * real paragraph (`onTrailingAreaClick` → `createSibling`), which is where * typing and "/" begin. The recommended "server seeds an initial empty * paragraph" was deliberately NOT taken: it would flip `contentBlockCount` * to 1 and hide the TASK-0126 Home AI card on every fresh page, and would * leave a stray empty leading block in the AI/template/import/duplicate * flows (all of which call `pages.create` then populate). The existing * `new-block` surface already provides a first-class fresh-page entry. * * Selector convention (tests/README.md): locate INTERACTIVE elements by * `data-testid` only. (Text/role locators for controls are banned — they * couple to copy and break on i18n. Reading rendered content text for an * ASSERTION is allowed and used in the pinned green specs.) * data-testid="editor-root" editor container * data-testid="block-{id}" a top-level block row * data-testid="new-block" trailing "keep typing" area * data-testid="slash-menu" / "-item-{id}" slash palette + items * data-testid="block-context-menu" right-click menu on a block * data-testid="context-menu-turn-into" "Turn into…" button * data-testid="context-menu-turn-{type}" a "Turn into" target type * data-testid="context-menu-delete" the Delete menu item * data-testid="todo-checkbox-{id}" checkbox on a TODO block * data-testid="toggle-chevron-{id}" toggle open/close caret * data-testid="column-list" / "column-{n}" columns container + cell * data-testid="code-block-pre" rendered (non-edit) code block * * Keyboard (the documented, implemented set): * "/" (in an EMPTY block) → slash menu * Enter (text block) → new sibling paragraph below * Enter (toggle title) → new CHILD inside the toggle */ import { test, expect } from "../fixtures"; import type { APIRequestContext, Page } from "@playwright/test"; interface BlockRow { id: string; parentId: string | null; type: string; content: unknown; properties: unknown; } interface BlocksResponse { data: BlockRow[]; } /** * Top-level block rows. The `[data-block-id]` guard excludes * `block-context-menu` (which also starts with the `block-` prefix) — only * real rows carry both attributes. */ function rows(page: Page) { return page.locator('[data-testid^="block-"][data-block-id]'); } /** Create a page under a workspace (BARE `{ id }` body — no wrapper). */ async function createPage( api: APIRequestContext, wsId: string, title: string, ): Promise { const res = await api.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title }, }); expect(res.status(), await res.text()).toBe(201); return ((await res.json()) as { id: string }).id; } /** Top-level CONTENT blocks (the seeded title block is excluded). */ async function contentBlocks( api: APIRequestContext, pageId: string, ): Promise { const res = await api.get(`/api/v1/pages/${pageId}/blocks`); expect(res.status()).toBe(200); const body = (await res.json()) as BlocksResponse; return body.data.filter( (b) => b.parentId === null && !(b.properties as { isTitle?: boolean })?.isTitle, ); } /** Every live block on the page (all parents) — for nested/count asserts. */ async function allBlocks(api: APIRequestContext, pageId: string): Promise { const res = await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`); expect(res.status()).toBe(200); return ((await res.json()) as BlocksResponse).data; } /** The server-stored text of a block (`{ text }` / html string / `{}`). */ function blockText(b: BlockRow): string { const c = b.content as { text?: unknown } | string | null; if (typeof c === "string") return c.replace(/<[^>]*>/g, ""); if (c && typeof c === "object" && typeof c.text === "string") return c.text; return ""; } /** Wait for a given number of top-level rows to render. */ async function expectRowCount(page: Page, n: number) { await expect(rows(page)).toHaveCount(n, { timeout: 5_000 }); } /** Wait until the top-level row count has been unchanged for ~450 ms. Used * after async insert/delete sequences (slash pick, Enter, TASK-0136 auto- * delete) so the count/id we read next is stable. Gives up silently at the * timeout (the caller's own assertions then apply). */ async function waitStableRows(page: Page, timeout = 8_000) { const start = Date.now(); let prev = await rows(page).count(); let stable = 0; for (;;) { if (Date.now() - start > timeout) return; await page.waitForTimeout(150); const c = await rows(page).count(); stable = c === prev ? stable + 1 : 0; prev = c; if (stable >= 3) return; } } /** * Press Enter in the focused text block (creates an empty sibling below) * and leave the caret in it. The create→re-render→focus round-trip is async, * and a TASK-0136 auto-delete of an earlier EMPTY sibling can be in flight * (or a stale refetch can briefly resurrect a deleted row), so a fixed * `before+1` count is racy — instead identify the new sibling by its fresh * `data-block-id` (it is the new last row) and settle before clicking. */ async function enterNewBlock(page: Page) { await waitStableRows(page); const lastIdBefore = await rows(page).last().getAttribute("data-block-id"); await page.keyboard.press("Enter"); await expect .poll(async () => { const n = await rows(page).count(); if (n === 0) return false; return (await rows(page).last().getAttribute("data-block-id")) !== lastIdBefore; }, { timeout: 8_000 }) .toBe(true); await waitStableRows(page); await rows(page).last().locator("[contenteditable]").first().click(); } /** * Pick a slash command from a focused EMPTY block and leave the caret in the * created block. * * The slash trigger only fires in a block whose text STARTS with "/" — so * the caller must have an empty, focused block (the one `openFreshPage` or a * prior `enterNewBlock` created). That block (the "anchor") is always the LAST * row; the pick clears it (→ it auto-deletes on blur via TASK-0136) and * inserts the new block AFTER it. Row-count arithmetic is racy here (the count * oscillates `before → before+1 → before` across the insert + anchor delete), * so instead we wait for the new block by its identity: a fresh * `data-block-id` that differs from the anchor's. The anchor stays the last * row until the new one renders, so the last row's id flips exactly once. */ async function slashCommand(page: Page, itemId: string, focusNew = true) { const anchorId = await rows(page).last().getAttribute("data-block-id"); await page.keyboard.type("/"); await expect(page.getByTestId("slash-menu")).toBeVisible({ timeout: 5_000 }); await page.getByTestId(`slash-menu-item-${itemId}`).click(); if (!focusNew) return; // structural blocks (divider, code, columns, …) await expect .poll(async () => { const n = await rows(page).count(); if (n === 0) return false; return (await rows(page).last().getAttribute("data-block-id")) !== anchorId; }, { timeout: 8_000 }) .toBe(true); await rows(page).last().locator("[contenteditable]").first().click(); } /** * Open a fresh page and leave the caret in a real, focused paragraph: * `new-block` click → the trailing area creates + focuses the first block. * Returns the page id. (A fresh page has no `block-*` rows in the body — * the seeded title is not rendered — so this must NOT click a first block.) */ async function openFreshPage( authPage: Page, api: APIRequestContext, wsId: string, ): Promise { const pageId = await createPage(api, wsId, "Editor Test Page"); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // No body blocks yet (title-only page; the title is not rendered in body). expect(await rows(authPage).count()).toBe(0); // Enter the trailing area → creates + focuses the first paragraph. await authPage.locator("#workspace-main").getByTestId("new-block").click(); await expectRowCount(authPage, 1); // Settle the caret in the (single) new row's editor. await rows(authPage).first().locator("[contenteditable]").click(); return pageId; } test.describe("Block Editor — Phase 4 (T-194)", () => { test("type a paragraph and it persists", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await authPage.keyboard.type("Hello from the editor"); // The block row renders the typed text… await expect(rows(authPage).first()).toContainText("Hello from the editor"); // …and it round-trips to the server (1 content block, text intact). await expect .poll(async () => { const blocks = await contentBlocks(api, pageId); return blocks.length === 1 ? blockText(blocks[0]!) : ""; }, { timeout: 10_000 }) .toBe("Hello from the editor"); }); test("slash menu: /h1 creates a heading", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await slashCommand(authPage, "h1"); await authPage.keyboard.type("A Heading One"); // Headings render as a styled contenteditable (NOT an

tag): assert // the change server-side (type HEADING_1) + the text is visible. await expect .poll(async () => { const b = (await contentBlocks(api, pageId)).find((x) => x.type === "HEADING_1"); return b ? blockText(b) : ""; }, { timeout: 10_000 }) .toBe("A Heading One"); await expect(authPage.getByText("A Heading One")).toBeVisible(); }); test("slash menu: /todo creates a checkable checkbox block", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await slashCommand(authPage, "todo"); await authPage.keyboard.type("Buy milk"); const checkbox = authPage.getByTestId(/todo-checkbox-/).first(); await expect(checkbox).toBeVisible({ timeout: 5_000 }); // Initially unchecked; checking it persists server-side (props.checked). await expect(checkbox).toHaveAttribute("aria-checked", "false"); await checkbox.click(); await expect .poll(async () => { const b = (await contentBlocks(api, pageId)).find((x) => x.type === "TODO"); return (b?.properties as { checked?: boolean })?.checked ?? false; }, { timeout: 10_000 }) .toBe(true); await expect(checkbox).toHaveAttribute("aria-checked", "true"); }); test("slash menu: /quote /callout /code create their blocks", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); // Each command is typed in its own EMPTY block (the slash trigger needs // the text to START with "/"), so Enter opens a fresh sibling between them. await slashCommand(authPage, "quote"); await authPage.keyboard.type("A quote"); await enterNewBlock(authPage); await slashCommand(authPage, "callout"); await authPage.keyboard.type("A callout"); await enterNewBlock(authPage); // Code is NOT a contenteditable: it is a
 + an Edit/Save overlay, so
    // the text is entered through the edit textarea, then read back from the
    // rendered 
 (the non-edit overlay).
    await slashCommand(authPage, "code", false);
    await expect(authPage.getByLabel("Edit code")).toBeVisible({ timeout: 5_000 });
    await authPage.getByLabel("Edit code").click();
    await authPage.getByLabel("Code", { exact: true }).fill("const x = 1;");
    // Commit via Escape (the documented save path; the Save button label
    // flip is flaky right after a rapid block sequence).
    await authPage.keyboard.press("Escape");
    await expect(authPage.getByTestId("code-block-pre")).toContainText("const x = 1;");

    // All three types persist server-side with their text.
    await expect
      .poll(async () => {
        const blocks = await contentBlocks(api, pageId);
        const quote = blocks.find((b) => b.type === "QUOTE");
        const callout = blocks.find((b) => b.type === "CALLOUT");
        const code = blocks.find((b) => b.type === "CODE");
        return (
          !!quote && blockText(quote) === "A quote" &&
          !!callout && blockText(callout) === "A callout" &&
          !!code && blockText(code).includes("const x = 1;")
        );
      }, { timeout: 10_000 })
      .toBe(true);
  });

  test("slash menu: /divider inserts a divider", async ({
    authPage,
    api,
    freshWorkspace,
  }) => {
    const ws = await freshWorkspace("Editor WS");
    const pageId = await openFreshPage(authPage, api, ws.id);
    await authPage.keyboard.type("above");
    await enterNewBlock(authPage); // fresh empty sibling (caret settled)
    await slashCommand(authPage, "divider", false);
    // A divider is a non-text block: the 
renders. await expect(authPage.locator("hr")).toBeVisible({ timeout: 5_000 }); await expect .poll(async () => (await contentBlocks(api, pageId)).some((b) => b.type === "DIVIDER")) .toBe(true); }); test("slash menu: /toggle creates a collapsible block with a child", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await slashCommand(authPage, "toggle"); await authPage.keyboard.type("Toggle me"); // Enter in the toggle TITLE creates a CHILD inside (Notion semantics). await authPage.keyboard.press("Enter"); // The toggle is the only top-level row; its child editor is the LAST // contenteditable within it (the title editor comes first). const toggleRow = rows(authPage).last(); await expect(toggleRow.locator("[contenteditable]")).toHaveCount(2, { timeout: 5_000 }); const child = toggleRow.locator("[contenteditable]").last(); await child.click(); await authPage.keyboard.type("hidden child"); await expect(child).toContainText("hidden child"); // The chevron reports expanded; collapse HIDES the child (a controlled //
keeps it in the DOM — assert visibility, not presence). const chevron = authPage.getByTestId(/toggle-chevron-/).first(); await expect(chevron).toHaveAttribute("aria-expanded", "true"); await chevron.click(); await expect(chevron).toHaveAttribute("aria-expanded", "false"); await expect(child).toBeHidden(); await chevron.click(); await expect(chevron).toHaveAttribute("aria-expanded", "true"); await expect(child).toBeVisible(); // Server: the toggle has exactly one child paragraph. await expect .poll(async () => { const all = await allBlocks(api, pageId); const toggle = all.find((b) => b.type === "TOGGLE"); return all.filter((b) => b.parentId === toggle?.id).length; }, { timeout: 10_000 }) .toBe(1); }); test("slash menu: /columns creates an editable 2-column grid", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await slashCommand(authPage, "columns", false); // The container + the two server-seeded column cells appear (async child // load — poll). `^column-\d` excludes the `column-list` container itself. await expect(authPage.getByTestId("column-list")).toBeVisible({ timeout: 10_000 }); await expect .poll(async () => authPage.getByTestId(/^column-\d/).count(), { timeout: 10_000 }) .toBe(2); // The first column holds an editable paragraph — type into it. await authPage.locator('[data-testid="column-1"] [contenteditable]').first().click(); await authPage.keyboard.type("left column"); await expect(authPage.getByTestId("column-list")).toContainText("left column"); // Server: column_list + two column children (a grid, not a dead end). await expect .poll(async () => (await allBlocks(api, pageId)).filter((b) => b.type === "COLUMN").length, { timeout: 10_000, }) .toBe(2); }); test("context menu: delete a block", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); // Two content blocks. await authPage.keyboard.type("keep me"); await enterNewBlock(authPage); await authPage.keyboard.type("remove me"); await expectRowCount(authPage, 2); // Right-click the second block → Delete. (The app deletes a SELECTION via // the menu — there is no block-level Backspace/undo keyboard path.) await rows(authPage).nth(1).click({ button: "right" }); const menu = authPage.getByTestId("block-context-menu"); await expect(menu).toBeVisible({ timeout: 5_000 }); await menu.getByTestId("context-menu-delete").click(); // The row is gone; the surviving block + its text remain. await expectRowCount(authPage, 1); await expect(rows(authPage).first()).toContainText("keep me"); await expect .poll(async () => (await contentBlocks(api, pageId)).map(blockText), { timeout: 10_000 }) .toEqual(["keep me"]); }); test("slash menu search filters commands", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); await openFreshPage(authPage, api, ws.id); await authPage.keyboard.type("/"); const menu = authPage.getByTestId("slash-menu"); await expect(menu).toBeVisible({ timeout: 5_000 }); // Filter to a single match; unrelated commands drop out. await authPage.keyboard.type("div"); await expect(menu.getByTestId("slash-menu-item-divider")).toBeVisible({ timeout: 5_000 }); await expect(menu.getByTestId("slash-menu-item-todo")).toHaveCount(0); }); test("context menu: turn into heading 1", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await authPage.keyboard.type("Turn me"); await rows(authPage).first().click({ button: "right" }); const menu = authPage.getByTestId("block-context-menu"); await expect(menu).toBeVisible({ timeout: 5_000 }); // Open the "Turn into…" submenu, then pick Heading 1. await menu.getByTestId("context-menu-turn-into").click(); await authPage.getByTestId("context-menu-turn-heading_1").click(); // The conversion is server-persisted (type HEADING_1); text survives. await expect .poll(async () => { const b = (await contentBlocks(api, pageId))[0]; return b && b.type === "HEADING_1" ? blockText(b) : ""; }, { timeout: 10_000 }) .toBe("Turn me"); await expect(authPage.getByText("Turn me")).toBeVisible(); }); test("copy / paste a paragraph (plain-text native paste)", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await authPage.keyboard.type("Copyable line"); await enterNewBlock(authPage); // empty sibling, caret settled // Synthetic plain-text paste on the FOCUSED editor: ProseMirror's native // paste handler inserts at the caret (the window handler only hijacks // structural/HTML + the app's internal payload — plain text passes // through). Dispatching on the contenteditable (not the window) is what // lets the editor's own handler see it; real Ctrl+C/V would need clipboard // permissions the fixture contexts don't grant. await authPage.evaluate(() => { const dt = new DataTransfer(); dt.setData("text/plain", "Copyable line"); const evt = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true, }); (document.activeElement as HTMLElement).dispatchEvent(evt); }); // Two blocks, both carrying the text. await expectRowCount(authPage, 2); await expect .poll(async () => { const blocks = await contentBlocks(api, pageId); return blocks.length === 2 && blocks.every((b) => blockText(b) === "Copyable line"); }, { timeout: 10_000 }) .toBe(true); }); test("empty-block cleanup on blur", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); // Type a block, Enter (creates an EMPTY sibling, caret in it), then // click back into the first block: the empty sibling loses focus while // still empty → it is auto-deleted (TASK-0136), page back to one block. await authPage.keyboard.type("first line"); await enterNewBlock(authPage); await expectRowCount(authPage, 2); // Click into the first (non-empty) block → the empty one blurs + cleans. await rows(authPage).first().locator("[contenteditable]").click(); await expect .poll(async () => (await contentBlocks(api, pageId)).length, { timeout: 10_000 }) .toBe(1); await expectRowCount(authPage, 1); }); test("reload preserves blocks (ordering + count survive)", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Editor WS"); const pageId = await openFreshPage(authPage, api, ws.id); await authPage.keyboard.type("persist A"); await enterNewBlock(authPage); await authPage.keyboard.type("persist B"); await expectRowCount(authPage, 2); // Settle: block content is debounced into a ~500 ms batch flush and a // hard reload won't run the unmount flush — so wait until BOTH blocks are // server-persisted before reloading, or the second (unflushed) block // would be lost. await expect .poll(async () => (await contentBlocks(api, pageId)).map(blockText), { timeout: 10_000 }) .toEqual(["persist A", "persist B"]); await authPage.reload(); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // Both blocks render, in order (read the contenteditable's own text — // the row also carries gutter/handle markup). Tiptap re-hydrates the // editors after the query resolves, so poll until BOTH texts are present // (a naive read can catch a row before its editor content mounts). const blockRows = rows(authPage); await expect(blockRows).toHaveCount(2, { timeout: 10_000 }); await expect .poll(async () => Promise.all( [0, 1].map( async (i) => ((await blockRows.nth(i).locator("[contenteditable]").first().textContent()) ?? "").trim(), ), ), { timeout: 10_000 }) .toEqual(["persist A", "persist B"]); // The server carries both content blocks in order (the title block is // excluded — it is not body content). expect((await contentBlocks(api, pageId)).map(blockText)).toEqual([ "persist A", "persist B", ]); }); });