Files
ComfyAssets-ComfyUI_PromptM…/tests/browser/workspace-library.spec.mjs
Vito 44b6ed2420 fix(library): editor follow-ups after storage restore and history selection parity (#243)
* fix(library): close recipe editor follow-ups from the native editor slice

- After restored storage, Create text revision and Archive are disabled so
  they cannot re-read an old numeric ID; only Reload Library remains.
- The storage-changed exit in Edit, revision and Add negative states that
  Reload Library closes the dialog and discards the entries (the concept
  has no discard confirmation, so the copy stays inline).
- When Save becomes disabled after an unconfirmed write or a conflict,
  focus moves to the recovery action instead of falling to the body; the
  archive confirmation does the same.
- A reuse toast only says the entered parent metadata was not applied when
  the controller found it differs from the stored metadata.
- Prompt history opens on its first negative variant, as the concept does,
  so the heading Send starts enabled.
- "1 linked image" is singular on cards, variant lanes and the embedded
  gallery count.

* fix(library): treat a blank category as equal to a stored uncategorized one

A reuse that left the category blank sent "Uncategorized" and compared it
with the stored NULL, so the toast claimed the entered metadata was not
applied. Both sides now normalize blank and "Uncategorized" the same way
before comparing; a genuinely different category still discloses it.

* test(coverage): raise frontend coverage floors after editor follow-ups

* fix(library): singular counts for one item and honest discard copy

- One shared countOf/noun helper (web/js/workspace/plural.mjs) now words
  linked images, saved negative variants (card chip and history count),
  distinct recorded runs and the Images toolbar count in the singular for
  exactly one; every other count keeps its existing wording.
- The restored-storage status says "Leaving this dialog discards your
  entries." because Cancel, the close button and Escape discard the
  draft too, not only Reload Library.
- The new helper gets its coverage floor; no other floor changes.

* fix(library): open every prompt history visit on its first negative

The concept resets to the first negative variant on every navigation to a
prompt (navigate: state.variant = 0) and selects another pair only right
after a save names it. The history no longer remembers a clicked variant
or an explicit All images choice across visits: the per-visit memory is
replaced by a one-shot saved selection that the next history mount (or the
same visit's reconciliation) consumes, and a verified pair outside the
loaded page is kept only for that saved selection. Within a visit, clicks
and All images behave as before.
2026-10-19 06:50:59 -07:00

448 lines
40 KiB
JavaScript

import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const media=page=>gallery(page).locator('article[data-image-key]');
async function openHistory(page,text='Cat portrait') {
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(history(page)).toBeVisible();
}
async function selectVariant(page,index) {
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
await expect(cards(page).filter({has:page.getByText('Dual role portrait',{exact:true})})).toHaveCount(1);
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
await page.goto(route);await openHistory(page);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
await expect(neg).toContainText(negative||'Empty negative prompt');
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
await expect(positive).toContainText('Cat portrait');
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
expect(order&4).toBeTruthy();
}
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
});
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
});
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:125}})).toBeOK();
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
expect(await cards(page).count()).toBeLessThan(128);
let hasMore=true;
while(hasMore) {
const nextPage=page.waitForResponse(response=>{const url=new URL(response.url());return url.pathname==='/prompt_manager/library'&&Boolean(url.searchParams.get('cursor'));});
await more.focus();await page.keyboard.press('Enter');
const appended=await (await nextPage).json();expect(appended.items.length).toBeGreaterThan(0);hasMore=appended.has_more;
if(hasMore)await expect(more).toHaveAttribute('aria-disabled','false');
}
await expect(more).toBeHidden();expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
const firstId=await cards(page).first().getAttribute('data-prompt-id');
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/library?*',async request=>{
const url=new URL(request.request().url());
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
if(url.searchParams.get('text')==='Cat')await hold;
await request.continue().catch(()=>{});
});
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id');
await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK();
await expect(await request.put(`/prompt_manager/prompts/${id}/rating`,{data:{rating:5}})).toBeOK();
await page.evaluate(()=>{
const native=requestAnimationFrame;const held=[];let paused=true;window.releaseLibraryFrames=()=>{paused=false;window.requestAnimationFrame=native;for(const callback of held)callback();};
window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);});
});
try {
await page.getByRole('button',{name:'Refresh library',exact:true}).click();
await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…');
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1);
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
await page.setViewportSize({width:390,height:844});await page.goto(route);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await openHistory(page);await selectVariant(page,0);
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
await page.setViewportSize({width:1600,height:300});await page.goto(route);
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive');
await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.');
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
const position=await page.evaluate(()=>scrollY);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).evaluate(node=>node.click());
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
await expect(cards(page)).toHaveCount(0);
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused();
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1);
expect(await page.evaluate(()=>scrollY)).toBe(0);
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
});
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route(/\/prompt_manager\/library\/\d+(?:\?|$)/,async route=>{
if(fail)return route.fulfill({status:503,json:{success:false,error:'Unavailable'}});
await hold;await route.continue().catch(()=>{});
});
try {
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release();
await expect(cards(page)).toHaveCount(3);await expect(history(page)).toHaveCount(0);
} finally {release();}
});
test('history shares real image metadata, vendored Viewer and the acknowledged prompt sender',async({page,context,request})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Library destination',type:'PromptManagerPair',roles:['positive','negative']};
window.libraryApplied=[];
window.libraryHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Library ComfyUI',adapter:{
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
apply:roles=>{window.libraryApplied.push(roles);return{targetId:target.id,values:roles};},
}});
});
try {
await page.goto(route);
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Library destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(sender).toContainText(chosenNegative||'(empty)');
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
});
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
await page.goto(route);await openHistory(page);
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
});
async function observeLibrary(page, hold=false) {
await page.addInitScript(({hold})=>{
const NativeResizeObserver=window.ResizeObserver;
const records=[];let holding=hold;
const raf=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
const pending=new Set(),heldFrames=new Map();let requested=0,mutations=0,unmounted=0,holdingFrames=false;
window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{const deliver=()=>{pending.delete(id);callback(time);};if(holdingFrames)heldFrames.set(id,deliver);else deliver();});pending.add(id);return id;};
window.cancelAnimationFrame=id=>{pending.delete(id);heldFrames.delete(id);cancel(id);};
window.ResizeObserver=class {
constructor(callback) {
const record={callback,targets:new Set(),entries:new Map(),latest:new Map(),disconnected:false};
record.native=new NativeResizeObserver(entries=>{
const ready=[];
for(const entry of entries) {
if(entry.target.matches('.library-card')) {
if(!entry.target.isConnected)unmounted++;
record.latest.set(entry.target,entry);
if(holding) {record.entries.set(entry.target,entry);continue;}
}
ready.push(entry);
}
if(ready.length)callback(ready,this);
});records.push(record);this.record=record;
}
observe(target,options){this.record.targets.add(target);this.record.native.observe(target,options);}
unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);}
disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();}
};
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
window.libraryObservation={
hold(){holding=true;},
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
holdFrames(){holdingFrames=true;},
releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();},
equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}},
stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};},
};
},{hold});
}
async function settleLibrary(page) {
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,60)));
await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0);
}
async function firstVisibleParent(page) {
return cards(page).evaluateAll(nodes=>{
const card=nodes.find(node=>{const box=node.getBoundingClientRect();return box.bottom>0&&box.top<innerHeight;});
return card?{id:card.dataset.promptId,offset:card.getBoundingClientRect().top}:null;
});
}
async function retainedParent(page, anchor) {
const card=page.locator(`article[data-prompt-id="${anchor.id}"]`);
await expect(card).toHaveCount(1);
await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2);
}
test('Library natural rows retain long prompt content without overflow',async({page,request})=>{
await request.post('/__test__/reset',{data:{workspace_visual:true}});
const data=await(await request.get('/prompt_manager/library')).json();
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
await expect(page.locator('.workspace-heading-copy > .intro')).toContainText('One positive.');
await expect(page.locator('.library-shelf > .intro')).toHaveCount(0);
for(const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
}
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
await expect(page.locator('.library-grid')).toHaveCSS('gap','18px');
const long=cards(page).filter({hasText:'A study in very long creative context'});
const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500);
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) {
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true);
}
await prompt.focus();await expect(prompt).toBeFocused();
await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible();
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1);
await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags);
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i);
const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};}));
for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
}
});
test('row measurement preserves anchor after width invalidation and append',async({page,request})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page,true);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/**',async route=>{await held;await route.continue().catch(()=>{});});
try {
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25));
await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor);
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
for(const changedWidth of [false,true]) {
const before=await firstVisibleParent(page);
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
expect(await cards(page).count()).toBeLessThan(80);
}
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {release();}
});
test('queued row growth preserves newer native wheel movement and correction events',async({page,request},testInfo)=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
await settleLibrary(page);await page.mouse.move(1100,550);
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
try {
// The same queued-frame wheel sequence is stable without changed measurements.
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(controlY+160);
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
expect(growth).toBeGreaterThan(0);expect(await page.evaluate(()=>scrollY)).toBe(beforeY);
await page.evaluate(()=>{window.libraryObservation.holdFrames();window.libraryObservation.release();});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
await page.mouse.wheel(0,160);await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(beforeY+160);
expect(await page.evaluate(()=>document.documentElement.scrollHeight-innerHeight-scrollY)).toBeGreaterThan(1000);
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
await testInfo.attach('queued-growth-wheel-geometry',{body:JSON.stringify({beforeY,offset:before.offset,growth,wheel:160,finalY:await page.evaluate(()=>scrollY),finalOffset:await parent.evaluate(node=>node.getBoundingClientRect().top)}),contentType:'application/json'});
await retainedParent(page,{...before,offset:before.offset-160});
expect(Math.abs(await page.evaluate(()=>scrollY)-(beforeY+160+growth))).toBeLessThanOrEqual(2);
// The correction's own native scroll event must not become new user motion.
const corrected=await firstVisibleParent(page);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
await page.evaluate(()=>{location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).heldFrames).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {await page.evaluate(()=>{window.libraryObservation.release();window.libraryObservation.releaseFrames();});}
});
test('unchanged row observations settle without resize feedback',async({page,request,browserName})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
let session,breakpoint;
try {
if(browserName==='chromium') {
session=await page.context().newCDPSession(page);await session.send('Debugger.enable');
await page.addInitScript(()=>{window.libraryLayoutCalls=0;});
const scripts=[];session.on('Debugger.scriptParsed',script=>scripts.push(script));
await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);
const script=scripts.find(script=>script.url.endsWith('/workspace/library-layout.mjs'));expect(script).toBeTruthy();
const {scriptSource}=await session.send('Debugger.getScriptSource',{scriptId:script.scriptId});
const lines=scriptSource.split('\n');const start=lines.findIndex(line=>line.includes('export function layoutLibraryRows('));expect(start).toBeGreaterThanOrEqual(0);
const possible=await session.send('Debugger.getPossibleBreakpoints',{start:{scriptId:script.scriptId,lineNumber:start},end:{scriptId:script.scriptId,lineNumber:start+3},restrictToFunction:false});
const location=possible.locations.find(location=>location.lineNumber>start);expect(location).toBeTruthy();
breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId;
} else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);}
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0);
const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page);
const after=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
expect(after.stats.requested-before.stats.requested).toBeLessThanOrEqual(1);expect(after.stats.mutations).toBe(before.stats.mutations);if(session)expect(after.calls).toBe(before.calls);
for(let i=0;i<3;i++) {await page.evaluate(()=>scrollBy(0,1));await settleLibrary(page);}
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls);
const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page);
expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1);
await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page);
expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0);
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
} finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();}
});
test('saved choices retain curved staggered lanes and the full negative immediately follows its positive',async({page,request})=>{
await page.goto(route);await openHistory(page);await selectVariant(page,1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
const selected=details.variants[1].negative_text;
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
});