import {writeFile} from 'node:fs/promises'; import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs'; // Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions. test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); const workspace='/prompt_manager/workspace'; const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']}; const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes']; const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']]; const editorDialog=page=>page.locator('dialog.library-editor'); test.beforeEach(async({page,request,browserName})=>{ await startCoverage(page,browserName); const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json(); page.editorErrors=[];page.on('pageerror',error=>page.editorErrors.push(error.message)); page.writes=[];page.on('request',item=>{if(item.method()!=='GET')page.writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});}); }); test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.editorErrors).toEqual([]);}); async function mountEditor(page,theme) { await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); await page.evaluate(async()=>{ const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger); window.editorEvents={committed:[],reconciled:[]}; try { const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs'); const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); const scope=createScope(); const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope, onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)}); window.editorHarness={scope,editor,showWorkspaceToast}; } catch(error) {window.editorHarness={missing:String(error)};} }); } async function openEditor(page,options) { await page.evaluate(options=>{const trigger=document.getElementById('editor-trigger');trigger.focus();window.editorHarness.editor?.open({...options,trigger});},options); const dialog=editorDialog(page);await expect(dialog).toBeVisible();return dialog; } const field=(dialog,label)=>dialog.getByLabel(label,{exact:true}); const button=(dialog,name)=>dialog.getByRole('button',{name,exact:true}); const note=dialog=>dialog.locator('.warning-note'); async function ready(dialog) {await expect(button(dialog,'Save')).toBeEnabled();} async function actionNames(dialog) {return dialog.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));} const detail=async(request,id,archive='all')=>(await(await request.get(`/prompt_manager/library/${id}?archive=${archive}`)).json()); const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pairs/${id}`)).json()).pair; test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{ const f=page.fixture;await mountEditor(page); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id}); await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible(); expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true); await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog); expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(LABELS); await expect(field(dialog,'Title')).toHaveValue(f.metadata.title); await expect(field(dialog,'Positive prompt')).toHaveValue(f.positive_text); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); for(const label of ['Positive prompt','Negative prompt (optional)'])await expect(field(dialog,label)).toHaveJSProperty('readOnly',true); expect(await dialog.locator('textarea[readonly]').evaluateAll(nodes=>nodes.map(node=>node.tagName))).toEqual(['TEXTAREA','TEXTAREA']); await expect(dialog.getByRole('button',{name:/copy|send/i})).toHaveCount(0); await expect(dialog.getByRole('region')).toHaveCount(0); await expect(field(dialog,'Category')).toHaveValue('Portraits'); await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light'); await expect(field(dialog,'Rating')).toHaveValue('4'); await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes); expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS); expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']); expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']); await expect(dialog.locator('[placeholder]')).toHaveCount(0); await expect(dialog.locator('.demo-note')).toHaveCount(0); expect(await actionNames(dialog)).toEqual(ACTIONS.metadata); await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/); await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); }); test('editor modes persist through reload with selected saved negative',async({page,request})=>{ const f=page.fixture;await mountEditor(page); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill(''); await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0'); await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click(); await expect(dialog).toHaveCount(0); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]); expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'}); await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1); const saved=(await detail(request,f.positive_id)).positive; expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']); await page.reload();await mountEditor(page); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue(''); await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); await button(dialog,'Create text revision').click(); await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible(); await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true); await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog); await expect(field(dialog,'Positive prompt')).toHaveJSProperty('readOnly',false); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); expect(await actionNames(dialog)).toEqual(ACTIONS.revision); await field(dialog,'Positive prompt').fill('Editor portrait, revised dusk light');await field(dialog,'Category').fill(''); await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library'); expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative, parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}}); expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']); await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1); const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt); expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id); expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'}); dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); expect(await actionNames(dialog)).toEqual(ACTIONS.new); for(const label of ['Title','Positive prompt'])await expect(field(dialog,label)).toHaveJSProperty('required',true); const before=page.writes.length;await button(dialog,'Save').click(); await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true); expect(page.writes).toHaveLength(before); await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn'); await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'', parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}}); dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id}); await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog); expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Negative prompt']); await expect(dialog.locator('p.small')).toHaveText('Leave empty for an explicitly empty negative. The positive stays unchanged.'); expect(await actionNames(dialog)).toEqual(ACTIONS.negative); await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); expect(page.writes.at(-1)).toMatchObject({method:'POST',url:`/prompt_manager/library/${f.positive_id}/variants`,body:{negative_text:'grain, dust'}}); const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt); expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}}); await expect(page.locator('#editor-trigger')).toBeFocused(); }); test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{ const f=page.fixture;await mountEditor(page); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog); const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled(); await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.'); await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.'); await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused(); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await button(dialog,'Archive…').click(); await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible(); await expect(button(dialog,'Close dialog')).toBeFocused(); const block=dialog.getByRole('region',{name:'Negative prompt',exact:true}); await expect(block).toHaveClass(/\bprompt-negative\b/);await expect(block.locator('pre.prompt-text')).toHaveText(f.selected_negative); await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); await expect(dialog).toContainText('You can restore it from Show archived in Negative variants.'); expect(await actionNames(dialog)).toEqual(['Cancel','Archive']);await expect(button(dialog,'Archive')).toHaveClass(/\bdanger\b/); await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); expect((await pairRead(request,f.pair_id)).archived).toBe(false); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]); expect((await pairRead(request,f.pair_id)).archived).toBe(true); await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.at(-1)?.commit.kind)).toBe('archive-pair'); }); test('a failed archive keeps focus on the Archive confirmation inside the dialog',async({page})=>{ const f=page.fixture;await mountEditor(page); let failed=false; await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{ if(route.request().method()!=='PATCH'||failed)return route.continue(); failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'}); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click(); await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1); await expect(button(dialog,'Archive')).toBeFocused(); expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); }); test('an unconfirmed archive moves focus to Retry instead of dropping it out of the dialog',async({page,request})=>{ const f=page.fixture;await mountEditor(page);let dropped=false; await page.route(`**/prompt_manager/pairs/${f.pair_id}`,async route=>{ if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;} await route.continue(); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click(); await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.'); await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); expect(page.writes).toHaveLength(1); await page.keyboard.press('Enter');await expect(dialog).toHaveCount(0); expect(page.writes).toHaveLength(2);expect((await pairRead(request,f.pair_id)).archived).toBe(true); }); test('an archive conflict offers an explicit Reload that re-reads and never replays the archive',async({page,request})=>{ const f=page.fixture;await mountEditor(page); let conflicted=false; await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{ if(route.request().method()!=='PATCH'||conflicted)return route.continue(); conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'}); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Archive').click(); await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.'); await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1); expect(await actionNames(dialog)).toEqual(['Reload','Cancel','Archive']); await button(dialog,'Reload').click(); await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review them, then archive.'); await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText(f.selected_negative); expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.pair_id)).archived).toBe(false); await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/pairs/${f.pair_id}`,`PATCH /prompt_manager/pairs/${f.pair_id}`]); expect((await pairRead(request,f.pair_id)).archived).toBe(true); }); test('cancel and busy submit have honest write counts',async({page})=>{ const f=page.fixture;await mountEditor(page); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(0); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('changed then cancelled');await button(dialog,'Close dialog').click(); await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0); let release;const held=new Promise(resolve=>{release=resolve;}); await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();}); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.'); for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape'); await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1); release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1); await expect(page.locator('#editor-trigger')).toBeFocused(); }); test('recovery states render inline and never replay a write',async({page,request})=>{ const f=page.fixture;await mountEditor(page); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null}); await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.'); await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeVisible(); await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); const current=await detail(request,f.positive_id,'active'); await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{notes:'Changed elsewhere'}}})).toBeOK(); await field(dialog,'Title').fill('Conflicting title');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('This prompt changed since it was loaded. Reload it and review your entries before saving.'); await expect(button(dialog,'Save')).toBeDisabled();await button(dialog,'Reload').click(); await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); await expect(field(dialog,'Title')).toHaveValue('Conflicting title');await expect(field(dialog,'Notes')).toHaveValue('Changed elsewhere'); await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); expect(page.writes.filter(write=>write.method==='PATCH')).toHaveLength(2); expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Conflicting title',notes:'Changed elsewhere'}); page.writes.length=0; dialog=await openEditor(page,{mode:'new'});await ready(dialog); const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK(); const restore=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:await snapshot.body()}}}); await expect(restore).toBeOK();page.writes.length=0; await field(dialog,'Title').fill('After restore');await field(dialog,'Positive prompt').fill('Restored storage prompt');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.'); await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1); await button(dialog,'Reload Library state').click(); await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); await expect(field(dialog,'Title')).toHaveValue('After restore');expect(page.writes).toHaveLength(1); await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); page.writes.length=0;let dropped=false; await page.route('**/prompt_manager/library',async route=>{ if(route.request().method()==='POST'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;} await route.continue(); }); dialog=await openEditor(page,{mode:'new'});await ready(dialog); await field(dialog,'Title').fill('Lost response');await field(dialog,'Positive prompt').fill('Lost response prompt');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry sends the same save once.'); await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); await page.unroute('**/prompt_manager/library'); await page.route('**/prompt_manager/library/*?variant_limit=40',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1}); await button(dialog,'Retry').click(); await expect(note(dialog)).toHaveText('Saved; Library refresh failed.'); expect(await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt)).toMatchObject({positive_created:false,pair_created:false}); expect(page.writes.map(write=>write.body.positive_text)).toEqual(['Lost response prompt','Lost response prompt']); await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(2); const rows=(await(await request.get('/prompt_manager/library?q=Lost%20response')).json()).items.filter(item=>item.text==='Lost response prompt'); expect(rows).toHaveLength(1); }); test('edge states keep drafts, explain disabled archive and clean up on dispose',async({page,request})=>{ const f=page.fixture; await page.route('**/prompt_manager/tags',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'})); await mountEditor(page); let dialog=await openEditor(page,{mode:'new',draft:{title:'Drafted title',notes:'Drafted notes'}});await ready(dialog); await expect(field(dialog,'Title')).toHaveValue('Drafted title');await expect(field(dialog,'Notes')).toHaveValue('Drafted notes'); expect(await field(dialog,'Tags, separated by commas').evaluate(node=>node.list.options.length)).toBe(0); await field(dialog,'Title').fill(' ');await field(dialog,'Positive prompt').fill('Spaces only title');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Title is required');expect(page.writes).toHaveLength(0); await page.route('**/prompt_manager/library',route=>route.request().method()==='POST' ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1}); await field(dialog,'Title').fill('Rejected title');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.'); await expect(button(dialog,'Save')).toBeEnabled();await expect(field(dialog,'Title')).toHaveValue('Rejected title'); await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null}); await expect(button(dialog,'Retry')).toBeVisible();await button(dialog,'Retry').click(); await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.');await button(dialog,'Cancel').click(); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative); await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.'); await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.'); await button(dialog,'Cancel').click(); let failed=true; await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();}); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(''); failed=false;await button(dialog,'Archive…').click(); await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.'); await expect(button(dialog,'Archive')).toBeDisabled(); await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt'); await button(dialog,'Retry').click();await expect(dialog).toContainText('0 images and 0 recorded runs stay linked.'); await expect(button(dialog,'Archive')).toBeEnabled();expect(page.writes).toHaveLength(1);// only the rejected create await page.evaluate(()=>window.editorHarness.scope.dispose()); await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused(); await page.evaluate(()=>window.editorHarness.editor.open({mode:'new'}));await expect(editorDialog(page)).toHaveCount(0); expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.empty_pair_id)).archived).toBe(false); }); test('the archive confirmation does not inherit a failed or invalid Edit save message',async({page})=>{ const f=page.fixture;await mountEditor(page); await page.route(`**/prompt_manager/library/${f.positive_id}`,route=>route.request().method()==='PATCH' ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Notes are too long.","code":null}'}):route.continue()); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.'); await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled(); await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required'); await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.'); await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled(); expect(page.writes).toHaveLength(1); }); test('an unconfirmed Edit save says Retry reloads for review and resends nothing',async({page})=>{ const f=page.fixture;await mountEditor(page);let dropped=false; await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{ if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;} await route.continue(); }); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('Unconfirmed notes');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'); // Save is disabled now; focus moves to the one recovery action instead of falling out of the modal. await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); await page.keyboard.press('Enter'); await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); await expect(field(dialog,'Notes')).toHaveValue('Unconfirmed notes');expect(page.writes).toHaveLength(1); }); function toastStyle(node) { const style=getComputedStyle(node); return Object.fromEntries(['position','bottom','left','transform','backgroundColor','borderTopWidth','borderTopColor','paddingTop','paddingLeft', 'borderTopLeftRadius','boxShadow','zIndex','maxWidth','fontSize','display'].map(key=>[key,style[key]])); } for(const width of [1600,390])test(`the shared workspace toast is one polite status matching the concept ${width}`,async({page})=>{ await page.setViewportSize({width,height:844}); await page.goto('/__test__/canonical/workspace#library');await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('dark'); await page.locator('#toast').evaluate(node=>{node.textContent='Negative saved without adding another positive or gallery.';});const reference=await page.locator('#toast').evaluate(toastStyle); await mountEditor(page,'dark'); const toast=page.locator('#workspace-toast');await expect(toast).toHaveCount(1); await expect(toast).toHaveAttribute('role','status');await expect(toast).toHaveAttribute('aria-live','polite');await expect(toast).toBeHidden(); await page.evaluate(()=>window.editorHarness.showWorkspaceToast('Negative saved without adding another positive or gallery.')); await expect(page.getByRole('status').filter({hasText:'Negative saved without adding another positive or gallery.'})).toBeVisible(); expect(await toast.evaluate(toastStyle)).toEqual(reference); expect(reference).toMatchObject({position:'fixed',bottom:width<=700?'12px':'22px',borderTopWidth:'1px',paddingTop:'12px',paddingLeft:'18px',borderTopLeftRadius:'6px',fontSize:'12px'}); }); const conceptModes={ new:['library',async page=>page.getByRole('button',{name:'+ New prompt',exact:true}).click()], metadata:['prompt/alpine',async page=>page.getByRole('button',{name:'Edit metadata',exact:true}).click()], revision:['prompt/alpine',async page=>{await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await page.locator('#modal').getByRole('button',{name:'Create text revision',exact:true}).click();}], negative:['prompt/alpine',async page=>page.getByRole('button',{name:'+ Add negative',exact:true}).click()], }; for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`dialog geometry, tokens and controls match the concept ${width} ${theme}`,async({page},testInfo)=>{ await page.setViewportSize({width,height});const f=page.fixture;const result={}; for(const mode of Object.keys(conceptModes)) { const [hash,openMode]=conceptModes[mode]; await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`); await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);const modal=page.locator('#modal');await expect(modal).toBeVisible(); if(mode==='revision')await page.locator('#modal-close').focus(); const reference=await modal.evaluate(inventoryOf); await captureCanonicalState(page,testInfo,`concept-editor-${mode}-${width}-${theme}`,'#modal'); result[mode]={reference}; } await mountEditor(page,theme); const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}}; for(const mode of Object.keys(conceptModes)) { const dialog=await openEditor(page,options[mode]);await ready(dialog); result[mode].actual=await dialog.evaluate(inventoryOf); await captureCanonicalState(page,testInfo,`production-editor-${mode}-${width}-${theme}`,'dialog.library-editor'); await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); } const path=testInfo.outputPath('editor-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); await testInfo.attach('editor-geometry',{path,contentType:'application/json'}); for(const [mode,{reference,actual}] of Object.entries(result)) { const expected=mapConcept(reference); expect.soft(reference.demo,`${mode} concept demo note`).toBe(true); expect.soft(expected.actions.buttons.map(item=>item.text),`${mode} actions`).toEqual(ACTIONS[mode]); expect.soft(actual.dialog,`${mode} dialog`).toEqual(expected.dialog); for(const key of ['header','title','close','fields','twoCol','small','actions','demo'])expect.soft(actual[key],`${mode} ${key}`).toEqual(expected[key]); // The UA modal max-inline-size (100% - 6px - 2em) also applies to the concept at 390px. expect.soft(actual.dialog).toMatchObject({paddingTop:'24px',borderTopLeftRadius:'9px',borderTopWidth:'1px',width:Math.min(650,width-24,width-34),modal:true}); expect.soft(actual.title.fontSize).toBe('18px'); expect.soft(actual.actions).toMatchObject({justifyContent:'flex-end',columnGap:'8px',flexWrap:'wrap',marginTop:'23px'}); for(const item of actual.fields)expect.soft(item.field).toMatchObject({rowGap:'7px',fontSize:'12px'}); for(const item of actual.fields.filter(item=>item.tag==='textarea'))expect.soft(item.control.minHeight).toBe('100px'); if(actual.twoCol)expect.soft(actual.twoCol.columns,`${mode} two columns`).toBe(width<=700?1:2); if(testInfo.project.name==='chromium')expect.soft(actual.dialog.backdrop).toBe('rgba(8, 11, 18, 0.72)'); } expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); }); // Real entry actions: Library, Images and prompt history headings, and the Negative variants panel. const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true}); const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions'); const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true}); const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true}); const toast=page=>page.locator('#workspace-toast'); const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim())); const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]'); async function openHistory(page,id) { await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible(); } async function selectPair(page,name) { const dot=variantsPanel(page).getByRole('button',{name,exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true'); } /** In-app navigation away and back, as the concept's hash router does (app.js navigate: state.variant = 0). */ async function leaveAndReturn(page,id) { await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1); } const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text}); test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{ await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); const actions=headingActions(page).locator('button'); expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']); // One linked image is counted in the singular. const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1); await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/); await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/); for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32); await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise'); await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0]; await expect(page).toHaveURL(new RegExp(`#/library/${created.id}$`)); await expect(page.locator('#workspace-main h1')).toHaveText('Harbor at dawn');await expect(page.locator('#workspace-main h1')).toBeFocused(); await expect(toast(page)).toHaveText('Saved.'); await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('fog, noise'); // Exactly one saved negative is counted in the singular, in history and on the shelf card. await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']); }); test('new from Images cancel preserves browse',async({page})=>{ await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images'); const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']); const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1); await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0); expect(page.writes).toHaveLength(0); await trigger.click();dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill('From images');await field(dialog,'Positive prompt').fill('A prompt saved from Images');await button(dialog,'Save').click(); await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('From images');await expect(page.locator('#workspace-main h1')).toBeFocused(); }); test('history heading follows the concept and sends the selected saved pair',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); const actions=headingActions(page).locator('button'); expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']); await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0); const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1); await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`); expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false'); // The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled. await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled(); await selectPair(page,'Select negative variant 2');await expect(send).toBeEnabled(); await expect(savedTexts(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0); await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible(); await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);await expect(send).toBeFocused(); await actions.nth(0).click();await expect(page).toHaveURL(/#\/library$/);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); expect(page.writes).toHaveLength(0); }); test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await selectPair(page,'Select negative variant 2'); await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}); await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0); await leaveAndReturn(page,f.positive_id); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false'); await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0); }); test('add negative re-renders history with the saved or reused pair selected',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); const add=variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}); await add.click();let dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click(); await expect(dialog).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText('Negative saved without adding another positive or gallery.');await expect(page.locator('#workspace-main h1')).toBeFocused(); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('grain, dust'); await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click(); await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3'); // The saved selection applies to that save only; returning opens on the first negative, as in the concept. await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); expect(page.writes.map(write=>write.url)).toEqual([`/prompt_manager/library/${f.positive_id}/variants`,`/prompt_manager/library/${f.positive_id}/variants`]); }); test('reused pair outside first forty remains selected without loading every page',async({page,request})=>{ test.setTimeout(90000); const f=page.fixture;let current=await detail(request,f.positive_id,'active'); for(let index=0;index<41;index++) { const response=await request.post(`/prompt_manager/library/${f.positive_id}/variants`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,negative_text:`bulk negative ${index}`}}); await expect(response).toBeOK();current={...current,positive:(await response.json()).positive}; } const variantCursorRequests=[];page.on('request',item=>{if(item.url().includes('variant_cursor'))variantCursorRequests.push(item.url());}); await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0; await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click(); const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative); await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants'); await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41); expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1); }); test('edit metadata stays on the page and re-reads the selected pair',async({page,request})=>{ const f=page.fixture;await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2'); const pairReads=[];page.on('request',item=>{if(item.method()==='GET'&&new URL(item.url()).pathname===`/prompt_manager/pairs/${f.pair_id}`)pairReads.push(item.url());}); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); const before=pairReads.length; await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused(); await expect(toast(page)).toHaveText('Saved.'); await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work'); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2'); await expect.poll(()=>pairReads.length).toBeGreaterThan(before); await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place'); expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Edited in place',category:'Studio work'}); }); test('saved refresh failure retries only reads',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); let failed=false; await page.route(`**/prompt_manager/library/${f.positive_id}?variant_limit=40`,route=>{if(page.writes.length&&!failed){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'});}return route.continue();}); await field(dialog,'Notes').fill('Refresh after failure');await button(dialog,'Save').click(); await expect(page.getByText('Saved; Library refresh failed.',{exact:true})).toBeVisible(); const writeRequests=page.writes;expect(writeRequests).toHaveLength(1); await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(writeRequests).toHaveLength(1); await expect(page.locator('#workspace-main h1')).toBeFocused(); }); test('restored storage after a confirmed save or before an archive offers an explicit Library reload',async({page,request})=>{ const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body(); const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}); await openHistory(page,f.positive_id); await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{ if(route.request().method()!=='PATCH')return route.continue(); const response=await route.fetch();await expect(await restore()).toBeOK();return route.fulfill({response}); }); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.'); expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0); await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1); await selectPair(page,'Select negative variant 2'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); await expect(await restore()).toBeOK();await button(dialog,'Archive').click(); await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.'); expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1); await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); expect((await pairRead(request,f.pair_id)).archived).toBe(false); }); const DISCARDS='Leaving this dialog discards your entries.'; test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{ const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body(); const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}); await openHistory(page,f.positive_id); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await expect(await restore()).toBeOK(); await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`); expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled(); // Revise and archive would re-read the old numeric IDs, which may now name another prompt. for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); await expect(button(dialog,'Reload Library')).toBeFocused(); expect(page.writes).toHaveLength(1);// the refused save await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); expect(page.writes).toHaveLength(1);expect((await detail(request,f.positive_id)).positive.notes).toBe(f.metadata.notes); await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(await restore()).toBeOK(); await field(dialog,'Negative prompt').fill('typed before the restore');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`); expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Save']); await expect(button(dialog,'Reload Library state')).toHaveCount(0);expect(page.writes).toHaveLength(2); await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); expect(page.writes).toHaveLength(2); expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false); }); test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{ const f=page.fixture;await openHistory(page,f.positive_id); let release;const held=new Promise(resolve=>{release=resolve;}); await page.route(`**/prompt_manager/pairs/${f.archived_pair_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();}); const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/}); const restoreButton=()=>variantsPanel(page).locator('.library-variant-archived').getByRole('button',{name:/^Restore/}); await toggle().click();await expect(restoreButton()).toBeEnabled(); await restoreButton().click();await expect.poll(()=>page.writes.length).toBe(1);await expect(restoreButton()).toBeDisabled(); // Hiding and showing archived variants rebuilds the lane while the restore is in flight. await toggle().click();await toggle().click(); await expect(restoreButton()).toBeDisabled();await restoreButton().evaluate(node=>node.click()); release(); await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0); expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]); expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false); }); test('archive hides a pair, Show archived finds it and Restore keeps it selected with links and counts unchanged',async({page,request})=>{ const f=page.fixture; const evidence=async()=>{ const pair=await pairRead(request,f.pair_id);const gallery=await(await request.get(`/prompt_manager/library/${f.positive_id}/images?limit=50`)).json(); const history=await(await request.get(`/prompt_manager/library/${f.positive_id}/history`)).json();const parent=(await detail(request,f.positive_id,'all')).positive; return {counts:{images:pair.image_count,runs:pair.run_count},gallery:gallery.images.map(item=>item.id),history:history.recorded_run_count,parentImages:parent.image_count}; }; const before=await evidence();expect(before.counts).toEqual({images:1,runs:2}); await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused(); await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative); expect(await evidence()).toEqual(before); const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false'); await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true'); const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2); const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1); const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1); const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);}); await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible(); expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id)); await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled(); const writes=page.writes.length; await lane.getByRole('button',{name:/^Restore/}).click(); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible(); await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1); await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]); expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); expect(galleryReads.at(-1).get('archive')).toBeNull(); }); test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{ await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);}); await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click(); await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter'); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible(); expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true); expect(shelfReads.at(-1).get('text')).toBe('Standalone'); }); test('export downloads the full Library JSON without a toast and reports a failure before any download',async({page,request})=>{ await page.addInitScript(()=>{window.revokedUrls=[];const revoke=URL.revokeObjectURL.bind(URL);URL.revokeObjectURL=url=>{window.revokedUrls.push(url);revoke(url);};}); await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); const exportButton=page.getByRole('button',{name:'Export full Library JSON',exact:true}); const [download]=await Promise.all([page.waitForEvent('download'),exportButton.click()]); expect(download.suggestedFilename()).toBe('prompt-manager-library-v1.json'); const saved=JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(),'utf8')); expect(saved).toEqual(await(await request.get('/prompt_manager/library/export')).json()); expect(saved).toMatchObject({schema_version:1,kind:'prompt-manager-library'}); await expect.poll(()=>page.evaluate(()=>window.revokedUrls.length)).toBe(1);await expect(toast(page)).toBeHidden(); await page.route('**/prompt_manager/library/export',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'})); let downloads=0;page.on('download',()=>downloads++);await exportButton.click(); await expect(page.getByRole('alert').filter({hasText:'The Library export could not be downloaded.'})).toBeVisible(); expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0); }); const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);}; test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);}); await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Negative prompt').fill(f.archived_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); const message='This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.'; await expect(toast(page)).toHaveText(message); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true'); const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1); await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative); await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id)); await uniqueDotNames(page); await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative); await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText(message); await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true'); expect(page.writes.every(write=>write.method==='POST')).toBe(true); }); test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{ const f=page.fixture;await page.goto(`${workspace}#/library`); await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative'); await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click(); await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'); await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes'); await button(dialog,'Cancel').click(); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click(); expect(page.writes).toHaveLength(1); }); test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{ const f=page.fixture;await page.goto(`${workspace}#/library`); await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative'); await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', ')); await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click(); expect(page.writes).toHaveLength(1); }); test('reuse of an uncategorized prompt with the category left blank is not unapplied metadata, a different category is',async({page,request})=>{ const f=page.fixture;const current=await detail(request,f.positive_id,'active'); await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{category:''}}})).toBeOK(); expect((await detail(request,f.positive_id)).positive.category).toBeNull(); const reuse=async(category,negative)=>{ await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative); await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', ')); await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); }; await reuse('','blank category negative'); await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.'); await reuse('Landscapes','different category negative'); await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']); }); test('turning Show archived on again after a hidden archive shows the newly archived pair',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/}); await toggle().click();await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1); await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0); await selectPair(page,'Select negative variant 2'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); await expect(toggle()).toHaveText('Show archived (2)');await toggle().click(); await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2); await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page); }); test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{ const f=page.fixture;await mountEditor(page,'dark'); const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); // Work on a copy of the production dialog so the live editor is untouched; ids are renamed to keep label/list wiring valid. await dialog.evaluate(node=>{ const copy=node.cloneNode(true);copy.removeAttribute('open');copy.id='mutant';copy.classList.add('mutant'); const ids=new Set([...copy.querySelectorAll('[id]')].map(item=>item.id)); for(const item of copy.querySelectorAll('[id]'))item.id=`${item.id}-copy`; for(const item of copy.querySelectorAll('[for],[list],[aria-labelledby],[aria-describedby]'))for(const attr of ['for','list','aria-labelledby','aria-describedby']) { const value=item.getAttribute(attr);if(value&&ids.has(value))item.setAttribute(attr,`${value}-copy`); } copy.setAttribute('open','');node.parentElement.append(copy); }); const mutant=page.locator('dialog.mutant');await expect(mutant).toBeVisible(); const baseline=await mutant.evaluate(inventoryOf); expect(diffInventory(baseline,await mutant.evaluate(inventoryOf))).toEqual([]); await page.addStyleTag({content:'dialog.mutant .danger{color:rgb(1,2,3)!important}dialog.mutant .field input{color:rgb(4,5,6)!important;background-color:rgb(7,8,9)!important}'}); await mutant.getByRole('button',{name:'Save',exact:true}).evaluate(node=>node.remove()); const paths=diffInventory(baseline,await mutant.evaluate(inventoryOf)).map(item=>item.path); expect(paths).toContain('actions.buttons.length'); expect(paths).toContain('actions.buttons.1.color'); expect(paths).toContain('fields.0.control.color'); expect(paths).toContain('fields.0.control.backgroundColor'); await page.keyboard.press('Escape'); }); test('long and error states retain footer focus and reflow',async({page})=>{ await page.setViewportSize({width:390,height:844}); await mountEditor(page,'light'); const dialog=await openEditor(page,{mode:'new'});await ready(dialog); const unbroken='W'.repeat(240); await field(dialog,'Title').fill(`A very long title that keeps going ${unbroken} and going`); await field(dialog,'Positive prompt').fill('A quiet harbor at dawn'); await field(dialog,'Notes').fill(Array.from({length:60},(_,index)=>`Long note line ${index} ${unbroken.slice(0,40)}`).join('\n')); await page.route('**/prompt_manager/library',route=>route.request().method()==='POST' ?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1}); await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.'); // Save had focus when the write started and keeps it after the failure; focus never falls out of the modal. await expect(button(dialog,'Save')).toBeFocused(); expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); const state=await dialog.evaluate(node=>({scrolls:node.scrollHeight>node.clientHeight,height:node.getBoundingClientRect().height,innerHeight,overflowY:getComputedStyle(node).overflowY, pageOverflow:document.documentElement.scrollWidth-document.documentElement.clientWidth,dialogOverflow:node.scrollWidth-node.clientWidth, left:node.getBoundingClientRect().left,right:node.getBoundingClientRect().right,viewport:innerWidth})); expect(state.scrolls).toBe(true);expect(state.height).toBeLessThanOrEqual(state.innerHeight*.9+1);expect(['auto','scroll']).toContain(state.overflowY); expect(state.pageOverflow).toBeLessThanOrEqual(1);expect(state.dialogOverflow).toBeLessThanOrEqual(1);expect(state.left).toBeGreaterThanOrEqual(0);expect(state.right).toBeLessThanOrEqual(state.viewport); for(const name of ['Cancel','Save']) { const item=button(dialog,name);await item.scrollIntoViewIfNeeded();await expect(item).toBeVisible();await expect(item).toBeEnabled(); await item.focus();await expect(item).toBeFocused(); const box=await item.boundingBox();expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390); } // Keyboard: Tab from Cancel reaches Save inside the dialog; the draft survives the error. await button(dialog,'Cancel').focus();await page.keyboard.press('Tab');await expect(button(dialog,'Save')).toBeFocused(); await expect(field(dialog,'Positive prompt')).toHaveValue('A quiet harbor at dawn'); expect(page.writes.filter(write=>write.method==='POST')).toHaveLength(1); await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); });