1
2
26(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27import { act } from "react";
28import { createRoot, type Root } from "react-dom/client";
29import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
30import type { Canvas } from "@factory/whiteboard";
31
32const XSS_PAYLOAD = '<img src=x onerror="window.__wb_pwned = true">';
33
34const mockCanvas: Canvas = {
35 version: 1,
36 elements: {
37 s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 400, h: 110, z: 1, text: XSS_PAYLOAD, color: "yellow" },
38 t1: { id: "t1", type: "text", x: 0, y: 200, w: 220, h: 32, z: 2, text: "plain text", fontSize: 16 }
39 }
40};
41
42
43const connectorCanvas: Canvas = {
44 version: 1,
45 elements: {
46 a: { id: "a", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null },
47 b: { id: "b", type: "rect", x: 200, y: 0, w: 100, h: 50, z: 2, fill: null, stroke: null }
48 }
49};
50
51
53const { mocks, presenceBox, routerBox } = vi.hoisted(() => ({
54 mocks: {
55 canvasValid: true,
56 canvas: {} as unknown,
57 saveMutate: vi.fn(),
58 mutateAsync: vi.fn(),
59 saveVersion: vi.fn(),
60 restoreVersion: vi.fn(),
61 wbGet: vi.fn()
62 },
63
64 presenceBox: { page: {} as Record<string, { userId: string; name: string; color: string; lastSeen: number }> },
65
66 routerBox: {
67 push: vi.fn(),
68 convert: vi.fn(async () => ({ page: { id: "newpage1", kind: "page", title: "t" }, task: null, blockCount: 2 }))
69 }
70}));
71const PEER = { userId: "peer1", name: "Ada Lovelace", color: "#7C3AED" } as const;
72
73vi.mock("next/navigation", () => ({
74 useRouter: () => ({ push: routerBox.push })
75}));
76
77vi.mock("@/stores/ui-store", () => ({
78 toast: vi.fn()
79}));
80
81vi.mock("@/hooks/use-whiteboard", () => ({
82 useWhiteboardCanvas: () => ({
83 data: { pageId: "p1", schemaVersion: 1, canvas: mocks.canvas, valid: mocks.canvasValid, updatedAt: "2026-01-01T00:00:00Z", updatedBy: "u1" },
84 isLoading: false
85 }),
86 useWhiteboardSave: () => ({ mutate: mocks.saveMutate, mutateAsync: mocks.mutateAsync, isPending: false }),
87 whiteboardKey: (pageId: string) => ["pages", pageId, "whiteboard"]
88}));
89
90vi.mock("@/lib/api", () => ({
91 whiteboardApi: {
92 get: mocks.wbGet,
93 save: vi.fn(),
94 saveVersion: mocks.saveVersion,
95 restoreVersion: mocks.restoreVersion,
96 convertSelection: routerBox.convert
97 }
98}));
99
100vi.mock("@/hooks/use-pages", () => ({
101 usePageTree: () => ({ data: [{ id: "pg1", title: "Roadmap", children: [] }], isLoading: false }),
102 usePageVersions: () => ({
103 data: [
104 { id: "pv-abc123", pageId: "p1", title: "t", blockCount: 2, createdBy: "u1", createdAt: "2026-01-02T00:00:00Z", source: "manual" },
105 { id: "pv-def456", pageId: "p1", title: "t", blockCount: 1, createdBy: "u1", createdAt: "2026-01-01T00:00:00Z", source: "auto" }
106 ],
107 isLoading: false,
108 refetch: vi.fn()
109 })
110}));
111
112vi.mock("@/lib/auth-store", () => ({
113 useAuthStore: (sel: (s: { user: { id: string } | null }) => unknown) => sel({ user: { id: "me" } })
114}));
115
116
117
118vi.mock("@/stores/block-patch-store", () => ({
119 useBlockPatchStore: (
120 sel: (s: { presence: Record<string, Record<string, { userId: string; name: string; color: string; lastSeen: number }>> }) => unknown
121 ) => sel({ presence: { p1: presenceBox.page } })
122}));
123
124vi.mock("@/components/comments/comment-panel", () => ({
125 CommentPanel: (props: { pageId: string; open: boolean }) =>
126 props.open ? <div data-testid="wb-mock-comment-panel">{props.pageId}</div> : null
127}));
128
129vi.mock("@/components/ui/confirm-dialog", () => ({
130 ConfirmDialog: (props: { open: boolean; onConfirm: () => void; onClose: () => void }) =>
131 props.open ? (
132 <div data-testid="wb-mock-confirm">
133 <button type="button" data-testid="wb-mock-confirm-btn" onClick={props.onConfirm}>
134 Confirm
135 </button>
136 </div>
137 ) : null
138}));
139
140import { WhiteboardCanvas } from "../whiteboard-canvas";
141
142let root: Root;
143let host: HTMLElement;
144
145beforeEach(() => {
146 host = document.createElement("div");
147 document.body.appendChild(host);
148 root = createRoot(host);
149 mocks.canvasValid = true;
150 mocks.canvas = mockCanvas;
151 mocks.saveMutate.mockReset();
152 mocks.mutateAsync.mockReset().mockResolvedValue({ pageId: "p1", schemaVersion: 1, canvas: mockCanvas, updatedAt: "2026-01-01T00:00:00Z" });
153 mocks.saveVersion.mockReset().mockResolvedValue({ id: "pv1", version: 1 });
154 mocks.restoreVersion.mockReset().mockResolvedValue({ restored: true, version: 1 });
155 mocks.wbGet.mockReset().mockImplementation(async () => ({ pageId: "p1", schemaVersion: 1, canvas: mocks.canvas, valid: mocks.canvasValid, updatedAt: "2026-01-01T00:00:00Z", updatedBy: "u1" }));
156 routerBox.push.mockReset();
157 routerBox.convert.mockReset().mockResolvedValue({ page: { id: "newpage1", kind: "page", title: "t" }, task: null, blockCount: 2 });
158 presenceBox.page = { peer1: { ...PEER, lastSeen: Date.now() } };
159
160 (HTMLElement.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture = vi.fn();
161 (SVGElement.prototype as unknown as { setPointerCapture?: unknown }).setPointerCapture = vi.fn();
162});
163
164afterEach(() => {
165 act(() => root.unmount());
166 host.remove();
167});
168
169const renderBoard = (props: { canvasValid?: boolean } = {}) =>
170 act(() => root.render(<WhiteboardCanvas pageId="p1" workspaceId="ws1" canvasValid={props.canvasValid ?? true} />));
171
172
173const fixSvgBox = () => {
174 const svg = host.querySelector<SVGElement>('[data-testid="wb-canvas"]')!;
175 svg.getBoundingClientRect = () =>
176 ({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
177 return svg;
178};
179
180const clickTool = (testid: string) =>
181 act(() => {
182 host.querySelector(`[data-testid="${testid}"]`)!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
183 });
184
185const pointer = (svg: SVGElement, type: string, clientX: number, clientY: number) =>
186 act(() => {
187 svg.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 }));
188 });
189
190describe("WhiteboardCanvas (T-168)", () => {
191 it("renders a sticky with its label as INERT text — an HTML payload is never parsed as markup (XSS)", () => {
192 renderBoard();
193 const sticky = host.querySelector('[data-testid="wb-sticky"]');
194 expect(sticky).not.toBeNull();
195 const label = host.querySelector('[data-testid="wb-label"]');
196 expect(label).not.toBeNull();
197
198 expect(label!.textContent).toBe(XSS_PAYLOAD);
199
200 expect(host.querySelector("img")).toBeNull();
201 expect(host.innerHTML.includes("onerror=")).toBe(true);
202 expect((window as unknown as { __wb_pwned?: boolean }).__wb_pwned).toBeUndefined();
203 });
204
205 it("renders a text element with its label", () => {
206 renderBoard();
207 const texts = Array.from(host.querySelectorAll('[data-testid="wb-label"]')).map((t) => t.textContent);
208 expect(texts).toContain("plain text");
209 });
210
211 it("an unvalidatable canvas (valid:false) → read-only banner, no editing tools", () => {
212 renderBoard({ canvasValid: false });
213 const banner = host.querySelector('[data-testid="wb-readonly-banner"]');
214 expect(banner).not.toBeNull();
215 expect(host.querySelector('[data-testid="wb-tool-select"]')).toBeNull();
216 expect(host.querySelector('[data-testid="wb-tool-sticky"]')).toBeNull();
217 expect(host.querySelector('[data-testid="wb-tool-text"]')).toBeNull();
218 expect(host.querySelector('[data-testid="wb-tool-rect"]')).toBeNull();
219 expect(host.querySelector('[data-testid="wb-tool-pen"]')).toBeNull();
220
221 expect(host.querySelector('[data-testid="wb-tool-pan"]')).not.toBeNull();
222
223 expect(host.querySelector('[data-testid="wb-sticky"]')).not.toBeNull();
224 });
225
226 it("a valid canvas shows all tools (select/sticky/text/rect/ellipse/connector/pen/pan)", () => {
227 renderBoard();
228 expect(host.querySelector('[data-testid="wb-readonly-banner"]')).toBeNull();
229 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"]) {
230 expect(host.querySelector(`[data-testid="${t}"]`)).not.toBeNull();
231 }
232 });
233
234 it("creates a sticky with the sticky tool (click on the canvas) and opens its editor", () => {
235 renderBoard();
236 clickTool("wb-tool-sticky");
237 const svg = fixSvgBox();
238
239 pointer(svg, "pointerdown", 100, 50);
240 expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2);
241
242 expect(host.querySelector('[data-testid="wb-editor-input"]')).not.toBeNull();
243 });
244
245 it("deletes the selected element with the Delete key", () => {
246 renderBoard();
247 expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(1);
248
249 const svg = fixSvgBox();
250 pointer(svg, "pointerdown", 100, 50);
251 pointer(svg, "pointerup", 100, 50);
252 act(() => {
253 window.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }));
254 });
255 expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(0);
256 });
257});
258
259describe("WhiteboardCanvas (T-169)", () => {
260 it("creates a rect by dragging with the rect tool", () => {
261 renderBoard();
262 clickTool("wb-tool-rect");
263 const svg = fixSvgBox();
264 pointer(svg, "pointerdown", 100, 100);
265 pointer(svg, "pointermove", 200, 150);
266 pointer(svg, "pointerup", 200, 150);
267 const rect = host.querySelector('[data-testid="wb-rect"]');
268 expect(rect).not.toBeNull();
269 expect(rect!.getAttribute("x")).toBe("68");
270 expect(rect!.getAttribute("y")).toBe("68");
271 expect(rect!.getAttribute("width")).toBe("100");
272 expect(rect!.getAttribute("height")).toBe("50");
273
274 expect(host.querySelector('[data-testid="wb-resize-handle"]')).not.toBeNull();
275 });
276
277 it("creates an ellipse by dragging with the ellipse tool", () => {
278 renderBoard();
279 clickTool("wb-tool-ellipse");
280 const svg = fixSvgBox();
281 pointer(svg, "pointerdown", 100, 100);
282 pointer(svg, "pointermove", 200, 150);
283 pointer(svg, "pointerup", 200, 150);
284 const el = host.querySelector('[data-testid="wb-ellipse"]');
285 expect(el).not.toBeNull();
286
287 expect(el!.getAttribute("cx")).toBe("118");
288 expect(el!.getAttribute("cy")).toBe("93");
289 });
290
291 it("creates a connector by dragging from one element to another (anchored + arrowhead)", () => {
292 mocks.canvas = connectorCanvas;
293 renderBoard();
294 clickTool("wb-tool-connector");
295 const svg = fixSvgBox();
296
297 pointer(svg, "pointerdown", 122, 57);
298
299 pointer(svg, "pointermove", 257, 57);
300 pointer(svg, "pointerup", 257, 57);
301 const conn = host.querySelector('[data-testid="wb-connector"]');
302 expect(conn).not.toBeNull();
303 const path = host.querySelector('[data-testid="wb-connector"] path')!;
304
305 expect(path.getAttribute("d")).toBe("M 100 25 L 150 25 L 150 25 L 200 25");
306
307 expect(host.querySelector('[data-testid="wb-connector"] polygon')).not.toBeNull();
308 });
309
310 it("captures a freehand stroke with the pen tool (decimated polyline)", () => {
311 renderBoard();
312 clickTool("wb-tool-pen");
313 const svg = fixSvgBox();
314 pointer(svg, "pointerdown", 100, 100);
315 pointer(svg, "pointermove", 150, 150);
316 pointer(svg, "pointermove", 200, 100);
317 pointer(svg, "pointermove", 250, 150);
318 pointer(svg, "pointerup", 250, 150);
319 const stroke = host.querySelector('[data-testid="wb-freehand"]');
320 expect(stroke).not.toBeNull();
321 const pts = (stroke!.getAttribute("points") ?? "").split(" ").filter(Boolean);
322 expect(pts.length).toBeGreaterThanOrEqual(3);
323
324 expect(pts[0]).toBe("68,68");
325 });
326
327 it("marquee-selects two elements and groups/ungroups them (package ops)", () => {
328 mocks.canvas = connectorCanvas;
329 renderBoard();
330 const svg = fixSvgBox();
331
332
333 pointer(svg, "pointerdown", 82, 22);
334 pointer(svg, "pointermove", 282, 92);
335 pointer(svg, "pointerup", 282, 92);
336
337 expect((host.querySelector('[data-testid="wb-btn-group"]') as HTMLButtonElement).disabled).toBe(false);
338
339 act(() => {
340 window.dispatchEvent(new KeyboardEvent("keydown", { key: "g", metaKey: true }));
341 });
342 const group = host.querySelector('[data-testid="wb-group"]');
343 expect(group).not.toBeNull();
344
345 expect(group!.getAttribute("x")).toBe("0");
346 expect(group!.getAttribute("y")).toBe("0");
347 expect(group!.getAttribute("width")).toBe("300");
348 expect(group!.getAttribute("height")).toBe("50");
349
350 act(() => {
351 window.dispatchEvent(new KeyboardEvent("keydown", { key: "g", metaKey: true, shiftKey: true }));
352 });
353 expect(host.querySelector('[data-testid="wb-group"]')).toBeNull();
354 expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(2);
355 });
356});
357
358describe("WhiteboardCanvas (T-170)", () => {
359 it("undo (⌘Z) reverts an edit, redo (⌘⇧Z) reapplies it", () => {
360 renderBoard();
361
362 clickTool("wb-tool-sticky");
363 const svg = fixSvgBox();
364 pointer(svg, "pointerdown", 100, 50);
365 expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2);
366
367 act(() => {
368 window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true }));
369 });
370 expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(1);
371 expect((host.querySelector('[data-testid="wb-btn-undo"]') as HTMLButtonElement).disabled).toBe(true);
372 expect((host.querySelector('[data-testid="wb-btn-redo"]') as HTMLButtonElement).disabled).toBe(false);
373
374 act(() => {
375 window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true }));
376 });
377 expect(host.querySelectorAll('[data-testid="wb-sticky"]').length).toBe(2);
378 });
379
380 it("attaches a page link via the link dialog (⌘K) — the element gets the link marker", () => {
381 renderBoard();
382 const svg = fixSvgBox();
383
384 pointer(svg, "pointerdown", 100, 50);
385
386 act(() => {
387 window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
388 });
389 const dialog = host.querySelector('[data-testid="wb-link-dialog"]');
390 expect(dialog).not.toBeNull();
391
392 const select = host.querySelector<HTMLSelectElement>('[data-testid="wb-link-page"]')!;
393 act(() => {
394 select.value = "pg1";
395 select.dispatchEvent(new Event("change", { bubbles: true }));
396 });
397
398 expect(host.querySelector('[data-testid="wb-link-dialog"]')).toBeNull();
399
400 const sticky = host.querySelector('[data-testid="wb-sticky"]')!;
401 expect(sticky.querySelector("svg")).not.toBeNull();
402 });
403
404 it("opens the page's comment drawer (comments are page-scoped)", () => {
405 renderBoard();
406 expect(host.querySelector('[data-testid="wb-mock-comment-panel"]')).toBeNull();
407 act(() => {
408 host.querySelector('[data-testid="wb-comments"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
409 });
410 const panel = host.querySelector('[data-testid="wb-mock-comment-panel"]');
411 expect(panel).not.toBeNull();
412 expect(panel!.textContent).toBe("p1");
413 });
414
415 it("shows the page channel's peers as the collaboration indicator (self excluded)", () => {
416
417 presenceBox.page.me = { userId: "me", name: "Self", color: "#111111", lastSeen: Date.now() };
418 renderBoard();
419 const avatars = host.querySelectorAll('[data-testid="wb-presence-peer"]');
420 expect(avatars.length).toBe(1);
421 expect(avatars[0]!.getAttribute("title")).toBe(PEER.name);
422 });
423
424 it("save & restore: the versions popover snapshots + restores (server round-trip)", async () => {
425 renderBoard();
426 act(() => {
427 host.querySelector('[data-testid="wb-versions"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
428 });
429 const popover = host.querySelector('[data-testid="wb-versions-popover"]');
430 expect(popover).not.toBeNull();
431
432 await act(async () => {
433 host.querySelector('[data-testid="wb-save-version"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
434 });
435 expect(mocks.saveVersion).toHaveBeenCalledWith("p1");
436
437 act(() => {
438 host.querySelector('[data-testid="wb-version-restore"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
439 });
440 expect(host.querySelector('[data-testid="wb-mock-confirm"]')).not.toBeNull();
441 await act(async () => {
442 host.querySelector('[data-testid="wb-mock-confirm-btn"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
443 });
444 expect(mocks.restoreVersion).toHaveBeenCalledWith("p1", "pv-abc123");
445
446 expect(mocks.wbGet).toHaveBeenCalledWith("p1");
447 });
448
449 it("converts a selection to a page/task (T-171) — server maps the text, then navigates", async () => {
450 renderBoard();
451 const svg = fixSvgBox();
452
453 pointer(svg, "pointerdown", 100, 50);
454
455 expect((host.querySelector('[data-testid="wb-btn-convert"]') as HTMLButtonElement).disabled).toBe(false);
456 act(() => {
457 host.querySelector('[data-testid="wb-btn-convert"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
458 });
459 const dialog = host.querySelector('[data-testid="wb-convert-dialog"]');
460 expect(dialog).not.toBeNull();
461
462 const select = host.querySelector<HTMLSelectElement>('[data-testid="wb-convert-target"]')!;
463 act(() => {
464 select.value = "task";
465 select.dispatchEvent(new Event("change", { bubbles: true }));
466 });
467 await act(async () => {
468 host.querySelector('[data-testid="wb-convert-go"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
469 });
470 expect(routerBox.convert).toHaveBeenCalledWith("p1", { elementIds: ["s1"], target: "task", parentId: null });
471
472 expect(routerBox.push).toHaveBeenCalledWith("/w/ws1/page/newpage1");
473 });
474});
475
476describe("WhiteboardCanvas — viewport culling (2,000-object perf gate)", () => {
477
479 function installROStub(width: number, height: number) {
480 class FakeRO {
481 constructor(cb: ResizeObserverCallback) {
482 this.cb = cb;
483 }
484 cb: ResizeObserverCallback;
485 observe() {
486 this.cb([{ contentRect: { width, height } } as unknown as ResizeObserverEntry], this);
487 }
488 unobserve() {}
489 disconnect() {}
490 }
491 (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeRO;
492 return () => {
493 delete (globalThis as Record<string, unknown>).ResizeObserver;
494 };
495 }
496
497 it("only mounts DOM nodes for elements inside the viewport (off-viewport elements are culled)", () => {
498
499 const cullCanvas: Canvas = {
500 version: 1,
501 elements: {
502 near: { id: "near", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null },
503 far: { id: "far", type: "rect", x: 5000, y: 5000, w: 100, h: 50, z: 2, fill: null, stroke: null }
504 }
505 };
506 mocks.canvas = cullCanvas;
507 const cleanup = installROStub(800, 600);
508 try {
509 renderBoard();
510
511
512 expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(1);
513 } finally {
514 cleanup();
515 }
516 });
517
518 it("renders ALL elements when the container has no layout (size 0×0 → culling off, headless-safe)", () => {
519 const cullCanvas: Canvas = {
520 version: 1,
521 elements: {
522 near: { id: "near", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null },
523 far: { id: "far", type: "rect", x: 5000, y: 5000, w: 100, h: 50, z: 2, fill: null, stroke: null }
524 }
525 };
526 mocks.canvas = cullCanvas;
527
528 renderBoard();
529 expect(host.querySelectorAll('[data-testid="wb-rect"]').length).toBe(2);
530 });
531});
532