Files
MajoorWaldi-ComfyUI-Majoor-…/ui/tests/workflow_sidebar_live_sync.vitest.ts
T
Ewald 85a82f130a test: migrate ui/tests from .mjs to .ts
- Rename 132 *.vitest.mjs -> *.vitest.ts
- Rename vitest.setup.mjs -> vitest.setup.ts
- Rename helpers/vitestEnvironment.mjs -> vitestEnvironment.ts
- Delete settings_store.test.mjs (Node test runner, superseded by .vitest.ts)
- Fix .mjs imports in vitest.setup.ts and grid_loader_adaptive_paging.vitest.ts

Config updates:
- vitest.config.mjs: setupFiles, include, coverage.include -> .ts
- tsconfig.test.json: new dedicated config for test files (strict:false)
- knip.json: update project pattern for tests
- package.json: add typecheck:tests script (audit-only)
2026-05-26 13:04:05 +02:00

294 lines
9.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const bridgeState = vi.hoisted(() => ({
app: null,
}));
const rendererState = vi.hoisted(() => ({
constructed: [],
disposed: [],
synced: [],
instances: [],
options: [],
}));
vi.mock("../app/comfyApiBridge.js", () => ({
getComfyApp: () => bridgeState.app,
}));
vi.mock("../features/viewer/workflowSidebar/NodeWidgetRenderer.js", () => ({
NodeWidgetRenderer: class NodeWidgetRendererMock {
constructor(node, opts = {}) {
this._node = node;
this.node = node;
this.opts = opts;
this._expanded = opts.expanded !== false;
this.el = makeElement("section");
this.el.dataset.nodeId = String(node?.id ?? "");
rendererState.constructed.push(node?.id ?? null);
rendererState.instances.push(this);
rendererState.options.push(opts);
}
setExpanded(expanded) {
this._expanded = Boolean(expanded);
}
syncFromGraph() {
rendererState.synced.push(this.node?.id ?? null);
}
dispose() {
rendererState.disposed.push(this.node?.id ?? null);
}
},
}));
function makeClassList() {
const classes = new Set();
return {
add: (...tokens) => tokens.forEach((token) => classes.add(String(token))),
remove: (...tokens) => tokens.forEach((token) => classes.delete(String(token))),
toggle: (token, force) => {
const key = String(token);
if (force === undefined) {
if (classes.has(key)) {
classes.delete(key);
return false;
}
classes.add(key);
return true;
}
if (force) classes.add(key);
else classes.delete(key);
return Boolean(force);
},
contains: (token) => classes.has(String(token)),
toString: () => Array.from(classes).join(" "),
};
}
function makeElement(tagName) {
const el = {
tagName: String(tagName || "").toUpperCase(),
children: [],
className: "",
classList: makeClassList(),
dataset: {},
style: {},
textContent: "",
ownerDocument: null,
appendChild(child) {
child.parentElement = this;
this.children.push(child);
return child;
},
insertBefore(child, before) {
child.parentElement = this;
const currentIndex = this.children.indexOf(child);
if (currentIndex >= 0) this.children.splice(currentIndex, 1);
const beforeIndex = this.children.indexOf(before);
if (beforeIndex >= 0) this.children.splice(beforeIndex, 0, child);
else this.children.push(child);
return child;
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setAttribute: vi.fn(),
remove: vi.fn(),
scrollIntoView: vi.fn(),
};
Object.defineProperty(el, "firstElementChild", {
get() {
return this.children[0] || null;
},
});
let innerHtmlValue = "";
Object.defineProperty(el, "innerHTML", {
get() {
return innerHtmlValue;
},
set(value) {
innerHtmlValue = String(value || "");
if (innerHtmlValue === "") {
this.children.length = 0;
}
},
});
return el;
}
describe("WorkflowSidebar live sync", () => {
beforeEach(() => {
rendererState.constructed = [];
rendererState.disposed = [];
rendererState.synced = [];
rendererState.instances = [];
rendererState.options = [];
const doc = {
createElement: vi.fn((tag) => {
const el = makeElement(tag);
el.ownerDocument = doc;
return el;
}),
activeElement: null,
defaultView: {
requestAnimationFrame: vi.fn(() => 1),
cancelAnimationFrame: vi.fn(),
setTimeout,
clearTimeout,
},
};
globalThis.document = doc;
globalThis.window = {
...(globalThis.window || {}),
requestAnimationFrame: vi.fn(() => 1),
cancelAnimationFrame: vi.fn(),
setTimeout,
clearTimeout,
};
bridgeState.app = {
canvas: {
selected_nodes: {},
},
graph: {
nodes: [],
},
};
});
it("lists all workflow nodes even when no canvas node is selected", async () => {
const { WorkflowSidebar } =
await import("../features/viewer/workflowSidebar/WorkflowSidebar.js");
const nodeA = { id: 11 };
const nodeB = { id: 22 };
bridgeState.app.graph.nodes = [nodeA, nodeB];
bridgeState.app.canvas.selected_nodes = {};
const sidebar = new WorkflowSidebar();
sidebar.show();
expect(rendererState.constructed).toEqual([11, 22]);
expect(rendererState.synced).toEqual([11, 22]);
});
it("syncs selection changes without rebuilding the full node list", async () => {
const { WorkflowSidebar } =
await import("../features/viewer/workflowSidebar/WorkflowSidebar.js");
const nodeA = { id: 11 };
const nodeB = { id: 22 };
bridgeState.app.graph.nodes = [nodeA, nodeB];
const sidebar = new WorkflowSidebar();
sidebar.show();
bridgeState.app.canvas.selected_nodes = { 22: nodeB };
sidebar.syncFromGraph();
expect(rendererState.constructed).toEqual([11, 22]);
expect(rendererState.disposed).toEqual([]);
expect(rendererState.synced.slice(-2)).toEqual([11, 22]);
expect(rendererState.instances[0].el.classList.contains("is-selected-from-graph")).toBe(false);
expect(rendererState.instances[1].el.classList.contains("is-selected-from-graph")).toBe(true);
bridgeState.app.canvas.selected_nodes = {};
sidebar.syncFromGraph();
expect(rendererState.instances[0].el.classList.contains("is-selected-from-graph")).toBe(false);
expect(rendererState.instances[1].el.classList.contains("is-selected-from-graph")).toBe(false);
});
it("keeps syncing current node values without forcing a rebuild", async () => {
const { WorkflowSidebar } =
await import("../features/viewer/workflowSidebar/WorkflowSidebar.js");
const nodeA = { id: 33 };
bridgeState.app.graph.nodes = [nodeA];
const sidebar = new WorkflowSidebar();
sidebar.show();
rendererState.synced = [];
sidebar.syncFromGraph();
expect(rendererState.constructed).toEqual([33]);
expect(rendererState.synced).toEqual([33]);
});
it("keeps subgraph inner nodes attached below the selected subgraph", async () => {
const { WorkflowSidebar } =
await import("../features/viewer/workflowSidebar/WorkflowSidebar.js");
const outsideNode = { id: 10, type: "PreviewImage" };
const innerNode = { id: 30, type: "KSampler" };
const subgraphNode = {
id: 20,
title: "Face Detailer",
type: "12345678-1234-1234-1234-123456789abc",
subgraph: { nodes: [innerNode] },
};
bridgeState.app.graph.nodes = [outsideNode, subgraphNode];
const sidebar = new WorkflowSidebar();
sidebar.show();
const list = sidebar._nodesTab._list;
const subgraphRenderer = rendererState.instances[1];
const innerRenderer = rendererState.instances[2];
const subgraphItem = subgraphRenderer._mjrTreeItemEl;
expect(rendererState.constructed).toEqual([10, 20, 30]);
expect(rendererState.options[1]).toMatchObject({ isSubgraph: true, childCount: 1 });
expect(subgraphItem.children[0]).toBe(subgraphRenderer.el);
expect(subgraphItem._mjrChildrenEl.children[0]).toBe(innerRenderer._mjrTreeItemEl);
expect(subgraphItem._mjrChildrenEl.hidden).toBe(true);
bridgeState.app.canvas.selected_nodes = { 20: subgraphNode };
sidebar.syncFromGraph();
expect(list.children[0]).toBe(subgraphItem);
expect(subgraphItem.children[0]).toBe(subgraphRenderer.el);
expect(subgraphItem._mjrChildrenEl.hidden).toBe(false);
expect(subgraphItem._mjrChildrenEl.children[0]).toBe(innerRenderer._mjrTreeItemEl);
expect(subgraphRenderer.el.classList.contains("is-selected-from-graph")).toBe(true);
});
it("throttles live sync so sidebar widgets are not refreshed on every animation frame", async () => {
let rafCallback = null;
document.defaultView.requestAnimationFrame = vi.fn((cb) => {
rafCallback = cb;
return 1;
});
window.requestAnimationFrame = document.defaultView.requestAnimationFrame;
const { WorkflowSidebar } =
await import("../features/viewer/workflowSidebar/WorkflowSidebar.js");
const nodeA = { id: 77 };
bridgeState.app.graph.nodes = [nodeA];
const sidebar = new WorkflowSidebar();
const syncSpy = vi.fn();
sidebar.syncFromGraph = syncSpy;
sidebar.show();
const baselineTs = Number(sidebar._lastLiveSyncAt) || 0;
expect(syncSpy).toHaveBeenCalledTimes(0);
expect(typeof rafCallback).toBe("function");
rafCallback(baselineTs + 100);
expect(syncSpy).toHaveBeenCalledTimes(0);
rafCallback(baselineTs + 300);
expect(syncSpy).toHaveBeenCalledTimes(1);
});
});