* 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
143 lines
13 KiB
JavaScript
143 lines
13 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const source = import('../../web/js/prompt-bridge-adapter.js');
|
|
function fixture(type='PromptManagerPair') {
|
|
const widgets = ['text','negative_text','prepend_text','append_text','category','tags'].map((name,index) => ({name,value:['old','bad',' start ',' end ','cat','tag'][index],inputEl:{value:['old','bad',' start ',' end ','cat','tag'][index]}}));
|
|
const node = {id:7,type,title:'My prompts',widgets,widgets_values:widgets.map(w=>w.value),inputs:[],_focusedView:{render(){node.rendered=(node.rendered||0)+1;}},setDirtyCanvas(){}};
|
|
const app = {graph:{_nodes:[node],_version:1,setDirtyCanvas(){}},canvas:{}};
|
|
return {app,node,widget:name=>node.widgets.find(w=>w.name===name)};
|
|
}
|
|
test('lists only explicit PromptManager destinations and roles for all five schemas',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
const types=['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair'];
|
|
const nodes=types.map((type,id)=>({...fixture(type).node,id})); nodes.push({...fixture().node,id:8,type:'CLIPTextEncode'});
|
|
const targets=createPromptAdapter({graph:{_nodes:nodes}}).listTargets();
|
|
assert.deepEqual(targets.map(t=>t.type),types);
|
|
assert.deepEqual(targets.map(t=>t.roles),types.map(type=>type==='PromptManagerPair'?['positive','negative']:['positive']));
|
|
assert.equal(targets[0].id,'0'); assert.equal(targets[0].label,'My prompts');
|
|
assert.deepEqual(createPromptAdapter({}).listTargets(),[]);
|
|
});
|
|
test('previews exact composition, updates requested visible/serialized values, preserves other fields',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app);
|
|
const {preview,snapshot}=adapter.preview('7',{positive:'new'});
|
|
assert.deepEqual(preview.fields,[{role:'positive',before:'old',after:'new'}]);
|
|
assert.deepEqual(preview.composition,{prepend:' start ',append:' end ',prependConnected:false,appendConnected:false,before:'start old end',after:'start new end'});
|
|
let callback; widget('text').callback=(value,canvas,target)=>{callback=[value,canvas,target];};
|
|
assert.deepEqual(adapter.apply(snapshot),{targetId:'7',values:{positive:'new'}});
|
|
assert.equal(widget('text').inputEl.value,'new'); assert.equal(node.widgets_values[0],'new'); assert.equal(node.rendered,1);
|
|
assert.deepEqual(callback,['new',app.canvas,node]);
|
|
for(const [name,value] of [['negative_text','bad'],['prepend_text',' start '],['append_text',' end '],['category','cat'],['tags','tag']]) assert.equal(widget(name).value,value);
|
|
assert.throws(()=>adapter.apply(snapshot),/snapshot|preview/i);
|
|
});
|
|
test('omitted positive stays intact, empty negative clears explicitly',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,widget}=fixture(); const adapter=createPromptAdapter(app);
|
|
adapter.apply(adapter.preview('7',{negative:''}).snapshot);
|
|
assert.equal(widget('text').value,'old'); assert.equal(widget('negative_text').value,'');
|
|
});
|
|
test('rejects invalid roles, unsupported targets, missing/connected fields before mutation',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node,widget}=fixture('PromptManagerText'); const adapter=createPromptAdapter(app);
|
|
for(const roles of [null,{},[],{positive:2},{positive:'x',other:'y'},{negative:'x'},Object.create({positive:'x'})]) assert.throws(()=>adapter.preview('7',roles));
|
|
assert.throws(()=>adapter.preview('99',{positive:'x'}));
|
|
node.inputs=[{name:'text',link:0}]; assert.throws(()=>adapter.preview('7',{positive:'x'}),/connect/i);
|
|
node.inputs=[]; node.widgets=node.widgets.filter(w=>w.name!=='text'); assert.throws(()=>adapter.preview('7',{positive:'x'}),/widget/i);
|
|
assert.equal(widget('negative_text').value,'bad');
|
|
});
|
|
test('connected composition is unknown and never overwritten',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node}=fixture(); node.inputs=[{name:'prepend_text',link:0}];
|
|
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'x'});
|
|
assert.equal(result.preview.composition.before,null); assert.equal(result.preview.composition.after,null); assert.equal(result.preview.composition.prependConnected,true);
|
|
adapter.apply(result.snapshot); assert.equal(node.inputs[0].link,0);
|
|
});
|
|
test('rejects stale workflow, node, widget, values, serialization, visible values, and connections',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.app.graph._version++;},f=>{f.app.graph._nodes=[{...f.node}];},f=>{f.node.type='PromptManagerText';},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='changed';},f=>{f.widget('text').inputEl.value='typing';},f=>{f.node.widgets_values[0]='changed';},f=>{f.node.inputs.push({name:'text',link:2});},f=>{f.node.inputs.push({name:'append_text',link:2});}];
|
|
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'}); mutate(f); assert.throws(()=>adapter.apply(snapshot),/changed|stale/i); assert.notEqual(f.widget('text').value,'new');}
|
|
});
|
|
test('validates both roles before writing and rejects forged snapshots',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
|
|
node.inputs.push({name:'negative_text',link:3}); assert.throws(()=>adapter.apply(snapshot)); assert.equal(widget('text').value,'old'); assert.throws(()=>adapter.apply({}));
|
|
});
|
|
test('callback failure or transformed value rolls all requested values back without an acknowledgement',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
for(const fail of ['throw','transform','visible','serialization']) {
|
|
const {app,node,widget}=fixture(),adapter=createPromptAdapter(app);
|
|
widget('negative_text').callback=()=>{if(fail==='throw') throw new Error('callback failed'); if(fail==='transform') widget('text').value='altered'; if(fail==='visible') widget('text').inputEl.value='altered'; if(fail==='serialization') node.widgets_values[0]='altered';};
|
|
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new',negative:'newbad'}).snapshot),/failed|changed|match/i);
|
|
assert.equal(widget('text').value,'old'); assert.equal(widget('text').inputEl.value,'old'); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('negative_text').value,'bad');
|
|
}
|
|
});
|
|
test('snapshots isolate caller mutations and require the originating adapter',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,widget}=fixture(),adapter=createPromptAdapter(app),roles={positive:'new'};
|
|
const result=adapter.preview('7',roles); roles.positive='changed'; result.preview.fields[0].after='changed';
|
|
assert.throws(()=>createPromptAdapter(app).apply(result.snapshot)); adapter.apply(result.snapshot); assert.equal(widget('text').value,'new');
|
|
});
|
|
test('applies positive text for every supported schema without a serialization array',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
for(const type of ['PromptManager','PromptManagerText','PromptManagerFocused','PromptManagerSplit','PromptManagerPair']) {
|
|
const {app,node,widget}=fixture(type); delete node.widgets_values; delete node._focusedView; delete node.setDirtyCanvas; delete app.graph.setDirtyCanvas;
|
|
const adapter=createPromptAdapter(app); adapter.apply(adapter.preview('7',{positive:''}).snapshot);
|
|
assert.equal(widget('text').value,''); assert.equal(node.widgets_values[0],'');
|
|
}
|
|
});
|
|
test('matches Python whitespace composition and validates the combined one MiB UTF-8 role limit',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.title=''; widget('prepend_text').value=' '; widget('append_text').value='';
|
|
const adapter=createPromptAdapter(app);
|
|
assert.equal(adapter.preview('7',{positive:' x '}).preview.composition.after,' x ');
|
|
assert.equal(adapter.preview('7',{positive:''}).preview.composition.after,'');
|
|
assert.equal(adapter.preview('7',{positive:'x'.repeat(1_048_576)}).preview.fields[0].after.length,1_048_576);
|
|
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(1_048_577)}));
|
|
assert.throws(()=>adapter.preview('7',{positive:'x'.repeat(524_288),negative:'y'.repeat(524_289)}));
|
|
assert.throws(()=>adapter.preview('7',{positive:'é'.repeat(524_289)}));
|
|
assert.equal(adapter.preview('7',{positive:'é'.repeat(524_288)}).preview.fields[0].after.length,524_288);
|
|
assert.throws(()=>adapter.preview('7',{get positive(){throw new Error('Getter must not run');}}),/strings/i);
|
|
assert.throws(()=>adapter.preview('7',{positive:'x',[Symbol('unknown')]:'y'}));
|
|
assert.equal(adapter.preview('7',Object.assign(Object.create(null),{positive:'x'})).preview.fields[0].after,'x');
|
|
assert.equal(adapter.listTargets()[0].label,'PromptManagerPair');
|
|
});
|
|
test('callback changes to graph, connections, widgets or protected prompt fields fail and restore fields',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
const mutations=[f=>{f.app.graph={...f.app.graph};},f=>{f.node.inputs=[{name:'text',link:4}];},f=>{f.node.widgets[0]={...f.node.widgets[0]};},f=>{f.widget('negative_text').value='unexpected';},f=>{f.widget('prepend_text').value='unexpected';},f=>{f.widget('category').value='unexpected';}];
|
|
for(const mutate of mutations){const f=fixture(),adapter=createPromptAdapter(f.app); f.widget('text').callback=()=>mutate(f); assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i); assert.equal(f.widget('negative_text').value,'bad'); assert.equal(f.widget('prepend_text').value,' start '); assert.equal(f.widget('category').value,'cat');}
|
|
});
|
|
test('minimal classic node without optional widgets remains a valid destination',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node}=fixture('PromptManager'); node.widgets=node.widgets.slice(0,1); node.widgets_values=['old']; delete node.widgets[0].inputEl;
|
|
const adapter=createPromptAdapter(app), result=adapter.preview('7',{positive:'new'});
|
|
assert.equal(result.preview.composition.before,'old'); assert.equal(result.preview.composition.after,'new');
|
|
assert.deepEqual(adapter.apply(result.snapshot).values,{positive:'new'});
|
|
});
|
|
test('callback removal of serialization or replacement of visible controls cannot acknowledge success',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
for(const mutate of [f=>{delete f.node.widgets_values;},f=>{f.widget('text').inputEl={value:'new'};},f=>{f.node.id=8;},f=>{f.node.widgets.reverse();},f=>{delete f.node.widgets;},f=>{f.node.widgets=[];}]) {
|
|
const f=fixture(),adapter=createPromptAdapter(f.app); const originalInput=f.widget('text').inputEl;
|
|
f.widget('text').callback=()=>mutate(f);
|
|
assert.throws(()=>adapter.apply(adapter.preview('7',{positive:'new'}).snapshot),/failed/i);
|
|
assert.equal(f.widget('text').value,'old'); assert.equal(originalInput.value,'old'); assert.equal(f.node.widgets_values[0],'old');
|
|
}
|
|
});
|
|
test('benign host serialization may establish or normalize equivalent serialized values after preview',async()=>{
|
|
const {createPromptAdapter}=await source;
|
|
for (const initial of ['absent','sparse','old']) {
|
|
const {app,node,widget}=fixture();
|
|
if(initial==='absent') delete node.widgets_values;
|
|
if(initial==='sparse') node.widgets_values=[];
|
|
if(initial==='old') node.widgets_values[0]='earlier serialized draft';
|
|
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new',negative:'newbad'});
|
|
// ComfyUI serialize() materializes the live widget values without editing them.
|
|
node.widgets_values=node.widgets.map(item=>item.inputEl ? item.inputEl.value : item.value);
|
|
assert.deepEqual(adapter.apply(snapshot).values,{positive:'new',negative:'newbad'});
|
|
assert.equal(widget('text').value,'new');
|
|
}
|
|
});
|
|
test('serialization normalization still rejects a different execution value',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); delete node.widgets_values;
|
|
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
|
|
node.widgets_values=node.widgets.map(item=>item.value); node.widgets_values[0]='unapproved';
|
|
assert.throws(()=>adapter.apply(snapshot),/changed/i); assert.equal(widget('text').value,'old');
|
|
});
|
|
test('callback failure preserves serialization normalized between preview and apply',async()=>{
|
|
const {createPromptAdapter}=await source; const {app,node,widget}=fixture(); node.widgets_values[0]='earlier draft';
|
|
const adapter=createPromptAdapter(app),{snapshot}=adapter.preview('7',{positive:'new'});
|
|
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
|
|
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
|
|
});
|