From 48b1292115fd369e315f4351799563c50c0af21f Mon Sep 17 00:00:00 2001 From: Vito Date: Sun, 25 Oct 2026 18:25:20 -0700 Subject: [PATCH] feat(nodes): let a link drive the prompt text of every PromptManager node (#345) * 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 : 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. --- TESTING.md | 80 ++ tests/browser/classic-node.spec.mjs | 46 + tests/browser/focused-node.spec.mjs | 33 +- tests/browser/pair-node.spec.mjs | 43 +- tests/comfyui/execution-text-link.spec.mjs | 195 +++ .../workflow-before-343-primitive.json | 379 +++++ .../comfyui/fixtures/workflow-before-343.json | 1241 +++++++++++++++++ tests/comfyui/text-link.spec.mjs | 404 ++++++ tests/fixtures/generation_params/vectors.json | 51 + tests/generation_params_cases.py | 89 ++ tests/js/text-links.test.js | 201 +++ tests/test_generation_params.py | 20 + tests/vendor/comfyui-config.test.mjs | 7 +- tools/vendor/comfyui.config.mjs | 2 +- tools/vendor/frontend-coverage-baseline.json | 6 + tools/vendor/playwright.execution.config.mjs | 2 +- utils/generation_params.py | 10 +- web/comfy/prompt_manager.js | 186 ++- web/js/classic-node.css | 2 + web/js/classic-node.js | 3 + web/js/comfy-metadata.js | 6 +- web/js/focused-node.js | 43 +- web/js/text-links.js | 165 +++ 23 files changed, 3144 insertions(+), 70 deletions(-) create mode 100644 tests/comfyui/execution-text-link.spec.mjs create mode 100644 tests/comfyui/fixtures/workflow-before-343-primitive.json create mode 100644 tests/comfyui/fixtures/workflow-before-343.json create mode 100644 tests/comfyui/text-link.spec.mjs create mode 100644 tests/js/text-links.test.js create mode 100644 web/js/text-links.js diff --git a/TESTING.md b/TESTING.md index 9149ba93..249ace84 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3958,6 +3958,86 @@ Viewer, including nested modal focus, mobile layouts and per-role sending. real Classic and Nodes 2.0 widgets and verifies workflow serialization without loading a workflow or queueing generation. +## Linked prompt text (#343) + +A link drives `text` on every PromptManager node, and `negative_text` on the Pair, +as it drives any core widget. The Focused, Split and Pair editors hide their widgets, +and the frontend only draws a widget's socket while the widget is visible, so +`web/js/text-links.js` detaches those two inputs from their hidden widgets. They become +ordinary sockets; the schema, widget order and saved `widgets_values` do not change. A +link dropped on a prompt box (or its link note) fills that box's input: Nodes 2.0 resolves +it through the box's `.lg-node-widget` and `[data-slot-key]`, Classic through the node's +`getInputOnPos`. An exact STRING link dropped elsewhere on the editor fills the main prompt +first, then the negative, before the frontend's own choice (it used to pick `prepend_text`, +which caused #342). A link being moved off a text input counts as free, as the frontend +counts it, so dropping it back on the editor keeps it on `text`. Wildcard links (a +frontend Primitive before it is typed) are not steered. + +The frontend's legacy Primitive is virtual: it writes its value into the target widget, +which the editor text no longer is. For the editors' detached inputs the `graphToPrompt` +wrapper puts the Primitive's value (through virtual reroutes, also inside subgraphs, where +the queued id is `:`) into the queued `text`/`negative_text`, +resolved as the host resolves a value written into the widget: the Primitive's text +replacement when its "Run widget replace on values" option is on, then the hidden widget's +own serializer (dynamic prompts: `{red|blue}` becomes one choice). Each queued output is +filled once (the queue hook only fills an output that did not come through the wrapper), +so a wildcard is drawn once per run. Inputs still bound to their widget (the Classic +nodes) are left as the host resolved them; as on main, the Classic text widget's own +serializer queues its text as written, wildcards included (specs wait on the node's +`_pmTextSerializerReady` before queuing). A Primitive drives the text as before, also in +workflows saved before #343. On every Run the host logs "Invalid widget or widget name" +for a Primitive wired to a detached editor input: its own widget write finds no widget +binding there. That is expected and harmless. A linked +Classic text box gives its canvas row back while linked (the core keeps an empty gap) and +gets its size back on disconnect. The metadata parsers map a known node's positional +widget values through its widget table when its widget-bound inputs do not line up with +it (the editors' text inputs are sockets now). + +Not covered, by ruling: linking a subgraph input to an inner editor's `text` gives the +subgraph a plain STRING socket, not a promoted typeable widget as a core widget input +would. The socket was unreachable before #343, so nothing regressed. +While linked, each editor says the value comes from a link, and the typed text is kept +in the workflow for when the link is disconnected. That includes the Classic nodes, +which used to save `null` under a link. + +- `tests/js/text-links.test.js`: socket exposure, drop preference (exact STRING, moved + links), box hit-test, Primitive values through reroutes, replacement and the widget + serializer (bound inputs left to the host), stored values, the Classic row. +- `tests/test_generation_params.py` (`workflow-only-detached-*` cases, with JS parity + vectors): editor text inputs without widget bindings keep their positional values, and so + does a core KSampler whose only widget-bound input (`cfg`) is out of schema order. +- `tests/browser/{focused,pair,classic}-node.spec.mjs`: the linked note, typed text + kept through save and configure (also with a workflow's widget binding restored), the + Classic "use prompt" guard while linked. +- `tests/comfyui/text-link.spec.mjs` (both startup projects): a real mouse drag of a + STRING output onto each node's prompt box (and onto the Pair's negative socket); the + link, the note and the typed text survive renderer switches both ways, a reload, + disconnect and node removal. `fixtures/workflow-before-343.json` and + `workflow-before-343-primitive.json` were serialized by the pre-#343 code on the pinned + host and must load unchanged; the second one's legacy Primitives keep driving the text + through a real Run. Also: a Primitive dropped on the Main prompt box and a string on the + Negative prompt box, a moved link released on the editor body, paste, clone and undo, a + legacy Primitive inside a subgraph, and wildcard Primitives on every node type queued + through a real Run as one choice, the same one another `graphToPrompt` wrapper sees. +- `tests/comfyui/execution-text-link.spec.mjs` (execution host): workflows built by the + real frontend through the sockets, with text from `PrimitiveString`, the legacy + Primitive or through `PreviewAny`, for PromptManager, PromptManagerText, Focused, Split + and the Pair (positive, negative, both, neither linked, and a Primitive negative). A + wildcard Primitive on PromptManager (queued as written) and Focused (one choice) saves + and counts what was queued. The linked text is saved, counted once, recorded as + the run's raw positive/negative, and after a scan the run's image links to it. Text + only known at run time (`PreviewAny`) cannot be read from the image; the case only + requires that it is never linked to another prompt (its attribution from the recorded + run belongs to #342). + +```sh +node --test tests/js/text-links.test.js +npm run --prefix tools/vendor test:workflows -- classic-node.spec.mjs focused-node.spec.mjs pair-node.spec.mjs \ + --project chromium --project touch --project firefox --project webkit +PM_COMFY_ROOT=... PM_TEST_PYTHON=... npm run --prefix tools/vendor test:comfyui -- text-link +PM_COMFY_ROOT=... PM_TEST_PYTHON=... npm run --prefix tools/vendor test:executions -- execution-text-link +``` + ## Durable execution ledger `npm run --prefix tools/vendor test:executions` runs 46 real execution cases diff --git a/tests/browser/classic-node.spec.mjs b/tests/browser/classic-node.spec.mjs index f4160623..bd627e22 100644 --- a/tests/browser/classic-node.spec.mjs +++ b/tests/browser/classic-node.spec.mjs @@ -149,3 +149,49 @@ test('Classic test button reports success and failure and releases expired notif await page.clock.fastForward(3001); await expect(page.locator('.pm-classic-notification')).toHaveCount(0); }); + +test('Classic shows linked text, keeps the typed text through save and refuses to overwrite it (#343)',async({page})=>{ + await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Saved prompt',tags:[]}]}})); + const note=page.locator('.pm-classic').getByText('Text comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + await expect(note).toBeHidden(); + await page.evaluate(()=>{ + const widget=node.widgets.find(item=>item.name==='text');widget.value=widget.inputEl.value='Typed classic'; + node.inputs=[{name:'text',type:'STRING',link:3,widget:{name:'text'}}];node.onConnectionsChange(); + }); + await expect(note).toBeVisible(); + // The link drives the run; the saved workflow keeps what the user typed, not a stale or empty value. + expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Typed classic'); + await page.getByRole('button',{name:'📋 Recent',exact:true}).click(); + await page.getByRole('button',{name:/Saved prompt/}).click(); + expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').value)).toBe('Typed classic'); + await expect(page.getByRole('alert').filter({hasText:'Text comes from a link. Disconnect it to use a saved prompt.'})).toBeVisible(); + await page.evaluate(()=>{node.inputs[0].link=null;node.onConnectionsChange();}); + await expect(note).toBeHidden(); + await page.getByRole('button',{name:/Saved prompt/}).click(); + expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').value)).toBe('Saved prompt'); + // A workflow load re-evaluates the link state. + await page.evaluate(()=>{node.inputs[0].link=8;node.configure({});}); + await expect(note).toBeVisible(); +}); + +test('PromptManagerText shows linked text in its legacy controls and keeps the typed text (#343)',async({page})=>{ + await page.evaluate(async()=>{ + class TextNode { + constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));this.widgets_values=[];this.inputs=[{name:'text',type:'STRING',link:null,widget:{name:'text'}}];} + addDOMWidget(name,type,element){element.id='legacy-text-node';document.body.append(element);return {element};} + setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};} + } + await fixtureApp.extensions[0].beforeRegisterNodeDef(TextNode,{name:'PromptManagerText'},fixtureApp); + window.textNode=new TextNode();textNode.onNodeCreated(); + const widget=textNode.widgets[0];widget.value=widget.inputEl.value='Typed text node'; + }); + const note=page.locator('#legacy-text-node').getByText('Text comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + await expect(note).toBeHidden(); + await page.evaluate(()=>{textNode.inputs[0].link=5;textNode.onConnectionsChange();}); + await expect(note).toBeVisible(); + expect(await page.evaluate(()=>textNode.serialize().widgets_values[0])).toBe('Typed text node'); + await page.evaluate(()=>{textNode.usePrompt({text:'Replacement'},0);}); + expect(await page.evaluate(()=>textNode.widgets[0].value)).toBe('Typed text node'); + await page.evaluate(()=>{textNode.inputs[0].link=null;textNode.onConnectionsChange();textNode.onRemoved();}); + await expect(note).toBeHidden(); +}); diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index 6f9eb316..4f2026bb 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -26,7 +26,8 @@ test.beforeEach(async({page,browserName})=>{ window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}}; await import('/extensions/promptmanager/prompt_manager.js'); class Node { - constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];} + constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[550,700];} + findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};} setSize(size){this.size=size;}setDirtyCanvas(){} @@ -246,13 +247,39 @@ test(`${variant} restores workflow values and disclosures and protects connected await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);}); await expect(page.getByLabel('Category',{exact:true})).toBeVisible(); await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category'); - await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();}); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=9;node.onConnectionsChange();}); await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden(); - await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible(); + await expect(page.getByText('Main prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true})).toBeVisible(); await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();}); await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft'); }); +test(`${variant} offers text as a link socket, keeps typed text while linked and through a reload (#343)`,async({page})=>{ + const sockets=()=>page.evaluate(()=>Object.fromEntries(node.inputs.map(input=>[input.name,input.widget?.name??null]))); + // The hidden text widget no longer owns the input, so the host draws it as a socket; the other widget inputs are unchanged. + expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text'}); + expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('text'); + expect(await page.evaluate(()=>node.findInputByType('CLIP').slot.name)).toBe('clip'); + const main=page.getByRole('textbox',{name:'Main prompt',exact:true}); + await main.fill('Typed main'); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=21;node.onConnectionsChange();}); + await expect(main).toBeHidden(); + const note=page.getByText('Main prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + await expect(note).toBeVisible(); + await expect(page.locator('[data-composed-preview]')).toHaveText('[text supplied by connection]'); + // A second STRING link dropped on the editor falls back to the frontend's own choice. + expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('prepend_text'); + // An older saved workflow brings the widget binding back with its inputs: configure exposes the socket again. + const saved=await page.evaluate(()=>{const saved=node.serialize();node.inputs.find(input=>input.name==='text').widget={name:'text'};node.configure(saved);return saved;}); + expect(saved.widgets_values[0]).toBe('Typed main'); + expect((await sockets()).text).toBeNull(); + await expect(note).toBeVisible(); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();}); + await expect(main).toBeVisible(); + await expect(main).toHaveValue('Typed main'); + await expect(note).toBeHidden(); +}); + test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => { let requested; await page.route('**/prompt_manager/search?**', async route => { diff --git a/tests/browser/pair-node.spec.mjs b/tests/browser/pair-node.spec.mjs index 1d0e4bd8..2d554a84 100644 --- a/tests/browser/pair-node.spec.mjs +++ b/tests/browser/pair-node.spec.mjs @@ -17,7 +17,8 @@ test.beforeEach(async({page,browserName})=>{ window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})}; await import('/extensions/promptmanager/prompt_manager.js'); class Node { - constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[1100,700];} + constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[1100,700];} + findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};} setSize(size){this.size=size;}setDirtyCanvas(){} @@ -123,6 +124,46 @@ test('a superseded library response never replaces the latest pair selection',as await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive'); }); +test('paired editor offers positive and negative link sockets and keeps both typed texts (#343)',async({page})=>{ + await draft(page,'Typed positive','Typed negative'); + expect(await page.evaluate(()=>node.inputs.filter(input=>!input.widget).map(input=>input.name))).toEqual(['clip','prepend_text','append_text','text','negative_text']); + const target=()=>page.evaluate(()=>node.findInputByType('STRING').slot.name); + expect(await target()).toBe('text'); + const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true}),negative=page.getByRole('textbox',{name:'Negative prompt',exact:true}); + const positiveNote=page.getByText('Positive prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + const negativeNote=page.getByText('Negative prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + const link=(name,value)=>page.evaluate(({name,value})=>{node.inputs.find(input=>input.name===name).link=value;node.onConnectionsChange();},{name,value}); + for(const [linked,next] of [[['text'],'negative_text'],[['negative_text'],'text'],[['text','negative_text'],'prepend_text'],[[],'text']]){ + for(const name of ['text','negative_text'])await link(name,linked.includes(name)?30:null); + expect(await target()).toBe(next); + await expect(positive).toBeVisible({visible:!linked.includes('text')}); + await expect(negative).toBeVisible({visible:!linked.includes('negative_text')}); + await expect(positiveNote).toBeVisible({visible:linked.includes('text')}); + await expect(negativeNote).toBeVisible({visible:linked.includes('negative_text')}); + const saved=await page.evaluate(()=>{const saved=node.serialize();for(const input of node.inputs)if(['text','negative_text'].includes(input.name))input.widget={name:input.name};node.configure(saved);return saved.widgets_values.slice(0,2);}); + expect(saved).toEqual(['Typed positive','Typed negative']); + expect(await page.evaluate(()=>node.inputs.filter(input=>input.widget&&['text','negative_text'].includes(input.name)).length)).toBe(0); + } + await expect(positive).toHaveValue('Typed positive');await expect(negative).toHaveValue('Typed negative'); +}); + +test('a link dropped on either prompt box, or its note, fills that box (#343 review M1)',async({page})=>{ + const keys=await page.evaluate(()=>[...document.querySelectorAll('.pm-pair .lg-node-widget [data-slot-key]')].map(key=>key.dataset.slotKey)); + // Nodes 2.0 resolves a drop through the nearest .lg-node-widget and its slot key: positive box and note, negative box and note. + expect(keys).toEqual(['1-in-3','1-in-3','1-in-4','1-in-4']); + const at=(name,connecting=true)=>page.evaluate(({name,connecting})=>{ + fixtureApp.canvas={linkConnector:{state:{connectingTo:connecting?'input':undefined}},ds:{convertOffsetToCanvas:point=>point},canvas:{getBoundingClientRect:()=>({left:0,top:0})}}; + const box=[...document.querySelectorAll('.pm-pair .lg-node-widget')].find(element=>element.getClientRects().length&&element.textContent.includes(name)).getBoundingClientRect(); + return node.getInputOnPos([box.left+box.width/2,box.top+box.height/2])?.name??null; + },{name,connecting}); + expect(await at('Negative prompt')).toBe('negative_text'); + expect(await at('Positive prompt')).toBe('text'); + expect(await at('Negative prompt',false)).toBeNull(); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='negative_text').link=4;node.onConnectionsChange();}); + expect(await at('Negative prompt comes from a link')).toBe('negative_text'); + expect(await page.evaluate(()=>{fixtureApp.canvas={};return node.getInputOnPos([1,1])??null;})).toBeNull(); +}); + test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{ await draft(page); const args=await page.evaluate(async()=>{ diff --git a/tests/comfyui/execution-text-link.spec.mjs b/tests/comfyui/execution-text-link.spec.mjs new file mode 100644 index 00000000..8eefb6da --- /dev/null +++ b/tests/comfyui/execution-text-link.spec.mjs @@ -0,0 +1,195 @@ +// #343: a link drives a PromptManager node's text (and the Pair's negative) on the real pinned host. +// The workflow is built by the real frontend through the nodes' sockets and queued as the frontend serializes it; +// only inference is replaced (fixtures/ledger_nodes.py). The linked text must be saved, counted, recorded in the +// execution history, and the run's image must link to it. +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { promisify } from 'node:util'; +import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import { test, readExecutionRuns } from './execution-fixture.mjs'; +import { installHostHooks } from './host-fixture.mjs'; + +// The worker's owned host must be up before the shared host hooks open the page. +test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); }); +installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerPair', 'PMHistoryInputs']); + +// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node, +// whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's +// TextGenerate -> PreviewAny chain, whose text is only known when the node runs. +function build(page, { kind, positive, negative, prefix }) { + return page.evaluate(async ({ kind, positive, negative, prefix }) => { + const { app } = await import('/scripts/app.js'); + app.graph.clear(); + const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; }; + const slot = (node, name) => { const index = node.inputs.findIndex(input => input.name === name); if (index < 0) throw new Error(`${node.type} has no ${name} socket`); return index; }; + const connect = (from, output, to, name) => { if (!from.connect(output, to, slot(to, name))) throw new Error(`${from.type} could not link to ${to.type}.${name}`); }; + const inputs = add('PMHistoryInputs', 0, 0); + const prompt = add(kind, 700, 0); + const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; }; + set('text', positive.typed); + if (negative) set('negative_text', negative.typed); + const drive = (name, side, y) => { + if (!side.via) return; + if (side.via === 'legacy') { + // The frontend's own Primitive: a virtual node whose value is applied when the workflow is queued. + const primitive = add('PrimitiveNode', 0, y); + connect(primitive, 0, prompt, name); + primitive.widgets[0].value = side.text; + return; + } + const text = add('PrimitiveString', 0, y); + text.widgets.find(widget => widget.name === 'value').value = side.text; + let source = text; + if (side.via === 'preview') { source = add('PreviewAny', 350, y); connect(text, 0, source, 'source'); } + connect(source, 0, prompt, name); + }; + drive('text', positive, 300); + if (negative) drive('negative_text', negative, 500); + if (kind !== 'PromptManagerText') connect(inputs, 0, prompt, 'clip'); + const sampler = add('KSampler', 1400, 0); + connect(inputs, 1, sampler, 'model'); + connect(inputs, 2, sampler, 'latent_image'); + if (kind === 'PromptManagerText') { + const encode = add('CLIPTextEncode', 1100, 0); + encode.convertWidgetToInput?.(encode.widgets.find(widget => widget.name === 'text')); + connect(inputs, 0, encode, 'clip'); + connect(prompt, 0, encode, 'text'); + connect(encode, 0, sampler, 'positive'); + } else connect(prompt, 0, sampler, 'positive'); + if (kind === 'PromptManagerPair') connect(prompt, 1, sampler, 'negative'); + else { + const empty = add('CLIPTextEncode', 1100, 300); + connect(inputs, 0, empty, 'clip'); + connect(empty, 0, sampler, 'negative'); + } + const images = add('PMHistoryImages', 1800, 0); + images.widgets.find(widget => widget.name === 'value').value = 99; + connect(sampler, 0, images, 'latent'); + const save = add('SaveImage', 2100, 0); + save.widgets.find(widget => widget.name === 'filename_prefix').value = prefix; + connect(images, 0, save, 'images'); + // The Classic nodes install their own text serializer shortly after creation; queue after it, as a user does. + await prompt._pmTextSerializerReady; + const { output, workflow } = await app.graphToPrompt(); + return { output, workflow, promptNode: String(prompt.id) }; + }, { kind, positive, negative, prefix }); +} + +async function run(execution, page, testCase) { + const prefix = `textlink/${randomUUID()}`; + const { output, workflow, promptNode } = await build(page, { ...testCase, prefix }); + const id = randomUUID(); + const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } }); + expect(response.ok(), await response.text()).toBe(true); + let item; + await expect.poll(async () => { + item = (await (await execution.request.get(`/history/${id}`)).json())[id]; + return item?.status?.status_str ?? null; + }, { timeout: 20000 }).toBe('success'); + let result; + await expect.poll(async () => { + result = await readExecutionRuns(execution.databasePath, id); + const run = result.rows[0]; + return run ? result.captures[run.run_id].state : null; + }, { timeout: 10000, message: 'the run and its prompt context reach SQLite' }).toBe('complete'); + const run = result.rows[0]; + const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename); + return { output, promptNode, context: result.captures[run.run_id].contexts[0], usage: result.usage[run.run_id], files }; +} + +async function scan(execution) { + const response = await execution.request.post('/prompt_manager/scan', { timeout: 60000 }); + expect(response.ok()).toBe(true); + const events = (await response.text()).split('\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6))); + expect(events.at(-1)?.type, JSON.stringify(events.at(-1))).toBe('complete'); +} + +// filename -> linked prompt id (null when the image is not linked), read-only from the owned host's library. +const LINKS = `import json, sqlite3, sys, pathlib +db = sqlite3.connect(pathlib.Path(sys.argv[1]).resolve().as_uri() + "?mode=ro", uri=True) +links = {name: None for name in sys.argv[2:]} +for name, prompt in db.execute("SELECT filename, prompt_id FROM generated_images WHERE filename IN (%s)" % ",".join("?" * (len(sys.argv) - 2)), sys.argv[2:]): + links[name] = prompt +print(json.dumps(links))`; +async function linkedPrompts(execution, files) { + const { stdout } = await promisify(execFile)(process.env.PM_TEST_PYTHON || 'python', ['-c', LINKS, execution.databasePath, ...files], { timeout: 5000 }); + return JSON.parse(stdout); +} + +// Text a link resolves from the queued graph (a primitive) links its images through their embedded metadata. +// Text only known at run time (PreviewAny) cannot be read from the image: its attribution from the recorded run is +// #342's. Here it must never be linked to another prompt. +async function expectImagesLinked(execution, evidence, viaRuntime) { + await scan(execution); + const links = await linkedPrompts(execution, evidence.files); + expect(evidence.files.length).toBeGreaterThan(0); + for (const [file, prompt] of Object.entries(links)) { + if (viaRuntime) expect([null, evidence.context.positive_prompt_id], file).toContain(prompt); + else expect(prompt, file).toBe(evidence.context.positive_prompt_id); + } +} + +const unique = label => `${label} ${randomUUID()}`; + +const sources = { primitive: 'PrimitiveString', legacy: 'a legacy Primitive', preview: 'PreviewAny' }; +for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']) { + for (const via of Object.keys(sources)) { + test(`${kind} saves, counts, records and links images to text linked from ${sources[via]}`, async ({ execution, page }) => { + test.setTimeout(90000); + const text = unique(`Linked ${kind} ${via}`); + const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text, via } }); + const source = evidence.output[evidence.promptNode].inputs.text; + // A real node is queued as a link; the virtual Primitive as its own value, never the hidden typed text. + if (via === 'legacy') expect(source).toBe(text); + else expect(Array.isArray(source), 'the frontend queued the link, not the typed text').toBe(true); + expect(evidence.context.positive.raw).toBe(text); + expect(evidence.context.positive.effective).toBe(text); + expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]); + const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json(); + expect(saved.positive.text).toBe(text); + await expectImagesLinked(execution, evidence, via === 'preview'); + }); + } +} + +// A legacy Primitive's wildcard is drawn once when the workflow is queued: the run saves and counts what was queued. +for (const kind of ['PromptManager', 'PromptManagerFocused']) { + test(`${kind} saves and counts the text a wildcard legacy Primitive queued (#343 re-review R1)`, async ({ execution, page }) => { + test.setTimeout(90000); + const stem = unique(`Wildcard ${kind}`); + const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text: `${stem} {alpha|beta}`, via: 'legacy' } }); + const queuedText = evidence.output[evidence.promptNode].inputs.text; + // The Classic node keeps its widget binding: the host applies the Primitive, and its own text serializer + // queues the text as written, as on main. The Focused editor queues one choice. + if (kind === 'PromptManager') expect(queuedText).toBe(`${stem} {alpha|beta}`); + else expect([`${stem} alpha`, `${stem} beta`]).toContain(queuedText); + expect(evidence.context.positive.raw).toBe(queuedText); + expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]); + const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json(); + expect(saved.positive.text).toBe(queuedText); + }); +} + +for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'], ['negative', ['negative_text'], 'primitive'], + ['both', ['text', 'negative_text'], 'primitive'], ['neither', [], 'primitive'], ['negative from a legacy Primitive', ['negative_text'], 'legacy']]) { + test(`PromptManagerPair with ${label} linked saves, counts and records the pair and links its images`, async ({ execution, page }) => { + test.setTimeout(90000); + const sides = { + text: { typed: unique('Typed positive'), text: unique('Linked positive'), via: linked.includes('text') ? 'preview' : null }, + negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? negativeVia : null }, + }; + const evidence = await run(execution, page, { kind: 'PromptManagerPair', positive: sides.text, negative: sides.negative_text }); + const expected = name => sides[name].via ? sides[name].text : sides[name].typed; + for (const name of ['text', 'negative_text']) { + const queuedValue = evidence.output[evidence.promptNode].inputs[name]; + if (sides[name].via === 'legacy') expect(queuedValue, name).toBe(sides[name].text); + else expect(Array.isArray(queuedValue), name).toBe(linked.includes(name)); + } + expect(evidence.context.positive.raw).toBe(expected('text')); + expect(evidence.context.negative.raw).toBe(expected('negative_text')); + expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]); + const pair = await (await execution.request.get(`/prompt_manager/pairs/${evidence.context.pair_id}`)).json(); + expect(pair.pair).toMatchObject({ positive_text: expected('text'), negative_text: expected('negative_text') }); + await expectImagesLinked(execution, evidence, sides.text.via === 'preview'); + }); +} diff --git a/tests/comfyui/fixtures/workflow-before-343-primitive.json b/tests/comfyui/fixtures/workflow-before-343-primitive.json new file mode 100644 index 00000000..4940c722 --- /dev/null +++ b/tests/comfyui/fixtures/workflow-before-343-primitive.json @@ -0,0 +1,379 @@ +{ + "id": "37b6baef-94f5-49d2-9b42-1ffc70e7da92", + "revision": 0, + "last_node_id": 4, + "last_link_id": 2, + "nodes": [ + { + "id": 1, + "type": "PromptManagerFocused", + "pos": [ + 600, + 0 + ], + "size": [ + 550, + 650 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "Focused fed by a Primitive", + "properties": { + "Node name for S&R": "PromptManagerFocused", + "focusedUI": { + "libraryOpen": false, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Primitive focused text", + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Primitive focused text", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 2, + "type": "PromptManagerPair", + "pos": [ + 600, + 1200 + ], + "size": [ + 1100, + 700 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "widget": { + "name": "negative_text" + }, + "link": 2 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "positive_text", + "name": "positive_text", + "type": "STRING", + "links": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "links": null + } + ], + "title": "Pair negative fed by a Primitive", + "properties": { + "Node name for S&R": "PromptManagerPair", + "pairUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Typed pair positive", + "Primitive pair negative", + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed pair positive", + "negative_text": "Primitive pair negative", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 3, + "type": "PrimitiveNode", + "pos": [ + 0, + 0 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "widget": { + "name": "text" + }, + "links": [ + 1 + ] + } + ], + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + "Primitive focused text" + ], + "widgets_values_named": { + "text": "Primitive focused text" + } + }, + { + "id": 4, + "type": "PrimitiveNode", + "pos": [ + 0, + 1200 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "widget": { + "name": "negative_text" + }, + "links": [ + 2 + ] + } + ], + "properties": { + "Run widget replace on values": false + }, + "widgets_values": [ + "Primitive pair negative" + ], + "widgets_values_named": { + "negative_text": "Primitive pair negative" + } + } + ], + "links": [ + [ + 1, + 3, + 0, + 1, + 3, + "STRING" + ], + [ + 2, + 4, + 0, + 2, + 4, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.9, + "offset": [ + 416, + 110 + ] + } + }, + "version": 0.4 +} diff --git a/tests/comfyui/fixtures/workflow-before-343.json b/tests/comfyui/fixtures/workflow-before-343.json new file mode 100644 index 00000000..3d514d9f --- /dev/null +++ b/tests/comfyui/fixtures/workflow-before-343.json @@ -0,0 +1,1241 @@ +{ + "id": "882efddb-48a1-4dd5-8575-14922103c4b8", + "revision": 0, + "last_node_id": 12, + "last_link_id": 4, + "nodes": [ + { + "id": 1, + "type": "PromptManager", + "pos": [ + 400, + 100 + ], + "size": [ + 600, + 610 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "prepend_text" + }, + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "append_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManager typed", + "properties": { + "Node name for S&R": "PromptManager", + "resultTimeout": 3, + "showTestButton": false, + "webuiDisplayMode": "popup" + }, + "widgets_values": [ + "Saved PromptManager text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManager text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_search_ui": "" + } + }, + { + "id": 2, + "type": "PromptManagerText", + "pos": [ + 1600, + 100 + ], + "size": [ + 600, + 610 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "prepend_text" + }, + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "append_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerText typed", + "properties": { + "Node name for S&R": "PromptManagerText", + "resultTimeout": 3, + "showTestButton": false, + "webuiDisplayMode": "popup" + }, + "widgets_values": [ + "Saved PromptManagerText text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerText text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_search_ui": "" + } + }, + { + "id": 3, + "type": "PromptManagerFocused", + "pos": [ + 2800, + 100 + ], + "size": [ + 550, + 650 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerFocused typed", + "properties": { + "Node name for S&R": "PromptManagerFocused", + "focusedUI": { + "libraryOpen": false, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Saved PromptManagerFocused text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerFocused text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 4, + "type": "PromptManagerSplit", + "pos": [ + 4000, + 100 + ], + "size": [ + 900, + 650 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerSplit typed", + "properties": { + "Node name for S&R": "PromptManagerSplit", + "splitUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Saved PromptManagerSplit text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerSplit text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 5, + "type": "PromptManagerPair", + "pos": [ + 5200, + 100 + ], + "size": [ + 1100, + 700 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "widget": { + "name": "negative_text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "positive_text", + "name": "positive_text", + "type": "STRING", + "links": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerPair typed", + "properties": { + "Node name for S&R": "PromptManagerPair", + "pairUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Saved PromptManagerPair text", + "Saved pair negative", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerPair text", + "negative_text": "Saved pair negative", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 6, + "type": "PromptManagerFocused", + "pos": [ + 400, + 1400 + ], + "size": [ + 550, + 650 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "Focused linked", + "properties": { + "Node name for S&R": "PromptManagerFocused", + "focusedUI": { + "libraryOpen": false, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Typed under focused link", + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed under focused link", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 7, + "type": "PrimitiveString", + "pos": [ + 0, + 1400 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked focused text" + ], + "widgets_values_named": { + "value": "Linked focused text" + } + }, + { + "id": 8, + "type": "PromptManagerPair", + "pos": [ + 1600, + 1400 + ], + "size": [ + 1100, + 700 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 2 + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "widget": { + "name": "negative_text" + }, + "link": 3 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "positive_text", + "name": "positive_text", + "type": "STRING", + "links": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "links": null + } + ], + "title": "Pair linked", + "properties": { + "Node name for S&R": "PromptManagerPair", + "pairUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Typed pair positive", + "Typed pair negative", + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed pair positive", + "negative_text": "Typed pair negative", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 9, + "type": "PrimitiveString", + "pos": [ + 1200, + 1400 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked pair text" + ], + "widgets_values_named": { + "value": "Linked pair text" + } + }, + { + "id": 10, + "type": "PrimitiveString", + "pos": [ + 1200, + 1600 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked pair negative_text" + ], + "widgets_values_named": { + "value": "Linked pair negative_text" + } + }, + { + "id": 11, + "type": "PromptManager", + "pos": [ + 3000, + 1400 + ], + "size": [ + 600, + 610 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 4 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "prepend_text" + }, + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "append_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "Classic linked", + "properties": { + "Node name for S&R": "PromptManager", + "resultTimeout": 3, + "showTestButton": false, + "webuiDisplayMode": "popup" + }, + "widgets_values": [ + null, + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed under classic link", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_search_ui": "" + } + }, + { + "id": 12, + "type": "PrimitiveString", + "pos": [ + 2600, + 1400 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked classic text" + ], + "widgets_values_named": { + "value": "Linked classic text" + } + } + ], + "links": [ + [ + 1, + 7, + 0, + 6, + 3, + "STRING" + ], + [ + 2, + 9, + 0, + 8, + 3, + "STRING" + ], + [ + 3, + 10, + 0, + 8, + 4, + "STRING" + ], + [ + 4, + 12, + 0, + 11, + 1, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.9, + "offset": [ + 416, + 110 + ] + } + }, + "version": 0.4 +} diff --git a/tests/comfyui/text-link.spec.mjs b/tests/comfyui/text-link.spec.mjs new file mode 100644 index 00000000..6bfaf01a --- /dev/null +++ b/tests/comfyui/text-link.spec.mjs @@ -0,0 +1,404 @@ +// #343: on the pinned real host, a STRING link dragged with the mouse drives each PromptManager node's text (and the +// Pair's negative) in Classic and Nodes 2.0, across renderer switches, a workflow reload, disconnect and removal. +import { readFile } from 'node:fs/promises'; +import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import { installHostHooks } from './host-fixture.mjs'; + +installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); + +const editors = { PromptManagerFocused: 'Main prompt', PromptManagerSplit: 'Main prompt', PromptManagerPair: 'Positive prompt' }; +const note = label => `${label} comes from a link. Your typed text is kept for when you disconnect it.`; + +/** Viewport position of a node's slot: the slot dot in Nodes 2.0, the canvas slot position in Classic. */ +function slotPosition(page, nodeId, kind, name) { + return page.evaluate(async ({ nodeId, kind, name }) => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(nodeId); + const slots = kind === 'out' ? node.outputs : node.inputs; + const index = typeof name === 'number' ? name : slots.findIndex(slot => slot.name === name); + if (LiteGraph.vueNodesMode) { + const dot = document.querySelector(`[data-slot-key="${nodeId}-${kind}-${index}"]`); + if (!dot) return null; + const box = dot.getBoundingClientRect(); + return [box.x + box.width / 2, box.y + box.height / 2]; + } + const position = app.canvas.ds.convertOffsetToCanvas(kind === 'out' ? node.getOutputPos(index) : node.getInputPos(index)); + const box = app.canvas.canvas.getBoundingClientRect(); + return [box.x + position[0], box.y + position[1]]; + }, { nodeId, kind, name }); +} + +async function drag(page, from, to) { + await page.mouse.move(...from); + await page.mouse.down(); + await page.mouse.move((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, { steps: 5 }); + await page.mouse.move(...to, { steps: 10 }); + await page.mouse.up(); +} + +const center = box => [box.x + box.width / 2, box.y + box.height / 2]; +const linkedInputs = (page, id) => page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + return app.graph.getNodeById(id).inputs.filter(input => input.link != null).map(input => input.name); +}, id); +const queued = (page, id) => page.evaluate(async id => (await (await import('/scripts/app.js')).app.graphToPrompt()).output[String(id)].inputs, id); + +async function setup(page, type) { + return page.evaluate(async type => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode(type); + app.graph.add(node); + node.pos = [500, 80]; + node.setSize([type === 'PromptManagerPair' ? 1100 : 650, 760]); + const sources = ['text', 'negative_text'].map((name, index) => { + const source = LiteGraph.createNode('PrimitiveString'); + app.graph.add(source); + source.pos = [40, 80 + index * 160]; + source.widgets.find(widget => widget.name === 'value').value = `Linked ${name} ${type}`; + return source.id; + }); + app.canvas.ds.scale = .8; + app.canvas.ds.offset = [40, 40]; + app.canvas.setDirty(true, true); + return { id: node.id, sources }; + }, type); +} + +/** The prompt box a user drops the link on: the editor's textarea, or the core text box of the Classic nodes. */ +async function promptBox(page, type, id) { + if (editors[type]) return page.getByRole('textbox', { name: editors[type], exact: true }); + // The Classic nodes keep the core text widget: Nodes 2.0 draws it as its own textarea, Classic as widget.inputEl. + if (await page.evaluate(() => LiteGraph.vueNodesMode)) return page.locator(`[data-node-id="${id}"] textarea`).first(); + await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl.dataset.pmTestPromptBox = String(id); + }, id); + return page.locator(`textarea[data-pm-test-prompt-box="${id}"]`); +} + +/** What the node shows while its text is linked. */ +function linkedNote(page, type) { + return editors[type] ? page.getByText(note(editors[type]), { exact: true }) : page.locator('.pm-text-link-note').getByText(note('Text'), { exact: true }); +} + +for (const type of ['PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair', 'PromptManager', 'PromptManagerText']) { + test(`${type}: a STRING link dropped on the prompt box drives text and the typed text returns on disconnect`, async ({ page }, testInfo) => { + test.setTimeout(90000); + const { id, sources } = await setup(page, type); + const typed = `Typed ${type} ${testInfo.project.name}`; + const box = await promptBox(page, type, id); + await box.fill(typed); + await expect(linkedNote(page, type)).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath(`${type}-unlinked.png`) }); + // The drop lands on text, never on prepend_text (#342). + await drag(page, await slotPosition(page, sources[0], 'out', 0), center(await box.boundingBox())); + await expect.poll(() => linkedInputs(page, id)).toEqual(['text']); + const links = { text: [String(sources[0]), 0] }; + if (type === 'PromptManagerPair') { + await page.getByRole('textbox', { name: 'Negative prompt', exact: true }).fill(`Typed negative ${testInfo.project.name}`); + await drag(page, await slotPosition(page, sources[1], 'out', 0), await slotPosition(page, id, 'in', 'negative_text')); + await expect.poll(() => linkedInputs(page, id)).toEqual(['text', 'negative_text']); + links.negative_text = [String(sources[1]), 0]; + await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible(); + } + await expect(linkedNote(page, type)).toBeVisible(); + expect(await queued(page, id)).toMatchObject(links); + await page.screenshot({ path: testInfo.outputPath(`${type}-linked.png`) }); + for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) { + await page.evaluate(async renderer => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await expect(linkedNote(page, type)).toBeVisible(); + expect(await queued(page, id)).toMatchObject(links); + } + const saved = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const workflow = app.graph.serialize(); + await app.loadGraphData(workflow); + return workflow.nodes.find(node => String(node.id) === String(id)).widgets_values; + }, id); + expect(saved[0]).toBe(typed); + await expect(linkedNote(page, type)).toBeVisible(); + expect(await queued(page, id)).toMatchObject(links); + const local = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(id); + for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input)); + return (await app.graphToPrompt()).output[String(id)].inputs; + }, id); + expect(local.text).toBe(typed); + if (type === 'PromptManagerPair') expect(local.negative_text).toBe(`Typed negative ${testInfo.project.name}`); + await expect(linkedNote(page, type)).toBeHidden(); + const restored = await promptBox(page, type, id); + await expect(restored).toBeVisible(); + await expect(restored).toHaveValue(typed); + await page.screenshot({ path: testInfo.outputPath(`${type}-disconnected.png`) }); + await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id); + await expect(page.locator('.pm-focused, .pm-classic, .pm-text-link-note')).toHaveCount(0); + }); +} + +async function addEditors(page) { + return page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; }; + const focused = add('PromptManagerFocused', 420, 40); + focused.setSize([560, 640]); + const pair = add('PromptManagerPair', 1020, 40); + pair.setSize([1000, 640]); + for (const [node, name, value] of [[focused, 'text', 'Typed focused'], [pair, 'text', 'Typed positive'], [pair, 'negative_text', 'Typed negative']]) { + const widget = node.widgets.find(item => item.name === name); + widget.value = value; + if (widget.inputEl) widget.inputEl.value = value; + } + const sources = [0, 1].map(index => add(index ? 'PrimitiveString' : 'PrimitiveNode', 20, 60 + index * 300).id); + app.canvas.ds.scale = .7; + // Clear of the floating graph menu (top left) and the PM run bar (top right). + app.canvas.ds.offset = [110, 150]; + app.canvas.setDirty(true, true); + return { focused: focused.id, pair: pair.id, primitive: sources[0], string: sources[1] }; + }); +} + +/** The prompt a Run sends to the server, through the real app.queuePrompt path. */ +async function runPrompt(page) { + let captured; + const sent = new Promise(resolve => { captured = resolve; }); + // Installed before the run starts, so the POST can never reach the real host. + await page.route('**/prompt', async route => { + if (route.request().method() !== 'POST') return route.fallback(); + captured(route.request().postDataJSON().prompt); + return route.fulfill({ json: { prompt_id: 'captured', number: 1, node_errors: {} } }); + }); + await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); await app.queuePrompt(0, 1); }); + const prompt = await sent; + await page.unroute('**/prompt'); + return prompt; +} + +const box = (page, name, nth = 0) => page.getByRole('textbox', { name, exact: true }).nth(nth); + +test('a legacy Primitive dropped on an editor box or socket drives the text it shows as linked (#343 review I1, M1)', async ({ page }) => { + test.setTimeout(90000); + const ids = await addEditors(page); + // A wildcard Primitive aimed at the Main prompt box, a PrimitiveString aimed at the Negative prompt box. + await drag(page, await slotPosition(page, ids.primitive, 'out', 0), center(await box(page, 'Main prompt').boundingBox())); + await expect.poll(() => linkedInputs(page, ids.focused)).toEqual(['text']); + await drag(page, await slotPosition(page, ids.string, 'out', 0), center(await box(page, 'Negative prompt').boundingBox())); + await expect.poll(() => linkedInputs(page, ids.pair)).toEqual(['negative_text']); + await expect(page.getByText(note('Main prompt'), { exact: true })).toBeVisible(); + await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible(); + await page.evaluate(async ({ primitive, string }) => { + const { app } = await import('/scripts/app.js'); + app.graph.getNodeById(primitive).widgets[0].value = 'From the legacy Primitive'; + app.graph.getNodeById(string).widgets.find(widget => widget.name === 'value').value = 'From PrimitiveString'; + }, ids); + const prompt = await runPrompt(page); + expect(prompt[String(ids.focused)].inputs.text).toBe('From the legacy Primitive'); + expect(prompt[String(ids.pair)].inputs).toMatchObject({ text: 'Typed positive', negative_text: [String(ids.string), 0] }); + // The Primitive is re-read on every run; the typed text stays for disconnect. + await page.evaluate(async ({ primitive }) => { const { app } = await import('/scripts/app.js'); app.graph.getNodeById(primitive).widgets[0].value = 'Changed in the Primitive'; }, ids); + expect((await queued(page, ids.focused)).text).toBe('Changed in the Primitive'); + await page.evaluate(async ({ focused }) => { const { app } = await import('/scripts/app.js'); const node = app.graph.getNodeById(focused); node.disconnectInput(node.inputs.findIndex(input => input.name === 'text')); }, ids); + expect((await queued(page, ids.focused)).text).toBe('Typed focused'); +}); + +test('a wildcard legacy Primitive queues one resolved choice, drawn once per run, on every PromptManager node (#343 re-review R1)', async ({ page }) => { + const choices = `{${Array.from({ length: 10 }, (_, index) => `c${index}`).join('|')}}`; + const ids = await page.evaluate(async choices => { + const { app } = await import('/scripts/app.js'); + const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; }; + // [node, input, Primitive value, its "Run widget replace on values" option] + const targets = [['PromptManagerFocused', 'text', `Focused ${choices}`, false], ['PromptManagerSplit', 'text', `Split %date:yyyy% ${choices}`, true], + ['PromptManagerPair', 'negative_text', `Pair ${choices}`, false], ['PromptManager', 'text', `Classic ${choices}`, false], + ['PromptManagerText', 'text', `Text ${choices}`, false]]; + const ids = {}; + targets.forEach(([type, name, value, replace], index) => { + const node = add(type, 500, index * 800); + const primitive = add('PrimitiveNode', 20, index * 800); + if (!primitive.connect(0, node, node.inputs.findIndex(input => input.name === name))) throw new Error(`no Primitive link to ${type}.${name}`); + primitive.widgets[0].value = value; + primitive.properties['Run widget replace on values'] = replace; + ids[type] = [String(node.id), name]; + }); + // The Classic nodes install their own text serializer shortly after creation; run after it, as a user does. + await Promise.all(app.graph.nodes.map(node => node._pmTextSerializerReady)); + // What another extension's graphToPrompt wrapper sees must be what the server gets. + const inner = app.graphToPrompt; + const seen = window.__pmSeen = []; + app.graphToPrompt = async function (...args) { const result = await inner.apply(this, args); seen.push(structuredClone(result.output)); return result; }; + // Each draw takes the next choice, so a second draw for the same text always changes it. + const random = Math.random; + let draw = 0; + Math.random = () => ((draw++ % 10) + .5) / 10; + window.__pmRestore = () => { app.graphToPrompt = inner; Math.random = random; }; + return ids; + }, choices); + const prompt = await runPrompt(page); + const seen = await page.evaluate(() => { window.__pmRestore(); return window.__pmSeen; }); + expect(seen).toHaveLength(1); + const year = String(new Date().getFullYear()); + const stems = { PromptManagerFocused: 'Focused', PromptManagerSplit: `Split ${year}`, PromptManagerPair: 'Pair', PromptManager: 'Classic', PromptManagerText: 'Text' }; + // The Classic nodes keep their widget binding, so the host applies the Primitive and #343 leaves the value alone. + // As on main, their text widget's own serializer queues the text as written, wildcard included. + const classic = new Set(['PromptManager', 'PromptManagerText']); + for (const [type, [id, name]] of Object.entries(ids)) { + const value = prompt[id].inputs[name]; + if (classic.has(type)) expect(value, type).toBe(`${stems[type]} ${choices}`); + else expect(value, type).toMatch(new RegExp(`^${stems[type]} c\\d$`)); + expect(value, `${type}: one draw per run`).toBe(seen[0][id].inputs[name]); + } +}); + +test('a legacy Primitive inside a subgraph drives the inner editor text it shows as linked (#343 re-review R2)', async ({ page }) => { + const result = await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; }; + const focused = add('PromptManagerFocused', 500, 40); + focused.widgets.find(widget => widget.name === 'text').value = 'Typed under the link'; + const primitive = add('PrimitiveNode', 20, 40); + if (!primitive.connect(0, focused, focused.inputs.findIndex(input => input.name === 'text'))) throw new Error('no Primitive link'); + const { node } = app.graph.convertToSubgraph(new Set([focused, primitive])); + const inner = node.subgraph.nodes.find(item => item.type === 'PromptManagerFocused'); + // The value the user gives the Primitive inside the subgraph. + node.subgraph.nodes.find(item => item.type === 'PrimitiveNode').widgets[0].value = 'Inner {only choice}'; + return { host: String(node.id), inner: String(inner.id), linked: inner.inputs.find(input => input.name === 'text').link != null }; + }); + expect(result.linked).toBe(true); + const prompt = await runPrompt(page); + expect(prompt[`${result.host}:${result.inner}`]?.inputs.text).toBe('Inner only choice'); +}); + +test('a workflow saved before #343 with legacy Primitives on editor text keeps running their values (#343 review I1)', async ({ page }) => { + const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343-primitive.json', import.meta.url), 'utf8')); + const result = await page.evaluate(async workflow => { + const { app } = await import('/scripts/app.js'); + await app.loadGraphData(structuredClone(workflow)); + const before = (await app.graphToPrompt()).output; + app.graph.getNodeById(4).widgets[0].value = 'Edited after the load'; + const after = (await app.graphToPrompt()).output; + const bound = id => app.graph.getNodeById(id).inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length; + return { before: [before['1'].inputs.text, before['2'].inputs.text, before['2'].inputs.negative_text], after: after['2'].inputs.negative_text, bound: [bound(1), bound(2)], + links: [app.graph.getNodeById(1).inputs.find(input => input.name === 'text').link != null, app.graph.getNodeById(2).inputs.find(input => input.name === 'negative_text').link != null] }; + }, workflow); + expect(result).toEqual({ before: ['Primitive focused text', 'Typed pair positive', 'Primitive pair negative'], after: 'Edited after the load', bound: [0, 0], links: [true, true] }); + const prompt = await runPrompt(page); + expect([prompt['1'].inputs.text, prompt['2'].inputs.negative_text]).toEqual(['Primitive focused text', 'Edited after the load']); +}); + +test('moving a linked text and dropping it on the editor body keeps it on text, never prepend_text (#343 review I2)', async ({ page }) => { + test.setTimeout(90000); + const ids = await addEditors(page); + await page.evaluate(async ({ focused, pair, string }) => { + const { app } = await import('/scripts/app.js'); + const source = app.graph.getNodeById(string); + for (const id of [focused, pair]) { const node = app.graph.getNodeById(id); source.connect(0, node, node.inputs.findIndex(input => input.name === 'text')); } + app.canvas.setDirty(true, true); + }, ids); + for (const [id, title] of [[ids.focused, 0], [ids.pair, 1]]) { + // Grab the link at the text socket and release it over the editor's toolbar, away from any prompt box. + const toolbar = page.locator('.pm-editor-toolbar strong').nth(title); + await drag(page, await slotPosition(page, id, 'in', 'text'), center(await toolbar.boundingBox())); + await expect.poll(() => linkedInputs(page, id)).toEqual(['text']); + } +}); + +test('paste, clone and undo keep the editor text a linked socket (#343 review M7)', async ({ page }) => { + const ids = await addEditors(page); + const state = await page.evaluate(async ({ pair, string }) => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(pair); + app.graph.getNodeById(string).connect(0, node, node.inputs.findIndex(input => input.name === 'text')); + const describe = target => ({ bound: target.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length, + linked: target.inputs.find(input => input.name === 'text').link != null }); + app.canvas.selectItems([node]); + app.canvas.copyToClipboard(); + app.canvas.pasteFromClipboard({ connectInputs: true }); + const pasted = app.graph._nodes.find(item => item.type === 'PromptManagerPair' && item.id !== pair); + const clone = node.clone(); + app.graph.add(clone); + const output = (await app.graphToPrompt()).output; + return { pasted: describe(pasted), clone: describe(clone), queued: output[String(pasted.id)].inputs.text, pastedId: pasted.id }; + }, ids); + expect(state).toMatchObject({ pasted: { bound: 0, linked: true }, clone: { bound: 0, linked: false }, queued: [String(ids.string), 0] }); + // Undo restores the graph through a workflow load: the restored link is still a socket link. + const undone = await page.evaluate(async ({ pair }) => { + const { app } = await import('/scripts/app.js'); + const tracker = app.extensionManager?.workflow?.activeWorkflow?.changeTracker; + if (!tracker) throw new Error('no change tracker on the active workflow'); + tracker.checkState(); + const node = app.graph.getNodeById(pair); + node.disconnectInput(node.inputs.findIndex(input => input.name === 'text')); + tracker.checkState(); + await tracker.undo(); + const restored = app.graph.getNodeById(pair); + return { bound: restored.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length, + text: (await app.graphToPrompt()).output[String(pair)].inputs.text }; + }, ids); + expect(undone).toEqual({ bound: 0, text: [String(ids.string), 0] }); +}); + +test('PromptSearchList text is a core widget socket and takes a dragged STRING link too', async ({ page }) => { + const { id, sources } = await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode('PromptSearchList'); + app.graph.add(node); + node.pos = [500, 80]; + const source = LiteGraph.createNode('PrimitiveString'); + app.graph.add(source); + source.pos = [40, 80]; + app.canvas.ds.scale = .8; + app.canvas.ds.offset = [40, 40]; + app.canvas.setDirty(true, true); + return { id: node.id, sources: [source.id] }; + }); + // A core widget's socket shows while a link is dragged; aim at where the frontend places it. + await expect.poll(async () => Boolean(await slotPosition(page, id, 'in', 'text'))).toBe(true); + await drag(page, await slotPosition(page, sources[0], 'out', 0), await slotPosition(page, id, 'in', 'text')); + await expect.poll(() => linkedInputs(page, id)).toEqual(['text']); + expect((await queued(page, id)).text).toEqual([String(sources[0]), 0]); +}); + +test('workflows saved before #343 load unchanged and their linked text keeps working', async ({ page }) => { + const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343.json', import.meta.url), 'utf8')); + const result = await page.evaluate(async workflow => { + const { app } = await import('/scripts/app.js'); + await app.loadGraphData(structuredClone(workflow)); + const output = (await app.graphToPrompt()).output; + const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node])); + const reloaded = app.graph.serialize(); + return { + values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])), + links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])), + widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])), + }; + }, workflow); + for (const node of workflow.nodes) { + // Every saved widget value comes back as it was saved, except the typed text main lost under a link (null). + if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values); + } + expect(result.links).toMatchObject({ + 1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' }, + 4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' }, + 6: { text: ['7', 0] }, 8: { text: ['9', 0], negative_text: ['10', 0] }, 11: { text: ['12', 0] }, + }); + // The editor nodes' text inputs are sockets now; the Classic nodes keep their core widget sockets. + expect(result.widgetBound).toEqual({ 1: ['text'], 2: ['text'], 3: [], 4: [], 5: [], 6: [], 8: [], 11: ['text'] }); + // The linked sockets are drawn where a user can see and re-drag them, in either renderer. + for (const [node, name] of [[6, 'text'], [8, 'text'], [8, 'negative_text']]) { + await page.evaluate(async node => { const { app } = await import('/scripts/app.js'); app.canvas.centerOnNode(app.graph.getNodeById(node)); app.canvas.setDirty(true, true); }, node); + await expect.poll(async () => { + const position = await slotPosition(page, node, 'in', name); + return Boolean(position && position[0] > 0 && position[1] > 0 && position[0] < 1600 && position[1] < 1100); + }, { message: `${node}.${name} socket on screen` }).toBe(true); + } + const unplugged = await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + for (const id of [6, 8]) { const node = app.graph.getNodeById(id); for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input)); } + const output = (await app.graphToPrompt()).output; + return { focused: output['6'].inputs.text, positive: output['8'].inputs.text, negative: output['8'].inputs.negative_text }; + }); + expect(unplugged).toEqual({ focused: 'Typed under focused link', positive: 'Typed pair positive', negative: 'Typed pair negative' }); +}); diff --git a/tests/fixtures/generation_params/vectors.json b/tests/fixtures/generation_params/vectors.json index 0f7097ef..9f30bb7c 100644 --- a/tests/fixtures/generation_params/vectors.json +++ b/tests/fixtures/generation_params/vectors.json @@ -186,6 +186,57 @@ "prompt": null, "workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}], \"widgets_values\": [\"a tidepool at noon\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"noisy\"]}, {\"id\": 10, \"type\": \"Reroute\", \"mode\": 0, \"inputs\": [{\"name\": \"\", \"link\": 7}]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}, {\"name\": \"latent_image\", \"link\": 2}], \"widgets_values\": [314, \"randomize\", 25, 6.5, \"dpmpp_2m\", \"karras\", 1]}, {\"id\": 11, \"type\": \"CLIPTextEncode\", \"mode\": 2, \"inputs\": [], \"widgets_values\": [\"muted node text\"]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [7, 6, 0, 10, 0, \"CONDITIONING\"], [8, 10, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"], {\"id\": 2, \"origin_id\": 99, \"origin_slot\": 0, \"target_id\": 3, \"target_slot\": 3, \"type\": \"LATENT\"}]}" }, + { + "expected": { + "ok": true, + "result": { + "cfgScale": 5, + "checkpoint": "example.safetensors", + "negativePrompt": "plain negative", + "positivePrompt": "a detached positive", + "sampler": "euler", + "seed": 7, + "steps": 12 + } + }, + "name": "case:workflow-only-detached-editor-text", + "prompt": null, + "workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerFocused\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"plain negative\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"]]}" + }, + { + "expected": { + "ok": true, + "result": { + "cfgScale": 5, + "checkpoint": "example.safetensors", + "negativePrompt": "a detached negative", + "positivePrompt": "a detached positive", + "sampler": "euler", + "seed": 7, + "steps": 12 + } + }, + "name": "case:workflow-only-detached-pair-text", + "prompt": null, + "workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerPair\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"negative_text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a detached negative\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 6, 1, 3, 2, \"CONDITIONING\"]]}" + }, + { + "expected": { + "ok": true, + "result": { + "cfgScale": 4.5, + "checkpoint": "example.safetensors", + "negativePrompt": "plain negative", + "positivePrompt": "a detached positive", + "sampler": "dpmpp_2m", + "seed": 9, + "steps": 30 + } + }, + "name": "case:workflow-only-core-widget-input-out-of-order", + "prompt": null, + "workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerFocused\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"plain negative\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}, {\"name\": \"cfg\", \"widget\": {\"name\": \"cfg\"}, \"link\": null}], \"widgets_values\": [9, \"fixed\", 30, 4.5, \"dpmpp_2m\", \"karras\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"]]}" + }, { "expected": { "ok": true, diff --git a/tests/generation_params_cases.py b/tests/generation_params_cases.py index debdc7c8..3bc153e3 100644 --- a/tests/generation_params_cases.py +++ b/tests/generation_params_cases.py @@ -244,6 +244,90 @@ def _workflow_only(): } +def _detached_editor_text(pair): + """#343: editor text inputs are sockets, not widget inputs; no named values.""" + names = ["text"] + (["negative_text"] if pair else []) + sockets = [ + {"name": "clip", "link": 3}, + {"name": "prepend_text", "link": None}, + {"name": "append_text", "link": None}, + ] + sockets += [{"name": name, "link": None} for name in names] + sockets += [ + {"name": name, "widget": {"name": name}, "link": None} + for name in ("category", "tags", "search_text") + ] + editor = { + "id": 6, + "type": "PromptManagerPair" if pair else "PromptManagerFocused", + "mode": 0, + "inputs": sockets, + "widgets_values": ["a detached positive"] + + (["a detached negative"] if pair else []) + + ["a category", "a tag", "", "", ""], + } + negative = ( + [] + if pair + else [ + { + "id": 7, + "type": "CLIPTextEncode", + "mode": 0, + "inputs": [{"name": "clip", "link": 5}], + "widgets_values": ["plain negative"], + } + ] + ) + return { + "nodes": [ + { + "id": 4, + "type": "CheckpointLoaderSimple", + "mode": 0, + "inputs": [], + "widgets_values": ["example.safetensors"], + }, + editor, + *negative, + { + "id": 3, + "type": "KSampler", + "mode": 0, + "inputs": [ + {"name": "model", "link": 1}, + {"name": "positive", "link": 8}, + {"name": "negative", "link": 4}, + ], + "widgets_values": [7, "fixed", 12, 5, "euler", "normal", 1], + }, + ], + "links": [ + [1, 4, 0, 3, 0, "MODEL"], + [3, 4, 1, 6, 0, "CLIP"], + [5, 4, 1, 7, 0, "CLIP"], + [8, 6, 0, 3, 1, "CONDITIONING"], + [4, 6 if pair else 7, 1 if pair else 0, 3, 2, "CONDITIONING"], + ], + } + + +def _core_widget_input_out_of_order(): + """#343 review R4: a core KSampler whose only widget input is ``cfg``. + + Older frontends saved only converted or linked widget inputs, so the list + is not a prefix of the schema and carries no named values; the positional + values must still land on the schema's names. + """ + workflow = _detached_editor_text(False) + sampler = workflow["nodes"][-1] + sampler["inputs"] = sampler["inputs"] + [ + {"name": "cfg", "widget": {"name": "cfg"}, "link": None} + ] + sampler["widgets_values"] = [9, "fixed", 30, 4.5, "dpmpp_2m", "karras", 1] + return workflow + + def _nested(depth): """A value nested ``depth`` arrays deep, as a crafted file can hold.""" value = ["leaf", 7] @@ -389,6 +473,11 @@ CASES = { } ), "workflow-only": case(workflow=_workflow_only()), + "workflow-only-detached-editor-text": case(workflow=_detached_editor_text(False)), + "workflow-only-detached-pair-text": case(workflow=_detached_editor_text(True)), + "workflow-only-core-widget-input-out-of-order": case( + workflow=_core_widget_input_out_of_order() + ), "overwritten-prompt-manager-text": case( { "4": CHECKPOINT, diff --git a/tests/js/text-links.test.js b/tests/js/text-links.test.js new file mode 100644 index 00000000..aae97db3 --- /dev/null +++ b/tests/js/text-links.test.js @@ -0,0 +1,201 @@ +// #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); +}); diff --git a/tests/test_generation_params.py b/tests/test_generation_params.py index 507cea29..9b42119a 100644 --- a/tests/test_generation_params.py +++ b/tests/test_generation_params.py @@ -82,6 +82,26 @@ class TracesPrompts(unittest.TestCase): self.assertEqual(result["positivePrompt"], "the real positive") self.assertEqual(result["negativePrompt"], "the real negative") + def test_core_sampler_with_an_out_of_order_widget_input_keeps_positions(self): + # #343 review R4: only ``cfg`` is widget-bound, so the bound names are not + # a schema prefix; the values follow the KSampler schema positions. + result = run("workflow-only-core-widget-input-out-of-order") + self.assertEqual( + (result["seed"], result["steps"], result["cfgScale"], result["sampler"]), + (9, 30, 4.5, "dpmpp_2m"), + ) + + def test_editor_text_inputs_without_widget_bindings_keep_their_positions(self): + # #343: Focused/Split/Pair text inputs are plain sockets. The widget-bound + # inputs that remain (category, tags, search_text) must not shift the + # positional widget values onto the wrong names. + result = run("workflow-only-detached-editor-text") + self.assertEqual(result["positivePrompt"], "a detached positive") + self.assertEqual(result["negativePrompt"], "plain negative") + result = run("workflow-only-detached-pair-text") + self.assertEqual(result["positivePrompt"], "a detached positive") + self.assertEqual(result["negativePrompt"], "a detached negative") + class ChoosesTheSampler(unittest.TestCase): def test_first_sampler_follows_javascript_key_order(self): diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index eed33dd2..284e2646 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -23,19 +23,22 @@ test('execution script discovers only the new matrix and preserves all 78 origin env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } }); const stock = list('tools/vendor/comfyui.config.mjs'); // + local Inspect host case (inspect-sender.spec.mjs, 2 projects), + the S10 renderer case (prompt-bridge.spec.mjs, 2 projects) - assert.match(stock, /Total: 84 tests in 8 files/); + // + #343 linked text: five node types, PromptSearchList, the pre-#343 workflows, legacy Primitive, moved link, + // paste/clone/undo, wildcard Primitive, subgraph Primitive (text-link.spec.mjs, 2 projects) + assert.match(stock, /Total: 110 tests in 9 files/); assert.doesNotMatch(stock, /execution-history/); assert.doesNotMatch(stock, /execution-outputs/); assert.doesNotMatch(stock, /library-location/); // it starts its own fresh host (#249 S6b) const executions = list('tools/vendor/playwright.execution.config.mjs'); assert.match(executions, /\[classic-start\]/); assert.match(executions, /\[nodes2-start\]/); - assert.match(executions, /Total: 48 tests in 3 files/); // + the fresh-host library location case + assert.match(executions, /Total: 86 tests in 4 files/); // + the fresh-host library location case, + #343 linked text for (const project of ['classic-start', 'nodes2-start']) { const cases = executions.split('\n').filter(line => line.includes(`[${project}]`)); assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15); assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8); assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1); + assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 19); } assert.doesNotMatch(executions, /prompt-manager.spec/); }); diff --git a/tools/vendor/comfyui.config.mjs b/tools/vendor/comfyui.config.mjs index bc1e5bfc..6cd502f5 100644 --- a/tools/vendor/comfyui.config.mjs +++ b/tools/vendor/comfyui.config.mjs @@ -19,7 +19,7 @@ const servers = [{ export default defineConfig({ testDir: '../../tests/comfyui', - testIgnore: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'library-location.spec.mjs'], + testIgnore: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'execution-text-link.spec.mjs', 'library-location.spec.mjs'], outputDir: './test-results/comfyui', timeout: 60000, workers: 1, diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index fca649a9..4210a827 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -149,6 +149,12 @@ "functions": 56, "statements": 57 }, + "text-links.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "theme-bridge.js": { "lines": 90, "branches": 53, diff --git a/tools/vendor/playwright.execution.config.mjs b/tools/vendor/playwright.execution.config.mjs index 82f1e054..25502028 100644 --- a/tools/vendor/playwright.execution.config.mjs +++ b/tools/vendor/playwright.execution.config.mjs @@ -6,7 +6,7 @@ if (host.href !== 'http://127.0.0.1:8196/') throw new Error('Execution tests req export default defineConfig({ testDir: '../../tests/comfyui', - testMatch: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'library-location.spec.mjs'], + testMatch: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'execution-text-link.spec.mjs', 'library-location.spec.mjs'], outputDir: './test-results/executions/cases', timeout: 60000, workers: 1, diff --git a/utils/generation_params.py b/utils/generation_params.py index 42b7c9b3..6b340d61 100644 --- a/utils/generation_params.py +++ b/utils/generation_params.py @@ -762,9 +762,15 @@ def _widget_names(node): and _truthy(_get(item, "widget")) and _truthy(_get(_get(item, "widget"), "name")) ] - if from_inputs: - return from_inputs node_type = _get(node, "type") + if from_inputs: + # A known node's widget-bound inputs must line up with its widget order. + # Sockets that are not widget inputs (#343: the editors' text) would shift + # every positional value onto the wrong name; use the table then. + known = WIDGET_POSITIONS.get(node_type) if isinstance(node_type, str) else None + if known is not None and list(known[: len(from_inputs)]) != from_inputs: + return list(known) + return from_inputs key = _js_string(node_type) if key in _OBJECT_PROTOTYPE_KEYS: raise GenerationParamsError(f"widget positions of {key!r} are not iterable") diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index 4a74bb79..51c1bdca 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -5,6 +5,7 @@ import { api } from "../../scripts/api.js"; const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js')); const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js')); const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js')); +const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js')); const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); // ============================================================================ @@ -208,15 +209,10 @@ function syncPromptManagerWidgets() { node.widgets.forEach((widget, idx) => { if (widget) { - // Skip widgets whose input is connected to another node — - // the connected value takes precedence over the DOM widget value - const isConnected = node.inputs?.some( - i => i.name === widget.name && i.link != null - ); - if (isConnected) return; - + // A linked widget keeps the text the user typed (#343): the link drives the run, + // the workflow keeps the typed text for when the link is disconnected. // For non-connected widgets, inputEl.value is the true source - const actualValue = widget.inputEl ? widget.inputEl.value : widget.value; + const actualValue = storedWidgetValue(node, widget); if (actualValue !== undefined) { node.widgets_values[idx] = actualValue; @@ -232,6 +228,45 @@ function syncPromptManagerWidgets() { } } +/** + * #343: a legacy frontend Primitive linked to a PromptManager editor text input runs with the Primitive's value. + * The Primitive writes into the target's widget, but the editor text inputs are no longer bound to their hidden + * widgets, so the queued value would otherwise be the hidden typed text while the editor says "linked". Inputs still + * bound to a widget (the Classic nodes) are left as the host resolved them. + * + * A wildcard (`{red|blue}`) draws a new choice each time it is resolved, so each queued output is filled once: by the + * graphToPrompt wrapper, or by the queue hook when a caller queues an output that did not come through it. + */ +const primitiveTextApplied = new WeakSet(); +const MAX_SUBGRAPH_DEPTH = 32; + +/** Every node with the id it is queued under: a node inside a subgraph is `:`. */ +function* queuedNodes(graph, prefix = '', depth = 0) { + for (const node of graph?._nodes || graph?.nodes || []) { + yield [`${prefix}${node.id}`, node]; + if (depth < MAX_SUBGRAPH_DEPTH && node.isSubgraphNode?.() && node.subgraph) { + yield* queuedNodes(node.subgraph, `${prefix}${node.id}:`, depth + 1); + } + } +} + +async function applyPrimitiveText(output) { + if (!output || typeof output !== 'object' || primitiveTextApplied.has(output)) return; + primitiveTextApplied.add(output); + const replaceText = window.comfyAPI?.utils?.applyTextReplacements; + for (const [id, node] of queuedNodes(app.graph)) { + const queued = promptManagerTypes.has(node.type) ? output[id] : null; + // Only an output of this graph: the same id must hold the same node type. + const inputs = queued?.class_type === node.type ? queued.inputs : null; + if (!inputs) continue; + for (const name of ['text', 'negative_text']) { + if (Array.isArray(inputs[name])) continue; + const value = await primitiveTextValue(node, name, { replaceText }); + if (value !== undefined) inputs[name] = value; + } + } +} + app.registerExtension({ name: "PromptManager.UI", @@ -247,7 +282,9 @@ app.registerExtension({ const originalGraphToPrompt = app.graphToPrompt.bind(app); app.graphToPrompt = async function(...args) { syncPromptManagerWidgets(); - return originalGraphToPrompt(...args); + const result = await originalGraphToPrompt(...args); + await applyPrimitiveText(result?.output); + return result; }; // CRITICAL FIX: Intercept api.queuePrompt to fix prompt data at the LAST moment @@ -274,6 +311,7 @@ app.registerExtension({ } } + await applyPrimitiveText(output); return originalQueuePrompt(number, { output, workflow }, ...rest); }; }, @@ -304,53 +342,60 @@ app.registerExtension({ // CRITICAL FIX: Ensure text widget always returns current value during serialization // This fixes the issue where graphToPrompt reads stale cached values const self = this; + // Settles once the serializer below is in place (or skipped), for callers that must queue after it. + let textSerializerSettled; + this._pmTextSerializerReady = new Promise((resolve) => { textSerializerSettled = resolve; }); this._textSyncTimer = setTimeout(() => { - if (self._classicScope?.signal.aborted) return; - const textWidget = self.widgets?.find((w) => w.name === "text"); - if (textWidget) { - // Store reference to the widget for the closure - const widgetRef = textWidget; - const originalSerializeValue = textWidget.serializeValue; + try { + if (self._classicScope?.signal.aborted) return; + const textWidget = self.widgets?.find((w) => w.name === "text"); + if (textWidget) { + // Store reference to the widget for the closure + const widgetRef = textWidget; + const originalSerializeValue = textWidget.serializeValue; - // Override serializeValue - use arrow function to capture widgetRef - textWidget.serializeValue = (nodeId, widgetIndex) => { - // Always return the current DOM value using our stored reference - if (widgetRef.inputEl) { - const currentValue = widgetRef.inputEl.value; - // Sync widget.value as well - widgetRef.value = currentValue; - return currentValue; - } - return widgetRef.value; - }; - - // Add aggressive sync listeners for all value change events - if (textWidget.inputEl) { - const syncValue = function() { - const currentVal = textWidget.inputEl.value; - textWidget.value = currentVal; - // Also update widgets_values array - const widgetIndex = self.widgets?.indexOf(textWidget); - if (self.widgets_values && widgetIndex >= 0) { - self.widgets_values[widgetIndex] = currentVal; + // Override serializeValue - use arrow function to capture widgetRef + textWidget.serializeValue = (nodeId, widgetIndex) => { + // Always return the current DOM value using our stored reference + if (widgetRef.inputEl) { + const currentValue = widgetRef.inputEl.value; + // Sync widget.value as well + widgetRef.value = currentValue; + return currentValue; } + return widgetRef.value; }; - // Sync on every keystroke - const listen = self._classicScope - ? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue) - : (event) => textWidget.inputEl.addEventListener(event, syncValue); - listen('input'); - // Sync on change (for paste, autocomplete, etc.) - listen('change'); - // Sync when focus leaves the field - listen('blur'); - // Sync on keyup as backup - listen('keyup'); + // Add aggressive sync listeners for all value change events + if (textWidget.inputEl) { + const syncValue = function() { + const currentVal = textWidget.inputEl.value; + textWidget.value = currentVal; + // Also update widgets_values array + const widgetIndex = self.widgets?.indexOf(textWidget); + if (self.widgets_values && widgetIndex >= 0) { + self.widgets_values[widgetIndex] = currentVal; + } + }; - // Initial sync - syncValue(); + // Sync on every keystroke + const listen = self._classicScope + ? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue) + : (event) => textWidget.inputEl.addEventListener(event, syncValue); + listen('input'); + // Sync on change (for paste, autocomplete, etc.) + listen('change'); + // Sync when focus leaves the field + listen('blur'); + // Sync on keyup as backup + listen('keyup'); + + // Initial sync + syncValue(); + } } + } finally { + textSerializerSettled(); } }, 50); // Shorter delay to beat other extensions @@ -443,13 +488,8 @@ app.registerExtension({ } this.widgets.forEach((widget, idx) => { if (widget) { - // Skip connected widgets — their value comes from the link - const isConnected = self.inputs?.some( - i => i.name === widget.name && i.link != null - ); - if (isConnected) return; - - const actualValue = widget.inputEl ? widget.inputEl.value : widget.value; + // A linked widget stores its typed text, not the live editor (#343) + const actualValue = storedWidgetValue(self, widget); if (actualValue !== undefined) { this.widgets_values[idx] = actualValue; if (widget.inputEl && widget.value !== actualValue) { @@ -488,11 +528,36 @@ app.registerExtension({ if (data._userHasResized) { this._userHasResized = data._userHasResized; } + this.renderTextLink(); }; this.setDirtyCanvas(true, true); }; + // #343: text can come from a link like any core widget. The host hides the text box; + // the controls say why, and the typed text is kept for when the link is disconnected. + const onConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function (...args) { + const result = onConnectionsChange?.apply(this, args); + this.renderTextLink(); + return result; + }; + nodeType.prototype.createTextLinkNote = function () { + const note = document.createElement("p"); + note.className = "pm-text-link-note"; + note.setAttribute("role", "status"); + note.textContent = linkNoteText("Text"); + this._textLinkNote = note; + this.renderTextLink(); + return note; + }; + nodeType.prototype.renderTextLink = function () { + const linked = isLinked(this, "text"); + if (this._textLinkNote) this._textLinkNote.hidden = !linked; + collapseLinkedWidget(this.widgets?.find((w) => w.name === "text"), linked); + this.setDirtyCanvas?.(true, true); + }; + // Override onRemoved to cleanup styles const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { @@ -526,6 +591,10 @@ app.registerExtension({ searchSection.style.height = "100%"; searchSection.style.gap = "8px"; + const linkNote = this.createTextLinkNote(); + linkNote.style.cssText = "margin: 0; color: var(--fg-color); font-size: 12px;"; + searchSection.appendChild(linkNote); + // Search buttons row const buttonRow = document.createElement("div"); buttonRow.style.display = "flex"; @@ -876,6 +945,11 @@ app.registerExtension({ // Method to use a selected prompt nodeType.prototype.usePrompt = function (prompt, index) { + // The typed text under a link is kept for when it is disconnected (#343) + if (isLinked(this, "text")) { + this.showNotification("Text comes from a link. Disconnect it to use a saved prompt.", "error"); + return; + } // Find the text widget and set its value const textWidget = this.widgets?.find((w) => w.name === "text"); if (textWidget) { diff --git a/web/js/classic-node.css b/web/js/classic-node.css index 4949bd28..c5beefd3 100644 --- a/web/js/classic-node.css +++ b/web/js/classic-node.css @@ -12,3 +12,5 @@ .pm-classic-restore { white-space: normal !important; } .pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; } .pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); } +.pm-classic .pm-classic-link { margin: 0; padding: 6px 8px; border: 1px solid var(--ca-line); border-radius: 5px; color: var(--ca-text); background: var(--ca-field); overflow-wrap: anywhere; } +.pm-classic .pm-classic-link[hidden] { display: none; } diff --git a/web/js/classic-node.js b/web/js/classic-node.js index c0c9daf0..fc72d647 100644 --- a/web/js/classic-node.js +++ b/web/js/classic-node.js @@ -44,6 +44,9 @@ export function installClassicControls(nodeType, app, parseSearchQuery, api) { this._classicContainer = container; const wrapper = document.createElement('div'); wrapper.className = 'pm-classic-workspace'; + const linkNote = this.createTextLinkNote(); + linkNote.classList.add('pm-classic-link'); + wrapper.append(linkNote); const buttons = document.createElement('div'); buttons.className = 'pm-classic-toolbar'; for (const [label, action, primary] of [ diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index d3d82935..ee525253 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -275,7 +275,11 @@ function widgetNames(n) { const fromInputs = (n.inputs || []).filter((i) => i && i.widget && i.widget.name).map((i) => i.widget.name); - if (fromInputs.length) return fromInputs; + if (fromInputs.length) { + // Socket inputs that are not widget inputs (#343: the editors' text) would shift the positional values. + const known = typeof n.type === "string" && Object.prototype.hasOwnProperty.call(WIDGET_POSITIONS, n.type) ? WIDGET_POSITIONS[n.type] : null; + return known && fromInputs.some((name, index) => known[index] !== name) ? known : fromInputs; + } if (WIDGET_POSITIONS[n.type]) return WIDGET_POSITIONS[n.type]; if (/checkpointloader/i.test(n.type || "")) return ["ckpt_name"]; if (/unet/i.test(n.type || "")) return ["unet_name"]; diff --git a/web/js/focused-node.js b/web/js/focused-node.js index f2eed6f6..55727284 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -1,6 +1,7 @@ import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js'; import {createPromptLibrary} from './prompt-library.js'; import {attachMetadataAutocomplete} from './metadata-autocomplete.js'; +import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js'; const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text']; let nextEditorId = 0; @@ -23,12 +24,16 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo const pair = layout === 'pair'; const split = layout !== 'focused'; const uiKey = pair ? 'pairUI' : split ? 'splitUI' : 'focusedUI'; + const textInputs = pair ? ['text','negative_text'] : ['text']; const prototype = nodeType.prototype; + preferTextInputs(prototype, textInputs); const created = prototype.onNodeCreated; const removed = prototype.onRemoved; const configure = prototype.configure; const serialize = prototype.serialize; const connections = prototype.onConnectionsChange; + const inputOnPos = prototype.getInputOnPos; + const added = prototype.onAdded; prototype.onNodeCreated = function (...args) { created?.apply(this, args); const scope = createScope(); this._focusedScope = scope; @@ -41,6 +46,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4]; widgets.set(name, widget); } + exposeTextSockets(this, textInputs); const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}${pair ? ' pm-pair' : ''}`; element.setAttribute('aria-label',pair ? 'Paired prompt editor' : split ? 'Split prompt editor' : 'Focused prompt editor'); loadStyles(); mountTheme(element, app.ui?.settings, scope); @@ -63,6 +69,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo const mode = data?.settings?.webui_display_mode; if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode; }).catch(() => {}); + const dropTargets = []; function textControl(name, label) { const widget = widgets.get(name); const nativeInput = widget.inputEl; @@ -78,7 +85,14 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo const field = createField(label,input,`${editorId}-${name}`); field.classList.add('pm-editor-main'); const note = document.createElement('p'); note.className = 'pm-editor-status'; - note.textContent = `${pair ? label : 'Text'} is supplied by an upstream connection.`; + note.textContent = linkNoteText(label); + // The prompt box and its link note are drop targets for this input (#343): Nodes 2.0 resolves a drop + // through the nearest `.lg-node-widget` and its `[data-slot-key]`; Classic asks getInputOnPos. + for (const target of [field,note]) { + const key = document.createElement('span'); key.hidden = true; key.dataset.slotKey = ''; + target.classList.add('lg-node-widget'); target.append(key); + dropTargets.push({name,element:target,key}); + } return {input,field,note}; } const positive = textControl('text',pair ? 'Positive prompt' : 'Main prompt'); @@ -143,7 +157,12 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo widget.callback?.(text, app.canvas, thisNode); thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true); } + // The node id is assigned when the node joins a graph (and changes on paste), so keys follow it. + function syncSlotKeys() { + for (const target of dropTargets) target.key.dataset.slotKey = `${thisNode.id}-in-${thisNode.inputs?.findIndex(input => input.name === target.name)}`; + } function render() { + syncSlotKeys(); mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text'); if (negative) { negative.field.hidden = Boolean(isConnected('negative_text')); negative.note.hidden = !isConnected('negative_text'); } if (save) { save.disabled = saving || hasConnectedPairValues(); saveConnectionNotice.hidden = !hasConnectedPairValues(); } @@ -214,15 +233,33 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo footer.setAttribute('role',opened ? 'status' : 'alert'); footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.'; }); - this._focusedView = {element,render,restore,snapshot}; + function inputAt(position) { + const canvas = app.canvas; + if (canvas?.linkConnector?.state?.connectingTo !== 'input' || !canvas.ds || !canvas.canvas) return undefined; + const [x,y] = canvas.ds.convertOffsetToCanvas(position); + const box = canvas.canvas.getBoundingClientRect(); + return textInputAtPoint(thisNode, [box.left + x, box.top + y], dropTargets); + } + this._focusedView = {element,render,restore,snapshot,inputAt,syncSlotKeys}; this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360}); this.size = [pair ? 1100 : split ? 900 : 550,pair ? 700 : 650]; this.setSize(this.size); restore(); }; - prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; }; + prototype.configure = function (...args) { + const result = configure?.apply(this,args); + // A saved workflow restores its inputs with their widget bindings; keep the text inputs as sockets. + exposeTextSockets(this, textInputs); + this._focusedView?.restore(); + return result; + }; prototype.serialize = function (...args) { if (this._focusedView) this.properties[uiKey] = this._focusedView.snapshot(); return serialize?.apply(this,args); }; prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; }; prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); }; + prototype.onAdded = function (...args) { const result = added?.apply(this,args); this._focusedView?.syncSlotKeys(); return result; }; + // Classic: a link dropped on a prompt box (or its link note) fills that box's input. + prototype.getInputOnPos = function (...args) { + return inputOnPos?.apply(this,args) ?? this._focusedView?.inputAt(args[0]); + }; } diff --git a/web/js/text-links.js b/web/js/text-links.js new file mode 100644 index 00000000..6b8af2d3 --- /dev/null +++ b/web/js/text-links.js @@ -0,0 +1,165 @@ +// #343: let a link drive the editor nodes' prompt text, the way a link drives any core widget. +// +// The Focused, Split and Pair editors hide their widgets to draw their own editor. ComfyUI's frontend draws +// and hit-tests a widget's input socket only while the widget itself is visible, so `text` (and the Pair's +// `negative_text`) had no socket, and a STRING link dropped on the editor fell through to the first free +// STRING input, `prepend_text` (#342). Detaching those inputs from their hidden widgets makes them ordinary +// sockets in both renderers; the schema, the widgets and their saved values are unchanged. + +/** What an editor shows in place of a text field whose value comes from a link. */ +export function linkNoteText(label) { + return `${label} comes from a link. Your typed text is kept for when you disconnect it.`; +} + +export function isLinked(node, name) { + return Boolean(node?.inputs?.some(input => input.name === name && input.link != null)); +} + +/** Detach the named inputs from their hidden widgets so the frontend shows them as sockets. Returns the names changed. */ +export function exposeTextSockets(node, names) { + const exposed = []; + for (const input of node?.inputs || []) { + if (!names.includes(input.name) || !('widget' in input || input._widget)) continue; + delete input.widget; + if ('_widget' in input) input._widget = undefined; + exposed.push(input.name); + } + return exposed; +} + +function acceptsString(type) { + // Only exact STRING links: a wildcard (a legacy Primitive before it is typed, an untyped reroute) keeps the + // frontend's own choice, and is aimed at a prompt by dropping it on the prompt box or its socket. + return type != null && String(type).split(',').some(part => part.trim() === 'STRING'); +} + +function linkOf(graph, id) { + if (id == null || !graph) return undefined; + return graph.getLink?.(id) ?? graph.links?.get?.(id) ?? graph.links?.[id]; +} + +// The frontend's own free test: an input whose link is being moved (`_dragging`), or whose link the graph no +// longer has, takes a drop. Without a graph only the input's own link field is known. +function isFree(node, input) { + if (input.link == null) return true; + if (!node.graph) return false; + const link = linkOf(node.graph, input.link); + return link == null || Boolean(link._dragging); +} + +/** The first free text input, in editor order, for a link of `type`; undefined leaves the choice to the frontend. */ +export function preferredTextSlot(node, type, names) { + if (!acceptsString(type)) return undefined; + const inputs = node?.inputs || []; + for (const name of names) { + const index = inputs.findIndex(input => input.name === name); + if (index >= 0 && isFree(node, inputs[index])) return {index, slot:inputs[index]}; + } + return undefined; +} + +const MAX_VIRTUAL_HOPS = 32; +const REPLACE_OPTION = 'Run widget replace on values'; + +/** The legacy frontend Primitive (a virtual node) at the source of a link, following virtual reroutes. */ +function primitiveSource(graph, id) { + let link = linkOf(graph, id); + for (let hop = 0; link && hop < MAX_VIRTUAL_HOPS; hop++) { + const origin = graph.getNodeById?.(link.origin_id); + if (!origin?.isVirtualNode) return undefined; + if (origin.type === 'PrimitiveNode') return origin; + link = linkOf(graph, origin.inputs?.[0]?.link); + } + return undefined; +} + +/** A widget's queued value for `value`: its own serializer (the host's dynamic prompts) without touching its text. */ +function serializeAs(widget, value) { + if (typeof widget?.serializeValue !== 'function') return value; + const typed = widget.value; + widget.value = value; + try { + // No node argument: the host would also write the result into the workflow's widgets_values. + return widget.serializeValue(); + } finally { + widget.value = typed; + } +} + +/** + * The value a legacy frontend Primitive supplies to a linked editor text input, resolved the way the host resolves a + * value the Primitive writes into a widget: its text replacement when its "Run widget replace on values" option is on, + * then the input's own widget serializer (`{red|blue}` becomes one choice). Undefined when the input is unlinked, its + * source is a real node (serialized as a link), or the input is still bound to its widget, where the host already + * applied the Primitive (#343 re-review R1). + * + * The editor text inputs are no longer bound to their hidden widgets, so the Primitive cannot write into them and the + * queue path fills the value in instead (#343 review I1). Each call draws a new wildcard choice: call it once per run. + */ +export async function primitiveTextValue(node, name, {replaceText} = {}) { + const input = node?.inputs?.find(item => item.name === name); + const graph = node?.graph; + if (input?.link == null || input.widget || !graph) return undefined; + const primitive = primitiveSource(graph, input.link); + let value = primitive?.widgets?.[0]?.value; + if (value == null) return undefined; + value = String(value); + if (value && primitive.properties?.[REPLACE_OPTION] && replaceText) { + try { + value = replaceText(graph, value); + } catch (error) { + console.warn('[PromptManager] text replacement skipped for a linked Primitive', error); + } + } + return serializeAs(node.widgets?.find(widget => widget.name === name), value); +} + +/** A link dropped on the editor body (not on a socket) fills the main prompt first, then the negative. */ +export function preferTextInputs(prototype, names) { + const original = prototype.findInputByType; + if (typeof original !== 'function') return; + prototype.findInputByType = function (type, ...rest) { + return preferredTextSlot(this, type, names) ?? original.call(this, type, ...rest); + }; +} + +/** + * The value a workflow stores for a widget. A linked widget keeps what the user typed (its value), so the text + * returns when the link is disconnected; an unlinked one stores its live editor text. + */ +export function storedWidgetValue(node, widget) { + if (isLinked(node, widget.name)) return widget.value; + return widget.inputEl ? widget.inputEl.value : widget.value; +} + +/** The text input whose visible drop target (a prompt box or its link note) contains a viewport point. */ +export function textInputAtPoint(node, [x, y], targets) { + for (const {name, element} of targets) { + const box = element.getBoundingClientRect(); + if (!box.width || !box.height || x < box.left || x > box.right || y < box.top || y > box.bottom) continue; + const input = node?.inputs?.find(item => item.name === name); + if (input) return input; + } + return undefined; +} + +const LAYOUT_OPTIONS = ['getMinHeight','getMaxHeight']; + +/** + * While its input is linked, the frontend hides a core text box but keeps its row, leaving an empty gap in the + * Classic renderer. Give the row back while linked and restore the widget's own sizing on disconnect. + */ +export function collapseLinkedWidget(widget, linked) { + const options = widget?.options; + if (!options) return; + if (linked && !widget._pmLinkedLayout) { + widget._pmLinkedLayout = Object.fromEntries(LAYOUT_OPTIONS.filter(name => name in options).map(name => [name, options[name]])); + for (const name of LAYOUT_OPTIONS) options[name] = () => 0; + } else if (!linked && widget._pmLinkedLayout) { + for (const name of LAYOUT_OPTIONS) { + if (name in widget._pmLinkedLayout) options[name] = widget._pmLinkedLayout[name]; + else delete options[name]; + } + delete widget._pmLinkedLayout; + } +}