// @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