/** * 旧画布 JSDOM 回归入口:临时 esbuild 打包实际 React 组件,再注入 DOM/布局/网络替身执行场景。 * 运行会编译临时 fixture;不启动浏览器或开发服务器。模拟尺寸、命中和指针捕获不能替代浏览器布局/手势验证。 * CANVAS_UI_FEEDBACK_ONLY 仅跑反馈;ROTATION_BASELINE 对打包输入移除旧点击阻止逻辑,用于复现旋转取消。 */ // Isolated DOM regression check; no browser or dev server is used. // npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom // CANVAS_UI_TOOLS=/tmp/canvas-ui-tools node tests/canvas-ui-check.mjs import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; // 测试工具从 CANVAS_UI_TOOLS 指定目录解析,业务 React/组件仍来自当前仓库依赖。 const tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json")); const { JSDOM } = tools("jsdom"); const { build, stop } = tools("esbuild"); // 独立 DOM 和 localhost origin 提供 localStorage 等环境,不请求 localhost 服务。 const dom = new JSDOM('
', { url: "http://localhost", pretendToBeVisual: true }); const { window } = dom; // React's async act creates MessageChannels; track and close test-owned ports. const NativeMessageChannel = globalThis.MessageChannel; const channels = []; globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } }; // 把组件依赖的 DOM API 暴露到 Node 全局;这是独立测试进程,finally 不逐项恢复这些全局。 for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true }); Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true }); globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window); globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window); // 媒体查询、矩阵和 React act 环境使用最小替身,仅满足组件读取的字段。 window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }); globalThis.IS_REACT_ACT_ENVIRONMENT = true; globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; }; // 把 MouseEvent 扩为最小 pointer 事件并固定鼠标类型;不是原生多指输入实现。 class PointerEvent extends window.MouseEvent { constructor(type, init = {}) { super(type, init); this.pointerId = init.pointerId || 1; this.isPrimary = true; this.pointerType = "mouse"; } } globalThis.PointerEvent = window.PointerEvent = PointerEvent; // WeakMap 记录元素持有的 pointerId,仅模拟捕获 API 状态,不会自动重定向后续事件。 const captures = new WeakMap(); window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); }; window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; }; window.HTMLElement.prototype.releasePointerCapture = function () { captures.delete(this); }; // 优先用内联宽高,其余给确定默认值;所有矩形起点为零,不进行 CSS 排版计算。 const width = (element) => parseFloat(element.style.width) || (element.className?.includes?.("react-flow__node") ? 260 : 1200); const height = (element) => parseFloat(element.style.height) || (element.getAttribute("aria-label") === "图标列表" ? 192 : element.className?.includes?.("react-flow__node") ? 200 : 800); for (const [key, get] of [["offsetWidth", width], ["offsetHeight", height], ["clientWidth", width], ["clientHeight", height]]) Object.defineProperty(window.HTMLElement.prototype, key, { get() { return get(this); } }); window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, width: width(this), height: height(this), right: width(this), bottom: height(this) }; }; // observe 立即同步回调尺寸,unobserve/disconnect 无行为;不验证真实观察器调度顺序。 globalThis.ResizeObserver = window.ResizeObserver = class { constructor(callback) { this.callback = callback; } observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); } unobserve() {} disconnect() {} }; // 场景可替换 Foundry 请求处理器;图标请求记录后从本地 public 读 JSON,拒绝其他网络目标。 let apiMock = null; const requests = []; globalThis.fetch = async (url, options) => { if (apiMock && String(url).startsWith("/api/foundry/")) return apiMock(url, options); requests.push(String(url)); assert.ok(String(url).startsWith("/canvas-icons/"), `Unexpected network request ${url}`); if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); return new Response(await readFile(resolve("public", String(url).slice(1))), { headers: { "Content-Type": "application/json" } }); }; // 临时目录只存打包 fixture,finally 删除;不把生成文件写入项目源码。 const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-")); try { const outfile = resolve(folder, "fixture.cjs"); // 打包实际组件和 React 为 CJS,CSS 置空;因此本测试不能证明最终样式或 Next 路由行为。 await build({ stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { AppFeedback } from "./src/components/ui/app-feedback"; export { IconButton } from "./src/components/ui/icon-button"; export { notify, dismissNotification } from "./src/lib/notifications"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" }, outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")], loader: { ".css": "empty" }, plugins: [{ name: "dynamic-component", setup(build) { // Next Link 替换为 a,dynamic 替换为 lazy/Suspense;这些模块不经过实际 Next 运行时。 build.onResolve({ filter: /^next\/link$/ }, () => ({ path: "link", namespace: "fixture" })); build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" })); build.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: path === "link" ? 'import React from "react"; export default function Link(props) { return React.createElement("a",props); }' : 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() })); // 只修改本次编译读入的 toolbar 文本来复现旧 portal 冒泡问题,不改磁盘源码。 if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" })); } }], }); const { GraphCanvas, AppFeedback, IconButton, notify, dismissNotification, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile); // 共享 React root 供各场景替换组件;tick 让 effect 推进,event 在 act 内选择事件类型并派发。 const root = createRoot(document.querySelector("#app")); const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); }; // 非 feedback-only 才执行目录、轮询、旧画布工具和三个子场景;反馈场景总是执行。 if (process.env.CANVAS_UI_FEEDBACK_ONLY !== "1") { { // 目录 fixture 验证折叠、当前项祖先展开以及搜索保留完整父路径。 const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }]; const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false }); await act(async () => { root.render(renderDirectory("root-slug")); await tick(); }); assert.equal(document.querySelectorAll("ul ul").length, 2); assert.ok(document.querySelector('a[href="/dashboard/space/child-slug"]')); await event(document.querySelector('[aria-label="收起主画布的子画布"]'), "click"); assert.equal(document.querySelector('a[href="/dashboard/space/child-slug"]'), null); await act(async () => { root.render(renderDirectory("nested")); await tick(); }); assert.ok(document.querySelector('a[aria-current="page"][href="/dashboard/space/nested"]')); await act(async () => { root.render(renderDirectory("nested", "孙画布")); await tick(); }); assert.equal(document.querySelectorAll("a").length, 3, "Search retains parent paths"); console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths"); } { // 记录长定时任务并手动触发,检查空闲轮询退避和可见性暂停;finally 清理所有捕获计时器。 const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout; const scheduled = []; globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; }; let calls = 0, controls, renders = 0; apiMock = async () => { calls++; return Response.json({ runs: [] }); }; const tree = { busy: false, writers: { current: new Map() } }; // 挂载 AI hook 并计数渲染,检查空结果轮询既不重复请求也不造成额外渲染。 function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; } try { await act(async () => { root.render(h(Probe)); await tick(); }); assert.equal(calls, 1, "One initial discovery"); assert.equal(scheduled.at(-1).delay, 30000, "Idle discovery backs off to 30 seconds"); const before = renders; const idle = scheduled.at(-1); originalClearTimeout(idle.handle); await act(async () => { await idle.callback(); await tick(); }); assert.equal(calls, 1, "No repeated discovery before its deadline"); assert.equal(renders, before, "An empty poll does not rerender the canvas"); Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); const pending = scheduled.at(-1); originalClearTimeout(pending.handle); await act(async () => { await pending.callback(); await tick(); }); assert.equal(calls, 1, "Hidden tabs do not query runs"); Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); await act(async () => { document.dispatchEvent(new window.Event("visibilitychange")); await tick(); }); assert.equal(calls, 2, "Returning to the tab checks immediately"); await act(async () => { root.render(null); await tick(); }); console.log("PASS polling: single discovery, idle backoff, no empty rerenders, hidden pause and visible resume"); } finally { globalThis.setTimeout = originalSetTimeout; for (const item of scheduled) originalClearTimeout(item.handle); apiMock = null; } } // 播种 demo 旧快照、分区和边,随后以 DOM 事件检查工具栏和历史操作。 const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one"; const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A"; localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] })); await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); }); await event(document.querySelector('[data-id="text-one"]'), "click"); // 每次重查旋转按钮,避免 portal 重渲染后持有旧引用。 const toggle = () => document.querySelector('[aria-label="旋转模式"]'); await event(toggle(), "click"); // baseline 预期复现即时取消;正常路径要求旋转保持并可 Escape、undo/redo。 if (process.env.ROTATION_BASELINE === "1") { assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation"); console.log("PASS baseline: portal click cancels rotation before the fix"); } else { assert.equal(toggle().getAttribute("aria-pressed"), "true", "Portal click must keep rotation active"); const frame = document.querySelector('[data-id="text-one"] [style*="rotate("]'); await event(frame, "pointerdown", { clientX: 330, clientY: 200, button: 0 }); await event(frame, "pointermove", { clientX: 230, clientY: 300, buttons: 1 }); await event(frame, "pointerup", { clientX: 230, clientY: 300 }); assert.ok(frame.style.transform.includes("90deg"), frame.style.transform); await event(window, "keydown", { key: "Escape" }); assert.equal(toggle().getAttribute("aria-pressed"), "false"); await event(document.querySelector('[aria-label="撤销"]'), "click"); assert.ok(frame.style.transform.includes("0deg")); await event(document.querySelector('[aria-label="重做"]'), "click"); assert.ok(frame.style.transform.includes("90deg")); assert.equal(requests.length, 0, "Closed picker and text-only board must not load icon shapes"); console.log("PASS rotation: portal click, pointer rotation, Escape, undo and redo"); // 分区进出通过 mouse 拖动触发,分别检查松开前预览、归属徽标和状态提示。 const textNode = document.querySelector('.react-flow__node[data-id="text-one"]'); await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 }); await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 }); await event(window, "mousemove", { clientX: 700, clientY: 200, buttons: 1 }); assert.ok(document.body.textContent.includes("松开加入"), "Dragging over a frame must preview membership"); await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 }); assert.ok(document.querySelector('[title="属于:分区 A"]')); assert.ok(document.querySelector('[role="status"]').textContent.includes("已加入「分区 A」")); assert.ok(!document.querySelector('.react-flow__node[data-id="frame-one"]').textContent.includes("1 项")); await event(textNode, "mousedown", { clientX: 700, clientY: 200, button: 0, buttons: 1 }); await event(window, "mousemove", { clientX: 690, clientY: 200, buttons: 1 }); await event(window, "mousemove", { clientX: 200, clientY: 200, buttons: 1 }); assert.ok(document.body.textContent.includes("松开移出")); await event(window, "mouseup", { clientX: 200, clientY: 200, button: 0 }); assert.equal(document.querySelector('[title="属于:分区 A"]'), null); assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」")); console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements"); // 分区名称模式支持鼠标与键盘选择及历史,八向缩放控制可由 Escape 退出。 const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]'); await event(frameElement, "click"); const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]'); assert.ok(group); assert.equal(group.querySelectorAll('[role="radio"]').length, 3); assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "固定字号"); await event(group.querySelector('[aria-label="隐藏名称"]'), "click"); assert.equal(frameElement.querySelector('[title="分区 A"]'), null); await event(group.querySelector('[aria-label="隐藏名称"]'), "keydown", { key: "End" }); assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放"); assert.ok(frameElement.querySelector('[title="分区 A"]')); await event(document.querySelector('[aria-label="撤销"]'), "click"); assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "隐藏名称"); await event(document.querySelector('[aria-label="重做"]'), "click"); assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放"); await act(async () => { await new Promise(resolve => setTimeout(resolve, 180)); }); await event(document.querySelector('[aria-label="八向拉伸模式"]'), "click"); assert.equal(document.querySelectorAll('[data-resize-handle]').length, 8); await event(window, "keydown", { key: "Escape" }); assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0); console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles"); // 连线编辑覆盖曲线点增删、箭头 marker、常驻动画和无执行事件时停止动画。 await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click"); assert.ok(document.querySelector('[aria-label="连线编辑"]')); await event(document.querySelector('[aria-label="添加曲线点"]'), "click"); assert.ok(document.querySelector('[aria-label="曲线点 1"]')); const arrow = document.querySelector('[aria-label="箭头类型"]'); await act(async () => { arrow.value = "triangle"; arrow.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); assert.ok(document.querySelector('.react-flow__edge[data-id="edge-test"] .react-flow__edge-path').getAttribute("marker-end")); const animation = document.querySelector('[aria-label="动画播放时机"]'); await act(async () => { animation.value = "always"; animation.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); assert.ok(document.querySelector("animateMotion")); await act(async () => { animation.value = "executing"; animation.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); assert.equal(document.querySelector("animateMotion"), null, "Execution-only animation remains stopped without a running edge"); await event(document.querySelector('[aria-label="删除选中曲线点"]'), "click"); assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null); console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation"); // IconPicker 以确定容器高度测试窗口化 DOM、按需分片、共享 Tabler 缓存和分类后滚动复位。 let choice; await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); }); await act(async () => { await tick(); }); const grid = document.querySelector('[aria-label="图标列表"]'); assert.ok(grid.querySelectorAll("button").length <= 30); const commonRequests = requests.length; assert.ok(commonRequests > 0 && commonRequests < 30); const source = document.querySelector('[aria-label="图标库"]'); await act(async () => { source.value = "tabler"; source.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); await act(async () => { await tick(); }); assert.equal(grid.querySelectorAll("button").length, 24); assert.equal(requests.filter((url) => url.split("/").at(-1).startsWith("tabler-")).length, 1, "Tabler shares one cached 24-icon shard"); await event(grid.querySelector('[data-icon-id="TablerAlien"]'), "click"); assert.equal(choice, "TablerAlien"); await act(async () => { source.value = "lucide"; source.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); await event([...document.querySelectorAll('[aria-label="图标分类"] button')].find((button) => button.textContent === "全部"), "click"); await act(async () => { grid.scrollTop = 4800; grid.dispatchEvent(new window.Event("scroll", { bubbles: true })); await tick(); }); assert.ok(grid.querySelectorAll("button").length <= 30, "Long scrolling must not accumulate DOM"); assert.ok(Number(grid.querySelector("button").dataset.index) >= 490, "Scroll should render a different window"); assert.ok(new Set(requests).size < 97, "Scrolling one window must not load the entire library"); await event([...document.querySelectorAll('[aria-label="图标分类"] button')].find((button) => button.textContent === "科技与设备"), "click"); assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window"); console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection"); } // 正常模式追加 V2 linked-board 场景;baseline 跳过它,但成员和上传场景仍会执行。 if (process.env.ROTATION_BASELINE !== "1") { let scenario; await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); }); const { canvasV2Scenario } = scenario; await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); } // 注入 hook 与 mock setter,成员场景可精确控制版本、能力和 ACK 竞态。 const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs"); await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } }); // 上传场景复用 DOM/root,以文件、Image 和 API 替身检查预览、编辑与清理。 const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs"); await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); } // 最后验证通用通知/tooltip 与 demo 画布提示,再卸载 root。 const { feedbackScenario } = await import("./feedback-scenario.mjs"); await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }); await act(async () => { root.unmount(); await tick(); }); // 清理临时打包文件、JSDOM、esbuild 及 act 创建的 MessageChannel 端口,恢复原通道构造器。 } finally { await rm(folder, { recursive: true, force: true }); window.close(); stop(); for (const channel of channels) { channel.port1.close(); channel.port2.close(); } globalThis.MessageChannel = NativeMessageChannel; }