/** * QA regression suite — the 6 issues from the QA bug report against * notion.filps.com: * * #1 Big paste from Notion → "internal error, please try later" * #2 Indentation + toggle parent-child not preserved on copy/paste * #3 Toggle list expand/collapse doesn't work (and Enter escapes) * #4 Convert block via the ⋮ (three-dots) menu * #5 "Many unreeled errors" on navigation / context-menu actions * #6 Page load lags then leads to an error page * * Fixtures are created SERVER-SIDE via the Bearer `api` fixture (reliable, * no dependency on the editor's own create path) and the browser is driven * through `authPage` on the REAL route `/w/{ws}/page/{id}`. We assert on * uncaught exceptions (`pageerror`) as the signal for the "unreeled errors" * class (#5) and on the absence of the error card for #6. */ import { test, expect } from "../fixtures"; import type { APIRequestContext, Page } from "@playwright/test"; interface BlockDto { id: string; parentId: string | null; type: string; content: unknown; properties: unknown; } /** Collect uncaught exceptions (the "unreeled error" signal). */ function watchPageErrors(page: Page) { const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); return errors; } async function firstWorkspaceId(api: APIRequestContext): Promise { const res = await api.get("/api/v1/workspaces"); expect(res.status()).toBe(200); const list = (await res.json()) as unknown as Array<{ id: string }> | { data: Array<{ id: string }> }; const arr = Array.isArray(list) ? list : (list as { data: Array<{ id: string }> }).data; expect(arr.length).toBeGreaterThan(0); return arr[0]!.id; } async function makePage(api: APIRequestContext, wsId: string, title: string): Promise { const res = await api.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title } }); expect(res.status()).toBe(201); const body = (await res.json()) as { id: string }; return body.id; } /** Best-effort teardown: scratch pages (and their blocks) are deleted after each test. */ async function deletePages(api: APIRequestContext, pageIds: string[]) { for (const id of pageIds) { await api.delete(`/api/v1/pages/${id}`).catch(() => {}); } } /** Top-level CONTENT blocks (the auto title block, `properties.isTitle`, is excluded). */ async function topLevelBlocks(api: APIRequestContext, pageId: string): Promise { const res = await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`); expect(res.status()).toBe(200); const body = (await res.json()) as { data: BlockDto[] }; return body.data.filter( (b) => b.parentId === null && !(b.properties as { isTitle?: boolean })?.isTitle ); } const NOTION_HTML = [ "

Project Plan

", "
    ", '
  • Goals', "
      ", "
    • Ship v1
    • ", "
    • Onboard 10 teams
    • ", "
    ", "
  • ", "
  • Timeline", "
      ", "
    • Q1: design
    • ", "
    • Q2: build
    • ", "
    ", "
  • ", "
", "

Footer paragraph

" ].join("\n"); test.describe("QA paste/toggle/convert/navigation", () => { test("#1+#2: pasting Notion HTML preserves indentation + toggle parent-child", async ({ authPage, api }) => { const wsId = await firstWorkspaceId(api); const pageId = await makePage(api, wsId, "QA Paste"); const errs = watchPageErrors(authPage); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // Dispatch a synthetic paste carrying structural HTML (text/html) + the // plain-text fallback. The window paste handler imports it as blocks. await authPage.evaluate((html) => { const dt = new DataTransfer(); dt.setData("text/html", html); dt.setData("text/plain", "Project Plan"); const evt = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }); window.dispatchEvent(evt); }, NOTION_HTML); // The import invalidates the block query → the rows render. await expect(authPage.getByText("Project Plan")).toBeVisible({ timeout: 15_000 }); await expect(authPage.getByText("Footer paragraph")).toBeVisible(); // Assert the SERVER-side tree: heading, a TOGGLE "Goals" with 2 children, // a bulleted "Timeline" with 2 children (indent preserved), footer para. const all = ( (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] } ).data; const top = all.filter( (b) => b.parentId === null && !(b.properties as { isTitle?: boolean })?.isTitle ); const types = top.map((b) => b.type); // heading + toggle(Goals) + bullet(Timeline) + paragraph = 4 top-level. expect(types).toEqual(["HEADING_1", "TOGGLE", "BULLETED_LIST", "PARAGRAPH"]); const goals = top.find((b) => b.type === "TOGGLE")!; const timeline = top.find((b) => b.type === "BULLETED_LIST")!; const goalsKids = all.filter((b) => b.parentId === goals.id); const timelineKids = all.filter((b) => b.parentId === timeline.id); expect(goalsKids).toHaveLength(2); expect(timelineKids).toHaveLength(2); // The toggle title did not swallow its children's text. expect(JSON.stringify(goals.content)).toContain("Goals"); expect(JSON.stringify(goals.content)).not.toContain("Ship v1"); // No uncaught exceptions during the import (the #5 "unreeled" class). expect(errs).toEqual([]); await deletePages(api, [pageId]); }); test("#1: pasting a large Notion document (~220 blocks) succeeds with no error", async ({ authPage, api }) => { const wsId = await firstWorkspaceId(api); const pageId = await makePage(api, wsId, "QA Big Paste"); // 60 numbered-list items + 20 toggles with 3 children each (60) + 60 // plain paragraphs + a heading = 141... scale the paragraphs up so the // paste exceeds the 200-op batch cap (the client must chunk). const N_PARA = 130; const html = [ "

Big Document

", "
    " + Array.from({ length: 60 }, (_, i) => `
  1. Item ${i + 1}
  2. `).join("") + "
", "
    " + Array.from({ length: 20 }, (_, t) => `
  • Toggle ${t + 1}
      ` + Array.from({ length: 3 }, (_, c) => `
    • Child ${t + 1}.${c + 1}
    • `).join("") + "
  • ").join("") + "
", ...Array.from({ length: N_PARA }, (_, i) => `

Paragraph ${i + 1}

`) ].join(""); const expected = 1 + 60 + 20 + 60 + N_PARA; // heading + ol items + toggles + children + paras const errs = watchPageErrors(authPage); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); await authPage.evaluate((payload) => { const dt = new DataTransfer(); dt.setData("text/html", payload.html); dt.setData("text/plain", "Big Document"); window.dispatchEvent( new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }) ); }, { html }); // The success toast reports the imported count (no "internal error"). // (getByText is a substring match — the toast reads "Pasted 271 blocks".) await expect(authPage.getByText(`Pasted ${expected} block`)).toBeVisible({ timeout: 30_000 }); // Server-side total matches (chunked batch all applied). await expect .poll(async () => { const list = ( (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] } ).data; return list.filter((b) => !(b.properties as { isTitle?: boolean })?.isTitle).length; }) .toBe(expected); // First content rendered. (exact — "Item 1" would substring-match // "Item 10"…"Item 19". The last block is off-viewport at the bottom of a // 271-row page; its existence is already proven by the server count.) await expect(authPage.getByText("Item 1", { exact: true })).toBeVisible(); expect(errs).toEqual([]); await deletePages(api, [pageId]); }); test("#3: toggle expand/collapse works and Enter adds a child inside", async ({ authPage, api }) => { const wsId = await firstWorkspaceId(api); const pageId = await makePage(api, wsId, "QA Toggle"); // Block ids are GLOBALLY unique (not per-page) → use per-run ids so // re-runs never 409 on a stale id from a previous run. const run = Math.random().toString(36).slice(2, 10); const toggleId = `blk_qa_tg_${run}`; const childId = `blk_qa_ch_${run}`; // A toggle with one child, server-side (known ids). const res = await api.post("/api/v1/blocks/batch", { data: { pageId, ops: [ { op: "upsert", block: { id: toggleId, type: "toggle", content: "Click me", properties: { collapsed: false }, parentId: null } }, { op: "upsert", block: { id: childId, type: "paragraph", content: "First child", properties: {}, parentId: toggleId, afterBlockId: toggleId } } ] } }); expect(res.status()).toBe(201); const errs = watchPageErrors(authPage); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); const chevron = authPage.getByTestId(`toggle-chevron-${toggleId}`); await expect(chevron).toBeVisible({ timeout: 10_000 }); // Starts expanded (collapsed !== true). await expect(chevron).toHaveAttribute("aria-expanded", "true"); // The id appears on BOTH the row wrapper and the inner Tiptap editor — // scope to the row (`.group.block-row`) so the locator is singular. const child = authPage.locator(`.group.block-row[data-block-id="${childId}"]`); await expect(child).toBeVisible(); // Collapse: aria-expanded flips and the child hides. await chevron.click(); await expect(chevron).toHaveAttribute("aria-expanded", "false"); await expect(child).toBeHidden(); // Expand again. await chevron.click(); await expect(chevron).toHaveAttribute("aria-expanded", "true"); await expect(child).toBeVisible(); // Enter in the toggle TITLE creates a child INSIDE the toggle (not a // top-level block). const childCount = async () => ( (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] } ).data.filter((b) => b.parentId === toggleId).length; const childCountBefore = await childCount(); // The toggle title is a Tiptap contenteditable (not an

/input). const title = authPage.locator(`[data-testid="block-${toggleId}"] [contenteditable="true"]`).first(); await title.click(); await authPage.keyboard.press("End"); await authPage.keyboard.press("Enter"); await expect .poll(async () => childCount()) .toBeGreaterThan(childCountBefore); expect(errs).toEqual([]); await deletePages(api, [pageId]); }); test("#4: convert a block via the ⋮ menu (Turn into) and it re-renders", async ({ authPage, api }) => { const wsId = await firstWorkspaceId(api); const pageId = await makePage(api, wsId, "QA TurnInto"); const paraId = `blk_qa_para_${Math.random().toString(36).slice(2, 10)}`; const res = await api.post("/api/v1/blocks/batch", { data: { pageId, ops: [{ op: "upsert", block: { id: paraId, type: "paragraph", content: "Convert me", properties: {}, parentId: null } }] } }); expect(res.status()).toBe(201); const errs = watchPageErrors(authPage); await authPage.goto(`/w/${wsId}/page/${pageId}`); const row = authPage.getByTestId(`block-${paraId}`); await expect(row).toBeVisible({ timeout: 20_000 }); // Reveal the left gutter (hover), open THIS row's ⋮ menu, pick Turn into → Heading 1. // (Scoped to `row` — every block row has its own ⋮ with the same aria-label.) await row.hover(); await row.getByLabel("More block actions").click(); await authPage.getByRole("menuitem", { name: "Turn into…" }).click(); await authPage.getByRole("menuitemradio", { name: "Heading 1" }).click(); // The conversion is server-persisted and the row re-renders (invalidation). // (Headings render as a Tiptap contenteditable — there is no

tag — // so the type change is asserted server-side, and the text stays visible.) await expect .poll(async () => { // (No single-block GET route — the list envelope is the read API.) const list = ( (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] } ).data; return list.find((b) => b.id === paraId)?.type; }) .toBe("HEADING_1"); await expect(authPage.getByTestId(`block-${paraId}`)).toContainText("Convert me"); expect(errs).toEqual([]); await deletePages(api, [pageId]); }); test("#5: context-menu duplicate + copy + paste below produce no unreeled errors", async ({ authPage, api }) => { const wsId = await firstWorkspaceId(api); const pageId = await makePage(api, wsId, "QA ContextMenu"); const srcId = `blk_qa_src_${Math.random().toString(36).slice(2, 10)}`; const seedRes = await api.post("/api/v1/blocks/batch", { data: { pageId, ops: [{ op: "upsert", block: { id: srcId, type: "paragraph", content: "Source block", properties: {}, parentId: null } }] } }); expect(seedRes.status()).toBe(201); // The context-menu Copy/Paste round-trips through navigator.clipboard — // grant it (headless Chromium blocks clipboard read/write otherwise). await authPage .context() .grantPermissions(["clipboard-read", "clipboard-write"]) .catch(() => {}); const errs = watchPageErrors(authPage); await authPage.goto(`/w/${wsId}/page/${pageId}`); const row = authPage.getByTestId(`block-${srcId}`); await expect(row).toBeVisible({ timeout: 20_000 }); // Duplicate. const before = (await topLevelBlocks(api, pageId)).length; await row.click({ button: "right" }); await authPage.getByRole("menuitem", { name: "Duplicate" }).click(); await expect .poll(async () => (await topLevelBlocks(api, pageId)).length) .toBe(before + 1); // Copy, then Paste below. const mid = (await topLevelBlocks(api, pageId)).length; await row.click({ button: "right" }); await authPage.getByRole("menuitem", { name: "Copy" }).click(); await row.click({ button: "right" }); await authPage.getByRole("menuitem", { name: "Paste below" }).click(); await expect .poll(async () => (await topLevelBlocks(api, pageId)).length) .toBe(mid + 1); expect(errs).toEqual([]); await deletePages(api, [pageId]); }); test("#6: navigating between pages loads the body (no error page / no lag-loop)", async ({ authPage, api }) => { const wsId = await firstWorkspaceId(api); const p1 = await makePage(api, wsId, "QA Nav One"); const p2 = await makePage(api, wsId, "QA Nav Two"); for (const pid of [p1, p2]) { await api.post("/api/v1/blocks/batch", { data: { pageId: pid, ops: [{ op: "upsert", block: { type: "paragraph", content: `Body of ${pid.slice(-4)}`, properties: {}, parentId: null } }] } }); } const errs = watchPageErrors(authPage); await authPage.goto(`/w/${wsId}/page/${p1}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); await expect(authPage.getByText("Body of")).toBeVisible(); // Navigate to the second page via the sidebar and confirm ITS body loads. await authPage.goto(`/w/${wsId}/page/${p2}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // A failed page load must show the retry card, never a blank shell or a // crash. (Assert the error card is NOT present on a healthy load.) await expect(authPage.getByText("Couldn’t load this page")).toHaveCount(0); // Navigating to a NON-EXISTENT page id must render the recoverable error // state (the #6 "leads to error page" path is now a visible, retryable // card — and, critically, the shell stays up and no exception is thrown). await authPage.goto(`/w/${wsId}/page/does-not-exist`); await expect(authPage.getByText("Couldn’t load this page")).toBeVisible({ timeout: 15_000 }); expect(errs).toEqual([]); await deletePages(api, [p1, p2]); }); });