1
18import { test, expect } from "../fixtures";
19import type { APIRequestContext, Page } from "@playwright/test";
20
21interface BlockDto {
22 id: string;
23 parentId: string | null;
24 type: string;
25 content: unknown;
26 properties: unknown;
27}
28
29
30function watchPageErrors(page: Page) {
31 const errors: string[] = [];
32 page.on("pageerror", (e) => errors.push(e.message));
33 return errors;
34}
35
36async function firstWorkspaceId(api: APIRequestContext): Promise<string> {
37 const res = await api.get("/api/v1/workspaces");
38 expect(res.status()).toBe(200);
39 const list = (await res.json()) as unknown as Array<{ id: string }> | { data: Array<{ id: string }> };
40 const arr = Array.isArray(list) ? list : (list as { data: Array<{ id: string }> }).data;
41 expect(arr.length).toBeGreaterThan(0);
42 return arr[0]!.id;
43}
44
45async function makePage(api: APIRequestContext, wsId: string, title: string): Promise<string> {
46 const res = await api.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title } });
47 expect(res.status()).toBe(201);
48 const body = (await res.json()) as { id: string };
49 return body.id;
50}
51
52
53async function deletePages(api: APIRequestContext, pageIds: string[]) {
54 for (const id of pageIds) {
55 await api.delete(`/api/v1/pages/${id}`).catch(() => {});
56 }
57}
58
59
60async function topLevelBlocks(api: APIRequestContext, pageId: string): Promise<BlockDto[]> {
61 const res = await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`);
62 expect(res.status()).toBe(200);
63 const body = (await res.json()) as { data: BlockDto[] };
64 return body.data.filter(
65 (b) => b.parentId === null && !(b.properties as { isTitle?: boolean })?.isTitle
66 );
67}
68
69const NOTION_HTML = [
70 "<h1>Project Plan</h1>",
71 "<ul>",
72 ' <li data-notion="toggle">Goals',
73 " <ul>",
74 " <li>Ship v1</li>",
75 " <li>Onboard 10 teams</li>",
76 " </ul>",
77 " </li>",
78 " <li>Timeline",
79 " <ul>",
80 " <li>Q1: design</li>",
81 " <li>Q2: build</li>",
82 " </ul>",
83 " </li>",
84 "</ul>",
85 "<p>Footer paragraph</p>"
86].join("\n");
87
88test.describe("QA paste/toggle/convert/navigation", () => {
89 test("#1+#2: pasting Notion HTML preserves indentation + toggle parent-child", async ({
90 authPage,
91 api
92 }) => {
93 const wsId = await firstWorkspaceId(api);
94 const pageId = await makePage(api, wsId, "QA Paste");
95 const errs = watchPageErrors(authPage);
96 await authPage.goto(`/w/${wsId}/page/${pageId}`);
97 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
98
99
100
101 await authPage.evaluate((html) => {
102 const dt = new DataTransfer();
103 dt.setData("text/html", html);
104 dt.setData("text/plain", "Project Plan");
105 const evt = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true });
106 window.dispatchEvent(evt);
107 }, NOTION_HTML);
108
109
110 await expect(authPage.getByText("Project Plan")).toBeVisible({ timeout: 15_000 });
111 await expect(authPage.getByText("Footer paragraph")).toBeVisible();
112
113
114
115 const all = (
116 (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] }
117 ).data;
118 const top = all.filter(
119 (b) => b.parentId === null && !(b.properties as { isTitle?: boolean })?.isTitle
120 );
121 const types = top.map((b) => b.type);
122
123 expect(types).toEqual(["HEADING_1", "TOGGLE", "BULLETED_LIST", "PARAGRAPH"]);
124
125 const goals = top.find((b) => b.type === "TOGGLE")!;
126 const timeline = top.find((b) => b.type === "BULLETED_LIST")!;
127 const goalsKids = all.filter((b) => b.parentId === goals.id);
128 const timelineKids = all.filter((b) => b.parentId === timeline.id);
129 expect(goalsKids).toHaveLength(2);
130 expect(timelineKids).toHaveLength(2);
131
132 expect(JSON.stringify(goals.content)).toContain("Goals");
133 expect(JSON.stringify(goals.content)).not.toContain("Ship v1");
134
135
136 expect(errs).toEqual([]);
137 await deletePages(api, [pageId]);
138 });
139
140 test("#1: pasting a large Notion document (~220 blocks) succeeds with no error", async ({
141 authPage,
142 api
143 }) => {
144 const wsId = await firstWorkspaceId(api);
145 const pageId = await makePage(api, wsId, "QA Big Paste");
146
147
148
149 const N_PARA = 130;
150 const html = [
151 "<h1>Big Document</h1>",
152 "<ol>" + Array.from({ length: 60 }, (_, i) => `<li>Item ${i + 1}</li>`).join("") + "</ol>",
153 "<ul>" +
154 Array.from({ length: 20 }, (_, t) => `<li>Toggle ${t + 1}<ul>` +
155 Array.from({ length: 3 }, (_, c) => `<li>Child ${t + 1}.${c + 1}</li>`).join("") +
156 "</ul></li>").join("") +
157 "</ul>",
158 ...Array.from({ length: N_PARA }, (_, i) => `<p>Paragraph ${i + 1}</p>`)
159 ].join("");
160 const expected = 1 + 60 + 20 + 60 + N_PARA;
161
162 const errs = watchPageErrors(authPage);
163 await authPage.goto(`/w/${wsId}/page/${pageId}`);
164 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
165
166 await authPage.evaluate((payload) => {
167 const dt = new DataTransfer();
168 dt.setData("text/html", payload.html);
169 dt.setData("text/plain", "Big Document");
170 window.dispatchEvent(
171 new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })
172 );
173 }, { html });
174
175
176
177 await expect(authPage.getByText(`Pasted ${expected} block`)).toBeVisible({ timeout: 30_000 });
178
179 await expect
180 .poll(async () => {
181 const list = (
182 (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] }
183 ).data;
184 return list.filter((b) => !(b.properties as { isTitle?: boolean })?.isTitle).length;
185 })
186 .toBe(expected);
187
188
189
190 await expect(authPage.getByText("Item 1", { exact: true })).toBeVisible();
191
192 expect(errs).toEqual([]);
193 await deletePages(api, [pageId]);
194 });
195
196 test("#3: toggle expand/collapse works and Enter adds a child inside", async ({
197 authPage,
198 api
199 }) => {
200 const wsId = await firstWorkspaceId(api);
201 const pageId = await makePage(api, wsId, "QA Toggle");
202
203
204 const run = Math.random().toString(36).slice(2, 10);
205 const toggleId = `blk_qa_tg_${run}`;
206 const childId = `blk_qa_ch_${run}`;
207
208 const res = await api.post("/api/v1/blocks/batch", {
209 data: {
210 pageId,
211 ops: [
212 { op: "upsert", block: { id: toggleId, type: "toggle", content: "Click me", properties: { collapsed: false }, parentId: null } },
213 { op: "upsert", block: { id: childId, type: "paragraph", content: "First child", properties: {}, parentId: toggleId, afterBlockId: toggleId } }
214 ]
215 }
216 });
217 expect(res.status()).toBe(201);
218
219 const errs = watchPageErrors(authPage);
220 await authPage.goto(`/w/${wsId}/page/${pageId}`);
221 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
222
223 const chevron = authPage.getByTestId(`toggle-chevron-${toggleId}`);
224 await expect(chevron).toBeVisible({ timeout: 10_000 });
225
226 await expect(chevron).toHaveAttribute("aria-expanded", "true");
227
228
229 const child = authPage.locator(`.group.block-row[data-block-id="${childId}"]`);
230 await expect(child).toBeVisible();
231
232
233 await chevron.click();
234 await expect(chevron).toHaveAttribute("aria-expanded", "false");
235 await expect(child).toBeHidden();
236
237 await chevron.click();
238 await expect(chevron).toHaveAttribute("aria-expanded", "true");
239 await expect(child).toBeVisible();
240
241
242
243 const childCount = async () =>
244 (
245 (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] }
246 ).data.filter((b) => b.parentId === toggleId).length;
247 const childCountBefore = await childCount();
248
249 const title = authPage.locator(`[data-testid="block-${toggleId}"] [contenteditable="true"]`).first();
250 await title.click();
251 await authPage.keyboard.press("End");
252 await authPage.keyboard.press("Enter");
253 await expect
254 .poll(async () => childCount())
255 .toBeGreaterThan(childCountBefore);
256
257 expect(errs).toEqual([]);
258 await deletePages(api, [pageId]);
259 });
260
261 test("#4: convert a block via the ⋮ menu (Turn into) and it re-renders", async ({
262 authPage,
263 api
264 }) => {
265 const wsId = await firstWorkspaceId(api);
266 const pageId = await makePage(api, wsId, "QA TurnInto");
267 const paraId = `blk_qa_para_${Math.random().toString(36).slice(2, 10)}`;
268 const res = await api.post("/api/v1/blocks/batch", {
269 data: {
270 pageId,
271 ops: [{ op: "upsert", block: { id: paraId, type: "paragraph", content: "Convert me", properties: {}, parentId: null } }]
272 }
273 });
274 expect(res.status()).toBe(201);
275
276 const errs = watchPageErrors(authPage);
277 await authPage.goto(`/w/${wsId}/page/${pageId}`);
278 const row = authPage.getByTestId(`block-${paraId}`);
279 await expect(row).toBeVisible({ timeout: 20_000 });
280
281
282
283 await row.hover();
284 await row.getByLabel("More block actions").click();
285 await authPage.getByRole("menuitem", { name: "Turn into…" }).click();
286 await authPage.getByRole("menuitemradio", { name: "Heading 1" }).click();
287
288
289
290
291 await expect
292 .poll(async () => {
293
294 const list = (
295 (await (await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`)).json()) as { data: BlockDto[] }
296 ).data;
297 return list.find((b) => b.id === paraId)?.type;
298 })
299 .toBe("HEADING_1");
300 await expect(authPage.getByTestId(`block-${paraId}`)).toContainText("Convert me");
301
302 expect(errs).toEqual([]);
303 await deletePages(api, [pageId]);
304 });
305
306 test("#5: context-menu duplicate + copy + paste below produce no unreeled errors", async ({
307 authPage,
308 api
309 }) => {
310 const wsId = await firstWorkspaceId(api);
311 const pageId = await makePage(api, wsId, "QA ContextMenu");
312 const srcId = `blk_qa_src_${Math.random().toString(36).slice(2, 10)}`;
313 const seedRes = await api.post("/api/v1/blocks/batch", {
314 data: {
315 pageId,
316 ops: [{ op: "upsert", block: { id: srcId, type: "paragraph", content: "Source block", properties: {}, parentId: null } }]
317 }
318 });
319 expect(seedRes.status()).toBe(201);
320
321
322
323 await authPage
324 .context()
325 .grantPermissions(["clipboard-read", "clipboard-write"])
326 .catch(() => {});
327
328 const errs = watchPageErrors(authPage);
329 await authPage.goto(`/w/${wsId}/page/${pageId}`);
330 const row = authPage.getByTestId(`block-${srcId}`);
331 await expect(row).toBeVisible({ timeout: 20_000 });
332
333
334 const before = (await topLevelBlocks(api, pageId)).length;
335 await row.click({ button: "right" });
336 await authPage.getByRole("menuitem", { name: "Duplicate" }).click();
337 await expect
338 .poll(async () => (await topLevelBlocks(api, pageId)).length)
339 .toBe(before + 1);
340
341
342 const mid = (await topLevelBlocks(api, pageId)).length;
343 await row.click({ button: "right" });
344 await authPage.getByRole("menuitem", { name: "Copy" }).click();
345 await row.click({ button: "right" });
346 await authPage.getByRole("menuitem", { name: "Paste below" }).click();
347 await expect
348 .poll(async () => (await topLevelBlocks(api, pageId)).length)
349 .toBe(mid + 1);
350
351 expect(errs).toEqual([]);
352 await deletePages(api, [pageId]);
353 });
354
355 test("#6: navigating between pages loads the body (no error page / no lag-loop)", async ({
356 authPage,
357 api
358 }) => {
359 const wsId = await firstWorkspaceId(api);
360 const p1 = await makePage(api, wsId, "QA Nav One");
361 const p2 = await makePage(api, wsId, "QA Nav Two");
362 for (const pid of [p1, p2]) {
363 await api.post("/api/v1/blocks/batch", {
364 data: { pageId: pid, ops: [{ op: "upsert", block: { type: "paragraph", content: `Body of ${pid.slice(-4)}`, properties: {}, parentId: null } }] }
365 });
366 }
367
368 const errs = watchPageErrors(authPage);
369 await authPage.goto(`/w/${wsId}/page/${p1}`);
370 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
371 await expect(authPage.getByText("Body of")).toBeVisible();
372
373
374 await authPage.goto(`/w/${wsId}/page/${p2}`);
375 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
376
377
378
379 await expect(authPage.getByText("Couldn’t load this page")).toHaveCount(0);
380
381
382
383
384 await authPage.goto(`/w/${wsId}/page/does-not-exist`);
385 await expect(authPage.getByText("Couldn’t load this page")).toBeVisible({ timeout: 15_000 });
386
387 expect(errs).toEqual([]);
388 await deletePages(api, [p1, p2]);
389 });
390});
391