/** * 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 `
+ 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",
]);
});
});