/** * T-168/T-169/T-170 — whiteboard E2E (dev-stack, Playwright). * * Verifies the hand-rolled SVG whiteboard (P5) end to end through the real * browser against the live web (:3000) + API (:4000): * - a `type:"whiteboard"` page renders the canvas (wb-root + wb-canvas) * - click-create a sticky (tool → click → wb-sticky appears, tool reverts * to select — the click-create contract in onPointerDown) * - edit the sticky's label (double-click → textarea overlay → text * commits, label re-renders) * - drag-create a rectangle (tool → drag → wb-rect appears) * - the edited document round-trips through the API (GET /pages/:id/ * whiteboard returns the saved elements) — the autosave path * * URL pattern: /w/{workspaceSlug}/page/{pageId} (the sidebar's own link * format — apps/web/src/components/layout/sidebar.tsx). The workspace param * accepts id or slug; we pass the slug the API returns on create. */ import { test, expect } from "../fixtures"; interface WhiteboardPageDto { id: string; kind?: string; } interface CanvasElement { id: string; type: string; text?: string; } test.describe("Whiteboard — P5 (T-168/T-169/T-170)", () => { test("renders the canvas, adds a sticky + rect, edits text, and autosaves to the API", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Whiteboard Space"); // Create a whiteboard page via the API (the UI create-dialog path is // exercised by the page-creation suite; here we want a deterministic // starting document). const createRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Team Wall", type: "whiteboard" } }); expect(createRes.status(), await createRes.text()).toBe(201); const page = (await createRes.json()) as WhiteboardPageDto; expect(page.id).toBeTruthy(); await authPage.goto(`/w/${ws.slug}/page/${page.id}`); // 1. The canvas renders (the wb-root shell + the SVG canvas). const root = authPage.getByTestId("wb-root"); await expect(root).toBeVisible({ timeout: 20_000 }); await expect(authPage.getByTestId("wb-canvas")).toBeVisible(); // 2. Click-create a sticky: select the sticky tool, click mid-canvas. // onPointerDown leaves the element in editing state (setEditingId), // which renders a focused textarea overlay (wb-editor-input). const canvas = authPage.getByTestId("wb-canvas"); const box = await canvas.boundingBox(); if (!box) throw new Error("wb-canvas has no bounding box"); await authPage.getByTestId("wb-tool-sticky").click(); await authPage.mouse.click(box.x + box.width / 2, box.y + box.height / 3); await expect(authPage.getByTestId("wb-sticky")).toHaveCount(1, { timeout: 10_000 }); await expect(authPage.getByTestId("wb-tool-select")).toHaveClass(/active/); // 3. Edit the sticky's label: double-click the sticky to open the inline // editor (onDoubleClick → setEditingId → the focused textarea overlay // wb-editor-input), type, commit with Escape. // NOTE (probed 2026-10-11): click-CREATE selects the sticky but does // NOT retain the editor in a real browser (focus stays on ), // while the double-click path opens it reliably — so the spec uses // double-click, the documented edit entry point. const LABEL = "E2E sticky text"; await authPage.getByTestId("wb-sticky").dblclick(); const editor = authPage.getByTestId("wb-editor-input"); await expect(editor).toBeVisible({ timeout: 10_000 }); await editor.fill(LABEL); await editor.press("Escape"); await expect(editor).toHaveCount(0); await expect(authPage.getByTestId("wb-label").first()).toContainText(LABEL, { timeout: 10_000 }); // 4. Drag-create a rectangle: rect tool → mouse down → move → up. await authPage.getByTestId("wb-tool-rect").click(); await authPage.mouse.move(box.x + box.width / 2 + 120, box.y + box.height * 0.6); await authPage.mouse.down(); await authPage.mouse.move(box.x + box.width / 2 + 220, box.y + box.height * 0.75, { steps: 5 }); await authPage.mouse.up(); await expect(authPage.getByTestId("wb-rect")).toHaveCount(1, { timeout: 10_000 }); // 5. Autosave round-trip: the debounced save (1.2 s) hits // PUT /pages/:id/whiteboard; GET must then carry both elements + the // edited text. // 5. Autosave round-trip: the debounced save (1.2 s, use-whiteboard.ts) // PUTs the full canvas to /pages/:id/whiteboard; GET must then carry // BOTH elements + the edited text. NOTE: `canvas.elements` is an // id→element OBJECT map (the @factory/whiteboard v1 shape), not an // array — Object.values it before searching. await expect .poll( async () => { const res = await api.get(`/api/v1/pages/${page.id}/whiteboard`); const body = (await res.json()) as { canvas?: { elements?: Record } }; const els = Object.values(body.canvas?.elements ?? {}); const sticky = els.find((e) => e.type === "sticky"); return !!sticky && sticky.text === LABEL && els.some((e) => e.type === "rect"); }, { timeout: 15_000 } ) .toBe(true); }); });