1
54import { test, expect } from "../fixtures";
55import type { APIRequestContext, Page } from "@playwright/test";
56
57interface BlockRow {
58 id: string;
59 parentId: string | null;
60 type: string;
61 content: unknown;
62 properties: unknown;
63}
64
65interface BlocksResponse {
66 data: BlockRow[];
67}
68
69
74function rows(page: Page) {
75 return page.locator('[data-testid^="block-"][data-block-id]');
76}
77
78
79async function createPage(
80 api: APIRequestContext,
81 wsId: string,
82 title: string,
83): Promise<string> {
84 const res = await api.post(`/api/v1/workspaces/${wsId}/pages`, {
85 data: { title },
86 });
87 expect(res.status(), await res.text()).toBe(201);
88 return ((await res.json()) as { id: string }).id;
89}
90
91
92async function contentBlocks(
93 api: APIRequestContext,
94 pageId: string,
95): Promise<BlockRow[]> {
96 const res = await api.get(`/api/v1/pages/${pageId}/blocks`);
97 expect(res.status()).toBe(200);
98 const body = (await res.json()) as BlocksResponse;
99 return body.data.filter(
100 (b) => b.parentId === null && !(b.properties as { isTitle?: boolean })?.isTitle,
101 );
102}
103
104
105async function allBlocks(api: APIRequestContext, pageId: string): Promise<BlockRow[]> {
106 const res = await api.get(`/api/v1/pages/${pageId}/blocks?depth=all`);
107 expect(res.status()).toBe(200);
108 return ((await res.json()) as BlocksResponse).data;
109}
110
111
112function blockText(b: BlockRow): string {
113 const c = b.content as { text?: unknown } | string | null;
114 if (typeof c === "string") return c.replace(/<[^>]*>/g, "");
115 if (c && typeof c === "object" && typeof c.text === "string") return c.text;
116 return "";
117}
118
119
120async function expectRowCount(page: Page, n: number) {
121 await expect(rows(page)).toHaveCount(n, { timeout: 5_000 });
122}
123
124
128async function waitStableRows(page: Page, timeout = 8_000) {
129 const start = Date.now();
130 let prev = await rows(page).count();
131 let stable = 0;
132 for (;;) {
133 if (Date.now() - start > timeout) return;
134 await page.waitForTimeout(150);
135 const c = await rows(page).count();
136 stable = c === prev ? stable + 1 : 0;
137 prev = c;
138 if (stable >= 3) return;
139 }
140}
141
142
150async function enterNewBlock(page: Page) {
151 await waitStableRows(page);
152 const lastIdBefore = await rows(page).last().getAttribute("data-block-id");
153 await page.keyboard.press("Enter");
154 await expect
155 .poll(async () => {
156 const n = await rows(page).count();
157 if (n === 0) return false;
158 return (await rows(page).last().getAttribute("data-block-id")) !== lastIdBefore;
159 }, { timeout: 8_000 })
160 .toBe(true);
161 await waitStableRows(page);
162 await rows(page).last().locator("[contenteditable]").first().click();
163}
164
165
179async function slashCommand(page: Page, itemId: string, focusNew = true) {
180 const anchorId = await rows(page).last().getAttribute("data-block-id");
181 await page.keyboard.type("/");
182 await expect(page.getByTestId("slash-menu")).toBeVisible({ timeout: 5_000 });
183 await page.getByTestId(`slash-menu-item-${itemId}`).click();
184 if (!focusNew) return;
185 await expect
186 .poll(async () => {
187 const n = await rows(page).count();
188 if (n === 0) return false;
189 return (await rows(page).last().getAttribute("data-block-id")) !== anchorId;
190 }, { timeout: 8_000 })
191 .toBe(true);
192 await rows(page).last().locator("[contenteditable]").first().click();
193}
194
195
201async function openFreshPage(
202 authPage: Page,
203 api: APIRequestContext,
204 wsId: string,
205): Promise<string> {
206 const pageId = await createPage(api, wsId, "Editor Test Page");
207 await authPage.goto(`/w/${wsId}/page/${pageId}`);
208 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
209
210 expect(await rows(authPage).count()).toBe(0);
211
212 await authPage.locator("#workspace-main").getByTestId("new-block").click();
213 await expectRowCount(authPage, 1);
214
215 await rows(authPage).first().locator("[contenteditable]").click();
216 return pageId;
217}
218
219test.describe("Block Editor — Phase 4 (T-194)", () => {
220 test("type a paragraph and it persists", async ({ authPage, api, freshWorkspace }) => {
221 const ws = await freshWorkspace("Editor WS");
222 const pageId = await openFreshPage(authPage, api, ws.id);
223 await authPage.keyboard.type("Hello from the editor");
224
225 await expect(rows(authPage).first()).toContainText("Hello from the editor");
226
227 await expect
228 .poll(async () => {
229 const blocks = await contentBlocks(api, pageId);
230 return blocks.length === 1 ? blockText(blocks[0]!) : "";
231 }, { timeout: 10_000 })
232 .toBe("Hello from the editor");
233 });
234
235 test("slash menu: /h1 creates a heading", async ({ authPage, api, freshWorkspace }) => {
236 const ws = await freshWorkspace("Editor WS");
237 const pageId = await openFreshPage(authPage, api, ws.id);
238 await slashCommand(authPage, "h1");
239 await authPage.keyboard.type("A Heading One");
240
241
242 await expect
243 .poll(async () => {
244 const b = (await contentBlocks(api, pageId)).find((x) => x.type === "HEADING_1");
245 return b ? blockText(b) : "";
246 }, { timeout: 10_000 })
247 .toBe("A Heading One");
248 await expect(authPage.getByText("A Heading One")).toBeVisible();
249 });
250
251 test("slash menu: /todo creates a checkable checkbox block", async ({
252 authPage,
253 api,
254 freshWorkspace,
255 }) => {
256 const ws = await freshWorkspace("Editor WS");
257 const pageId = await openFreshPage(authPage, api, ws.id);
258 await slashCommand(authPage, "todo");
259 await authPage.keyboard.type("Buy milk");
260 const checkbox = authPage.getByTestId(/todo-checkbox-/).first();
261 await expect(checkbox).toBeVisible({ timeout: 5_000 });
262
263 await expect(checkbox).toHaveAttribute("aria-checked", "false");
264 await checkbox.click();
265 await expect
266 .poll(async () => {
267 const b = (await contentBlocks(api, pageId)).find((x) => x.type === "TODO");
268 return (b?.properties as { checked?: boolean })?.checked ?? false;
269 }, { timeout: 10_000 })
270 .toBe(true);
271 await expect(checkbox).toHaveAttribute("aria-checked", "true");
272 });
273
274 test("slash menu: /quote /callout /code create their blocks", async ({
275 authPage,
276 api,
277 freshWorkspace,
278 }) => {
279 const ws = await freshWorkspace("Editor WS");
280 const pageId = await openFreshPage(authPage, api, ws.id);
281
282
283
284 await slashCommand(authPage, "quote");
285 await authPage.keyboard.type("A quote");
286 await enterNewBlock(authPage);
287
288 await slashCommand(authPage, "callout");
289 await authPage.keyboard.type("A callout");
290 await enterNewBlock(authPage);
291
292
293
294
295 await slashCommand(authPage, "code", false);
296 await expect(authPage.getByLabel("Edit code")).toBeVisible({ timeout: 5_000 });
297 await authPage.getByLabel("Edit code").click();
298 await authPage.getByLabel("Code", { exact: true }).fill("const x = 1;");
299
300
301 await authPage.keyboard.press("Escape");
302 await expect(authPage.getByTestId("code-block-pre")).toContainText("const x = 1;");
303
304
305 await expect
306 .poll(async () => {
307 const blocks = await contentBlocks(api, pageId);
308 const quote = blocks.find((b) => b.type === "QUOTE");
309 const callout = blocks.find((b) => b.type === "CALLOUT");
310 const code = blocks.find((b) => b.type === "CODE");
311 return (
312 !!quote && blockText(quote) === "A quote" &&
313 !!callout && blockText(callout) === "A callout" &&
314 !!code && blockText(code).includes("const x = 1;")
315 );
316 }, { timeout: 10_000 })
317 .toBe(true);
318 });
319
320 test("slash menu: /divider inserts a divider", async ({
321 authPage,
322 api,
323 freshWorkspace,
324 }) => {
325 const ws = await freshWorkspace("Editor WS");
326 const pageId = await openFreshPage(authPage, api, ws.id);
327 await authPage.keyboard.type("above");
328 await enterNewBlock(authPage);
329 await slashCommand(authPage, "divider", false);
330
331 await expect(authPage.locator("hr")).toBeVisible({ timeout: 5_000 });
332 await expect
333 .poll(async () => (await contentBlocks(api, pageId)).some((b) => b.type === "DIVIDER"))
334 .toBe(true);
335 });
336
337 test("slash menu: /toggle creates a collapsible block with a child", async ({
338 authPage,
339 api,
340 freshWorkspace,
341 }) => {
342 const ws = await freshWorkspace("Editor WS");
343 const pageId = await openFreshPage(authPage, api, ws.id);
344 await slashCommand(authPage, "toggle");
345 await authPage.keyboard.type("Toggle me");
346
347 await authPage.keyboard.press("Enter");
348
349
350 const toggleRow = rows(authPage).last();
351 await expect(toggleRow.locator("[contenteditable]")).toHaveCount(2, { timeout: 5_000 });
352 const child = toggleRow.locator("[contenteditable]").last();
353 await child.click();
354 await authPage.keyboard.type("hidden child");
355 await expect(child).toContainText("hidden child");
356
357
358
359 const chevron = authPage.getByTestId(/toggle-chevron-/).first();
360 await expect(chevron).toHaveAttribute("aria-expanded", "true");
361 await chevron.click();
362 await expect(chevron).toHaveAttribute("aria-expanded", "false");
363 await expect(child).toBeHidden();
364 await chevron.click();
365 await expect(chevron).toHaveAttribute("aria-expanded", "true");
366 await expect(child).toBeVisible();
367
368
369 await expect
370 .poll(async () => {
371 const all = await allBlocks(api, pageId);
372 const toggle = all.find((b) => b.type === "TOGGLE");
373 return all.filter((b) => b.parentId === toggle?.id).length;
374 }, { timeout: 10_000 })
375 .toBe(1);
376 });
377
378 test("slash menu: /columns creates an editable 2-column grid", async ({
379 authPage,
380 api,
381 freshWorkspace,
382 }) => {
383 const ws = await freshWorkspace("Editor WS");
384 const pageId = await openFreshPage(authPage, api, ws.id);
385 await slashCommand(authPage, "columns", false);
386
387
388 await expect(authPage.getByTestId("column-list")).toBeVisible({ timeout: 10_000 });
389 await expect
390 .poll(async () => authPage.getByTestId(/^column-\d/).count(), { timeout: 10_000 })
391 .toBe(2);
392
393 await authPage.locator('[data-testid="column-1"] [contenteditable]').first().click();
394 await authPage.keyboard.type("left column");
395 await expect(authPage.getByTestId("column-list")).toContainText("left column");
396
397 await expect
398 .poll(async () => (await allBlocks(api, pageId)).filter((b) => b.type === "COLUMN").length, {
399 timeout: 10_000,
400 })
401 .toBe(2);
402 });
403
404 test("context menu: delete a block", async ({ authPage, api, freshWorkspace }) => {
405 const ws = await freshWorkspace("Editor WS");
406 const pageId = await openFreshPage(authPage, api, ws.id);
407
408 await authPage.keyboard.type("keep me");
409 await enterNewBlock(authPage);
410 await authPage.keyboard.type("remove me");
411 await expectRowCount(authPage, 2);
412
413
414 await rows(authPage).nth(1).click({ button: "right" });
415 const menu = authPage.getByTestId("block-context-menu");
416 await expect(menu).toBeVisible({ timeout: 5_000 });
417 await menu.getByTestId("context-menu-delete").click();
418
419 await expectRowCount(authPage, 1);
420 await expect(rows(authPage).first()).toContainText("keep me");
421 await expect
422 .poll(async () => (await contentBlocks(api, pageId)).map(blockText), { timeout: 10_000 })
423 .toEqual(["keep me"]);
424 });
425
426 test("slash menu search filters commands", async ({
427 authPage,
428 api,
429 freshWorkspace,
430 }) => {
431 const ws = await freshWorkspace("Editor WS");
432 await openFreshPage(authPage, api, ws.id);
433 await authPage.keyboard.type("/");
434 const menu = authPage.getByTestId("slash-menu");
435 await expect(menu).toBeVisible({ timeout: 5_000 });
436
437 await authPage.keyboard.type("div");
438 await expect(menu.getByTestId("slash-menu-item-divider")).toBeVisible({ timeout: 5_000 });
439 await expect(menu.getByTestId("slash-menu-item-todo")).toHaveCount(0);
440 });
441
442 test("context menu: turn into heading 1", async ({
443 authPage,
444 api,
445 freshWorkspace,
446 }) => {
447 const ws = await freshWorkspace("Editor WS");
448 const pageId = await openFreshPage(authPage, api, ws.id);
449 await authPage.keyboard.type("Turn me");
450 await rows(authPage).first().click({ button: "right" });
451 const menu = authPage.getByTestId("block-context-menu");
452 await expect(menu).toBeVisible({ timeout: 5_000 });
453
454 await menu.getByTestId("context-menu-turn-into").click();
455 await authPage.getByTestId("context-menu-turn-heading_1").click();
456
457 await expect
458 .poll(async () => {
459 const b = (await contentBlocks(api, pageId))[0];
460 return b && b.type === "HEADING_1" ? blockText(b) : "";
461 }, { timeout: 10_000 })
462 .toBe("Turn me");
463 await expect(authPage.getByText("Turn me")).toBeVisible();
464 });
465
466 test("copy / paste a paragraph (plain-text native paste)", async ({
467 authPage,
468 api,
469 freshWorkspace,
470 }) => {
471 const ws = await freshWorkspace("Editor WS");
472 const pageId = await openFreshPage(authPage, api, ws.id);
473 await authPage.keyboard.type("Copyable line");
474 await enterNewBlock(authPage);
475
476
477
478
479
480
481 await authPage.evaluate(() => {
482 const dt = new DataTransfer();
483 dt.setData("text/plain", "Copyable line");
484 const evt = new ClipboardEvent("paste", {
485 clipboardData: dt,
486 bubbles: true,
487 cancelable: true,
488 });
489 (document.activeElement as HTMLElement).dispatchEvent(evt);
490 });
491
492 await expectRowCount(authPage, 2);
493 await expect
494 .poll(async () => {
495 const blocks = await contentBlocks(api, pageId);
496 return blocks.length === 2 && blocks.every((b) => blockText(b) === "Copyable line");
497 }, { timeout: 10_000 })
498 .toBe(true);
499 });
500
501 test("empty-block cleanup on blur", async ({ authPage, api, freshWorkspace }) => {
502 const ws = await freshWorkspace("Editor WS");
503 const pageId = await openFreshPage(authPage, api, ws.id);
504
505
506
507 await authPage.keyboard.type("first line");
508 await enterNewBlock(authPage);
509 await expectRowCount(authPage, 2);
510
511 await rows(authPage).first().locator("[contenteditable]").click();
512 await expect
513 .poll(async () => (await contentBlocks(api, pageId)).length, { timeout: 10_000 })
514 .toBe(1);
515 await expectRowCount(authPage, 1);
516 });
517
518 test("reload preserves blocks (ordering + count survive)", async ({
519 authPage,
520 api,
521 freshWorkspace,
522 }) => {
523 const ws = await freshWorkspace("Editor WS");
524 const pageId = await openFreshPage(authPage, api, ws.id);
525 await authPage.keyboard.type("persist A");
526 await enterNewBlock(authPage);
527 await authPage.keyboard.type("persist B");
528 await expectRowCount(authPage, 2);
529
530
531
532
533 await expect
534 .poll(async () => (await contentBlocks(api, pageId)).map(blockText), { timeout: 10_000 })
535 .toEqual(["persist A", "persist B"]);
536
537 await authPage.reload();
538 await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 });
539
540
541
542
543
544 const blockRows = rows(authPage);
545 await expect(blockRows).toHaveCount(2, { timeout: 10_000 });
546 await expect
547 .poll(async () =>
548 Promise.all(
549 [0, 1].map(
550 async (i) =>
551 ((await blockRows.nth(i).locator("[contenteditable]").first().textContent()) ?? "").trim(),
552 ),
553 ),
554 { timeout: 10_000 })
555 .toEqual(["persist A", "persist B"]);
556
557
558
559 expect((await contentBlocks(api, pageId)).map(blockText)).toEqual([
560 "persist A",
561 "persist B",
562 ]);
563 });
564});
565