/** * T-146/T-147 — editor table + table-of-contents E2E (dev-stack, Playwright). * * Verifies the two T-146/T-147 block surfaces end to end through the real * browser against the live web (:3000) + API (:4000): * * Table (T-146) * - "/table" in the slash menu opens the insert dialog's Table tab * (the command is `special` — a bare table block has no columns/rows * and the renderer draws nothing, so the dialog is the only usable * entry; the same pattern the "image" command uses) * - the dialog pre-creates the chosen rows × cols grid * - "+ Add row" appends a row; a cell edit persists * - the round-trip: GET /pages/:id/blocks carries the table's * properties.columns / properties.rows (the web's canonical storage — * see the rule-29 note in FEATURE_TRACKER T-146: the API DOC * (docs/api/blocks.md) prescribes content.rows of {text,marks} cells, * the shared Zod schema prescribes content.rows of strings, and the * live web stores properties.rows/columns of strings) * * Table of contents (T-147) * - a toc block lists the page's H1–H3 headings by title (the entries * are derived from the page's blocks at render time) * - clicking an entry deep-links to the heading block (URL * /w//page// + smooth scroll) * - headings authored via the API (content = the canonical `{ text }` * shape) show their real title, NOT "Untitled" — regression guard for * the extractTitle bug that only handled HTML-string content * * URL pattern: /w/{workspaceSlug}/page/{pageId} (the sidebar's own link * format). Workspace param accepts id or slug; we pass the slug. */ import { test, expect } from "../fixtures"; interface BlockRow { id: string; type: string; content: unknown; properties: Record; } interface BlocksResponse { data: BlockRow[]; } async function createPage(api: import("@playwright/test").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; } async function createBlock( api: import("@playwright/test").APIRequestContext, pageId: string, type: string, text: string, ): Promise { const res = await api.post(`/api/v1/pages/${pageId}/blocks`, { data: { type, content: { text }, parentId: null }, }); expect(res.status(), await res.text()).toBe(201); return ((await res.json()) as { id: string }).id; } /** * Focus a fresh EMPTY block in the main content column so the next "/" opens * the slash menu (the trigger only fires in a block whose text STARTS with * "/", so the caret must be in an empty block — never at the end of a * heading/paragraph). * * Clicking the trailing `new-block` area creates (or reuses) the empty * paragraph and focuses it — but that is an async client-side round-trip * (create → re-render → focus), so focus is NOT set when `click()` resolves. * Poll with waitForFunction for it to settle. Only click ONCE: once a block * exists, re-clicking the trailing area moves focus onto an internal button * (verified by probe), so a click-retry loop is harmful, not helpful. The * fallback nudges the empty contenteditable directly via the DOM. */ async function focusFreshBlock(page: import("@playwright/test").Page) { const area = page.locator("#workspace-main").getByTestId("new-block"); await area.click(); const settled = () => page.waitForFunction( () => { const ae = document.activeElement; return ( ae instanceof HTMLElement && ae.isContentEditable && (ae.textContent ?? "").trim() === "" ); }, undefined, { timeout: 3000 }, ); try { await settled(); } catch { // Focus race lost on the first pass — click the empty contenteditable // directly (native focus, no React handler involved) and re-poll. await page.evaluate(() => { const eds = Array.from( document.querySelectorAll("#workspace-main [contenteditable='true']"), ); const empty = [...eds].reverse().find((el) => (el.textContent ?? "").trim() === ""); empty?.click(); }); await settled(); } } test.describe("Editor table + TOC — T-146/T-147", () => { test("/table → insert dialog → pre-created grid; add row + edit cell persist to the API", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Table Space"); const pageId = await createPage(api, ws.id, "Table page"); await authPage.goto(`/w/${ws.slug}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // Focus a fresh block in the main content column, then open the slash // menu with "/". await focusFreshBlock(authPage); await authPage.keyboard.type("/", { delay: 30 }); const menu = authPage.getByTestId("slash-menu"); await expect(menu).toBeVisible({ timeout: 10_000 }); // Filter to the table command and pick it — this must OPEN THE INSERT // DIALOG'S TABLE TAB (a bare table block renders nothing; the grid has // to be pre-created). await authPage.keyboard.type("table", { delay: 30 }); await menu.getByTestId("slash-menu-item-table").click(); const dialog = authPage.getByTestId("insert-dialog"); await expect(dialog).toBeVisible({ timeout: 10_000 }); await expect(dialog.getByRole("tab", { name: "Table" })).toHaveAttribute("aria-selected", "true"); // A 2×2 grid. await dialog.locator("#tbl-rows").fill("2"); await dialog.locator("#tbl-cols").fill("2"); await dialog.getByRole("button", { name: "Insert table" }).click(); await expect(dialog).toHaveCount(0); // The grid renders: two labeled columns + the add-column action, and // 2×2 editable cells. const table = authPage.locator("table").first(); await expect(table).toBeVisible({ timeout: 10_000 }); await expect(table.locator("thead th").filter({ hasText: "Column 1" })).toBeVisible(); await expect(table.locator("thead th").filter({ hasText: "Column 2" })).toBeVisible(); await expect(table.locator("tbody input")).toHaveCount(4); // "+ Add row" appends a third row (2×2 → 3×2). NOTE: the button is a // SIBLING of the element (in the outer .group/table wrapper), not // a descendant — so it is addressed page-level, not via `table`. await authPage.getByRole("button", { name: "+ Add row" }).click(); await expect(table.locator("tbody tr")).toHaveCount(3, { timeout: 10_000 }); // Edit the first cell and blur (the blur save is the final persistence). const firstCell = table.locator("tbody input").first(); await firstCell.fill("HELLO"); await firstCell.blur(); // Round-trip: the server must carry columns (2) + rows (3) with the // edited cell. The web stores the grid in `properties` (columns/rows), // NOT content — see the rule-29 storage-shape note for T-146. await expect .poll( async () => { const res = await api.get(`/api/v1/pages/${pageId}/blocks`); const body = (await res.json()) as BlocksResponse; const t = body.data.find((b) => b.type === "TABLE"); if (!t) return false; const rows = t.properties.rows as string[][]; const cols = t.properties.columns as unknown[]; return ( Array.isArray(rows) && rows.length === 3 && Array.isArray(cols) && cols.length === 2 && rows[0][0] === "HELLO" ); }, { timeout: 15_000 }, ) .toBe(true); }); test("/toc → table of contents lists the headings and deep-links to them", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("TOC Space"); const pageId = await createPage(api, ws.id, "TOC page"); // Seed two headings through the API with the CANONICAL content shape // ({ text }) — the shape the old extractTitle mishandled as "Untitled". const h1Id = await createBlock(api, pageId, "heading_1", "Alpha"); const h2Id = await createBlock(api, pageId, "heading_2", "Beta"); await authPage.goto(`/w/${ws.slug}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); await focusFreshBlock(authPage); await authPage.keyboard.type("/", { delay: 30 }); const menu = authPage.getByTestId("slash-menu"); await expect(menu).toBeVisible({ timeout: 10_000 }); await authPage.keyboard.type("toc", { delay: 30 }); await menu.getByTestId("slash-menu-item-toc").click(); // The TOC block renders its entries from the page's headings. const toc = authPage.getByRole("navigation", { name: "Table of contents" }); await expect(toc).toBeVisible({ timeout: 10_000 }); const entries = toc.locator("a"); await expect(entries.first()).toHaveText("Alpha"); await expect(entries.nth(1)).toHaveText("Beta"); // Regression guard: no "Untitled" fallback entries (the { text } shape // must resolve to the heading title). await expect(toc.getByText("Untitled")).toHaveCount(0); // Deep-link: clicking "Alpha" pushes the heading block's anchor into the // URL and the heading block is on screen. (Assert on the path suffix so // the spec is origin-agnostic — the canonical run pins the domain.) await toc.getByRole("link", { name: "Alpha" }).click(); await expect .poll(() => authPage.url(), { timeout: 10_000 }) .toBe(new URL(`/w/${ws.slug}/page/${pageId}/${h1Id}`, authPage.url()).toString()); await expect(authPage.getByTestId(`block-${h1Id}`)).toContainText("Alpha"); // The H2 anchor is also reachable. await toc.getByRole("link", { name: "Beta" }).click(); await expect .poll(() => authPage.url(), { timeout: 10_000 }) .toBe(new URL(`/w/${ws.slug}/page/${pageId}/${h2Id}`, authPage.url()).toString()); }); });