* feat(nodes): let a link drive the prompt text of every PromptManager node The Focused, Split and Pair editors hide their widgets, and ComfyUI only draws a widget's input socket while the widget is visible. `text` (and the Pair's `negative_text`) therefore had no socket, and a STRING link dropped on the editor fell through to the first free STRING input, `prepend_text`, which is how #342's workflow was wired. - Detach those inputs from their hidden widgets on create and after a workflow load, so they are ordinary sockets in Classic and Nodes 2.0. The schema, widget order and saved values are unchanged. - A STRING link dropped on the editor body fills the main prompt, then the negative, before the frontend's own choice. - Each editor says when its text comes from a link; the typed text is kept for disconnect. The Classic nodes now save the typed text under a link instead of null, and "use prompt" no longer overwrites it while linked. - Real-host tests: mouse drags in both renderers, a workflow saved before this change, and executions proving the linked text is saved, counted, recorded in history and linked to the run's image. Closes #343 * test(coverage): floor for the linked-text module * test(nodes): PromptSearchList text takes a dragged link like a core widget * fix(nodes): linked text review round for #343 - A legacy frontend Primitive linked to an editor's text or the Pair's negative now runs with its value: the graphToPrompt wrapper and the queue hook fill it in (through virtual reroutes), in new and pre-#343 workflows. - Moving a linked text and releasing it on the editor keeps it on `text`: a link being moved counts as free, as the frontend counts it. - A link dropped on a prompt box (or its link note) fills that box's input, so the Pair's Negative prompt box takes `negative_text` (Classic through getInputOnPos, Nodes 2.0 through the box's slot key). - Only exact STRING links are steered; wildcards keep the frontend choice. - A linked Classic text box gives its row back and gets it back on disconnect; the PromptManagerText note uses the theme colour. - The metadata parsers map positional widget values through the widget table when an editor's widget-bound inputs do not line up with it. - Real-host cases for the legacy Primitive (new and saved workflows, a real Run), the moved link, the Negative box, paste/clone/undo and Split. * fix(nodes): resolve a legacy Primitive's text like the host, once per run (#343) - Inputs still bound to their widget (Classic PromptManager/PromptManagerText) are left as the host resolved them; the injection no longer overwrites them. - For the editors' detached text inputs, the Primitive's value goes through its "Run widget replace on values" replacement and the hidden widget's own serializer, so {a|b} wildcards and %date% tokens resolve as on main. - Each queued output is filled once (graphToPrompt wrapper, or the queue hook for an output that did not come through it): a wildcard is drawn once. - Editors inside subgraphs are found under their <subgraph>:<inner> id. - Real-host runPrompt installs its request route before the run starts. - Shared parser case pins the widget-table fallback for a core KSampler. * fix(nodes): pin the Classic wildcard behaviour and match queued ids by type (#343) - Classic PromptManager/PromptManagerText expose _pmTextSerializerReady, settled once their own text serializer is installed; the real-host specs wait on it and assert the Classic text is queued as written, as on main. - The Primitive fill only touches an output entry whose class_type matches the graph node at that id. - TESTING.md notes the host's harmless "Invalid widget" warning per Run.
202 lines
12 KiB
JavaScript
202 lines
12 KiB
JavaScript
// #343: a link can drive the editor nodes' text inputs (web/js/text-links.js).
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const source = import('../../web/js/text-links.js');
|
|
|
|
// The frontend's own slot order for the Focused/Split/Pair schemas: forceInput sockets first, then the widget sockets.
|
|
function pairNode() {
|
|
const inputs = [
|
|
{name:'clip',type:'CLIP',link:null},
|
|
{name:'prepend_text',type:'STRING',link:null},
|
|
{name:'append_text',type:'STRING',link:null},
|
|
{name:'text',type:'STRING',link:null,widget:{name:'text'},_widget:{name:'text'}},
|
|
{name:'negative_text',type:'STRING',link:null,widget:{name:'negative_text'}},
|
|
{name:'category',type:'STRING',link:null,widget:{name:'category'}},
|
|
];
|
|
return {inputs};
|
|
}
|
|
|
|
test('the editor note says the value comes from a link and that typed text is kept', async () => {
|
|
const {linkNoteText} = await source;
|
|
assert.equal(linkNoteText('Negative prompt'), 'Negative prompt comes from a link. Your typed text is kept for when you disconnect it.');
|
|
});
|
|
|
|
test('a linked widget stores its typed value; an unlinked one its live editor text', async () => {
|
|
const {storedWidgetValue} = await source;
|
|
const widget = {name:'text', value:'typed', inputEl:{value:'live'}};
|
|
assert.equal(storedWidgetValue({inputs:[{name:'text',link:3}]}, widget), 'typed');
|
|
assert.equal(storedWidgetValue({inputs:[{name:'text',link:null}]}, widget), 'live');
|
|
assert.equal(storedWidgetValue({}, {name:'tags', value:'a, b'}), 'a, b');
|
|
});
|
|
|
|
test('exposing a socket detaches only the named inputs from their hidden widgets', async () => {
|
|
const {exposeTextSockets} = await source;
|
|
const node = pairNode();
|
|
assert.deepEqual(exposeTextSockets(node, ['text','negative_text']), ['text','negative_text']);
|
|
const byName = Object.fromEntries(node.inputs.map(input => [input.name,input]));
|
|
for (const name of ['text','negative_text']) {
|
|
assert.equal('widget' in byName[name], false);
|
|
assert.equal(byName[name]._widget, undefined);
|
|
assert.equal(byName[name].type, 'STRING');
|
|
}
|
|
assert.deepEqual(byName.category.widget, {name:'category'});
|
|
assert.deepEqual(exposeTextSockets(node, ['text','negative_text']), [], 'idempotent');
|
|
assert.deepEqual(exposeTextSockets({}, ['text']), [], 'a node without inputs is left alone');
|
|
});
|
|
|
|
test('exposing a socket keeps an existing link and the widget value', async () => {
|
|
const {exposeTextSockets,isLinked} = await source;
|
|
const node = pairNode();
|
|
node.inputs[3].link = 12;
|
|
exposeTextSockets(node, ['text']);
|
|
assert.equal(node.inputs[3].link, 12);
|
|
assert.equal(isLinked(node,'text'), true);
|
|
assert.equal(isLinked(node,'negative_text'), false);
|
|
assert.equal(isLinked({},'text'), false);
|
|
});
|
|
|
|
test('a STRING link dropped on the editor prefers the first free text input over prepend', async () => {
|
|
const {preferredTextSlot} = await source;
|
|
const node = pairNode();
|
|
assert.deepEqual(preferredTextSlot(node, 'STRING', ['text','negative_text']), {index:3,slot:node.inputs[3]});
|
|
node.inputs[3].link = 4;
|
|
assert.deepEqual(preferredTextSlot(node, 'STRING', ['text','negative_text']), {index:4,slot:node.inputs[4]});
|
|
node.inputs[4].link = 5;
|
|
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined, 'both linked: the frontend chooses');
|
|
});
|
|
|
|
test('only exact STRING links are steered; wildcards keep the frontend choice (#343 review M2)', async () => {
|
|
const {preferredTextSlot} = await source;
|
|
const node = pairNode();
|
|
for (const type of ['STRING','STRING,INT',' STRING ']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type);
|
|
for (const type of ['*','','CLIP','INT','IMAGE',undefined,null,0]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type));
|
|
assert.equal(preferredTextSlot({}, 'STRING', ['text']), undefined);
|
|
});
|
|
|
|
test('a link being moved off a text input leaves it free, as the frontend counts it (#343 review I2)', async () => {
|
|
const {preferredTextSlot} = await source;
|
|
const links = new Map([[7,{id:7,_dragging:true}],[8,{id:8}]]);
|
|
const node = Object.assign(pairNode(), {graph:{getLink:id => links.get(id)}});
|
|
node.inputs[3].link = 7;
|
|
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'text', 'the dragged link does not block text');
|
|
links.get(7)._dragging = false;
|
|
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text');
|
|
node.inputs[4].link = 8;
|
|
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined);
|
|
node.inputs[4].link = 99;
|
|
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text', 'a link the graph no longer has is free');
|
|
const legacy = Object.assign(pairNode(), {graph:{links:{5:{id:5,_dragging:true}}}});
|
|
legacy.inputs[3].link = 5;
|
|
assert.equal(preferredTextSlot(legacy, 'STRING', ['text']).slot.name, 'text', 'object-keyed link tables too');
|
|
});
|
|
|
|
function primitiveGraph() {
|
|
const nodes = new Map();
|
|
const links = new Map();
|
|
const graph = {getNodeById:id => nodes.get(id), getLink:id => links.get(id)};
|
|
const add = (id, node) => { nodes.set(id, {id, graph, ...node}); return nodes.get(id); };
|
|
return {graph, nodes, links, add};
|
|
}
|
|
|
|
test('a legacy frontend Primitive feeding a detached text input supplies its value (#343 review I1)', async () => {
|
|
const {primitiveTextValue} = await source;
|
|
const {nodes, links, add} = primitiveGraph();
|
|
const target = add(1, {inputs:[{name:'text',link:10},{name:'negative_text',link:11},{name:'prepend_text',link:12},{name:'category',link:null}]});
|
|
add(2, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:'From the primitive'}]});
|
|
add(3, {type:'Reroute', isVirtualNode:true, inputs:[{link:13}]});
|
|
add(4, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:42}]});
|
|
add(5, {type:'PrimitiveStringMultiline', widgets:[{value:'a real node'}]});
|
|
links.set(10, {origin_id:2}); links.set(11, {origin_id:3}); links.set(13, {origin_id:4}); links.set(12, {origin_id:5});
|
|
assert.equal(await primitiveTextValue(target, 'text'), 'From the primitive');
|
|
assert.equal(await primitiveTextValue(target, 'negative_text'), '42', 'through a virtual reroute');
|
|
assert.equal(await primitiveTextValue(target, 'prepend_text'), undefined, 'a real node is serialized as a link');
|
|
assert.equal(await primitiveTextValue(target, 'category'), undefined, 'not linked');
|
|
assert.equal(await primitiveTextValue(target, 'missing'), undefined);
|
|
nodes.get(2).widgets = [];
|
|
assert.equal(await primitiveTextValue(target, 'text'), undefined, 'a primitive without its widget yet');
|
|
links.set(13, {origin_id:3});
|
|
assert.equal(await primitiveTextValue(target, 'negative_text'), undefined, 'a reroute cycle ends');
|
|
assert.equal(await primitiveTextValue({inputs:[{name:'text',link:1}]}, 'text'), undefined, 'no graph');
|
|
});
|
|
|
|
test('an input still bound to its widget is left to the host, which applies the Primitive itself (#343 re-review R1)', async () => {
|
|
const {primitiveTextValue} = await source;
|
|
const {links, add} = primitiveGraph();
|
|
const target = add(1, {inputs:[{name:'text',link:10,widget:{name:'text'}}], widgets:[{name:'text',value:'typed'}]});
|
|
add(2, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:'{red|blue}'}]});
|
|
links.set(10, {origin_id:2});
|
|
assert.equal(await primitiveTextValue(target, 'text'), undefined);
|
|
});
|
|
|
|
test('a detached input runs the Primitive value the way the host runs a widget value: replace, then the widget serializer (#343 re-review R1)', async () => {
|
|
const {primitiveTextValue} = await source;
|
|
const {graph, links, add} = primitiveGraph();
|
|
const calls = [];
|
|
const widget = {name:'text', value:'typed under the link', serializeValue(...args) { calls.push([this.value, args.length]); return this.value.replace('{alpha|beta}', 'alpha'); }};
|
|
const target = add(1, {inputs:[{name:'text',link:10},{name:'negative_text',link:11}], widgets:[widget]});
|
|
const primitive = add(2, {type:'PrimitiveNode', isVirtualNode:true, properties:{'Run widget replace on values':false}, widgets:[{value:'a %year% {alpha|beta} car'}]});
|
|
add(3, {type:'PrimitiveNode', isVirtualNode:true, properties:{'Run widget replace on values':true}, widgets:[{value:'no serializer %year%'}]});
|
|
links.set(10, {origin_id:2}); links.set(11, {origin_id:3});
|
|
const replaced = [];
|
|
const replaceText = (from, value) => { replaced.push(from); return value.replace('%year%', '2026'); };
|
|
assert.equal(await primitiveTextValue(target, 'text', {replaceText}), 'a %year% alpha car', 'replace stays off unless the Primitive asks for it');
|
|
assert.deepEqual(calls, [['a %year% {alpha|beta} car', 0]], 'serialized once, without a node, so widgets_values is not touched');
|
|
assert.equal(widget.value, 'typed under the link', 'the typed text is restored');
|
|
primitive.properties['Run widget replace on values'] = true;
|
|
assert.equal(await primitiveTextValue(target, 'text', {replaceText}), 'a 2026 alpha car');
|
|
assert.deepEqual(replaced, [graph]);
|
|
assert.equal(await primitiveTextValue(target, 'negative_text', {replaceText}), 'no serializer 2026', 'no widget serializer: the replaced value');
|
|
assert.equal(await primitiveTextValue(target, 'negative_text'), 'no serializer %year%', 'no replacer: the value as is');
|
|
const failing = () => { throw new Error('not this host'); };
|
|
assert.equal(await primitiveTextValue(target, 'negative_text', {replaceText:failing}), 'no serializer %year%', 'a replacer that fails leaves the value');
|
|
widget.serializeValue = () => { throw new Error('broken serializer'); };
|
|
await assert.rejects(primitiveTextValue(target, 'text'), /broken serializer/);
|
|
assert.equal(widget.value, 'typed under the link', 'restored even when the serializer throws');
|
|
});
|
|
|
|
test('installing the preference wraps findInputByType and restores nothing it did not own', async () => {
|
|
const {preferTextInputs} = await source;
|
|
const calls = [];
|
|
const prototype = {findInputByType(type) { calls.push(type); return {index:1,slot:this.inputs[1]}; }};
|
|
preferTextInputs(prototype, ['text']);
|
|
const node = Object.assign(Object.create(prototype), pairNode());
|
|
assert.equal(node.findInputByType('STRING').index, 3);
|
|
assert.deepEqual(calls, []);
|
|
assert.equal(node.findInputByType('CLIP').index, 1);
|
|
node.inputs[3].link = 9;
|
|
assert.equal(node.findInputByType('STRING').index, 1, 'the main prompt is taken: fall back to the frontend');
|
|
assert.deepEqual(calls, ['CLIP','STRING']);
|
|
const bare = {};
|
|
preferTextInputs(bare, ['text']);
|
|
assert.equal(bare.findInputByType, undefined, 'an older frontend without findInputByType is left unchanged');
|
|
});
|
|
|
|
test('a drop point over a visible prompt box or its note picks that box input (#343 review M1)', async () => {
|
|
const {textInputAtPoint} = await source;
|
|
const box = (left, top, width, height) => ({getBoundingClientRect:() => ({left, top, width, height, right:left + width, bottom:top + height})});
|
|
const node = pairNode();
|
|
const targets = [{name:'text',element:box(0,0,100,100)},{name:'negative_text',element:box(120,0,100,100)},{name:'text',element:box(0,0,0,0)},{name:'missing',element:box(300,0,50,50)}];
|
|
assert.equal(textInputAtPoint(node, [150,50], targets).name, 'negative_text');
|
|
assert.equal(textInputAtPoint(node, [10,10], targets).name, 'text');
|
|
assert.equal(textInputAtPoint(node, [110,50], targets), undefined, 'between the boxes');
|
|
assert.equal(textInputAtPoint(node, [320,10], targets), undefined, 'a target whose input the node lacks');
|
|
assert.equal(textInputAtPoint(node, [0,0], [{name:'text',element:box(0,0,0,0)}]), undefined, 'a hidden target');
|
|
});
|
|
|
|
test('a linked Classic text box gives its row back and gets its size back on disconnect (#343 review M5)', async () => {
|
|
const {collapseLinkedWidget} = await source;
|
|
const own = () => 120;
|
|
const widget = {options:{getMinHeight:own}};
|
|
collapseLinkedWidget(widget, true);
|
|
assert.equal(widget.options.getMinHeight(), 0);
|
|
assert.equal(widget.options.getMaxHeight(), 0);
|
|
collapseLinkedWidget(widget, true);
|
|
collapseLinkedWidget(widget, false);
|
|
assert.equal(widget.options.getMinHeight, own);
|
|
assert.equal('getMaxHeight' in widget.options, false, 'an option it did not have is removed again');
|
|
collapseLinkedWidget(widget, false);
|
|
assert.equal(widget.options.getMinHeight, own);
|
|
collapseLinkedWidget(undefined, true);
|
|
collapseLinkedWidget({}, true);
|
|
});
|