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';
for (const variant of ['Focused', 'Split']) {
test.describe(variant, () => {
const isSplit = variant === 'Split';
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
async function openLibrary(page) {
if (isSplit) {
await expect(page.locator('.pm-editor-library')).toBeVisible();
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
}
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 variant=>{
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){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
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:`PromptManager${variant}`},fixtureApp);
window.node=new Node();node.onNodeCreated();
},variant);
await expect(page.locator('.pm-focused')).toBeVisible();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
test(`${variant} 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 openLibrary(page);
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[uiProperty].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);
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
test(`${variant} 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 openLibrary(page);
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(`${variant} 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(`${variant} focused node tag suggestions include a newly created tag`, async ({page,request}) => {
const name=`created_${variant.toLowerCase()}_vocabulary`;
const listing=await request.get('/prompt_manager/vocabulary/tags?limit=1');await expect(listing).toBeOK();
const created=await request.post('/prompt_manager/vocabulary/tags',{data:{expected_epoch:(await listing.json()).library_epoch,name}});
await expect(created).toBeOK();expect((await created.json()).tag).toMatchObject({name,prompt_count:0});
await page.getByText('Metadata · category / tags',{exact:true}).click();
const tags=page.getByLabel('Tags',{exact:true});
await tags.fill(`created_${variant.toLowerCase()}`);
await expect(page.getByRole('option',{name,exact:true})).toBeVisible();
await tags.press('ArrowDown');await tags.press('Enter');
await expect(tags).toHaveValue(`${name}, `);
});
test(`${variant} 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(`${variant} 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(`${variant} 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(`${variant} 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 openLibrary(page);
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(`${variant} 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(`${variant} 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 openLibrary(page);
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(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
let requests=0;
let mode='empty';
const malicious='
';
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 openLibrary(page);
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(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
const malicious='
';
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'',tags:['