// @vitest-environment jsdom /** * WhiteboardCanvas (T-168/T-169/T-170) — label escaping (XSS), read-only * contract, the canvas gestures, and the T-170 surfaces. * * Pins: * - a sticky label containing an HTML payload renders as INERT SVG text: * the payload is the text content, and no `` (or any injected element) * exists in the DOM. Labels flow through React `` children (textContent * semantics), never through `dangerouslySetInnerHTML`. * - `canvasValid: false` (server validation failed — e.g. an unknown canvas * version) renders the read-only banner and hides the editing tools — * the roadmap's unknown-version contract. * - T-169 gestures: rect drag-create, connector (element→element, anchored * via nearestAnchor + arrowhead), freehand pen capture (decimated), * marquee select + group/ungroup (package ops). * - T-170: undo/redo (⌘Z/⌘⇧Z over the package OpStack), an element link * (⌘K dialog → page reference) renders the link marker and is stored on * the element, the comments button mounts the page's CommentPanel, and * the collaboration indicator shows the page channel's peers. * * Viewport math: default viewport = {scale:1, tx:32, ty:32}, and the test * stubs the svg's getBoundingClientRect to (0,0), so clientX/Y map to the * world as (clientX−32, clientY−32). */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Canvas } from "@factory/whiteboard"; const XSS_PAYLOAD = ''; const mockCanvas: Canvas = { version: 1, elements: { s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 400, h: 110, z: 1, text: XSS_PAYLOAD, color: "yellow" }, t1: { id: "t1", type: "text", x: 0, y: 200, w: 220, h: 32, z: 2, text: "plain text", fontSize: 16 } } }; /** Two rects with a gap — for the connector test (A right → B left). */ const connectorCanvas: Canvas = { version: 1, elements: { a: { id: "a", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null }, b: { id: "b", type: "rect", x: 200, y: 0, w: 100, h: 50, z: 2, fill: null, stroke: null } } }; /** Shared mock state — `vi.hoisted` so the (hoisted) vi.mock factories can * reference it without a TDZ error. */ const { mocks, presenceBox, routerBox } = vi.hoisted(() => ({ mocks: { canvasValid: true, canvas: {} as unknown, saveMutate: vi.fn(), mutateAsync: vi.fn(), saveVersion: vi.fn(), restoreVersion: vi.fn(), wbGet: vi.fn() }, /** The page channel's roster for page "p1" (the mock presence store). */ presenceBox: { page: {} as Record }, /** Captured router.push calls (the convert flow navigates to the new page). */ routerBox: { push: vi.fn(), convert: vi.fn(async () => ({ page: { id: "newpage1", kind: "page", title: "t" }, task: null, blockCount: 2 })) } })); const PEER = { userId: "peer1", name: "Ada Lovelace", color: "#7C3AED" } as const; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: routerBox.push }) })); vi.mock("@/stores/ui-store", () => ({ toast: vi.fn() })); vi.mock("@/hooks/use-whiteboard", () => ({ useWhiteboardCanvas: () => ({ data: { pageId: "p1", schemaVersion: 1, canvas: mocks.canvas, valid: mocks.canvasValid, updatedAt: "2026-01-01T00:00:00Z", updatedBy: "u1" }, isLoading: false }), useWhiteboardSave: () => ({ mutate: mocks.saveMutate, mutateAsync: mocks.mutateAsync, isPending: false }), whiteboardKey: (pageId: string) => ["pages", pageId, "whiteboard"] })); vi.mock("@/lib/api", () => ({ whiteboardApi: { get: mocks.wbGet, save: vi.fn(), saveVersion: mocks.saveVersion, restoreVersion: mocks.restoreVersion, convertSelection: routerBox.convert } })); vi.mock("@/hooks/use-pages", () => ({ usePageTree: () => ({ data: [{ id: "pg1", title: "Roadmap", children: [] }], isLoading: false }), usePageVersions: () => ({ data: [ { id: "pv-abc123", pageId: "p1", title: "t", blockCount: 2, createdBy: "u1", createdAt: "2026-01-02T00:00:00Z", source: "manual" }, { id: "pv-def456", pageId: "p1", title: "t", blockCount: 1, createdBy: "u1", createdAt: "2026-01-01T00:00:00Z", source: "auto" } ], isLoading: false, refetch: vi.fn() }) })); vi.mock("@/lib/auth-store", () => ({ useAuthStore: (sel: (s: { user: { id: string } | null }) => unknown) => sel({ user: { id: "me" } }) })); // The renderer reads `s.presence[pageId]` via the store's selector — a // selector-based mock (the real zustand store shape) keeps it honest. vi.mock("@/stores/block-patch-store", () => ({ useBlockPatchStore: ( sel: (s: { presence: Record> }) => unknown ) => sel({ presence: { p1: presenceBox.page } }) })); vi.mock("@/components/comments/comment-panel", () => ({ CommentPanel: (props: { pageId: string; open: boolean }) => props.open ?
{props.pageId}
: null })); vi.mock("@/components/ui/confirm-dialog", () => ({ ConfirmDialog: (props: { open: boolean; onConfirm: () => void; onClose: () => void }) => props.open ? (
) : null })); import { WhiteboardCanvas } from "../whiteboard-canvas"; let root: Root; let host: HTMLElement; beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); mocks.canvasValid = true; mocks.canvas = mockCanvas; mocks.saveMutate.mockReset(); mocks.mutateAsync.mockReset().mockResolvedValue({ pageId: "p1", schemaVersion: 1, canvas: mockCanvas, updatedAt: "2026-01-01T00:00:00Z" }); mocks.saveVersion.mockReset().mockResolvedValue({ id: "pv1", version: 1 }); mocks.restoreVersion.mockReset().mockResolvedValue({ restored: true, version: 1 }); mocks.wbGet.mockReset().mockImplementation(async () => ({ pageId: "p1", schemaVersion: 1, canvas: mocks.canvas, valid: mocks.canvasValid, updatedAt: "2026-01-01T00:00:00Z", updatedBy: "u1" })); routerBox.push.mockReset(); routerBox.convert.mockReset().mockResolvedValue({ page: { id: "newpage1", kind: "page", title: "t" }, task: null, blockCount: 2 }); presenceBox.page = { peer1: { ...PEER, lastSeen: Date.now() } }; // jsdom has no pointer capture — the renderer calls it on drag start. (HTMLElement.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture = vi.fn(); (SVGElement.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture = vi.fn(); }); afterEach(() => { act(() => root.unmount()); host.remove(); }); const renderBoard = (props: { canvasValid?: boolean } = {}) => act(() => root.render()); /** Make world math exact: the svg sits at (0,0) on screen. */ const fixSvgBox = () => { const svg = host.querySelector('[data-testid="wb-canvas"]')!; svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; return svg; }; const clickTool = (testid: string) => act(() => { host.querySelector(`[data-testid="${testid}"]`)!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const pointer = (svg: SVGElement, type: string, clientX: number, clientY: number) => act(() => { svg.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 })); }); describe("WhiteboardCanvas (T-168)", () => { it("renders a sticky with its label as INERT text — an HTML payload is never parsed as markup (XSS)", () => { renderBoard(); const sticky = host.querySelector('[data-testid="wb-sticky"]'); expect(sticky).not.toBeNull(); const label = host.querySelector('[data-testid="wb-label"]'); expect(label).not.toBeNull(); // the payload survives as TEXT content… expect(label!.textContent).toBe(XSS_PAYLOAD); // …and no element from the payload exists anywhere in the canvas DOM. expect(host.querySelector("img")).toBeNull(); expect(host.innerHTML.includes("onerror=")).toBe(true); // present only as escaped text expect((window as unknown as { __wb_pwned?: boolean }).__wb_pwned).toBeUndefined(); }); it("renders a text element with its label", () => { renderBoard(); const texts = Array.from(host.querySelectorAll('[data-testid="wb-label"]')).map((t) => t.textContent); expect(texts).toContain("plain text"); }); it("an unvalidatable canvas (valid:false) → read-only banner, no editing tools", () => { renderBoard({ canvasValid: false }); const banner = host.querySelector('[data-testid="wb-readonly-banner"]'); expect(banner).not.toBeNull(); expect(host.querySelector('[data-testid="wb-tool-select"]')).toBeNull(); expect(host.querySelector('[data-testid="wb-tool-sticky"]')).toBeNull(); expect(host.querySelector('[data-testid="wb-tool-text"]')).toBeNull(); expect(host.querySelector('[data-testid="wb-tool-rect"]')).toBeNull(); expect(host.querySelector('[data-testid="wb-tool-pen"]')).toBeNull(); // the pan tool stays (a read-only user can still move around). expect(host.querySelector('[data-testid="wb-tool-pan"]')).not.toBeNull(); // …but the content still renders. expect(host.querySelector('[data-testid="wb-sticky"]')).not.toBeNull(); }); it("a valid canvas shows all tools (select/sticky/text/rect/ellipse/connector/pen/pan)", () => { renderBoard(); expect(host.querySelector('[data-testid="wb-readonly-banner"]')).toBeNull(); for (const t of ["wb-tool-select", "wb-tool-sticky", "wb-tool-text", "wb-tool-rect", "wb-tool-ellipse", "wb-tool-connector", "wb-tool-pen", "wb-tool-pan"]) { expect(host.querySelector(`[data-testid="${t}"]`)).not.toBeNull(); } }); it("creates a sticky with the sticky tool (click on the canvas) and opens its editor", () => { renderBoard(); clickTool("wb-tool-sticky"); const svg = fixSvgBox(); // pointer-down at client (100,50) → world (68,18) pointer(svg, "pointerdown", 100, 50); expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2); // the seeded one + the new one // the new element is selected and its inline editor opened expect(host.querySelector('[data-testid="wb-editor-input"]')).not.toBeNull(); }); it("deletes the selected element with the Delete key", () => { renderBoard(); expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(1); // select the seeded sticky (world (68,18) is inside 0,0,400,110) const svg = fixSvgBox(); pointer(svg, "pointerdown", 100, 50); pointer(svg, "pointerup", 100, 50); act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" })); }); expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(0); }); }); describe("WhiteboardCanvas (T-169)", () => { it("creates a rect by dragging with the rect tool", () => { renderBoard(); clickTool("wb-tool-rect"); const svg = fixSvgBox(); pointer(svg, "pointerdown", 100, 100); // world (68,68) pointer(svg, "pointermove", 200, 150); // world (168,118) pointer(svg, "pointerup", 200, 150); const rect = host.querySelector('[data-testid="wb-rect"]'); expect(rect).not.toBeNull(); expect(rect!.getAttribute("x")).toBe("68"); expect(rect!.getAttribute("y")).toBe("68"); expect(rect!.getAttribute("width")).toBe("100"); expect(rect!.getAttribute("height")).toBe("50"); // the new element is selected (resize handle visible) expect(host.querySelector('[data-testid="wb-resize-handle"]')).not.toBeNull(); }); it("creates an ellipse by dragging with the ellipse tool", () => { renderBoard(); clickTool("wb-tool-ellipse"); const svg = fixSvgBox(); pointer(svg, "pointerdown", 100, 100); pointer(svg, "pointermove", 200, 150); pointer(svg, "pointerup", 200, 150); const el = host.querySelector('[data-testid="wb-ellipse"]'); expect(el).not.toBeNull(); // center (68+50, 68+25) = (118,93), rx=50 ry=25 expect(el!.getAttribute("cx")).toBe("118"); expect(el!.getAttribute("cy")).toBe("93"); }); it("creates a connector by dragging from one element to another (anchored + arrowhead)", () => { mocks.canvas = connectorCanvas; renderBoard(); clickTool("wb-tool-connector"); const svg = fixSvgBox(); // start on the RIGHT edge of rect A (world 90,25 → client 122,57): nearest anchor = "right" (100,25) pointer(svg, "pointerdown", 122, 57); // drag to rect B (world 225,25 → client 257,57): nearest anchor = "left" (200,25) pointer(svg, "pointermove", 257, 57); pointer(svg, "pointerup", 257, 57); const conn = host.querySelector('[data-testid="wb-connector"]'); expect(conn).not.toBeNull(); const path = host.querySelector('[data-testid="wb-connector"] path')!; // orthogonal (default) between (100,25) and (200,25): H-V-H with mid (150,25) expect(path.getAttribute("d")).toBe("M 100 25 L 150 25 L 150 25 L 200 25"); // the arrowhead polygon is rendered (arrow on by default) expect(host.querySelector('[data-testid="wb-connector"] polygon')).not.toBeNull(); }); it("captures a freehand stroke with the pen tool (decimated polyline)", () => { renderBoard(); clickTool("wb-tool-pen"); const svg = fixSvgBox(); pointer(svg, "pointerdown", 100, 100); // world (68,68) pointer(svg, "pointermove", 150, 150); // world (118,118) pointer(svg, "pointermove", 200, 100); // world (168,68) pointer(svg, "pointermove", 250, 150); // world (218,118) pointer(svg, "pointerup", 250, 150); const stroke = host.querySelector('[data-testid="wb-freehand"]'); expect(stroke).not.toBeNull(); const pts = (stroke!.getAttribute("points") ?? "").split(" ").filter(Boolean); expect(pts.length).toBeGreaterThanOrEqual(3); // the first point is the stroke start (world 68,68) expect(pts[0]).toBe("68,68"); }); it("marquee-selects two elements and groups/ungroups them (package ops)", () => { mocks.canvas = connectorCanvas; renderBoard(); const svg = fixSvgBox(); // marquee from empty space above A (world 50,-10) to below-B gap (world 250,60) // → box x 50-250, y -10-60 overlaps A(0,0,100,50) and B(200,0,100,50) pointer(svg, "pointerdown", 82, 22); pointer(svg, "pointermove", 282, 92); pointer(svg, "pointerup", 282, 92); // two elements selected → the group button enables expect((host.querySelector('[data-testid="wb-btn-group"]') as HTMLButtonElement).disabled).toBe(false); // ⌘G groups them act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "g", metaKey: true })); }); const group = host.querySelector('[data-testid="wb-group"]'); expect(group).not.toBeNull(); // the group's bounds are the union of the children (0,0 → 300,50) expect(group!.getAttribute("x")).toBe("0"); expect(group!.getAttribute("y")).toBe("0"); expect(group!.getAttribute("width")).toBe("300"); expect(group!.getAttribute("height")).toBe("50"); // ⌘⇧G ungroups (the container goes, the children stay) act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "g", metaKey: true, shiftKey: true })); }); expect(host.querySelector('[data-testid="wb-group"]')).toBeNull(); expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(2); }); }); describe("WhiteboardCanvas (T-170)", () => { it("undo (⌘Z) reverts an edit, redo (⌘⇧Z) reapplies it", () => { renderBoard(); // create a sticky (pushes an undoable step) clickTool("wb-tool-sticky"); const svg = fixSvgBox(); pointer(svg, "pointerdown", 100, 50); expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2); // ⌘Z → back to one sticky; the undo button now disables (nothing left) act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true })); }); expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(1); expect((host.querySelector('[data-testid="wb-btn-undo"]') as HTMLButtonElement).disabled).toBe(true); expect((host.querySelector('[data-testid="wb-btn-redo"]') as HTMLButtonElement).disabled).toBe(false); // ⌘⇧Z → the sticky is back act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true })); }); expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2); }); it("attaches a page link via the link dialog (⌘K) — the element gets the link marker", () => { renderBoard(); const svg = fixSvgBox(); // select the seeded sticky (world 68,18 is inside 0,0,400,110) pointer(svg, "pointerdown", 100, 50); // ⌘K opens the link dialog for the single selected element act(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); }); const dialog = host.querySelector('[data-testid="wb-link-dialog"]'); expect(dialog).not.toBeNull(); // choose the internal page "Roadmap" (pg1) const select = host.querySelector('[data-testid="wb-link-page"]')!; act(() => { select.value = "pg1"; select.dispatchEvent(new Event("change", { bubbles: true })); }); // the dialog closed… expect(host.querySelector('[data-testid="wb-link-dialog"]')).toBeNull(); // …and the sticky now shows the link marker (a lucide inside it) const sticky = host.querySelector('[data-testid="wb-sticky"]')!; expect(sticky.querySelector("svg")).not.toBeNull(); }); it("opens the page's comment drawer (comments are page-scoped)", () => { renderBoard(); expect(host.querySelector('[data-testid="wb-mock-comment-panel"]')).toBeNull(); act(() => { host.querySelector('[data-testid="wb-comments"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const panel = host.querySelector('[data-testid="wb-mock-comment-panel"]'); expect(panel).not.toBeNull(); expect(panel!.textContent).toBe("p1"); }); it("shows the page channel's peers as the collaboration indicator (self excluded)", () => { // self ("me") is in the roster too — must NOT render an avatar. presenceBox.page.me = { userId: "me", name: "Self", color: "#111111", lastSeen: Date.now() }; renderBoard(); const avatars = host.querySelectorAll('[data-testid="wb-presence-peer"]'); expect(avatars.length).toBe(1); expect(avatars[0]!.getAttribute("title")).toBe(PEER.name); }); it("save & restore: the versions popover snapshots + restores (server round-trip)", async () => { renderBoard(); act(() => { host.querySelector('[data-testid="wb-versions"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const popover = host.querySelector('[data-testid="wb-versions-popover"]'); expect(popover).not.toBeNull(); // "Save version" → the explicit snapshot endpoint await act(async () => { host.querySelector('[data-testid="wb-save-version"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mocks.saveVersion).toHaveBeenCalledWith("p1"); // restore the first listed version → confirm → server round-trip act(() => { host.querySelector('[data-testid="wb-version-restore"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(host.querySelector('[data-testid="wb-mock-confirm"]')).not.toBeNull(); await act(async () => { host.querySelector('[data-testid="wb-mock-confirm-btn"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mocks.restoreVersion).toHaveBeenCalledWith("p1", "pv-abc123"); // the restored document is re-read from the server (GET whiteboard) expect(mocks.wbGet).toHaveBeenCalledWith("p1"); }); it("converts a selection to a page/task (T-171) — server maps the text, then navigates", async () => { renderBoard(); const svg = fixSvgBox(); // select the seeded sticky (world 68,18) pointer(svg, "pointerdown", 100, 50); // the Convert button is enabled for any non-empty selection expect((host.querySelector('[data-testid="wb-btn-convert"]') as HTMLButtonElement).disabled).toBe(false); act(() => { host.querySelector('[data-testid="wb-btn-convert"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const dialog = host.querySelector('[data-testid="wb-convert-dialog"]'); expect(dialog).not.toBeNull(); // pick "task" and convert const select = host.querySelector('[data-testid="wb-convert-target"]')!; act(() => { select.value = "task"; select.dispatchEvent(new Event("change", { bubbles: true })); }); await act(async () => { host.querySelector('[data-testid="wb-convert-go"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(routerBox.convert).toHaveBeenCalledWith("p1", { elementIds: ["s1"], target: "task", parentId: null }); // …and the user is taken to the new page expect(routerBox.push).toHaveBeenCalledWith("/w/ws1/page/newpage1"); }); }); describe("WhiteboardCanvas — viewport culling (2,000-object perf gate)", () => { /** A stub that reports a fixed 800×600 content rect on observe (jsdom has * no layout, so the real ResizeObserver never fires). */ function installROStub(width: number, height: number) { class FakeRO { constructor(cb: ResizeObserverCallback) { this.cb = cb; } cb: ResizeObserverCallback; observe() { this.cb([{ contentRect: { width, height } } as unknown as ResizeObserverEntry], this); } unobserve() {} disconnect() {} } (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeRO; return () => { delete (globalThis as Record).ResizeObserver; }; } it("only mounts DOM nodes for elements inside the viewport (off-viewport elements are culled)", () => { // One rect on the default viewport (world 0..100), one far off-screen. const cullCanvas: Canvas = { version: 1, elements: { near: { id: "near", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null }, far: { id: "far", type: "rect", x: 5000, y: 5000, w: 100, h: 50, z: 2, fill: null, stroke: null } } }; mocks.canvas = cullCanvas; const cleanup = installROStub(800, 600); try { renderBoard(); // Without culling both rects render (2). With culling, only the // on-viewport one is mounted — the off-viewport node never enters the DOM. expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(1); } finally { cleanup(); } }); it("renders ALL elements when the container has no layout (size 0×0 → culling off, headless-safe)", () => { const cullCanvas: Canvas = { version: 1, elements: { near: { id: "near", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null }, far: { id: "far", type: "rect", x: 5000, y: 5000, w: 100, h: 50, z: 2, fill: null, stroke: null } } }; mocks.canvas = cullCanvas; // No ResizeObserver stub → size stays 0×0 → viewportToWorldRect null → no culling. renderBoard(); expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(2); }); });