Files
ComfyAssets-ComfyUI_PromptM…/tests/js/text-links.test.js
T
Vito Sansevero bd2556f32f 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
2026-10-25 13:24:19 -07:00

92 lines
4.7 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 STRING-compatible links are steered to a text input', async () => {
const {preferredTextSlot} = await source;
const node = pairNode();
for (const type of ['STRING','STRING,INT','*','']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type);
for (const type of ['CLIP','INT','IMAGE',undefined,null]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type));
assert.equal(preferredTextSlot({}, 'STRING', ['text']), undefined);
});
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');
});