Files
ComfyAssets-ComfyUI_PromptM…/tests/comfyui/prompt-bridge.spec.mjs
Vito 76e9b51079 feat(bridge): add acknowledged ComfyUI prompt destinations (#203)
* feat(bridge): add guarded PromptManager prompt destinations

* fix(ci): install shared contracts before frontend unit tests

* test(comfyui): wait for paired editor resize before measuring layout
2026-10-17 13:14:02 -07:00

193 lines
12 KiB
JavaScript

import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {installHostHooks} from './host-fixture.mjs';
const TYPES=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
installHostHooks(test,TYPES);
async function addNode(page,type,values={}) {
return page.evaluate(async({type,values})=>{
const {app}=await import('/scripts/app.js');
app.graph.clear();
const node=LiteGraph.createNode(type);app.graph.add(node);
node.setSize([type==='PromptManagerSplit'?1000:700,1050]);
app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
for(const [name,value]of Object.entries(values)) {
const widget=node.widgets.find(widget=>widget.name===name);
widget.value=value;if(widget.inputEl)widget.inputEl.value=value;
widget.callback?.(value,app.canvas,node);
}
return node.id;
},{type,values});
}
async function withClient(page,context,run) {
const clientPage=await context.newPage();const errors=[];
clientPage.on('pageerror',error=>errors.push(error.message));
try {
await clientPage.goto(new URL('/prompt_manager/workspace#/overview',page.url()).href,{waitUntil:'domcontentloaded'});
await clientPage.evaluate(async()=>{
const {createBridgeClient}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.bridgeClient=createBridgeClient({origin:location.origin,namespace:'/'});
});
await run(clientPage);
expect(errors).toEqual([]);
} finally {
await clientPage.evaluate(()=>window.bridgeClient?.dispose()).catch(()=>{});
await clientPage.close();
}
}
async function destination(clientPage,id) {
const hosts=await clientPage.evaluate(()=>window.bridgeClient.discover({waitMs:200}));
const host=hosts.find(host=>host.targets.some(target=>String(target.id)===String(id)));
expect(host,'independently opened workspace discovers the real host').toBeDefined();
const target=host.targets.find(target=>String(target.id)===String(id));
return {hostId:host.hostId,target};
}
async function preview(clientPage,hostId,target,payload) {
return clientPage.evaluate(async({hostId,target,payload})=>{
window.bridgePreview=await window.bridgeClient.preview(hostId,target.id,payload);
return window.bridgePreview;
},{hostId,target,payload});
}
async function apply(clientPage,hostId) {
return clientPage.evaluate(hostId=>window.bridgeClient.apply(hostId,window.bridgePreview.ticket),hostId);
}
async function rejection(clientPage,action) {
return clientPage.evaluate(async action=>{
try {
if(action.kind==='preview')await window.bridgeClient.preview(action.hostId,action.targetId,action.payload);
else await window.bridgeClient.apply(action.hostId,window.bridgePreview.ticket);
return null;
} catch(error) {return String(error.message);}
},action);
}
async function snapshot(page,id) {
return page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
const serialized=node.serialize();
return {
widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
inputs:Object.fromEntries(node.widgets.filter(widget=>widget.inputEl).map(widget=>[widget.name,widget.inputEl.value])),
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
prompt:(await app.graphToPrompt()).output[String(id)].inputs,
};
},id);
}
async function connect(page,id,field) {
return page.evaluate(async({id,field})=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name===field);
if(!node.inputs.some(input=>input.name===field))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.widgets.find(widget=>widget.name==='value').value=`Upstream ${field}`;
source.connect(0,node,node.inputs.findIndex(input=>input.name===field));
return [String(source.id),0];
},{id,field});
}
test('independent workspace discovers all Prompt Manager destinations and writes real widgets without executing or loading workflows',async({page,context})=>{
await withClient(page,context,async clientPage=>{
for(const type of TYPES) {
const id=await addNode(page,type,{text:'Before bridge'});
const draft=`Visible draft ${type}`;
await page.locator('textarea:visible').first().fill(draft);
const {hostId,target}=await destination(clientPage,id);
expect(target.type).toBe(type);expect(target.label).toBeTruthy();expect(target.roles).toContain('positive');
expect(target.roles.includes('negative')).toBe(type==='PromptManagerPair');
await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');window.bridgeSideEffects={queue:0,load:0};
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]) {
const original=app[method];app[method]=function(...args){window.bridgeSideEffects[key]++;return original.apply(this,args);};
}
});
const text=`Bridge sent to ${type}`;const proposed=await preview(clientPage,hostId,target,{positive:text});
expect(proposed.ticket).toBeTruthy();expect(proposed.fields).toContainEqual({role:'positive',before:draft,after:text});
expect((await snapshot(page,id)).widgets.text).toBe(draft);
expect(await apply(clientPage,hostId)).toMatchObject({targetId:target.id,values:{positive:text}});
await expect.poll(()=>page.locator('textarea:visible').evaluateAll(inputs=>inputs.map(input=>input.value))).toContain(text);
const state=await snapshot(page,id);
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
expect(await page.evaluate(()=>window.bridgeSideEffects)).toEqual({queue:0,load:0});
}
});
});
test('pair sends preserve omitted negatives, accept an empty negative and synchronize visible editors and composition',async({page,context})=>{
const id=await addNode(page,'PromptManagerPair',{text:'Old positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
await withClient(page,context,async clientPage=>{
const {hostId,target}=await destination(clientPage,id);
const proposed=await preview(clientPage,hostId,target,{positive:'New positive'});
expect(proposed.composition).toMatchObject({prepend:'LoRA trigger',append:'fine detail',prependConnected:false,appendConnected:false,before:'LoRA trigger Old positive fine detail',after:'LoRA trigger New positive fine detail'});
await apply(clientPage,hostId);
expect((await snapshot(page,id)).widgets).toMatchObject({text:'New positive',negative_text:'Keep negative',prepend_text:'LoRA trigger',append_text:'fine detail'});
const root=page.locator('.pm-pair');
await expect(root.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('New positive');
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Keep negative');
await root.getByText('Composed text preview',{exact:true}).click();
await expect(root.locator('[data-composed-preview]')).toHaveText('LoRA trigger New positive fine detail');
await preview(clientPage,hostId,target,{positive:'Paired positive',negative:''});
await apply(clientPage,hostId);
await expect(root.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('');
const state=await snapshot(page,id);
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name]).toMatchObject({text:'Paired positive',negative_text:''});
});
});
test('connected requested fields reject atomically while connected composition remains linked',async({page,context})=>{
const id=await addNode(page,'PromptManagerPair',{text:'Keep positive',negative_text:'Keep negative',prepend_text:'Local prepend'});
const negativeLink=await connect(page,id,'negative_text');const prependLink=await connect(page,id,'prepend_text');
await withClient(page,context,async clientPage=>{
const {hostId,target}=await destination(clientPage,id);
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Must not apply',negative:'Blocked'}})).toMatch(/connected/i);
expect((await snapshot(page,id)).widgets).toMatchObject({text:'Keep positive',negative_text:'Keep negative'});
const proposed=await preview(clientPage,hostId,target,{positive:'Allowed positive'});
expect(proposed.composition.prependConnected).toBe(true);
await apply(clientPage,hostId);
expect((await snapshot(page,id)).prompt).toMatchObject({text:'Allowed positive',negative_text:negativeLink,prepend_text:prependLink});
const positiveLink=await connect(page,id,'text');
expect(await rejection(clientPage,{kind:'preview',hostId,targetId:target.id,payload:{positive:'Blocked positive'}})).toMatch(/connected/i);
expect((await snapshot(page,id)).prompt.text).toEqual(positiveLink);
});
});
test('user edits and workflow replacement invalidate acknowledged previews without overwriting changes',async({page,context})=>{
const id=await addNode(page,'PromptManagerFocused',{text:'Original draft'});
await withClient(page,context,async clientPage=>{
let {hostId,target}=await destination(clientPage,id);
await preview(clientPage,hostId,target,{positive:'Pending change'});
await page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true}).fill('User edited draft');
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
({hostId,target}=await destination(clientPage,id));
await preview(clientPage,hostId,target,{positive:'Pending after replacement'});
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
expect(await rejection(clientPage,{kind:'apply',hostId})).toMatch(/changed|stale|workflow/i);
expect((await snapshot(page,id)).widgets.text).toBe('User edited draft');
});
});
test('bridge remains live through both renderer switches and workflow reload with synchronized Focused controls',async({page,context},testInfo)=>{
const id=await addNode(page,'PromptManagerFocused',{text:'Initial',prepend_text:'trigger',append_text:'detail'});
await withClient(page,context,async clientPage=>{
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);app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
const {hostId,target}=await destination(clientPage,id);const text=`Renderer ${renderer}`;
await preview(clientPage,hostId,target,{positive:text});await apply(clientPage,hostId);
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(text);
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});
const state=await snapshot(page,id);
for(const name of ['widgets','inputs','serialized','prompt'])expect(state[name].text).toBe(text);
}
const {hostId,target}=await destination(clientPage,id);
await preview(clientPage,hostId,target,{positive:'After reload'});await apply(clientPage,hostId);
await expect(page.locator('.pm-focused').getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('After reload');
});
});