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

138 lines
12 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() {}
};
const requests = [];
globalThis.fetch = async (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] }));
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");
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");
}
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;
}