182 lines
15 KiB
JavaScript
182 lines
15 KiB
JavaScript
// 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";
|
|
|
|
const tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json"));
|
|
const { JSDOM } = tools("jsdom");
|
|
const { build, stop } = tools("esbuild");
|
|
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); } };
|
|
for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "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);
|
|
window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} });
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; };
|
|
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;
|
|
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); };
|
|
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) }; };
|
|
globalThis.ResizeObserver = window.ResizeObserver = class {
|
|
constructor(callback) { this.callback = callback; }
|
|
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
|
|
unobserve() {} disconnect() {}
|
|
};
|
|
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" } });
|
|
};
|
|
const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-"));
|
|
try {
|
|
const outfile = resolve(folder, "fixture.cjs");
|
|
await build({
|
|
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; 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) {
|
|
build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" }));
|
|
build.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ contents: '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() }));
|
|
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, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
|
|
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(); }); };
|
|
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");
|
|
const toggle = () => document.querySelector('[aria-label="旋转模式"]');
|
|
await event(toggle(), "click");
|
|
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");
|
|
|
|
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");
|
|
|
|
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 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");
|
|
|
|
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");
|
|
|
|
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");
|
|
}
|
|
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; } });
|
|
}
|
|
await act(async () => { root.unmount(); await tick(); });
|
|
} 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;
|
|
}
|