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:
@@ -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)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user