feat(ui): add Focused Prompt Manager with shared autocomplete (#191)
* feat(ui): add Focused Prompt Manager node (#186) * feat(ui): autocomplete saved Focused metadata with shared controls (#186) * feat(ui): autocomplete Focused library filters (#186)
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
|
||||
await startCoverage(page,browserName);
|
||||
page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.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(async()=>{
|
||||
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
|
||||
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];}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
||||
setSize(size){this.size=size;}setDirtyCanvas(){}
|
||||
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
||||
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
|
||||
}
|
||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated();
|
||||
});
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||
|
||||
test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
||||
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
|
||||
await page.route('**/prompt_manager/search?**',route=>{
|
||||
requests.push(new URL(route.request().url()));
|
||||
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
|
||||
});
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
const category=page.getByLabel('Filter category',{exact:true});
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
||||
await category.press('ArrowDown');
|
||||
await category.press('Enter');
|
||||
expect(requests).toHaveLength(0);
|
||||
const tags=page.getByLabel('Filter tags',{exact:true});
|
||||
await tags.fill('stu');
|
||||
await page.getByRole('option',{name:'studio',exact:true}).click();
|
||||
await expect(tags).toHaveValue('studio, ');
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
|
||||
expect(requests[0].searchParams.get('text')).toBe('portrait');
|
||||
expect(requests[0].searchParams.get('category')).toBe('Portrait');
|
||||
expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
|
||||
await page.locator('.pm-editor-result').click();
|
||||
await category.fill('Landscape');
|
||||
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
|
||||
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
|
||||
expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
|
||||
await expect(category).toHaveValue('Landscape');
|
||||
await expect(tags).toHaveValue('studio, ');
|
||||
let recentRequest;
|
||||
await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
|
||||
expect(recentRequest.searchParams.has('category')).toBe(false);
|
||||
expect(recentRequest.searchParams.has('tags')).toBe(false);
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('New category');
|
||||
await page.getByLabel('Filter category',{exact:true}).press('Enter');
|
||||
await expect.poll(()=>requests.length).toBe(1);
|
||||
expect(requests[0].searchParams.get('category')).toBe('New category');
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('');
|
||||
await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
|
||||
await page.getByLabel('Filter tags',{exact:true}).press('Enter');
|
||||
await expect.poll(()=>requests.length).toBe(2);
|
||||
expect(requests[1].searchParams.get('tags')).toBe('custom');
|
||||
});
|
||||
|
||||
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
|
||||
await page.route('**/prompt_manager/tags/suggest?**', route => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
|
||||
});
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const tags = page.getByLabel('Tags',{exact:true});
|
||||
await tags.fill('portrait, bl');
|
||||
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
|
||||
await tags.press('ArrowDown');
|
||||
await tags.press('Enter');
|
||||
await expect(tags).toHaveValue('portrait, blue eyes, ');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[2])).toBe('portrait, blue eyes, ');
|
||||
await tags.fill('bl');
|
||||
await page.getByRole('option',{name:'blue sky',exact:true}).click();
|
||||
await expect(tags).toHaveValue('blue sky, ');
|
||||
await tags.fill('portrait, ');
|
||||
await expect(page.getByRole('option',{name:'portrait',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
|
||||
await tags.press('Escape');
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
await tags.fill('bl');
|
||||
await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible();
|
||||
await page.locator('.pm-focused').dispatchEvent('scroll');
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
await tags.fill('A brand new tag');
|
||||
await tags.press('Tab');
|
||||
await expect(tags).toHaveValue('A brand new tag');
|
||||
});
|
||||
|
||||
test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
|
||||
let release;
|
||||
let mode='delayed';
|
||||
await page.route('**/prompt_manager/tags/suggest?**', async route => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
if(q === 'old') await new Promise(resolve=>{release=resolve;});
|
||||
if(mode === 'error') return route.fulfill({status:503,body:'Unavailable'});
|
||||
await route.fulfill({json:{success:true,suggestions:[{name:q+' result'}]}}).catch(()=>{});
|
||||
});
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const tags=page.getByLabel('Tags',{exact:true});
|
||||
await tags.fill('old');
|
||||
await expect.poll(()=>Boolean(release)).toBe(true);
|
||||
await tags.fill('new');
|
||||
await expect(page.getByRole('option',{name:'new result',exact:true})).toBeVisible();
|
||||
release();
|
||||
await expect(page.getByRole('option',{name:'old result',exact:true})).toHaveCount(0);
|
||||
mode='error';
|
||||
await tags.fill('unavailable');
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
await expect(tags).toHaveValue('unavailable');
|
||||
mode='ok';
|
||||
await tags.fill('retry');
|
||||
await expect(page.getByRole('option',{name:'retry result',exact:true})).toBeVisible();
|
||||
await page.evaluate(()=>node.onRemoved());
|
||||
await expect(page.getByRole('listbox')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const category=page.getByLabel('Category',{exact:true});
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeVisible();
|
||||
await category.press('ArrowUp');
|
||||
await expect(page.getByRole('option',{name:'Portrait studio',exact:true})).toHaveAttribute('aria-selected','true');
|
||||
await category.press('Enter');
|
||||
await expect(category).toHaveValue('Portrait studio');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[1])).toBe('Portrait studio');
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
||||
await category.dispatchEvent('keydown',{key:'ArrowDown',isComposing:true});
|
||||
await expect(category).not.toHaveAttribute('aria-activedescendant',/.+/);
|
||||
await page.getByLabel('Tags',{exact:true}).focus();
|
||||
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
|
||||
await category.fill('New category');
|
||||
await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden();
|
||||
await expect(category).toHaveValue('New category');
|
||||
});
|
||||
|
||||
test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
|
||||
await page.evaluate(async()=>{
|
||||
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
|
||||
const second=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=second');
|
||||
window.completionFixture={scope:createScope(),selected:[],inserted:[]};
|
||||
const one=first.createSuggestionView(completionFixture.scope,null);
|
||||
const two=second.createSuggestionView(completionFixture.scope,null,{label:'Other pack'});
|
||||
completionFixture.views=[one,two];
|
||||
one.hide();
|
||||
const items=[{name:'plain'},{name:'tag',type:'tag'},{name:'model',type:'lora'},{name:'nested',type:'embedding',fullPath:'folder/nested'},{name:'flat',type:'embedding',fullPath:'flat'}];
|
||||
one.render(items,index=>{completionFixture.selected.push(index);one.select(index);},item=>completionFixture.inserted.push(item.name));
|
||||
one.select(-1);
|
||||
one.position({left:innerWidth-5,bottom:innerHeight,top:innerHeight-20});
|
||||
two.render([{name:'Other pack suggestion'}],()=>{},()=>{});
|
||||
two.position({left:0,bottom:5,top:0});
|
||||
});
|
||||
const lists=page.getByRole('listbox');
|
||||
const ids=await lists.evaluateAll(elements=>elements.map(element=>element.id));
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
await page.getByRole('option',{name:'[LORA] model'}).hover();
|
||||
await expect(page.getByRole('option',{name:'[LORA] model'})).toHaveAttribute('aria-selected','true');
|
||||
await page.getByRole('option',{name:'[EMB] folder/nested'}).click();
|
||||
expect(await page.evaluate(()=>completionFixture.inserted)).toEqual(['nested']);
|
||||
await page.evaluate(()=>completionFixture.scope.dispose());
|
||||
await expect(lists).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.getByRole('button',{name:/Library portrait/}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
|
||||
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByLabel('Prepend',{exact:true}).fill('trigger');
|
||||
await page.getByLabel('Append',{exact:true}).fill('detailed');
|
||||
await page.getByText('Composed text preview',{exact:true}).click();
|
||||
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
|
||||
});
|
||||
|
||||
test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await page.getByLabel('Category',{exact:true}).fill('Saved category');
|
||||
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 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 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('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
|
||||
let requested;
|
||||
await page.route('**/prompt_manager/search?**', async route => {
|
||||
requested = new URL(route.request().url());
|
||||
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
|
||||
});
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
|
||||
await query.fill('fox tag:"film grain"');
|
||||
await query.press('Enter');
|
||||
await page.getByRole('button',{name:/Selected fox/}).click();
|
||||
expect(requested.searchParams.get('text')).toBe('fox');
|
||||
expect(requested.searchParams.get('tags')).toBe('film grain');
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
|
||||
await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
|
||||
});
|
||||
|
||||
test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
|
||||
let requests=0;
|
||||
let mode='empty';
|
||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||
await page.route('**/prompt_manager/search?**', async route => {
|
||||
requests++;
|
||||
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
|
||||
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
|
||||
});
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
const search=page.getByRole('button',{name:'Search',exact:true});
|
||||
await search.click();
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
||||
expect(requests).toBe(0);
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
|
||||
await search.click();
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
||||
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
||||
mode='error';
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
|
||||
await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
|
||||
mode='http';
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toContainText('HTTP 503');
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
||||
mode='empty';
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
|
||||
await search.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
||||
});
|
||||
|
||||
test('Focused treats saved prompt content and metadata as text', async ({page}) => {
|
||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.locator('.pm-editor-result').click();
|
||||
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
|
||||
await expect(page.locator('.pm-editor-library img, .pm-editor-library svg, .pm-editor-library script')).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(malicious);
|
||||
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
|
||||
});
|
||||
|
||||
async function deferLibraryRequests(page) {
|
||||
await page.evaluate(()=>{
|
||||
const originalFetch=window.fetch;
|
||||
window.libraryRequests=[];
|
||||
window.fetch=(url,options)=>{
|
||||
if(!/\/prompt_manager\/(search|recent)\?/.test(String(url))) return originalFetch(url,options);
|
||||
return new Promise(resolve=>window.libraryRequests.push({url:String(url),signal:options.signal,resolve}));
|
||||
};
|
||||
window.completeLibraryRequest=(index,text)=>libraryRequests[index].resolve(new Response(JSON.stringify({success:true,results:[{id:index,text}]}),{status:200,headers:{'Content-Type':'application/json'}}));
|
||||
});
|
||||
}
|
||||
|
||||
test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
expect(await page.evaluate(()=>libraryRequests[0].signal.aborted)).toBe(true);
|
||||
await page.evaluate(()=>completeLibraryRequest(1,'Latest recent'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
|
||||
await page.evaluate(()=>completeLibraryRequest(0,'Stale search'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('latest search');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
expect(await page.evaluate(()=>libraryRequests[2].signal.aborted)).toBe(true);
|
||||
await page.evaluate(()=>completeLibraryRequest(3,'Latest search'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
||||
await page.evaluate(()=>completeLibraryRequest(2,'Stale recent'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
||||
});
|
||||
|
||||
test('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
const removed=await page.evaluate(()=>{
|
||||
node.onRemoved();
|
||||
document.body.style.setProperty('--bg-color','#ffffff');
|
||||
fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));
|
||||
return {requestAborted:libraryRequests[0].signal.aborted,scopeAborted:node._focusedScope.signal.aborted};
|
||||
});
|
||||
expect(removed).toEqual({requestAborted:true,scopeAborted:true});
|
||||
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Focused fields reflow with node width and follow live host themes', async ({page}) => {
|
||||
await page.setViewportSize({width:1000,height:1000});
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
|
||||
const prepend=page.getByLabel('Prepend',{exact:true});
|
||||
const append=page.getByLabel('Append',{exact:true});
|
||||
let first=await prepend.boundingBox(),second=await append.boundingBox();
|
||||
expect(Math.abs(first.y-second.y)).toBeLessThan(2);
|
||||
expect(second.x).toBeGreaterThan(first.x+first.width);
|
||||
await page.evaluate(()=>{node._focusedView.element.style.width='300px';});
|
||||
await expect.poll(async()=>{const a=await prepend.boundingBox(),b=await append.boundingBox();return b.y>a.y+a.height;}).toBe(true);
|
||||
expect(await page.locator('.pm-focused').evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||
const dark=await prepend.evaluate(element=>getComputedStyle(element).backgroundColor);
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#ffffff'));
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','light');
|
||||
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).not.toBe(dark);
|
||||
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222222'));
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
|
||||
});
|
||||
|
||||
test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => {
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
|
||||
await page.getByLabel('Append',{exact:true}).fill(' detail ');
|
||||
await page.getByText('Composed text preview',{exact:true}).click();
|
||||
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
|
||||
});
|
||||
|
||||
test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => {
|
||||
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
|
||||
});
|
||||
|
||||
test('Focused expands its main editor when the node gets taller',async({page})=>{
|
||||
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
|
||||
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
|
||||
expect(editor.height).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
test('Focused respects the existing Web UI display preference',async({page})=>{
|
||||
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
|
||||
await page.evaluate(()=>{
|
||||
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
|
||||
window.open=(...args)=>{window.openArgs=args;return {};};
|
||||
});
|
||||
await page.evaluate(()=>node._focusedSettingsReady);
|
||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
||||
});
|
||||
|
||||
test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{
|
||||
await page.evaluate(()=>{window.open=()=>null;});
|
||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
|
||||
|
||||
async function addFocused(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptManagerFocused');
|
||||
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
|
||||
app.graph.add(node);
|
||||
node.setSize([680, 1050]);
|
||||
app.canvas.ds.scale = .8;
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
});
|
||||
}
|
||||
|
||||
async function model(page, id) {
|
||||
return page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value]));
|
||||
}, id);
|
||||
}
|
||||
|
||||
async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) {
|
||||
const response = await request.post('/prompt_manager/save', { data: { text, category, tags } });
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).success).toBe(true);
|
||||
}
|
||||
|
||||
async function browse(page, text) {
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||
return root;
|
||||
}
|
||||
|
||||
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
|
||||
const definitions = await (await request.get('/object_info')).json();
|
||||
const focused = definitions.PromptManagerFocused;
|
||||
expect(focused).toBeDefined();
|
||||
expect(focused.display_name).toBe('Prompt Manager (Focused)');
|
||||
expect(focused.output).toEqual(definitions.PromptManager.output);
|
||||
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
|
||||
for (const name of ['prepend_text', 'append_text']) {
|
||||
expect(focused.input.optional[name][0]).toBe('STRING');
|
||||
expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' });
|
||||
}
|
||||
});
|
||||
|
||||
test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => {
|
||||
const text = `Focused selected ${testInfo.project.name}`;
|
||||
await seed(request, text);
|
||||
const id = await addFocused(page);
|
||||
const root = page.locator('.pm-focused');
|
||||
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||
await expect(root).toBeVisible();
|
||||
expect(await editor.evaluate(async (element, id) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element;
|
||||
}, id)).toBe(true);
|
||||
await editor.fill('Original editing text');
|
||||
await browse(page, text);
|
||||
await expect(editor).toHaveValue('Original editing text');
|
||||
expect((await model(page, id)).text).toBe('Original editing text');
|
||||
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
|
||||
await expect(editor).toHaveValue(text);
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' });
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger');
|
||||
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail');
|
||||
await root.getByText('Composed text preview', { exact: true }).click();
|
||||
await expect(root).toContainText(`LoRA trigger ${text} high detail`);
|
||||
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);
|
||||
await expect(root).toHaveCount(1);
|
||||
await expect(editor).toHaveValue(text);
|
||||
}
|
||||
const queued = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||
await expect(editor).toHaveValue(text);
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||
await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') });
|
||||
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
|
||||
await expect(root).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => {
|
||||
const text = `Focused search ${testInfo.project.name}`;
|
||||
await seed(request, text, 'Incoming category', ['incoming-tag']);
|
||||
const id = await addFocused(page);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
|
||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||
await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck();
|
||||
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
|
||||
});
|
||||
|
||||
test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => {
|
||||
const text = `Connected library ${testInfo.project.name}`;
|
||||
await seed(request, text);
|
||||
const id = await addFocused(page);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend');
|
||||
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append');
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
const links = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const { convertToInput } = await import('/extensions/core/widgetInputs.js');
|
||||
const target = app.graph.getNodeById(id);
|
||||
convertToInput(target, target.widgets.find(widget => widget.name === 'text'));
|
||||
const result = {};
|
||||
for (const name of ['text', 'prepend_text', 'append_text']) {
|
||||
const source = LiteGraph.createNode('PrimitiveString');
|
||||
app.graph.add(source);
|
||||
source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150];
|
||||
source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`;
|
||||
const slot = target.inputs.findIndex(input => input.name === name);
|
||||
if (slot < 0) throw new Error(`Missing ${name} input socket`);
|
||||
source.connect(0, target, slot);
|
||||
result[name] = [String(source.id), 0];
|
||||
}
|
||||
app.canvas.centerOnNode(target);
|
||||
return result;
|
||||
}, id);
|
||||
await browse(page, text);
|
||||
await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden();
|
||||
await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled();
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden();
|
||||
const guardedValues = await model(page, id);
|
||||
await root.locator('textarea, input').evaluateAll(inputs => {
|
||||
for (const input of inputs) {
|
||||
if (!input.hidden && !input.closest('[hidden]')) continue;
|
||||
input.value = 'Attempt to replace upstream text';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
expect(await model(page, id)).toMatchObject(guardedValues);
|
||||
for (let reload = 0; reload < 2; reload++) {
|
||||
const inputs = await page.evaluate(async ({ id, reload }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
if (reload) await app.loadGraphData(app.graph.serialize());
|
||||
return (await app.graphToPrompt()).output[String(id)].inputs;
|
||||
}, { id, reload });
|
||||
expect(inputs).toMatchObject(links);
|
||||
}
|
||||
await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled();
|
||||
const disconnected = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name));
|
||||
return (await app.graphToPrompt()).output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' });
|
||||
for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) {
|
||||
const input = page.getByRole('textbox', { name, exact: true });
|
||||
await expect(input).toHaveCount(1);
|
||||
await expect(input).toBeEditable();
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') });
|
||||
});
|
||||
|
||||
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
||||
await addFocused(page);
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
const root = page.locator('.pm-focused');
|
||||
await expect(root).toHaveAttribute('data-ca-theme', palette);
|
||||
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
|
||||
}
|
||||
});
|
||||
|
||||
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
|
||||
await addFocused(page);
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => {
|
||||
const category = `Host completion category ${testInfo.project.name}`;
|
||||
const tag = `host-completion-${testInfo.project.name}`;
|
||||
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
|
||||
const id = await addFocused(page);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
const categoryInput = root.getByRole('combobox', { name: 'Category', exact: true });
|
||||
const tagInput = root.getByRole('combobox', { name: 'Tags', exact: true });
|
||||
await categoryInput.fill(category.slice(0, -3));
|
||||
const categories = page.getByRole('listbox', { name: 'Saved categories', exact: true });
|
||||
await expect(categories.getByRole('option', { name: category, exact: true })).toBeVisible();
|
||||
await categoryInput.press('ArrowDown');
|
||||
await expect(categoryInput).toHaveAttribute('aria-activedescendant', /.+/);
|
||||
await categoryInput.press('Enter');
|
||||
await expect(categoryInput).toHaveValue(category);
|
||||
await expect(categoryInput).toHaveAttribute('aria-expanded', 'false');
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
await tagInput.fill(`keep, ${tag.slice(0, -3)}`);
|
||||
const tags = page.getByRole('listbox', { name: 'Saved tags', exact: true });
|
||||
const option = tags.getByRole('option', { name: tag, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await expect(tags).toHaveAttribute('data-ca-theme', palette);
|
||||
const bounds = await tags.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) });
|
||||
await option.click();
|
||||
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
|
||||
}
|
||||
const expected = { text: 'Autocomplete workflow', category, tags: `keep, ${tag}, ` };
|
||||
await expect.poll(() => model(page, id)).toMatchObject(expected);
|
||||
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);
|
||||
await expect(categoryInput).toHaveValue(category);
|
||||
await expect(tagInput).toHaveValue(expected.tags);
|
||||
}
|
||||
const queued = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(queued).toMatchObject(expected);
|
||||
await expect(categoryInput).toHaveValue(category);
|
||||
await expect(tagInput).toHaveValue(expected.tags);
|
||||
await categoryInput.fill(category.slice(0, -3));
|
||||
await expect(categories).toBeVisible();
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph.getNodeById(id));
|
||||
}, id);
|
||||
await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => {
|
||||
const category = `Library category ${testInfo.project.name}`;
|
||||
const tag = `library-filter-${testInfo.project.name}`;
|
||||
const matchingText = `Matching library prompt ${testInfo.project.name}`;
|
||||
await seed(request, matchingText, category, [tag]);
|
||||
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
|
||||
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
|
||||
const id = await addFocused(page);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
|
||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
|
||||
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
|
||||
await filterCategory.fill(category.slice(0, -3));
|
||||
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
|
||||
await filterCategory.press('ArrowDown');
|
||||
await filterCategory.press('Enter');
|
||||
await expect(filterCategory).toHaveValue(category);
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
|
||||
}, palette);
|
||||
await filterTags.fill(tag.slice(0, -3));
|
||||
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) });
|
||||
await option.click();
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
}
|
||||
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
const results = root.locator('.pm-editor-results').getByRole('button');
|
||||
await expect(results).toHaveCount(1);
|
||||
await expect(results.first()).toContainText(matchingText);
|
||||
await results.first().click();
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
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);
|
||||
await expect(filterCategory).toHaveValue(category);
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
}
|
||||
const queued = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const prompt = await app.graphToPrompt();
|
||||
await app.loadGraphData(app.graph.serialize());
|
||||
return prompt.output[String(id)].inputs;
|
||||
}, id);
|
||||
expect(queued).toMatchObject(draft);
|
||||
await expect(filterCategory).toHaveValue(category);
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await expect(results).toHaveCount(1);
|
||||
await expect(results.first()).toContainText(matchingText);
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
|
||||
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
|
||||
test.beforeEach(async ({ page, context, request }, testInfo) => {
|
||||
const response = await request.get('/system_stats');
|
||||
expect(response.ok()).toBe(true);
|
||||
const info = (await response.json()).system;
|
||||
expect(info.comfyui_version).toBe(runtime.serverVersion);
|
||||
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
|
||||
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
|
||||
page.hostErrors = [];
|
||||
page.on('pageerror', error => page.hostErrors.push(error.message));
|
||||
await context.route('**/*', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
|
||||
if (path === '/settings' && request.method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async expectedTypes => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && expectedTypes.every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
|
||||
}, expectedTypes);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expect(page.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -1,43 +1,7 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
test.beforeEach(async ({ page, context, request }, testInfo) => {
|
||||
const response = await request.get('/system_stats');
|
||||
expect(response.ok()).toBe(true);
|
||||
const info = (await response.json()).system;
|
||||
expect(info.comfyui_version).toBe(runtime.serverVersion);
|
||||
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
|
||||
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
|
||||
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
|
||||
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
|
||||
page.hostErrors = [];
|
||||
page.on('pageerror', error => page.hostErrors.push(error.message));
|
||||
await context.route('**/*', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
|
||||
if (path === '/settings' && request.method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
return route.fulfill({ json: { ...await response.json(),
|
||||
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
||||
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
||||
'Comfy.Notification.ShowVersionUpdates': false } });
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
||||
await page.waitForFunction(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expect(page.hostErrors).toEqual([]);
|
||||
});
|
||||
installHostHooks(test);
|
||||
|
||||
for (const type of ['PromptManager', 'PromptManagerText']) {
|
||||
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
|
||||
|
||||
@@ -327,3 +327,37 @@ test("missing or empty metadata yields placeholders", () => {
|
||||
assert.equal(r.checkpoint, "Unknown");
|
||||
assert.equal(extractGenerationParams(null).seed, "Unknown");
|
||||
});
|
||||
|
||||
|
||||
test("Focused API metadata composes linked positive text and preserves negative role", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
||||
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
||||
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
||||
};
|
||||
const result = extractGenerationParams({ prompt });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry");
|
||||
});
|
||||
|
||||
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
||||
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
||||
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
||||
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
||||
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
||||
],
|
||||
links: [
|
||||
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
||||
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
||||
],
|
||||
};
|
||||
const result = extractGenerationParams({ workflow });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||
assert.equal(result.checkpoint, "model.safetensors");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Focused shares encoding and tracking while exposing composition sockets."""
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from prompt_manager import PromptManager, PromptManagerFocused
|
||||
from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes
|
||||
|
||||
|
||||
class TestFocusedNode(unittest.TestCase):
|
||||
def test_focused_keeps_the_execution_contract_and_visible_string_inputs(self):
|
||||
self.assertTrue(issubclass(PromptManagerFocused, PromptManager))
|
||||
self.assertEqual(PromptManagerFocused.RETURN_TYPES, PromptManager.RETURN_TYPES)
|
||||
self.assertEqual(PromptManagerFocused.FUNCTION, PromptManager.FUNCTION)
|
||||
focused = PromptManagerFocused.INPUT_TYPES()
|
||||
for name in ("prepend_text", "append_text"):
|
||||
self.assertTrue(focused["optional"][name][1]["forceInput"])
|
||||
self.assertNotIn(
|
||||
"forceInput", PromptManager.INPUT_TYPES()["optional"][name][1]
|
||||
)
|
||||
self.assertEqual(focused["hidden"]["unique_id"], "UNIQUE_ID")
|
||||
|
||||
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
|
||||
node = PromptManagerFocused.__new__(PromptManagerFocused)
|
||||
node.logger = logging.getLogger("test.focused")
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = mock.Mock(return_value=42)
|
||||
clip = mock.Mock()
|
||||
graph = {"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}}
|
||||
conditioning, text = node.encode_prompt(
|
||||
clip,
|
||||
"cat",
|
||||
prepend_text="trigger",
|
||||
append_text="detailed",
|
||||
prompt=graph,
|
||||
unique_id="1",
|
||||
)
|
||||
self.assertEqual(text, "trigger cat detailed")
|
||||
clip.tokenize.assert_called_once_with(text)
|
||||
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
|
||||
tracked = node._track_prompt_execution.call_args.kwargs
|
||||
self.assertEqual(tracked["text"], "cat")
|
||||
self.assertEqual(tracked["prompt_graph"], graph)
|
||||
|
||||
def test_focused_is_traced_as_positive_and_not_as_the_negative_run(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
|
||||
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["2", 0],
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
@@ -135,7 +135,12 @@ class TestPackageWiring(PackageTestCase):
|
||||
def test_nodes_are_registered(self):
|
||||
self.assertEqual(
|
||||
sorted(self.package.NODE_CLASS_MAPPINGS),
|
||||
["PromptManager", "PromptManagerText", "PromptSearchList"],
|
||||
[
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerText",
|
||||
"PromptSearchList",
|
||||
],
|
||||
)
|
||||
|
||||
def test_db_factory_returns_the_shared_database(self):
|
||||
|
||||
@@ -54,6 +54,19 @@ class TestPositivePromptNodes(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["134"])
|
||||
|
||||
def test_focused_nodes_preserve_positive_roles_and_samplerless_fallback(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
|
||||
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
|
||||
"5": ksampler("1", "2"),
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
del graph["5"]
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
|
||||
def test_negative_only_prompt_manager_is_never_positive(self):
|
||||
graph = {
|
||||
"176": pm("blurry"),
|
||||
|
||||
@@ -126,6 +126,16 @@ class TestQueueHook(DbTestCase):
|
||||
self.assertEqual(self._runs(positive), 1)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_focused_queued_runs_count_only_positive_even_when_cached(self):
|
||||
positive, negative = self._save("a cat"), self._save("blurry")
|
||||
graph = workflow()
|
||||
for node_id in ("134", "176"):
|
||||
graph[node_id]["class_type"] = "PromptManagerFocused"
|
||||
self._queue(graph)
|
||||
self._queue(graph)
|
||||
self.assertEqual(self._runs(positive), 2)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
|
||||
positive = self._save("a cat")
|
||||
self._queue(workflow())
|
||||
|
||||
Vendored
+2
-2
@@ -15,14 +15,14 @@ function fixture(t) {
|
||||
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
|
||||
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' }));
|
||||
for (const file of ['ui/core.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
|
||||
for (const file of ['ui/core.js', 'ui/suggestions.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file);
|
||||
return { root, tooling, dependency, destination: join(root, 'web/js/shared/toolkit') };
|
||||
}
|
||||
|
||||
test('toolkit browser assets ship unchanged with deterministic locked provenance', t => {
|
||||
const f = fixture(t);
|
||||
syncToolkit({ root: f.root });
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source)));
|
||||
for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source)));
|
||||
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
|
||||
assert.equal(JSON.parse(first).integrity, 'sha512-fixture');
|
||||
syncToolkit({ root: f.root });
|
||||
|
||||
Reference in New Issue
Block a user