268 lines
24 KiB
JavaScript
268 lines
24 KiB
JavaScript
/**
|
||
* 旧画布 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;
|
||
// 记录此进程后来创建的 MessageChannel;React 异步 act 可持有这些端口,最终关闭以免测试进程悬挂。
|
||
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 并计数 Probe 函数调用,检查截止时间前触发的空闲回调不请求也不增加调用次数。
|
||
// 这是函数执行计数,不是 React DOM commit 次数;未推进墙上时间便手动触发 timer,验证的是 deadline 门禁。
|
||
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(); });
|
||
// 断言失败也会进入以下清理;root.unmount 只在正常路径执行,finally 仍关闭 JSDOM、esbuild 和记录到的通道。
|
||
// 清理临时打包文件并恢复原通道构造器,不逐项恢复仅供此独立进程使用的 DOM 全局替身。
|
||
} 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;
|
||
}
|