* feat(nodes): let a link drive the prompt text of every PromptManager node The Focused, Split and Pair editors hide their widgets, and ComfyUI only draws a widget's input socket while the widget is visible. `text` (and the Pair's `negative_text`) therefore had no socket, and a STRING link dropped on the editor fell through to the first free STRING input, `prepend_text`, which is how #342's workflow was wired. - Detach those inputs from their hidden widgets on create and after a workflow load, so they are ordinary sockets in Classic and Nodes 2.0. The schema, widget order and saved values are unchanged. - A STRING link dropped on the editor body fills the main prompt, then the negative, before the frontend's own choice. - Each editor says when its text comes from a link; the typed text is kept for disconnect. The Classic nodes now save the typed text under a link instead of null, and "use prompt" no longer overwrites it while linked. - Real-host tests: mouse drags in both renderers, a workflow saved before this change, and executions proving the linked text is saved, counted, recorded in history and linked to the run's image. Closes #343 * test(coverage): floor for the linked-text module * test(nodes): PromptSearchList text takes a dragged link like a core widget * fix(nodes): linked text review round for #343 - A legacy frontend Primitive linked to an editor's text or the Pair's negative now runs with its value: the graphToPrompt wrapper and the queue hook fill it in (through virtual reroutes), in new and pre-#343 workflows. - Moving a linked text and releasing it on the editor keeps it on `text`: a link being moved counts as free, as the frontend counts it. - A link dropped on a prompt box (or its link note) fills that box's input, so the Pair's Negative prompt box takes `negative_text` (Classic through getInputOnPos, Nodes 2.0 through the box's slot key). - Only exact STRING links are steered; wildcards keep the frontend choice. - A linked Classic text box gives its row back and gets it back on disconnect; the PromptManagerText note uses the theme colour. - The metadata parsers map positional widget values through the widget table when an editor's widget-bound inputs do not line up with it. - Real-host cases for the legacy Primitive (new and saved workflows, a real Run), the moved link, the Negative box, paste/clone/undo and Split. * fix(nodes): resolve a legacy Primitive's text like the host, once per run (#343) - Inputs still bound to their widget (Classic PromptManager/PromptManagerText) are left as the host resolved them; the injection no longer overwrites them. - For the editors' detached text inputs, the Primitive's value goes through its "Run widget replace on values" replacement and the hidden widget's own serializer, so {a|b} wildcards and %date% tokens resolve as on main. - Each queued output is filled once (graphToPrompt wrapper, or the queue hook for an output that did not come through it): a wildcard is drawn once. - Editors inside subgraphs are found under their <subgraph>:<inner> id. - Real-host runPrompt installs its request route before the run starts. - Shared parser case pins the widget-table fallback for a core KSampler. * fix(nodes): pin the Classic wildcard behaviour and match queued ids by type (#343) - Classic PromptManager/PromptManagerText expose _pmTextSerializerReady, settled once their own text serializer is installed; the real-host specs wait on it and assert the Classic text is queued as written, as on main. - The Primitive fill only touches an output entry whose class_type matches the graph node at that id. - TESTING.md notes the host's harmless "Invalid widget" warning per Run.
198 lines
14 KiB
JavaScript
198 lines
14 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
|
import { test, expect } from './fixtures.mjs';
|
|
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
|
|
|
test.beforeEach(async ({page, browserName}) => {
|
|
await startCoverage(page, browserName);
|
|
page.classicErrors = [];
|
|
page.on('pageerror', error => page.classicErrors.push(error.message));
|
|
await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'}));
|
|
await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'}));
|
|
await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')}));
|
|
await page.goto('/prompt_manager/test');
|
|
await page.evaluate(() => {
|
|
document.body.replaceChildren();
|
|
document.body.style.setProperty('--bg-color','#222');
|
|
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
|
|
});
|
|
await page.evaluate(async () => {
|
|
await import('/extensions/promptmanager/prompt_manager.js');
|
|
class Node {
|
|
constructor(){
|
|
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
|
|
this.widgets_values=[]; this.inputs=[];
|
|
}
|
|
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
|
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
|
|
}
|
|
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
|
|
window.node=new Node();node.onNodeCreated();
|
|
});
|
|
await expect(page.locator('.pm-classic')).toBeVisible();
|
|
});
|
|
|
|
test.afterEach(async ({page,browserName}) => {
|
|
await saveCoverage(page,browserName);
|
|
expect(page.classicErrors).toEqual([]);
|
|
});
|
|
|
|
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
|
|
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
|
|
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
|
|
const response=page.waitForRequest('**/prompt_manager/search?**');
|
|
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
|
|
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
|
|
const result=page.getByRole('button',{name:/A new portrait/});
|
|
await result.focus();await result.press('Enter');
|
|
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
|
|
await expect(result).toHaveAttribute('aria-pressed','true');
|
|
const released=await page.evaluate(()=>{
|
|
const previous=node.resultsSection.querySelector('button');
|
|
node.displayResults([{text:'Replacement',tags:[]}]);
|
|
previous.click();
|
|
return node.widgets.find(widget=>widget.name==='text').value;
|
|
});
|
|
expect(released).toBe('A new portrait');
|
|
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
|
|
});
|
|
|
|
test('Classic detaches native text synchronization on removal',async({page})=>{
|
|
// The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time.
|
|
await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function');
|
|
const values=await page.evaluate(()=>{
|
|
const widget=node.widgets.find(item=>item.name==='text');
|
|
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
|
|
const before=widget.value;node.onRemoved();
|
|
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
|
|
return [before,widget.value];
|
|
});
|
|
expect(values).toEqual(['Before removal','Before removal']);
|
|
});
|
|
|
|
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
|
|
const panel=page.locator('.pm-classic');
|
|
await expect(panel).toHaveAttribute('data-ca-theme','dark');
|
|
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
|
|
await expect(panel).toHaveAttribute('data-ca-theme','light');
|
|
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
|
|
await page.getByRole('button',{name:/Results auto-hidden/}).click();
|
|
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
|
|
const state=await page.evaluate(()=>{
|
|
node.showNotification('Failure','error');node.onRemoved();
|
|
const disposed=node._classicScope.signal.aborted;
|
|
node.showNotification('Late response','info');
|
|
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
|
|
});
|
|
expect(state).toEqual({disposed:true,notifications:0,style:false});
|
|
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
|
|
await expect(panel).toHaveAttribute('data-ca-theme','light');
|
|
});
|
|
|
|
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
|
|
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
|
|
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
|
|
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
|
|
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
|
|
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
|
|
await page.evaluate(async()=>{
|
|
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
|
|
await node.loadRecentPrompts();
|
|
});
|
|
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
|
|
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
|
|
const late=await page.evaluate(async()=>{
|
|
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
|
|
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
|
|
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
|
|
});
|
|
expect(late).toEqual({notifications:0,late:false});
|
|
});
|
|
|
|
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
|
|
await page.evaluate(async()=>{
|
|
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
|
|
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
|
|
window.shared={ui,scope,panel,events:[]};
|
|
document.body.append(panel);
|
|
ui.mountTheme(panel,null,scope);
|
|
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
|
|
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
|
|
});
|
|
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
|
|
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
|
|
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
|
|
await page.getByRole('switch',{name:'Enabled'}).uncheck();
|
|
await page.getByLabel('Order').selectOption('date');
|
|
await page.getByRole('radio',{name:'List',exact:true}).check();
|
|
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
|
|
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
|
|
await page.emulateMedia({colorScheme:'light'});
|
|
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
|
|
await page.emulateMedia({colorScheme:'dark'});
|
|
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
|
|
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
|
|
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
|
|
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
|
|
});
|
|
|
|
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
|
|
await page.clock.install();
|
|
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
|
|
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
|
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
|
|
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
|
|
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
|
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
|
|
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
|
|
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
|
|
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
|
|
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();
|
|
});
|