When a Saver's load_saved / prompt_lists were promoted to subgraph inputs, changing them on the subgraph node never ran the Saver's callbacks: the prompt didn't load, the list didn't refilter, and the outer dropdowns kept a stale snapshot of the options. - Add js/subgraph_widgets.js (pure module): find the widget that supplies a value through nested subgraphs, and hook the subgraph node's outer widget callbacks to forward changes to opted-in nodes inside. Re-hooks on widget-promoted and when the frontend silently rebuilds an outer widget (input._widget slot watcher). - Saver mirrors the outer widgets: outer changes run the normal callbacks; inner values and option lists are pushed back out, including to a Primitive feeding the subgraph node. Unchanged values are ignored so a Primitive's per-queue re-apply doesn't reset the selection. - Save / Delete / load use the outer list name when promoted. - Add plain Node tests (tests/), a mock workflow loader and the bug-report workflow as a fixture. - Rename AGENT.md to AGENTS.md; document promoted widgets, testing and README style. - Bump version to 1.8.1.
284 lines
9.5 KiB
JavaScript
284 lines
9.5 KiB
JavaScript
/**
|
|
* Mock LiteGraph objects for testing the pure JS modules under Node, plus a
|
|
* loader that builds them from a serialized ComfyUI workflow (UI format 0.4,
|
|
* root graph + `definitions.subgraphs`).
|
|
*
|
|
* The mocks reproduce only what the modules read: graph `nodes`, `links`
|
|
* (Map), `getLink`, `getNodeById`, `rootGraph`, subgraph `inputs` (name +
|
|
* `linkIds`) and `events`; node `id` (string), `type`, `title`, `inputs`
|
|
* (`link`, `widget`), `widgets` (`name`, `value`, `options`, `callback`),
|
|
* `graph`, `subgraph` and `isSubgraphNode`.
|
|
*
|
|
* Widgets are built from `widgets_values_named` (written by current
|
|
* frontends). A container's widgets are its promoted inputs (inputs with a
|
|
* `widget`), valued from its own `widgets_values_named`, like the frontend's
|
|
* store-backed outer widgets.
|
|
*
|
|
* Adapted from ComfyUI-Enhancement-Utils `tests/workflowLoader.mjs`.
|
|
*/
|
|
|
|
// ── Mock Classes ──────────────────────────────────────────────────────────
|
|
|
|
/** Minimal LGraphNode stand-in. */
|
|
export class MockNode {
|
|
/**
|
|
* @param {*} id - Node id (stored as a string, like frontend >= 1.46).
|
|
* @param {Object} [opts] - Options.
|
|
* @param {string} [opts.type] - Node type.
|
|
* @param {string} [opts.title] - Node title.
|
|
* @param {Array<Object>} [opts.inputs] - Input slots (`name`, `type`, `link`, `widget`).
|
|
* @param {Array<Object>} [opts.outputs] - Output slots.
|
|
* @param {Object} [opts.widgets] - Widget name -> value.
|
|
*/
|
|
constructor(id, opts = {}) {
|
|
this.id = String(id);
|
|
this.type = opts.type;
|
|
this.title = opts.title;
|
|
this.inputs = (opts.inputs || []).map((i) => ({ link: null, ...i }));
|
|
this.outputs = (opts.outputs || []).map((o) => ({ ...o }));
|
|
this.widgets = Object.entries(opts.widgets || {}).map(([name, value]) => mockWidget(name, value));
|
|
}
|
|
|
|
/**
|
|
* @param {string} name - Widget name.
|
|
* @returns {Object|undefined} The widget.
|
|
*/
|
|
widget(name) {
|
|
return this.widgets.find((w) => w.name === name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A plain widget object.
|
|
*
|
|
* @param {string} name - Widget name.
|
|
* @param {*} value - Initial value.
|
|
* @returns {Object} Widget with `name`, `value`, `options.values`.
|
|
*/
|
|
export function mockWidget(name, value) {
|
|
return { name, value, options: { values: [] } };
|
|
}
|
|
|
|
/** Minimal LGraph / Subgraph stand-in. */
|
|
export class MockGraph {
|
|
/**
|
|
* @param {string} id - Graph id (subgraph definition id for subgraphs).
|
|
* @param {MockGraph} [root] - Root graph (omit for the root itself).
|
|
*/
|
|
constructor(id, root = null) {
|
|
this.id = id;
|
|
this.rootGraph = root ?? this;
|
|
this.isRootGraph = !root;
|
|
this._nodes = [];
|
|
this._byId = new Map();
|
|
this.links = new Map();
|
|
this.inputs = [];
|
|
this.events = new EventTarget();
|
|
this.nextLink = 1;
|
|
}
|
|
|
|
/** @returns {Array<MockNode>} Nodes. */
|
|
get nodes() {
|
|
return this._nodes;
|
|
}
|
|
|
|
/**
|
|
* Add nodes to the graph.
|
|
* @param {...MockNode} nodes - Nodes to add.
|
|
* @returns {MockNode} The first node added.
|
|
*/
|
|
add(...nodes) {
|
|
for (const n of nodes) {
|
|
n.graph = this;
|
|
this._nodes.push(n);
|
|
this._byId.set(String(n.id), n);
|
|
}
|
|
return nodes[0];
|
|
}
|
|
|
|
/**
|
|
* @param {*} id - Node id.
|
|
* @returns {MockNode|null} The node, or null.
|
|
*/
|
|
getNodeById(id) {
|
|
return this._byId.get(String(id)) ?? null;
|
|
}
|
|
|
|
/**
|
|
* @param {*} id - Link id.
|
|
* @returns {Object|undefined} The link.
|
|
*/
|
|
getLink(id) {
|
|
return this.links.get(id);
|
|
}
|
|
|
|
/**
|
|
* Add a subgraph input slot.
|
|
* @param {string} name - Input name.
|
|
* @returns {Object} The slot (`name`, `linkIds`).
|
|
*/
|
|
addInput(name) {
|
|
const slot = { name, linkIds: [] };
|
|
this.inputs.push(slot);
|
|
return slot;
|
|
}
|
|
|
|
/**
|
|
* Connect two slots with a new link. `from` may be "-10" (this subgraph's
|
|
* input node; `fromSlot` is then the subgraph input index).
|
|
* @param {MockNode|string} from - Origin node or "-10".
|
|
* @param {number} fromSlot - Origin slot.
|
|
* @param {MockNode} to - Target node.
|
|
* @param {number} toSlot - Target input slot.
|
|
* @returns {Object} The link.
|
|
*/
|
|
connect(from, fromSlot, to, toSlot) {
|
|
const id = this.nextLink++;
|
|
const link = {
|
|
id,
|
|
origin_id: typeof from === "string" ? from : from.id,
|
|
origin_slot: fromSlot,
|
|
target_id: to.id,
|
|
target_slot: toSlot,
|
|
};
|
|
this.links.set(id, link);
|
|
to.inputs[toSlot].link = id;
|
|
if (link.origin_id === "-10") this.inputs[fromSlot].linkIds.push(id);
|
|
return link;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turn a node into a subgraph container for `subgraph`. Its widgets become
|
|
* one outer widget per promoted input (input with a `widget`), valued from
|
|
* `values` (name -> value), each with a store-like callback that only sets
|
|
* the value.
|
|
*
|
|
* @param {MockNode} node - Node to decorate.
|
|
* @param {MockGraph} subgraph - Subgraph definition graph.
|
|
* @param {Object} [values] - Outer widget values by name.
|
|
* @returns {MockNode} The same node.
|
|
*/
|
|
export function makeContainer(node, subgraph, values = {}) {
|
|
node.subgraph = subgraph;
|
|
node.isSubgraphNode = () => true;
|
|
node.widgets = node.inputs
|
|
.filter((i) => i.widget)
|
|
.map((i) => {
|
|
const w = mockWidget(i.widget.name, values[i.widget.name]);
|
|
w.callback = function (next) {
|
|
w.value = next;
|
|
};
|
|
return w;
|
|
});
|
|
return node;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Workflow Loader
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Normalise a serialized link (array or object form) into a link object.
|
|
*
|
|
* @param {Array|Object} raw - `[id, origin_id, origin_slot, target_id, target_slot, type]` or object.
|
|
* @returns {Object|null} Link with string node ids, or null.
|
|
*/
|
|
function normaliseLink(raw) {
|
|
if (!raw) return null;
|
|
const l = Array.isArray(raw)
|
|
? { id: raw[0], origin_id: raw[1], origin_slot: raw[2], target_id: raw[3], target_slot: raw[4], type: raw[5] }
|
|
: { ...raw };
|
|
l.origin_id = String(l.origin_id);
|
|
l.target_id = String(l.target_id);
|
|
return l;
|
|
}
|
|
|
|
/**
|
|
* Widget name -> value for a serialized node.
|
|
*
|
|
* @param {Object} raw - Serialized node.
|
|
* @returns {Object} Named widget values (empty when not recorded).
|
|
*/
|
|
function namedValues(raw) {
|
|
return raw.widgets_values_named && typeof raw.widgets_values_named === "object" ? raw.widgets_values_named : {};
|
|
}
|
|
|
|
/**
|
|
* Build mock graphs from a serialized workflow.
|
|
*
|
|
* @param {Object} workflow - Serialized workflow (UI format 0.4).
|
|
* @returns {{root: MockGraph, subgraphs: Map<string, MockGraph>}} Root graph and subgraphs by id.
|
|
*/
|
|
export function loadWorkflow(workflow) {
|
|
const root = new MockGraph(workflow.id ?? "root");
|
|
const subgraphs = new Map();
|
|
root.subgraphs = subgraphs;
|
|
|
|
const defs = workflow?.definitions?.subgraphs || [];
|
|
for (const def of defs) {
|
|
const sg = new MockGraph(def.id, root);
|
|
sg.name = def.name;
|
|
sg.inputs = (def.inputs || []).map((i) => ({ name: i.name, linkIds: [...(i.linkIds || [])] }));
|
|
subgraphs.set(def.id, sg);
|
|
}
|
|
|
|
/**
|
|
* Instantiate one serialized node into a graph.
|
|
* @param {MockGraph} graph - Target graph.
|
|
* @param {Object} raw - Serialized node.
|
|
* @returns {void}
|
|
*/
|
|
const addNode = (graph, raw) => {
|
|
const sub = subgraphs.get(raw.type);
|
|
const node = new MockNode(raw.id, {
|
|
type: raw.type,
|
|
title: raw.title,
|
|
inputs: (raw.inputs || []).map((i) => ({
|
|
name: i.name,
|
|
type: i.type,
|
|
link: i.link ?? null,
|
|
...(i.widget ? { widget: { ...i.widget } } : {}),
|
|
})),
|
|
outputs: raw.outputs || [],
|
|
widgets: sub ? {} : namedValues(raw),
|
|
});
|
|
if (sub) makeContainer(node, sub, namedValues(raw));
|
|
graph.add(node);
|
|
};
|
|
|
|
/**
|
|
* Fill a graph's nodes and links.
|
|
* @param {MockGraph} graph - Target graph.
|
|
* @param {Array<Object>} nodes - Serialized nodes.
|
|
* @param {Array<Array|Object>} links - Serialized links.
|
|
* @returns {void}
|
|
*/
|
|
const fill = (graph, nodes, links) => {
|
|
for (const raw of nodes || []) addNode(graph, raw);
|
|
for (const raw of links || []) {
|
|
const link = normaliseLink(raw);
|
|
if (link) graph.links.set(link.id, link);
|
|
}
|
|
};
|
|
|
|
fill(root, workflow.nodes, workflow.links);
|
|
for (const def of defs) fill(subgraphs.get(def.id), def.nodes, def.links);
|
|
return { root, subgraphs };
|
|
}
|
|
|
|
/**
|
|
* Find a node by title anywhere in the loaded graphs.
|
|
*
|
|
* @param {MockGraph} root - Root graph.
|
|
* @param {string} title - Node title.
|
|
* @returns {MockNode|null} The node, or null.
|
|
*/
|
|
export function findByTitle(root, title) {
|
|
for (const g of [root, ...(root.subgraphs?.values() ?? [])]) {
|
|
const node = g.nodes.find((n) => n.title === title);
|
|
if (node) return node;
|
|
}
|
|
return null;
|
|
}
|