fix: promoted Saver widgets in subgraphs load prompts and refilter lists (#21)
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.
This commit is contained in:
+50
-3
@@ -1,4 +1,4 @@
|
||||
# AGENT.md
|
||||
# AGENTS.md
|
||||
|
||||
Guidelines for working on this project. Read this before making changes.
|
||||
|
||||
@@ -25,9 +25,10 @@ Prompt Stash is a small ComfyUI custom node pack for saving prompts into named l
|
||||
| `stash_sync.py` | In-memory carry-forward of captured input text per (tab path, node), `/prompt_stash_saver/latest_input` route |
|
||||
| `data_utils.py` | Data file location/init/migration, import validation + merge, workflow/prompt helpers |
|
||||
| `default_prompt_stash_data.json` | Template copied to the user data file on first run |
|
||||
| `js/*.js` | Frontend extensions, one per node + `multi_button.js` widget + shared `utils.js` |
|
||||
| `js/*.js` | Frontend extensions, one per node + `multi_button.js` widget + shared `utils.js` + `subgraph_widgets.js` (promoted widgets) |
|
||||
| `node_list.json` | Registry descriptions per node |
|
||||
| `example_workflows/` | Sample workflow (JSON + PNG) |
|
||||
| `tests/` | Plain Node tests for the pure JS modules, mock graphs, workflow fixtures (see `tests/README.md`) |
|
||||
|
||||
### Python (Nodes + Routes)
|
||||
|
||||
@@ -83,6 +84,16 @@ Prompt Stash is a small ComfyUI custom node pack for saving prompts into named l
|
||||
- `nodeMatchesUniqueId`, `getUniqueIdFromNode`, `isUnassignedNode`, `walkGraph`, `setDefaultLabel`
|
||||
- Primitive-node support: `findGetConfigSymbol`, `installComboGetConfig`, `refreshConnectedPrimitives`, `syncValueToConnectedPrimitive`
|
||||
- `forceWidgetReactiveUpdate(node, widget)` -- Vue re-render + clears `value_not_in_list` error
|
||||
- Keep `utils.js` free of imports and `app` / DOM access so tests can load it under Node
|
||||
|
||||
### Promoted Subgraph Widgets (`js/subgraph_widgets.js`)
|
||||
|
||||
Pure module (imports only `utils.js`), unit-tested in `tests/subgraph_widgets.test.mjs`.
|
||||
- `getSourceWidget(node, name)` -- the widget that supplies the value: the widget itself, or (input linked to a subgraph input) the container's outer widget, followed up through nested containers; null when the outermost container input is fed by another node
|
||||
- `findContainer(graph)`, `findPromotedTargets(container, input)` (real nodes + widget names an outer input feeds, through nesting), `hostWidgetForInput`, `isContainer`
|
||||
- `installPromotedWidgetHooks(container)` -- wraps each outer widget's `callback` (original first, then forward) and re-wraps on `subgraph.events` `widget-promoted` and whenever a new object lands in an input's `_widget` slot; listener dropped in `onRemoved`. Only inputs that are unlinked and feed a node with `onPromotedWidgetChanged` are wrapped
|
||||
- Opt-in interface on inner nodes: `onPromotedWidgetChanged(name, value, host)` (user changed the outer widget) and `onPromotedWidgetBound(name, host)` (outer widget (re)created; sync without side effects)
|
||||
- Saver extension hooks: `nodeCreated` and `afterConfigureGraph` install the hooks on every container
|
||||
|
||||
## LiteGraph + Nodes 2.0 Compatibility
|
||||
|
||||
@@ -144,6 +155,15 @@ Prefer the frontend's own data model (widgets, values, labels) over custom drawi
|
||||
- After changing widget values from code: `this.serialize_widgets = true` and `app.graph.setDirtyCanvas(true, true)`
|
||||
- Long explanatory comments for any frontend workaround, including *why* and the ComfyUI version/issue that caused it
|
||||
|
||||
### README
|
||||
|
||||
The README is for users, not a changelog. Keep the first look short:
|
||||
|
||||
- Each section: one or two plain sentences, a picture if there is one, and at most 3-4 bullets.
|
||||
- Everything else (input/output tables, settings, limitations, edge cases) goes in a collapsed `<details><summary>...</summary>` block, with a blank line after `<summary>` so the markdown renders.
|
||||
- Describe what the user sees and does, not how it's implemented. Implementation notes belong in this file.
|
||||
- A small tweak gets a word or a table row, or nothing at all. Don't add a sentence for every change.
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
### Widget Order Is Saved Workflow Format
|
||||
@@ -171,6 +191,19 @@ Frontend >= 1.46 brands `node.id` as a string (`"5"`); an unassigned node is `"-
|
||||
### Subgraphs
|
||||
- UNIQUE_ID for nodes inside subgraphs is a path (`"54:62:73"`). `update_node_in_workflow` walks `workflow.nodes` -> `definitions.subgraphs[*].nodes` by that path
|
||||
- `app.graph` is the root graph; walk subgraphs with `walkGraph` (uses `node.subgraph`)
|
||||
- Copies / clones of a subgraph node get their own subgraph definition, so a container's inner nodes belong to it alone
|
||||
|
||||
### Promoted Widgets Don't Run Our Callbacks
|
||||
Linking a widget input inside a subgraph to a subgraph input "promotes" it: the subgraph node (container) shows its own widget. On current frontends (checked against 1.57, `SubgraphNode._setWidget` / `promotedWidgetStoreProjection.ts`):
|
||||
- The outer widget keeps its **own value** in `widgetValueStore`, keyed by the container's id. The inner widget keeps a stale value the backend never sees -- the outer value is substituted at queue time
|
||||
- Its options are a `cloneDeep` **snapshot taken at promotion**. Later changes to the inner options (new saves, list switches) never reach it
|
||||
- Its `callback` only writes the store. The inner widget's callback never runs, so the Saver's load-prompt / refilter logic didn't either (the subgraph bug report; fixture `tests/fixtures/saver_subgraph_promotion.json`)
|
||||
- Both renderers call the outer widget's `callback` on a user change (classic: `BaseWidget.setValue`; Vue: `processedWidgetRenderModel` update handler). Only the classic canvas also calls the container's `onWidgetChanged`, so don't hook that
|
||||
- The outer widget object is rebuilt on every (re)promotion, followed by `subgraph.events` `widget-promoted`. It is **also rebuilt lazily with no event**: the container's `widgets` getter projects a fresh object into `input._widget` whenever that is empty (store id migration, `promotionUtils` re-registration). Seen on 1.57.6 after a page refresh. So `subgraph_widgets.js` also watches each input's `_widget` slot with an accessor (`trapInputWidget`) and binds new objects in a microtask
|
||||
- Assigning `.value` / `.options` on the outer widget writes the store (no callback). Assign a new options object; don't mutate `.options.values` in place
|
||||
- A Primitive can feed the container's input. It writes the outer widget and calls its `callback` (`applyFirstWidgetValueToGraph`), on change **and again on every queue**. So such inputs are bound (only inputs fed from an enclosing subgraph's input are skipped), `resolveWidgetSource` treats the container widget as the source, the Saver pushes values/options on to that Primitive (`syncValueToConnectedPrimitive` / `refreshConnectedPrimitives` on the container), and `onPromotedWidgetChanged` ignores unchanged values so the per-queue re-apply doesn't reset the selection
|
||||
|
||||
The Saver keeps its inner widgets as a mirror of the outer ones: `onPromotedWidgetChanged` copies the value in and runs the normal callback; `_syncComboInput` pushes option lists out (`_pushPromotedOptions`) and `_syncPromotedValues` pushes value changes out (resets to `"None"`, loaded prompt, Save / Delete, captured text). Server calls read the list name through `_sourceValue` (outer widget when promoted). The backend is unchanged -- it still outputs `prompt_text`.
|
||||
|
||||
### Pause-to-Edit Blocks Execution
|
||||
The paused node sleeps in ComfyUI's executor thread -- the whole queue waits. If the browser tab is closed while paused, the only ways out are the Continue button after reopening (re-sent every 2s) or Manager's "(Clear All Paused)". ComfyUI's Interrupt does **not** break the loop (it doesn't check `comfy.model_management.processing_interrupted()`). Changing pause state must not change `IS_CHANGED` (it's deliberately excluded from the hash).
|
||||
@@ -207,7 +240,20 @@ Most JS uses `api.fetchApi('/...')` (respects ComfyUI's API base path). Manager'
|
||||
|
||||
## Testing
|
||||
|
||||
There is no automated test suite. Verify manually in ComfyUI. **Run each UI check twice, once with Nodes 2.0 off and once with it on** (toggle in ComfyUI settings). Also switch renderers with the workflow open, and confirm dropdowns, buttons and the Continue button still work without reloading. Check the browser console for errors in both modes. Checks:
|
||||
### Automated
|
||||
|
||||
Plain Node scripts, no install (details in `tests/README.md`):
|
||||
|
||||
```sh
|
||||
node tests/subgraph_widgets.test.mjs
|
||||
```
|
||||
|
||||
- Logic worth testing goes in a pure module (no `app` / `api` / DOM imports) like `js/subgraph_widgets.js`; the extension file keeps only the frontend glue
|
||||
- Mock graphs come from `tests/workflowLoader.mjs`; real workflows that reproduce a bug go in `tests/fixtures/` with descriptive names (and descriptive node titles, which tests look up)
|
||||
|
||||
### Manual
|
||||
|
||||
Frontend behaviour can't be covered by the mocks. Verify in ComfyUI. **Run each UI check twice, once with Nodes 2.0 off and once with it on** (toggle in ComfyUI settings). Also switch renderers with the workflow open, and confirm dropdowns, buttons and the Continue button still work without reloading. Check the browser console for errors in both modes. Checks:
|
||||
- Saving / loading / deleting prompts, switching lists, Manager add / delete / sort, import / export
|
||||
- `use_input_text` on and off (and on with nothing connected -> clear error)
|
||||
- Every Nth Queue driving `use_input_text`: off runs keep the LLM and sampler cached; batch count > 1 advances once per item
|
||||
@@ -216,6 +262,7 @@ There is no automated test suite. Verify manually in ComfyUI. **Run each UI chec
|
||||
- Save / Delete / Sort buttons respond to clicks and grey out correctly (`MULTI_BUTTON` disabled state)
|
||||
- Saving a prompt shows it in every Saver's dropdown right away, with no stale options or red outline in Vue
|
||||
- A Primitive connected to `load_saved` / `prompt_lists`, including after the "R" refresh and after reloading the workflow
|
||||
- Saver inside a subgraph with `load_saved` / `prompt_lists` promoted (load `tests/fixtures/saver_subgraph_promotion.json`): picking a prompt outside loads it inside; switching the list outside refilters the outer prompt dropdown; typing in the prompt resets the outer selection to `None`; Save / Delete update the outer dropdown; options are live after reloading the workflow and after "R"; also nested two subgraphs deep; also with Primitives on the outside feeding the subgraph node's `load_saved` / `prompt_lists` (one or both), across several queued runs
|
||||
- Reloading an old saved workflow / image metadata
|
||||
|
||||
`ruff` is used for linting (`.ruff_cache/` exists; no config, so defaults).
|
||||
+125
-5
@@ -10,6 +10,7 @@ import {
|
||||
walkGraph,
|
||||
} from "./utils.js";
|
||||
import { handlePushedUpdate, installStashSync } from "./stash_sync.js";
|
||||
import { installPromotedWidgetHooks, isContainer, resolveWidgetSource } from "./subgraph_widgets.js";
|
||||
|
||||
// ── Cache-Invalidation Workaround ────────────────────────────────────────
|
||||
//
|
||||
@@ -167,11 +168,99 @@ app.registerExtension({
|
||||
});
|
||||
};
|
||||
|
||||
// Expose the combos' live options to connected Primitive nodes,
|
||||
// and notify them whenever the option lists change.
|
||||
// ── Promoted Subgraph Widgets ──────────────────────────────
|
||||
//
|
||||
// When one of our widget inputs is linked to a subgraph input,
|
||||
// the subgraph node shows its own copy of the widget. Its value
|
||||
// is what gets queued, its options are a snapshot taken at
|
||||
// promotion, and changing it never runs our callbacks (see
|
||||
// subgraph_widgets.js). We keep the inner widget as a mirror of
|
||||
// the outer one: outer changes are forwarded in and run our
|
||||
// normal callbacks; our own changes (values and option lists)
|
||||
// are pushed back out.
|
||||
const ownWidgets = [useInputWidget, promptWidget, saveKeyWidget, loadSavedWidget, promptListsWidget].filter(Boolean);
|
||||
const widgetByName = (name) => ownWidgets.find((w) => w.name === name);
|
||||
|
||||
// The subgraph node + outer widget supplying this widget's value,
|
||||
// or null when the widget isn't promoted.
|
||||
this._promotedSourceInfo = (widget) => {
|
||||
const graph = this.graph;
|
||||
if (!widget || !graph?.rootGraph || graph.rootGraph === graph) return null;
|
||||
const source = resolveWidgetSource(this, widget.name);
|
||||
return source && source.node !== this && source.widget !== widget ? source : null;
|
||||
};
|
||||
this._promotedSource = (widget) => this._promotedSourceInfo(widget)?.widget ?? null;
|
||||
|
||||
// The value that will actually be queued for one of our widgets.
|
||||
this._sourceValue = (widget) => (this._promotedSource(widget) ?? widget)?.value;
|
||||
|
||||
// Copy an inner value / option list out to the outer widget, and
|
||||
// on to a Primitive feeding the subgraph node's input. Writing
|
||||
// `.value` / `.options` on the outer widget only updates its
|
||||
// stored state; its callback isn't run, so there's no loop. The
|
||||
// Primitive gets its value first, so the option refresh doesn't
|
||||
// find it out of range and reset it.
|
||||
this._pushPromotedValue = (widget) => {
|
||||
const info = this._promotedSourceInfo(widget);
|
||||
if (!info) return;
|
||||
if (info.widget.value !== widget.value) info.widget.value = widget.value;
|
||||
syncValueToConnectedPrimitive(info.node, info.widget.name);
|
||||
};
|
||||
this._pushPromotedOptions = (widget) => {
|
||||
const info = this._promotedSourceInfo(widget);
|
||||
const values = widget?.options?.values;
|
||||
if (!info || !Array.isArray(values)) return;
|
||||
info.widget.options = { ...(info.widget.options ?? {}), values: values.slice() };
|
||||
if (info.widget.value !== widget.value) info.widget.value = widget.value;
|
||||
syncValueToConnectedPrimitive(info.node, info.widget.name);
|
||||
refreshConnectedPrimitives(info.node, info.widget.name);
|
||||
};
|
||||
this._syncPromotedValues = () => {
|
||||
for (const widget of ownWidgets) this._pushPromotedValue(widget);
|
||||
};
|
||||
|
||||
// Outer widget (re)created: push our value and live option lists
|
||||
// out to it. The inner state wins: it is always self-consistent
|
||||
// (selection matches the loaded prompt), while the outer value can
|
||||
// be stale -- the frontend's widget store keeps an existing
|
||||
// entry's value when a new subgraph node reuses an old node's id
|
||||
// (widgetValueStore.registerWidget, keyed graphId:nodeId:name).
|
||||
this.onPromotedWidgetBound = (name) => {
|
||||
const widget = widgetByName(name);
|
||||
if (!widget) return;
|
||||
if (widget === promptListsWidget || widget === loadSavedWidget) {
|
||||
if (this.data?.lists) {
|
||||
this._repopulateCombos?.();
|
||||
} else {
|
||||
this._pushPromotedOptions(widget);
|
||||
}
|
||||
}
|
||||
this._pushPromotedValue(widget);
|
||||
};
|
||||
|
||||
// User changed the outer widget: mirror it and run the same
|
||||
// callback as a change on the node itself (loads the prompt,
|
||||
// refilters the list, clears a stale selection...). An unchanged
|
||||
// value is ignored: a Primitive feeding the subgraph node
|
||||
// re-applies its value (and calls the callback) on every queue,
|
||||
// which would otherwise reset the loaded prompt each run.
|
||||
this.onPromotedWidgetChanged = (name, value) => {
|
||||
const widget = widgetByName(name);
|
||||
if (!widget || widget.value === value) return;
|
||||
widget.value = value;
|
||||
this.serialize_widgets = true;
|
||||
widget.callback?.(value);
|
||||
this._refreshSaveDeleteRow?.();
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// Expose the combos' live options to connected Primitive nodes
|
||||
// and promoted outer widgets, and notify them whenever the
|
||||
// option lists change.
|
||||
this._syncComboInput = (widgetName) => {
|
||||
this._installComboInput(widgetName);
|
||||
refreshConnectedPrimitives(this, widgetName);
|
||||
this._pushPromotedOptions(widgetByName(widgetName));
|
||||
};
|
||||
|
||||
// Rebuild both combos' options from the surviving `this.data`
|
||||
@@ -236,6 +325,8 @@ app.registerExtension({
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
// Reflect the reset (and the text) onto promoted outer widgets.
|
||||
this._syncPromotedValues?.();
|
||||
// Refresh Save/Delete row whenever the prompt text changes
|
||||
this._refreshSaveDeleteRow?.();
|
||||
};
|
||||
@@ -253,6 +344,8 @@ app.registerExtension({
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
// Reflect the trimmed key and any reset onto promoted outer widgets.
|
||||
this._syncPromotedValues?.();
|
||||
// Refresh Save/Delete row whenever the save key changes
|
||||
this._refreshSaveDeleteRow?.();
|
||||
};
|
||||
@@ -271,7 +364,7 @@ app.registerExtension({
|
||||
if (!canSave()) return;
|
||||
const promptToSave = promptWidget.value;
|
||||
const keyToSave = saveKeyWidget.value.trim();
|
||||
const selectedList = promptListsWidget.value;
|
||||
const selectedList = this._sourceValue(promptListsWidget);
|
||||
|
||||
api.fetchApi('/prompt_stash_saver/save', {
|
||||
method: 'POST',
|
||||
@@ -308,7 +401,7 @@ app.registerExtension({
|
||||
if (!canDelete()) return;
|
||||
|
||||
const deletedItemValue = loadSavedWidget.value;
|
||||
const selectedList = promptListsWidget.value;
|
||||
const selectedList = this._sourceValue(promptListsWidget);
|
||||
|
||||
// Get current list and find index of deleted item
|
||||
const currentList = loadSavedWidget.options.values;
|
||||
@@ -366,6 +459,7 @@ app.registerExtension({
|
||||
// item) isn't in the new options.
|
||||
syncValueToConnectedPrimitive(this, "load_saved");
|
||||
this._syncComboInput?.("load_saved");
|
||||
this._syncPromotedValues?.();
|
||||
|
||||
this.serialize_widgets = true;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
@@ -403,6 +497,7 @@ app.registerExtension({
|
||||
saveKeyWidget.value = "";
|
||||
this.isLoadingPrompt = false;
|
||||
this.serialize_widgets = true;
|
||||
this._syncPromotedValues?.();
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
// After clearing, also refresh Save/Delete states
|
||||
this._refreshSaveDeleteRow?.();
|
||||
@@ -413,7 +508,7 @@ app.registerExtension({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: value,
|
||||
list_name: promptListsWidget.value,
|
||||
list_name: this._sourceValue(promptListsWidget),
|
||||
node_id: this.id
|
||||
})
|
||||
}).then(response => response.json())
|
||||
@@ -424,6 +519,7 @@ app.registerExtension({
|
||||
saveKeyWidget.value = value;
|
||||
this.isLoadingPrompt = false;
|
||||
this.serialize_widgets = true;
|
||||
this._syncPromotedValues?.();
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
// After loading, refresh Save/Delete states (Save is now valid)
|
||||
this._refreshSaveDeleteRow?.();
|
||||
@@ -475,6 +571,7 @@ app.registerExtension({
|
||||
promptWidget.value = text;
|
||||
saveKeyWidget.value = "";
|
||||
this.serialize_widgets = true;
|
||||
this._syncPromotedValues?.();
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
// Update Save/Delete row after external prompt injection
|
||||
this._refreshSaveDeleteRow?.();
|
||||
@@ -525,6 +622,29 @@ app.registerExtension({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook every subgraph node so changes to its promoted widgets reach Prompt
|
||||
* Stash Savers inside it (see subgraph_widgets.js). Runs for containers on
|
||||
* creation, load and paste; the hook re-wraps whenever the frontend
|
||||
* rebuilds a promoted widget.
|
||||
*
|
||||
* @param {LGraphNode} node - The created node.
|
||||
*/
|
||||
nodeCreated(node) {
|
||||
if (isContainer(node)) installPromotedWidgetHooks(node);
|
||||
},
|
||||
|
||||
/**
|
||||
* After a workflow loads, (re)bind every container. Containers are created
|
||||
* before some of the nodes inside them finish configuring, so this pass
|
||||
* catches Savers that weren't ready when the container was created.
|
||||
*/
|
||||
afterConfigureGraph() {
|
||||
walkGraph(app.graph, (node) => {
|
||||
if (isContainer(node)) installPromotedWidgetHooks(node);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-populate the combo options after a global Refresh ("R" key /
|
||||
* refresh button). reloadNodeDefs resets every combo's options.values to
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Promoted subgraph widgets: finding the widget that really supplies a value,
|
||||
* and forwarding changes made on a subgraph node's widgets to the nodes inside.
|
||||
*
|
||||
* When a node's widget input inside a subgraph is linked to a subgraph input,
|
||||
* the frontend "promotes" it: the subgraph node (the container) shows its own
|
||||
* widget for that input. On current frontends (checked against 1.57) that
|
||||
* outer widget:
|
||||
* - keeps its OWN value (widgetValueStore, keyed by the container's id); the
|
||||
* inner widget keeps a stale value that the backend never sees -- at queue
|
||||
* time the outer value is substituted;
|
||||
* - got its options as a deep copy taken at promotion time, so later changes
|
||||
* to the inner widget's options never reach it;
|
||||
* - has a `callback` that only writes the store. The inner widget's callback
|
||||
* never runs, so node logic driven by it (Saver: load the selected prompt,
|
||||
* refilter the list) never runs either.
|
||||
*
|
||||
* Both renderers call the outer widget's `callback` when the user changes it
|
||||
* (classic canvas: BaseWidget.setValue; Vue: processedWidgetRenderModel's
|
||||
* update handler). Only the classic canvas also calls the container's
|
||||
* `onWidgetChanged`, so the callback is the hook. The outer widget object is
|
||||
* rebuilt on every (re)promotion (the subgraph then dispatches
|
||||
* `widget-promoted`) and also lazily, with no event, by the container's
|
||||
* `widgets` getter. Both are caught: the event re-binds, and each input's
|
||||
* `_widget` slot is watched (`trapInputWidget`).
|
||||
*
|
||||
* Inner nodes opt in by defining:
|
||||
* - `onPromotedWidgetChanged(widgetName, value, hostWidget)` -- the user
|
||||
* changed the outer widget;
|
||||
* - `onPromotedWidgetBound(widgetName, hostWidget)` (optional) -- the outer
|
||||
* widget was (re)created; sync state without side effects.
|
||||
*
|
||||
* Pure module: no `app` import, so it runs under Node with mock graphs
|
||||
* (tests/subgraph_widgets.test.mjs).
|
||||
*/
|
||||
|
||||
import { walkGraph } from "./utils.js";
|
||||
|
||||
/** Origin id of links that start at a subgraph's input node (a string since frontend 1.46). */
|
||||
export const SUBGRAPH_INPUT_ID = "-10";
|
||||
|
||||
/** Marks an outer widget whose callback has already been wrapped. */
|
||||
const WRAPPED = Symbol("promptStashPromotedWrap");
|
||||
|
||||
/** Marks a container input whose `_widget` slot is watched. */
|
||||
const TRAPPED = Symbol("promptStashPromotedSlot");
|
||||
|
||||
// ── Lookups ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The input slot that carries a widget (by `input.widget.name`, else by name).
|
||||
*
|
||||
* @param {Object} node - Node.
|
||||
* @param {string} name - Widget name.
|
||||
* @returns {Object|null} Input slot, or null.
|
||||
*/
|
||||
function widgetInput(node, name) {
|
||||
const inputs = node?.inputs ?? [];
|
||||
return inputs.find((i) => i?.widget?.name === name) ?? inputs.find((i) => i?.name === name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A graph's link by id.
|
||||
*
|
||||
* @param {Object} graph - Graph or subgraph.
|
||||
* @param {*} id - Link id.
|
||||
* @returns {Object|null} Link, or null.
|
||||
*/
|
||||
function linkOf(graph, id) {
|
||||
if (id == null) return null;
|
||||
try {
|
||||
return graph?.getLink?.(id) ?? (graph?.links instanceof Map ? graph.links.get(id) : graph?.links?.[id]) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a node is a subgraph container (SubgraphNode).
|
||||
*
|
||||
* @param {Object} node - Node.
|
||||
* @returns {boolean} True for containers.
|
||||
*/
|
||||
export function isContainer(node) {
|
||||
if (!node?.subgraph) return false;
|
||||
return typeof node.isSubgraphNode === "function" ? !!node.isSubgraphNode() : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container node whose subgraph is `graph`, searched from the root
|
||||
* through every nesting level. Each container has its own subgraph (copies
|
||||
* and clones get their own definition), so the first match is the one.
|
||||
*
|
||||
* @param {Object} graph - A subgraph.
|
||||
* @returns {Object|null} The container node, or null (root graph / not found).
|
||||
*/
|
||||
export function findContainer(graph) {
|
||||
const root = graph?.rootGraph;
|
||||
if (!root || root === graph) return null;
|
||||
let found = null;
|
||||
walkGraph(root, (node) => {
|
||||
if (!found && node.subgraph === graph) found = node;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outer widget a container shows for one of its inputs.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - One of its input slots.
|
||||
* @returns {Object|null} The widget, or null when the input has none.
|
||||
*/
|
||||
export function hostWidgetForInput(container, input) {
|
||||
if (!input) return null;
|
||||
if (input._widget) return input._widget;
|
||||
const name = input.widget?.name ?? input.name;
|
||||
return (container?.widgets ?? []).find((w) => w?.name === name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container input matching a subgraph input slot.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} subgraph - Its subgraph.
|
||||
* @param {number} slot - Subgraph input index (link `origin_slot`).
|
||||
* @returns {Object|null} Container input, or null.
|
||||
*/
|
||||
function containerInputForSlot(container, subgraph, slot) {
|
||||
const sgInput = subgraph?.inputs?.[slot];
|
||||
const inputs = container?.inputs ?? [];
|
||||
if (sgInput) {
|
||||
const bySlot = inputs.find((i) => i?._subgraphSlot === sgInput);
|
||||
if (bySlot) return bySlot;
|
||||
const byName = inputs.find((i) => i?.name === sgInput.name);
|
||||
if (byName) return byName;
|
||||
}
|
||||
return inputs[slot] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link starts at a virtual value node (Primitive), which copies its
|
||||
* value into the target widget and calls the widget's callback.
|
||||
*
|
||||
* @param {Object} graph - Graph holding the link.
|
||||
* @param {Object} link - The link.
|
||||
* @returns {boolean} True for Primitive-like origins.
|
||||
*/
|
||||
function isValueSourceLink(graph, link) {
|
||||
const origin = graph?.getNodeById?.(link.origin_id);
|
||||
return !!origin?.isVirtualNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a container input is fed from a subgraph input one level further
|
||||
* out (a nested container): its widget is hidden and stale, the outer one
|
||||
* counts.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - One of its input slots.
|
||||
* @returns {boolean} True when fed from an enclosing subgraph's input.
|
||||
*/
|
||||
function isFedFromEnclosingSubgraph(container, input) {
|
||||
if (input?.link == null) return false;
|
||||
const link = linkOf(container.graph, input.link);
|
||||
return !!link && String(link.origin_id) === SUBGRAPH_INPUT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the value of `node`'s widget `name` really comes from: the node and
|
||||
* input slot whose widget holds it.
|
||||
*
|
||||
* Normally the node itself (also when a Primitive or other node feeds it:
|
||||
* those copy their value into the widget). When the input is linked to a
|
||||
* subgraph input, it is the container's widget for that input, followed up
|
||||
* through nested containers -- and a Primitive feeding that container input
|
||||
* writes into the container's widget, so it stays the source.
|
||||
*
|
||||
* @param {Object} node - Node owning the widget.
|
||||
* @param {string} name - Widget name.
|
||||
* @returns {{node: Object, input: Object|null, widget: Object}|null} The
|
||||
* source; null when it can't be determined (e.g. the outermost container
|
||||
* input is fed by a non-Primitive node).
|
||||
*/
|
||||
export function resolveWidgetSource(node, name) {
|
||||
const own = (node?.widgets ?? []).find((w) => w?.name === name) ?? null;
|
||||
let current = node;
|
||||
let input = widgetInput(node, name);
|
||||
|
||||
/** @returns {Object|null} The result for the current hop. */
|
||||
const here = () => {
|
||||
const widget = current === node ? own : hostWidgetForInput(current, input);
|
||||
return widget ? { node: current, input, widget } : null;
|
||||
};
|
||||
|
||||
for (let guard = 0; guard < 32; guard++) {
|
||||
if (input?.link == null) return here();
|
||||
const link = linkOf(current.graph, input.link);
|
||||
if (!link) return here();
|
||||
if (String(link.origin_id) !== SUBGRAPH_INPUT_ID) {
|
||||
return current === node || isValueSourceLink(current.graph, link) ? here() : null;
|
||||
}
|
||||
|
||||
const container = findContainer(current.graph);
|
||||
if (!container) return null;
|
||||
const outer = containerInputForSlot(container, current.graph, link.origin_slot);
|
||||
if (!outer) return null;
|
||||
current = container;
|
||||
input = outer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget that actually supplies the value of `node`'s widget `name`
|
||||
* (see `resolveWidgetSource`).
|
||||
*
|
||||
* @param {Object} node - Node owning the widget.
|
||||
* @param {string} name - Widget name.
|
||||
* @returns {Object|null} The supplying widget, or null when unknown.
|
||||
*/
|
||||
export function getSourceWidget(node, name) {
|
||||
return resolveWidgetSource(node, name)?.widget ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real (non-container) nodes and widget names a container input feeds,
|
||||
* followed down through nested containers.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - One of its input slots.
|
||||
* @returns {Array<{node: Object, widgetName: string}>} Targets (may be empty).
|
||||
*/
|
||||
export function findPromotedTargets(container, input) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
|
||||
/**
|
||||
* @param {Object} host - Container node.
|
||||
* @param {Object} hostInput - Its input slot.
|
||||
* @param {number} depth - Nesting guard.
|
||||
*/
|
||||
const visit = (host, hostInput, depth) => {
|
||||
if (depth > 32 || !hostInput) return;
|
||||
const subgraph = host.subgraph;
|
||||
const index = (host.inputs ?? []).indexOf(hostInput);
|
||||
const sgInput =
|
||||
hostInput._subgraphSlot ??
|
||||
(subgraph?.inputs ?? []).find((i) => i?.name === hostInput.name) ??
|
||||
subgraph?.inputs?.[index];
|
||||
for (const linkId of sgInput?.linkIds ?? []) {
|
||||
const link = linkOf(subgraph, linkId);
|
||||
if (!link) continue;
|
||||
const target = subgraph.getNodeById?.(link.target_id);
|
||||
const targetInput = target?.inputs?.[link.target_slot];
|
||||
if (!target || !targetInput) continue;
|
||||
if (isContainer(target)) {
|
||||
visit(target, targetInput, depth + 1);
|
||||
continue;
|
||||
}
|
||||
const widgetName = targetInput.widget?.name ?? targetInput.name;
|
||||
const key = `${target.id}\u0000${widgetName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ node: target, widgetName });
|
||||
}
|
||||
};
|
||||
|
||||
visit(container, input, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Forwarding ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Targets of a container input that opted in to promoted-widget forwarding.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - Its input slot.
|
||||
* @returns {Array<{node: Object, widgetName: string}>} Opted-in targets.
|
||||
*/
|
||||
function listeningTargets(container, input) {
|
||||
return findPromotedTargets(container, input).filter(
|
||||
(t) => typeof t.node.onPromotedWidgetChanged === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a container's outer widgets so user changes reach the opted-in nodes
|
||||
* inside, and tell those nodes the widgets are (re)bound. Idempotent: an
|
||||
* already wrapped widget object is left alone. Widgets whose input feeds no
|
||||
* opted-in node are not touched, nor are inputs fed from an enclosing
|
||||
* subgraph's input (a nested container: its widget is hidden and stale, the
|
||||
* outermost container's widget is the one that counts). Inputs fed by a
|
||||
* Primitive ARE bound: the Primitive writes the widget and calls its callback.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function bindPromotedWidgets(container) {
|
||||
if (!isContainer(container)) return;
|
||||
for (const input of container.inputs ?? []) {
|
||||
if (!input) continue;
|
||||
trapInputWidget(container, input);
|
||||
bindInput(container, input);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one container input's outer widget (if it feeds an opted-in node) and
|
||||
* tell those nodes it is bound.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - One of its input slots.
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindInput(container, input) {
|
||||
if (isFedFromEnclosingSubgraph(container, input)) return;
|
||||
const host = hostWidgetForInput(container, input);
|
||||
if (!host) return;
|
||||
const targets = listeningTargets(container, input);
|
||||
if (!targets.length) return;
|
||||
|
||||
if (!host[WRAPPED]) wrapHostCallback(container, input, host);
|
||||
|
||||
for (const { node, widgetName } of targets) {
|
||||
try {
|
||||
node.onPromotedWidgetBound?.(widgetName, host);
|
||||
} catch (error) {
|
||||
console.error("PromptStash: promoted widget bind failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch an input's `_widget` slot so every new outer widget object gets bound.
|
||||
*
|
||||
* `widget-promoted` isn't enough: the frontend also rebuilds the outer widget
|
||||
* lazily -- the container's `widgets` getter projects a fresh store-backed
|
||||
* object into `input._widget` whenever it is empty (after a store id
|
||||
* migration, `promotionUtils` re-registration, ...) without dispatching any
|
||||
* event. Seen live on 1.57.6: after a page refresh the container was hooked
|
||||
* but its outer widgets were unwrapped, so outer changes went nowhere.
|
||||
*
|
||||
* The accessor keeps the frontend's semantics (plain get / set of the same
|
||||
* value). Binding is deferred to a microtask so it never runs in the middle
|
||||
* of the frontend's own projection / render.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - One of its input slots.
|
||||
* @returns {void}
|
||||
*/
|
||||
function trapInputWidget(container, input) {
|
||||
if (input[TRAPPED]) return;
|
||||
const desc = Object.getOwnPropertyDescriptor(input, "_widget");
|
||||
if (desc && (desc.get || desc.set || desc.configurable === false)) return;
|
||||
let current = input._widget;
|
||||
try {
|
||||
Object.defineProperty(input, "_widget", {
|
||||
configurable: true,
|
||||
enumerable: desc?.enumerable ?? true,
|
||||
get: () => current,
|
||||
set: (next) => {
|
||||
current = next;
|
||||
if (next && typeof next === "object" && !next[WRAPPED]) {
|
||||
queueMicrotask(() => {
|
||||
if (current === next && !next[WRAPPED]) bindInput(container, input);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
input[TRAPPED] = true;
|
||||
} catch (error) {
|
||||
console.warn("PromptStash: could not watch promoted widget slot", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an outer widget's callback with one that runs the original (which
|
||||
* stores the value) and then notifies the opted-in nodes inside. Targets are
|
||||
* looked up on every change, so relinking inside the subgraph is picked up.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @param {Object} input - The input slot the widget belongs to.
|
||||
* @param {Object} host - The outer widget.
|
||||
* @returns {void}
|
||||
*/
|
||||
function wrapHostCallback(container, input, host) {
|
||||
const original = host.callback;
|
||||
const wrapped = function (value, ...rest) {
|
||||
const result = original?.call(this, value, ...rest);
|
||||
// The original stored the value; read it back in case it normalised it.
|
||||
const current = host.value === undefined ? value : host.value;
|
||||
for (const { node, widgetName } of listeningTargets(container, input)) {
|
||||
try {
|
||||
node.onPromotedWidgetChanged(widgetName, current, host);
|
||||
} catch (error) {
|
||||
console.error("PromptStash: promoted widget change failed", error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
host.callback = wrapped;
|
||||
if (host.callback !== wrapped) {
|
||||
Object.defineProperty(host, "callback", { value: wrapped, writable: true, configurable: true });
|
||||
}
|
||||
host[WRAPPED] = true;
|
||||
} catch (error) {
|
||||
console.warn("PromptStash: could not hook promoted widget", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook a container so its outer widgets are (re)wrapped now and whenever the
|
||||
* frontend rebuilds them (`widget-promoted`). Listeners are dropped when the
|
||||
* container is removed. Safe to call more than once.
|
||||
*
|
||||
* @param {Object} container - Container node.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function installPromotedWidgetHooks(container) {
|
||||
if (!isContainer(container)) return;
|
||||
bindPromotedWidgets(container);
|
||||
if (container._promptStashPromotedHooks) return;
|
||||
|
||||
const events = container.subgraph?.events;
|
||||
if (typeof events?.addEventListener !== "function") return;
|
||||
|
||||
const controller = new AbortController();
|
||||
container._promptStashPromotedHooks = controller;
|
||||
events.addEventListener(
|
||||
"widget-promoted",
|
||||
(e) => {
|
||||
const host = e?.detail?.subgraphNode;
|
||||
if (host && host !== container) return;
|
||||
bindPromotedWidgets(container);
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
const onRemoved = container.onRemoved;
|
||||
container.onRemoved = function (...args) {
|
||||
controller.abort();
|
||||
container._promptStashPromotedHooks = null;
|
||||
return onRemoved?.apply(this, args);
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "confyui-node-prompt-stash-saver"
|
||||
description = "Prompt Stash is a simple plugin for ComfyUI that lets you save your prompts and organize them into multiple lists. It also features a pass-through functionality, so you can hook it up to an LLM node (or any text outputting node) and capture its outputs directly."
|
||||
version = "1.8.0"
|
||||
version = "1.8.1"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tests
|
||||
|
||||
Plain Node tests for the pure JS modules (no npm dependencies, no browser, no ComfyUI).
|
||||
|
||||
```sh
|
||||
node tests/subgraph_widgets.test.mjs # promoted subgraph widgets: source lookup + change forwarding
|
||||
```
|
||||
|
||||
Each script exits non-zero on failure.
|
||||
|
||||
## Files
|
||||
|
||||
- `workflowLoader.mjs` -- mock `LGraph` / `LGraphNode` stand-ins (`MockGraph`, `MockNode`, `makeContainer`) and `loadWorkflow(workflow)`, which builds them from a serialized workflow (UI format 0.4, root + `definitions.subgraphs`). Widgets come from `widgets_values_named`; a container's widgets are its promoted inputs, valued from its own `widgets_values_named`, with a callback that only stores the value (like the frontend's store-backed outer widgets). Adapted from ComfyUI-Enhancement-Utils.
|
||||
- `subgraph_widgets.test.mjs` -- `js/subgraph_widgets.js` against the fixture below and hand-built nested subgraphs: the widget that supplies a value (own widget, outer widget, outermost widget through nesting, unknown when the outer input is fed by another node), the nodes a container input feeds, wrapping / re-wrapping outer widget callbacks and forwarding changes to opted-in nodes.
|
||||
- `fixtures/saver_subgraph_promotion.json` -- the workflow from the subgraph bug report. One subgraph with two Prompt Stash Savers: "Promoted to subgraph" has `load_saved` / `prompt_lists` linked to subgraph inputs (outer values `SystemPrompt Klein 9B` / `WAN Video Prompts`, stale inner values `None` / `Flux`); "Primitive inside subgraph" is driven by two Primitive nodes inside the subgraph.
|
||||
|
||||
## Limits
|
||||
|
||||
The mocks cover graph structure only. Frontend behaviour still needs checking by hand (see "Testing" in `AGENTS.md`): that both renderers call the outer widget's callback, that writing `.value` / `.options` on it updates what is shown, and Vue re-rendering.
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
{
|
||||
"id": "630b04e2-a2ab-47ca-a484-a5b948558335",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Display Any (rgthree)",
|
||||
"pos": [
|
||||
4811.764867419244,
|
||||
5347.058239682933
|
||||
],
|
||||
"size": [
|
||||
179.640234375,
|
||||
76
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"dir": 3,
|
||||
"localized_name": "source",
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"aux_id": "phazei/rgthree-comfy",
|
||||
"ver": "66af6c30af8d8582097f28362871985fd23c3bf1",
|
||||
"Node name for S&R": "Display Any (rgthree)"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"output": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "217d9ec3-5667-4ef2-982c-645517e7f1ea",
|
||||
"pos": [
|
||||
4240,
|
||||
5490
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
120
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "load_saved",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "load_saved"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "prompt_lists",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "prompt_lists"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "text_1",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.43.0",
|
||||
"previewExposures": []
|
||||
},
|
||||
"widgets_values": [
|
||||
"SystemPrompt Klein 9B",
|
||||
"WAN Video Prompts"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"load_saved": "SystemPrompt Klein 9B",
|
||||
"prompt_lists": "WAN Video Prompts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "Display Any (rgthree)",
|
||||
"pos": [
|
||||
4810,
|
||||
5470
|
||||
],
|
||||
"size": [
|
||||
179.640234375,
|
||||
76
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"dir": 3,
|
||||
"localized_name": "source",
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"aux_id": "phazei/rgthree-comfy",
|
||||
"ver": "66af6c30af8d8582097f28362871985fd23c3bf1",
|
||||
"Node name for S&R": "Display Any (rgthree)"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"output": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
9,
|
||||
3,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "217d9ec3-5667-4ef2-982c-645517e7f1ea",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 7,
|
||||
"lastLinkId": 9,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
3690,
|
||||
5480,
|
||||
128,
|
||||
88
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
4870,
|
||||
5220,
|
||||
128,
|
||||
88
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0d2a87c2-0738-4cc3-9852-348632b25cc0",
|
||||
"name": "load_saved",
|
||||
"type": "COMBO",
|
||||
"linkIds": [
|
||||
3
|
||||
],
|
||||
"pos": [
|
||||
3794,
|
||||
5504
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "74587ca9-e8d9-4f9b-9418-987d05ef2367",
|
||||
"name": "prompt_lists",
|
||||
"type": "COMBO",
|
||||
"linkIds": [
|
||||
4
|
||||
],
|
||||
"pos": [
|
||||
3794,
|
||||
5524
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "3581d4cd-1cba-4128-9b67-c223fdaca5bc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
7
|
||||
],
|
||||
"localized_name": "text",
|
||||
"pos": [
|
||||
4894,
|
||||
5244
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3c027030-4583-4463-a9fa-27404487c73d",
|
||||
"name": "text_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
8
|
||||
],
|
||||
"pos": [
|
||||
4894,
|
||||
5264
|
||||
]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PromptStashSaver",
|
||||
"pos": [
|
||||
4020,
|
||||
5240
|
||||
],
|
||||
"size": [
|
||||
570,
|
||||
530
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "use_input_text",
|
||||
"name": "use_input_text",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "use_input_text"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "prompt_text",
|
||||
"name": "prompt_text",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt_text"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "save_as_key",
|
||||
"name": "save_as_key",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "save_as_key"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "load_saved",
|
||||
"name": "load_saved",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "load_saved"
|
||||
},
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "prompt_lists",
|
||||
"name": "prompt_lists",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "prompt_lists"
|
||||
},
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "meta",
|
||||
"name": "meta",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"title": "Promoted to subgraph",
|
||||
"properties": {
|
||||
"cnr_id": "confyui-node-prompt-stash-saver",
|
||||
"ver": "591aa08da43a510fe0d2a7579c4608c87e577668",
|
||||
"Node name for S&R": "PromptStashSaver"
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
"None",
|
||||
"Flux"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"use_input_text": false,
|
||||
"prompt_text": "",
|
||||
"save_as_key": "",
|
||||
"load_saved": "None",
|
||||
"prompt_lists": "Flux"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PromptStashSaver",
|
||||
"pos": [
|
||||
4010,
|
||||
4670
|
||||
],
|
||||
"size": [
|
||||
550,
|
||||
300
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "use_input_text",
|
||||
"name": "use_input_text",
|
||||
"shape": 7,
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "use_input_text"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "prompt_text",
|
||||
"name": "prompt_text",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt_text"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "save_as_key",
|
||||
"name": "save_as_key",
|
||||
"shape": 7,
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "save_as_key"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "load_saved",
|
||||
"name": "load_saved",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "load_saved"
|
||||
},
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "prompt_lists",
|
||||
"name": "prompt_lists",
|
||||
"shape": 7,
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "prompt_lists"
|
||||
},
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "meta",
|
||||
"name": "meta",
|
||||
"type": "STRING",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"title": "Primitive inside subgraph",
|
||||
"properties": {
|
||||
"cnr_id": "confyui-node-prompt-stash-saver",
|
||||
"ver": "591aa08da43a510fe0d2a7579c4608c87e577668",
|
||||
"Node name for S&R": "PromptStashSaver"
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
"You are an AI trained to generate detailed prompts for Stable Diffusion image generation. Your role is to convert simple image requests into a single rich, descriptive prompt. Generate a single prompt up to 70 words long. This is very important.\n\nFollow these rules for EVERY response:\n\n1. Output ONLY the single prompt text, without any quotation marks.\n2. Stable Diffusion does not understand figurative language - use plain, literal descriptions instead of metaphors, symbolism, or abstract phrasing.\n3. Always include these elements:\n * Main subject description\n * Setting or environment details\n * Lighting and atmospheric conditions\n * Multiple weighted modifiers in parentheses\n4. Weights must be between 1 and 2 only.\n5. Keep descriptions focused and concise.\n\nFormat your response like this:\n{detailed subject}, {detailed setting}, {lighting/atmosphere}, ({modifier:weight}), ({modifier:weight}), ({modifier:weight})\n\nDo not explain your response or add any commentary. Output only the single prompt text.\n",
|
||||
"SDXL Clean",
|
||||
"SDXL Clean",
|
||||
"SDXL Personas"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"use_input_text": false,
|
||||
"prompt_text": "You are an AI trained to generate detailed prompts for Stable Diffusion image generation. Your role is to convert simple image requests into a single rich, descriptive prompt. Generate a single prompt up to 70 words long. This is very important.\n\nFollow these rules for EVERY response:\n\n1. Output ONLY the single prompt text, without any quotation marks.\n2. Stable Diffusion does not understand figurative language - use plain, literal descriptions instead of metaphors, symbolism, or abstract phrasing.\n3. Always include these elements:\n * Main subject description\n * Setting or environment details\n * Lighting and atmospheric conditions\n * Multiple weighted modifiers in parentheses\n4. Weights must be between 1 and 2 only.\n5. Keep descriptions focused and concise.\n\nFormat your response like this:\n{detailed subject}, {detailed setting}, {lighting/atmosphere}, ({modifier:weight}), ({modifier:weight}), ({modifier:weight})\n\nDo not explain your response or add any commentary. Output only the single prompt text.\n",
|
||||
"save_as_key": "SDXL Clean",
|
||||
"load_saved": "SDXL Clean",
|
||||
"prompt_lists": "SDXL Personas"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [
|
||||
3520,
|
||||
4700
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "COMBO",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "load_saved"
|
||||
},
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "load_saved",
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [
|
||||
"SDXL Clean",
|
||||
"fixed",
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"value": "SDXL Clean",
|
||||
"control_after_generate": "fixed",
|
||||
"control_filter_list": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [
|
||||
3520,
|
||||
4890
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "COMBO",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "prompt_lists"
|
||||
},
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "prompt_lists",
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [
|
||||
"SDXL Personas",
|
||||
"randomize",
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"value": "SDXL Personas",
|
||||
"control_after_generate": "randomize",
|
||||
"control_filter_list": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 4,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 1,
|
||||
"target_slot": 5,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 4,
|
||||
"target_slot": 4,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": 4,
|
||||
"target_slot": 5,
|
||||
"type": "COMBO"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"comfyui_mcp": {
|
||||
"workflow_uuid": "a00e6b5a-255e-4fcc-b5cc-990e1974e1ea"
|
||||
},
|
||||
"ds": {
|
||||
"scale": 1.2100000000000006,
|
||||
"offset": [
|
||||
-3421.934126241228,
|
||||
-4812.350001294816
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Tests for js/subgraph_widgets.js: resolving the widget that supplies a
|
||||
* value (inner widget vs. a subgraph node's promoted widget) and forwarding
|
||||
* outer widget changes to the nodes inside.
|
||||
*
|
||||
* Uses the real workflow from the bug report
|
||||
* (`fixtures/saver_subgraph_promotion.json`) plus hand-built nested graphs.
|
||||
*
|
||||
* Run: node tests/subgraph_widgets.test.mjs
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { MockGraph, MockNode, findByTitle, loadWorkflow, makeContainer } from "./workflowLoader.mjs";
|
||||
|
||||
const {
|
||||
bindPromotedWidgets,
|
||||
findContainer,
|
||||
findPromotedTargets,
|
||||
getSourceWidget,
|
||||
installPromotedWidgetHooks,
|
||||
resolveWidgetSource,
|
||||
} = await import(new URL("../js/subgraph_widgets.js", import.meta.url).href);
|
||||
|
||||
// ── Test Runner ───────────────────────────────────────────────────────────
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
/**
|
||||
* Run one named test, recording pass / fail.
|
||||
*
|
||||
* @param {string} name - Test name.
|
||||
* @param {Function} fn - Test body (throws on failure).
|
||||
* @returns {void}
|
||||
*/
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` FAIL ${name}\n ${err.stack.split("\n").slice(0, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one named async test, recording pass / fail.
|
||||
*
|
||||
* @param {string} name - Test name.
|
||||
* @param {Function} fn - Async test body (rejects on failure).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function testAsync(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` FAIL ${name}\n ${err.stack.split("\n").slice(0, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WORKFLOW = JSON.parse(readFileSync(new URL("./fixtures/saver_subgraph_promotion.json", import.meta.url), "utf8"));
|
||||
|
||||
/**
|
||||
* Load the bug-report workflow.
|
||||
*
|
||||
* @returns {{root: MockGraph, container: MockNode, promoted: MockNode, primitiveDriven: MockNode}}
|
||||
*/
|
||||
function bugWorkflow() {
|
||||
const { root } = loadWorkflow(WORKFLOW);
|
||||
const container = root.nodes.find((n) => n.subgraph);
|
||||
return {
|
||||
root,
|
||||
container,
|
||||
promoted: findByTitle(root, "Promoted to subgraph"),
|
||||
primitiveDriven: findByTitle(root, "Primitive inside subgraph"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a node the opt-in hooks and record every call.
|
||||
*
|
||||
* @param {MockNode} node - Node to decorate.
|
||||
* @returns {{changed: Array, bound: Array}} Recorded calls.
|
||||
*/
|
||||
function listen(node) {
|
||||
const calls = { changed: [], bound: [] };
|
||||
node.onPromotedWidgetChanged = (name, value, host) => calls.changed.push({ name, value, host });
|
||||
node.onPromotedWidgetBound = (name, host) => calls.bound.push({ name, host });
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Saver-like node with a `load_saved` widget input.
|
||||
*
|
||||
* @param {*} id - Node id.
|
||||
* @param {string} [value] - Inner widget value.
|
||||
* @returns {MockNode} The node.
|
||||
*/
|
||||
function saverLike(id, value = "None") {
|
||||
return new MockNode(id, {
|
||||
type: "PromptStashSaver",
|
||||
inputs: [{ name: "load_saved", widget: { name: "load_saved" } }],
|
||||
widgets: { load_saved: value },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A container node with one promoted `load_saved` input.
|
||||
*
|
||||
* @param {*} id - Node id.
|
||||
* @param {MockGraph} subgraph - Its subgraph.
|
||||
* @param {string} [value] - Outer widget value.
|
||||
* @returns {MockNode} The container.
|
||||
*/
|
||||
function containerLike(id, subgraph, value) {
|
||||
const node = new MockNode(id, { inputs: [{ name: "load_saved", widget: { name: "load_saved" } }] });
|
||||
return makeContainer(node, subgraph, { load_saved: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Root -> container A (outer widget "Outer") -> container B (input fed by
|
||||
* A's subgraph input) -> Saver (input fed by B's subgraph input).
|
||||
*
|
||||
* @returns {{root: MockGraph, a: MockNode, b: MockNode, saver: MockNode}}
|
||||
*/
|
||||
function nestedGraph() {
|
||||
const root = new MockGraph("root");
|
||||
root.subgraphs = new Map();
|
||||
const sa = new MockGraph("sa", root);
|
||||
const sb = new MockGraph("sb", root);
|
||||
root.subgraphs.set("sa", sa);
|
||||
root.subgraphs.set("sb", sb);
|
||||
|
||||
const a = root.add(containerLike(1, sa, "Outer"));
|
||||
const b = sa.add(containerLike(2, sb, "Stale middle"));
|
||||
const saver = sb.add(saverLike(3, "Stale inner"));
|
||||
|
||||
sa.addInput("load_saved");
|
||||
sa.connect("-10", 0, b, 0);
|
||||
sb.addInput("load_saved");
|
||||
sb.connect("-10", 0, saver, 0);
|
||||
return { root, a, b, saver };
|
||||
}
|
||||
|
||||
// ── Source Widget (bug-report workflow) ───────────────────────────────────
|
||||
|
||||
test("fixture: containers are found for the subgraph", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
assert.ok(container, "container present");
|
||||
assert.equal(findContainer(promoted.graph), container);
|
||||
assert.equal(findContainer(container.graph), null, "root graph has no container");
|
||||
});
|
||||
|
||||
test("fixture: promoted Saver reads the outer widget, not its stale inner value", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
assert.equal(promoted.widget("load_saved").value, "None");
|
||||
assert.equal(promoted.widget("prompt_lists").value, "Flux");
|
||||
|
||||
const loadSaved = getSourceWidget(promoted, "load_saved");
|
||||
const lists = getSourceWidget(promoted, "prompt_lists");
|
||||
assert.equal(loadSaved, container.widgets.find((w) => w.name === "load_saved"));
|
||||
assert.equal(loadSaved.value, "SystemPrompt Klein 9B");
|
||||
assert.equal(lists.value, "WAN Video Prompts");
|
||||
});
|
||||
|
||||
test("fixture: unlinked widgets resolve to themselves", () => {
|
||||
const { promoted } = bugWorkflow();
|
||||
assert.equal(getSourceWidget(promoted, "prompt_text"), promoted.widget("prompt_text"));
|
||||
assert.equal(getSourceWidget(promoted, "save_as_key"), promoted.widget("save_as_key"));
|
||||
});
|
||||
|
||||
test("fixture: Primitive-driven Saver reads its own widget", () => {
|
||||
const { primitiveDriven } = bugWorkflow();
|
||||
assert.equal(getSourceWidget(primitiveDriven, "load_saved"), primitiveDriven.widget("load_saved"));
|
||||
assert.equal(getSourceWidget(primitiveDriven, "load_saved").value, "SDXL Clean");
|
||||
assert.equal(getSourceWidget(primitiveDriven, "prompt_lists").value, "SDXL Personas");
|
||||
});
|
||||
|
||||
test("fixture: unknown widget name resolves to null", () => {
|
||||
const { promoted } = bugWorkflow();
|
||||
assert.equal(getSourceWidget(promoted, "nope"), null);
|
||||
});
|
||||
|
||||
// ── Targets (bug-report workflow) ─────────────────────────────────────────
|
||||
|
||||
test("fixture: each container input feeds only the promoted Saver", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
for (const name of ["load_saved", "prompt_lists"]) {
|
||||
const input = container.inputs.find((i) => i.name === name);
|
||||
const targets = findPromotedTargets(container, input);
|
||||
assert.equal(targets.length, 1, name);
|
||||
assert.equal(targets[0].node, promoted, name);
|
||||
assert.equal(targets[0].widgetName, name);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Forwarding ────────────────────────────────────────────────────────────
|
||||
|
||||
test("bind: opted-in node is told about its outer widgets", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
const calls = listen(promoted);
|
||||
bindPromotedWidgets(container);
|
||||
assert.deepEqual(calls.bound.map((c) => c.name).sort(), ["load_saved", "prompt_lists"]);
|
||||
assert.equal(calls.bound.find((c) => c.name === "load_saved").host.value, "SystemPrompt Klein 9B");
|
||||
assert.equal(calls.changed.length, 0, "binding is not a change");
|
||||
});
|
||||
|
||||
test("bind: outer change stores the value, then notifies the node inside", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
const calls = listen(promoted);
|
||||
bindPromotedWidgets(container);
|
||||
const host = container.widgets.find((w) => w.name === "load_saved");
|
||||
|
||||
let storedFirst = false;
|
||||
promoted.onPromotedWidgetChanged = (name, value) => {
|
||||
storedFirst = host.value === "Other";
|
||||
calls.changed.push({ name, value });
|
||||
};
|
||||
host.callback("Other");
|
||||
|
||||
assert.equal(host.value, "Other");
|
||||
assert.ok(storedFirst, "original callback ran before the notification");
|
||||
assert.deepEqual(calls.changed, [{ name: "load_saved", value: "Other" }]);
|
||||
});
|
||||
|
||||
test("bind: is idempotent (one notification per change)", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
const calls = listen(promoted);
|
||||
bindPromotedWidgets(container);
|
||||
bindPromotedWidgets(container);
|
||||
container.widgets.find((w) => w.name === "prompt_lists").callback("SDXL Personas");
|
||||
assert.equal(calls.changed.length, 1);
|
||||
});
|
||||
|
||||
test("bind: widgets feeding no opted-in node are left alone", () => {
|
||||
const { container } = bugWorkflow();
|
||||
const before = container.widgets.map((w) => w.callback);
|
||||
bindPromotedWidgets(container);
|
||||
assert.deepEqual(container.widgets.map((w) => w.callback), before);
|
||||
});
|
||||
|
||||
test("hooks: a rebuilt outer widget is wrapped again on widget-promoted", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
const calls = listen(promoted);
|
||||
installPromotedWidgetHooks(container);
|
||||
|
||||
// The frontend replaces the outer widget object on re-promotion.
|
||||
const index = container.widgets.findIndex((w) => w.name === "load_saved");
|
||||
const fresh = { name: "load_saved", value: "Fresh", options: { values: [] } };
|
||||
fresh.callback = (next) => {
|
||||
fresh.value = next;
|
||||
};
|
||||
container.widgets[index] = fresh;
|
||||
container.subgraph.events.dispatchEvent(
|
||||
new CustomEvent("widget-promoted", { detail: { subgraphNode: container } })
|
||||
);
|
||||
|
||||
fresh.callback("After");
|
||||
assert.deepEqual(calls.changed.map((c) => [c.name, c.value]), [["load_saved", "After"]]);
|
||||
});
|
||||
|
||||
await testAsync("hooks: an outer widget rebuilt silently into input._widget is wrapped", async () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
const calls = listen(promoted);
|
||||
const input = container.inputs.find((i) => i.name === "load_saved");
|
||||
input._widget = container.widgets.find((w) => w.name === "load_saved");
|
||||
installPromotedWidgetHooks(container);
|
||||
|
||||
// The frontend's widgets getter re-projects a fresh object, no event.
|
||||
const fresh = { name: "load_saved", value: "Lazy", options: { values: [] } };
|
||||
fresh.callback = (next) => {
|
||||
fresh.value = next;
|
||||
};
|
||||
input._widget = fresh;
|
||||
assert.equal(input._widget, fresh, "slot still reads back what was written");
|
||||
await Promise.resolve();
|
||||
|
||||
fresh.callback("Changed");
|
||||
assert.equal(fresh.value, "Changed");
|
||||
assert.deepEqual(calls.changed.map((c) => [c.name, c.value]), [["load_saved", "Changed"]]);
|
||||
});
|
||||
|
||||
test("hooks: removing the container drops the listener", () => {
|
||||
const { container, promoted } = bugWorkflow();
|
||||
const calls = listen(promoted);
|
||||
installPromotedWidgetHooks(container);
|
||||
container.onRemoved();
|
||||
calls.bound.length = 0;
|
||||
container.subgraph.events.dispatchEvent(
|
||||
new CustomEvent("widget-promoted", { detail: { subgraphNode: container } })
|
||||
);
|
||||
assert.equal(calls.bound.length, 0);
|
||||
});
|
||||
|
||||
// ── Nested Subgraphs ──────────────────────────────────────────────────────
|
||||
|
||||
test("nested: source is the outermost container's widget", () => {
|
||||
const { a, saver } = nestedGraph();
|
||||
const source = getSourceWidget(saver, "load_saved");
|
||||
assert.equal(source, a.widgets[0]);
|
||||
assert.equal(source.value, "Outer");
|
||||
});
|
||||
|
||||
test("nested: targets are followed down to the real node", () => {
|
||||
const { a, saver } = nestedGraph();
|
||||
const targets = findPromotedTargets(a, a.inputs[0]);
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0].node, saver);
|
||||
assert.equal(targets[0].widgetName, "load_saved");
|
||||
});
|
||||
|
||||
test("nested: only the outermost widget is bound and forwarded", () => {
|
||||
const { a, b, saver } = nestedGraph();
|
||||
const calls = listen(saver);
|
||||
const middleCallback = b.widgets[0].callback;
|
||||
|
||||
bindPromotedWidgets(b);
|
||||
assert.equal(b.widgets[0].callback, middleCallback, "linked middle input not wrapped");
|
||||
assert.equal(calls.bound.length, 0);
|
||||
|
||||
bindPromotedWidgets(a);
|
||||
assert.equal(calls.bound.length, 1);
|
||||
assert.equal(calls.bound[0].host, a.widgets[0]);
|
||||
|
||||
a.widgets[0].callback("Picked");
|
||||
assert.deepEqual(calls.changed.map((c) => [c.name, c.value]), [["load_saved", "Picked"]]);
|
||||
});
|
||||
|
||||
test("nested: outermost input fed by another node -> unknown source", () => {
|
||||
const { root, a, saver } = nestedGraph();
|
||||
const feeder = root.add(new MockNode(9, { outputs: [{ name: "out" }] }));
|
||||
root.connect(feeder, 0, a, 0);
|
||||
assert.equal(getSourceWidget(saver, "load_saved"), null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Root -> Primitive -> container A's input -> Saver inside.
|
||||
*
|
||||
* @returns {{root: MockGraph, a: MockNode, saver: MockNode, primitive: MockNode}}
|
||||
*/
|
||||
function primitiveFedContainer() {
|
||||
const root = new MockGraph("root");
|
||||
root.subgraphs = new Map();
|
||||
const sa = new MockGraph("sa", root);
|
||||
root.subgraphs.set("sa", sa);
|
||||
const a = root.add(containerLike(1, sa, "Outer"));
|
||||
const saver = sa.add(saverLike(2, "Inner"));
|
||||
sa.addInput("load_saved");
|
||||
sa.connect("-10", 0, saver, 0);
|
||||
const primitive = root.add(new MockNode(5, { type: "PrimitiveNode", outputs: [{ name: "COMBO" }] }));
|
||||
primitive.isVirtualNode = true;
|
||||
root.connect(primitive, 0, a, 0);
|
||||
return { root, a, saver, primitive };
|
||||
}
|
||||
|
||||
test("primitive-fed container: source is the container's widget", () => {
|
||||
const { a, saver } = primitiveFedContainer();
|
||||
assert.equal(getSourceWidget(saver, "load_saved"), a.widgets[0]);
|
||||
const source = resolveWidgetSource(saver, "load_saved");
|
||||
assert.equal(source.node, a);
|
||||
assert.equal(source.widget.name, "load_saved");
|
||||
});
|
||||
|
||||
test("primitive-fed container: widget is bound and forwards the Primitive's writes", () => {
|
||||
const { a, saver } = primitiveFedContainer();
|
||||
const calls = listen(saver);
|
||||
bindPromotedWidgets(a);
|
||||
assert.equal(calls.bound.length, 1);
|
||||
// What the Primitive's applyToGraph does: set the value, call the callback.
|
||||
a.widgets[0].value = "FromPrimitive";
|
||||
a.widgets[0].callback(a.widgets[0].value);
|
||||
assert.deepEqual(calls.changed.map((c) => [c.name, c.value]), [["load_saved", "FromPrimitive"]]);
|
||||
});
|
||||
|
||||
test("direct link from a node (Primitive) keeps the widget itself", () => {
|
||||
const g = new MockGraph("root");
|
||||
const primitive = g.add(new MockNode(1, { type: "PrimitiveNode", outputs: [{ name: "COMBO" }] }));
|
||||
const saver = g.add(saverLike(2, "Chosen"));
|
||||
g.connect(primitive, 0, saver, 0);
|
||||
assert.equal(getSourceWidget(saver, "load_saved"), saver.widget("load_saved"));
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed) process.exit(1);
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user