Files
Endoleap/tests/canvas-ui-check.mjs

266 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧画布 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('<!doctype html><html><body><div id="app"></div></body></html>', { 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;
}