feat: support subgraph instances in node context menu

- Detect subgraph instance scope and distinguish top-level vs nested nodes
- Aggregate inner resolved models for outer subgraph instances so menus show
  promoted and hidden models together
- Patch both class prototypes and individual node instances when they define
  getExtraMenuOptions or getMenuOptions, preserving native menu hooks
- Re-inject context menu after loadedGraphNode and queueMicrotask for
  subgraph-created nodes
- Add tests for menu injection on overridden getMenuOptions and instance
  patching, plus subgraph scope and aggregation behavior
This commit is contained in:
Dariusz L
2026-08-03 21:05:49 +02:00
parent 0f70d85f56
commit 514b7d5114
5 changed files with 392 additions and 41 deletions
+101
View File
@@ -1195,6 +1195,107 @@ test('node context integration preserves existing menu hooks and refreshes after
]);
});
test('node context integration injects the menu when a node overrides getMenuOptions', () => {
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
class NodeType {}
NodeType.prototype.getMenuOptions = function() {
return [{ content: 'Native subgraph action' }];
};
const resolver = {
getResolvedModelsForNodeContextMenu: () => [{
node_id: 30,
widget_index: 0,
original_path: 'model.safetensors',
full_path: 'E:\\models\\model.safetensors',
category: 'checkpoints',
exists: true,
}],
showResolvedNodeModelInResolver: () => {},
resolveNodeContextMenuModel: model => model,
dialog: {
getCategoryDisplayName: () => 'checkpoint',
showModelInfo: () => {},
openContainingFolder: () => {},
},
};
configureNodeContextMenu.call(resolver, NodeType);
const options = new NodeType().getMenuOptions(null);
assert.equal(options[0].title, 'Model Resolver');
assert.equal(options[1].content, 'Native subgraph action');
});
test('node context integration patches instance menu hooks installed by subgraph handlers', () => {
const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`);
class NodeType {}
const node = new NodeType();
node.getExtraMenuOptions = function(_canvas, options) {
options.push({ content: 'Native subgraph action' });
return [];
};
const resolver = {
getResolvedModelsForNodeContextMenu: () => [{
node_id: 31,
widget_index: 0,
original_path: 'model.safetensors',
full_path: 'E:\\models\\model.safetensors',
category: 'checkpoints',
exists: true,
}],
showResolvedNodeModelInResolver: () => {},
resolveNodeContextMenuModel: model => model,
dialog: {
getCategoryDisplayName: () => 'checkpoint',
showModelInfo: () => {},
openContainingFolder: () => {},
},
};
configureNodeContextMenu.call(resolver, NodeType);
configureNodeContextMenu.call(resolver, node);
const options = [];
node.getExtraMenuOptions(null, options);
assert.equal(options[0].title, 'Model Resolver');
assert.equal(options[1].content, 'Native subgraph action');
});
test('node context scope identifies an outer subgraph instance', () => {
const getSubgraphDefinitionIdForNode = eval(
`(${extractMethod(modelResolverSource, 'getSubgraphDefinitionIdForNode')})`
);
const getNodeContextScope = eval(
`(${extractMethod(modelResolverSource, 'getNodeContextScope')})`
);
const rootGraph = {};
const previousApp = globalThis.app;
globalThis.app = { graph: rootGraph };
try {
const scope = getNodeContextScope.call(
{ getSubgraphDefinitionIdForNode },
{ type: 'subgraph-a', graph: rootGraph },
{ definitions: { subgraphs: [{ id: 'subgraph-a' }] } },
);
assert.deepEqual(scope, {
is_top_level: true,
subgraph_id: 'subgraph-a',
subgraph_instance_id: 'subgraph-a',
is_subgraph_instance: true,
});
} finally {
if (previousApp === undefined) {
delete globalThis.app;
} else {
globalThis.app = previousApp;
}
}
});
test('custom node registry exposes normalized LoRA Manager entries', () => {
const node = {
comfyClass: 'Lora Loader (LoraManager)',
+56
View File
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
buildModelResolverNodeMenu,
getImmediateModelsForNode,
getModelsForSubgraphInstance,
getResolvedModelsForNode,
isExistingResolvedModel,
matchesWorkflowModelReference,
@@ -46,6 +47,61 @@ test('node context menu filters existing models by path and workflow scope', ()
assert.deepEqual(models.map(item => item.widget_index), [1, 2]);
});
test('node context menu aggregates inner models for an outer subgraph instance', () => {
const models = getModelsForSubgraphInstance({
resolved_models: [
{
node_id: 7,
widget_index: 0,
original_path: 'promoted.safetensors',
full_path: 'C:\\promoted',
is_top_level: true,
subgraph_id: 'subgraph-a',
promoted_inner_node_id: 10,
promoted_inner_widget_index: 0,
exists: true,
},
{
node_id: 10,
widget_index: 0,
original_path: 'promoted.safetensors',
full_path: 'C:\\promoted',
is_top_level: false,
subgraph_id: 'subgraph-a',
exists: true,
},
{
node_id: 11,
widget_index: 1,
original_path: 'hidden.safetensors',
full_path: 'C:\\hidden',
is_top_level: false,
subgraph_id: 'subgraph-a',
exists: true,
},
{
node_id: 12,
widget_index: 0,
original_path: 'other.safetensors',
full_path: 'C:\\other',
is_top_level: false,
subgraph_id: 'subgraph-b',
exists: true,
},
],
}, 7, {
is_top_level: true,
subgraph_id: 'subgraph-a',
subgraph_instance_id: 'subgraph-a',
is_subgraph_instance: true,
});
assert.deepEqual(
models.map(model => model.original_path),
['promoted.safetensors', 'hidden.safetensors'],
);
});
test('node context menu resolves immediate widget selections and ignores invalid references', () => {
const node = {
id: 7,