import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,measureInspector,withChipSource} from './concept-inventory.mjs'; // Task F5 of #249: the production Prompt history (#/library/) against the immutable concept's (#prompt/alpine, // app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real // fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and // real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's // heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both // documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order // (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are // artifacts only; there are no PNG baselines. test.use({locale:'en-US',timezoneId:'UTC'}); const TITLE='Alpine observatory'; const SELECTED='Clean architecture'; const LANES=3; const SHOW_ARCHIVED='Show archived (0)'; const SECTIONS=['heading','history','noPageScroll']; /** * Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production. * `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one * (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves. */ export function mapHistoryConcept(reference) { const {history}=reference.oracle; return {...reference,history:{...history, // D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so // the concept's "4 tracked runs" holds by construction (asserted on both documents below). summary:history.summary, variants:{...history.variants, // D45: the bounded "N of M saved negative variants" count stays a visually hidden status. structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}}, compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item, // D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page. sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button, label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}}, // D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and // heading are compared here; the cards are the Images comparison's. gallery:history.gallery&&withoutHeight(history.gallery), }}; } const COMPARED=['baseline','selected']; const withoutHeight=({height,...gallery})=>gallery; function compare(reference,actual) { const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery ?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]])); return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual))); } /** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ function assertHistoryMatch(reference,actual) { const diff=compare(reference,actual); if(diff.length)throw new Error(`the Prompt history differs from the concept at ${diff.map(item=>item.path).join(', ')}`); } // Two frames, then every running transition finished. const settle=page=>page.evaluate(async()=>{ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))), new Promise(resolve=>setTimeout(resolve,2000))]); }); const decoded=page=>page.evaluate(async()=>{ await document.fonts.ready; await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); }); const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true}); const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true}); const galleryCards=page=>page.locator('.history-gallery .media-card'); /** Press a variant dot and wait until the summary and the gallery follow it. */ async function selectVariant(page,name) { await dot(page,name).click(); await expect(dot(page,name)).toHaveAttribute('aria-pressed','true'); await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`); await expect(galleryCards(page).first()).toBeVisible(); await page.mouse.move(0,0);await decoded(page);await settle(page); } async function setCompare(page,pressed) { const button=compareButton(page); if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click(); await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed)); await page.mouse.move(0,0);await settle(page); } async function openConceptHistory(page,theme,motion) { await openConcept(page,'prompt/alpine',theme,motion); await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES); await selectVariant(page,SELECTED); } async function openProductionHistory(page,theme,motion,positiveId) { await openProduction(page,`library/${positiveId}`,theme,motion); await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES); await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible(); await selectVariant(page,SELECTED); } // D45: the recorded production rules that lay the panel out (workspace.css): the panel heading and the legend row wrap, // All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser // lays out what follows; at 1600px nothing wraps. const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; } .variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; } .ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`; /** * The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular * (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo. */ async function conceptAsRecorded(page) { const restoreLayout=await mutate(page,{css:D45_LAYOUT}); await page.evaluate(label=>{ const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')]; const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]); lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);}); for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`); const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative'); const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt'); archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived); window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived}; },SHOW_ARCHIVED); await settle(page); return ()=>page.evaluate(()=>{ const {timeline,order,texts,archived}=window.__historyOracle; order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);}); for(const [span,text] of texts)span.textContent=text; archived.remove();delete window.__historyOracle; }).then(restoreLayout); } /** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */ async function conceptInventory(page) { const reference=await inventoryOf(page,'#main'); const restore=await conceptAsRecorded(page); const oracle=await inventoryOf(page,'#main'); await restore();await settle(page); return {...reference,oracle}; } const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent(); const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(), cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))}); /** A dialog the page opens: its accessible name, how many media cards it shows once loaded and its captions; then closed. */ async function dialogOf(page,trigger,{name,cards}) { await trigger.click(); const dialog=page.getByRole('dialog',{name,exact:true}); await expect(dialog).toBeVisible(); if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards); // D47: neither document's media dialog carries a collection caption ("Linked images", "N loaded · …"). const captions=await dialog.locator('.section-caption').count(); const result=cards?{name,cards:await dialog.locator('.media-card').count(),captions}:{name,captions}; await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); return result; } /** The behaviors Task F5 lists, with identical selectors in both documents. */ async function behaviorsOf(page,{sent,runDialog}) { await selectVariant(page,'Quality baseline'); const before={negative:await negativeText(page),gallery:await galleryOf(page)}; await selectVariant(page,SELECTED); const after={negative:await negativeText(page),gallery:await galleryOf(page)}; const pressed=[await compareButton(page).getAttribute('aria-pressed')]; await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed')); const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent(); await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed')); const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4}); const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog}); await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click(); const send=await sent(page); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator('h1#page-title')).toHaveText('Prompt library'); return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()}; } /** The concept's demo sender: the roles its preview lists, with their text. */ async function conceptSent(page) { const modal=page.locator('dialog#modal[open]'); await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI'); const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=> [node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent]))); await page.keyboard.press('Escape');await expect(modal).toHaveCount(0); return roles; } /** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */ function productionSent(host) { return async page=>{ const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'}); await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'}); await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1); const roles=await host.evaluate(()=>window.previewed.pop()); await page.keyboard.press('Escape');await expect(sender).toHaveCount(0); return roles; }; } async function openHost(context) { 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:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']}; window.previewed=[]; window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{ listTargets:()=>[target], preview:(_id,roles)=>{window.previewed.push(roles);return {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=>({targetId:target.id,values:roles}), }}); }); return host; } test.beforeEach(async({page,request},testInfo)=>{ testInfo.manifest=await seedConcept(request); await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});}); }); for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{ test.setTimeout(240_000); await page.setViewportSize({width,height}); const positiveId=testInfo.manifest.prompts[0].positive_id; const host=await openHost(context); try { const result={}; for(const motion of MOTIONS) { const capture=motion==='no-preference'; await openConceptHistory(page,theme,motion); const reference=await conceptInventory(page); await setCompare(page,true); const referenceCompared=await conceptInventory(page); if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`); const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null; await openProductionHistory(page,theme,motion,positiveId); const actual=await inventoryOf(page,'#workspace-main'); await setCompare(page,true); const actualCompared=await inventoryOf(page,'#workspace-main'); if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`); const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null; result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared, compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors}; } const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); await testInfo.attach('history-geometry',{path,contentType:'application/json'}); for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) { // The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546). const {summary,variants,compare:compared,gallery}=referenceCompared.history; expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'}); // D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them. expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']); expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']); expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'}); variants.timeline.lanes.forEach((lane,index)=>{ expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57}); expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'}); }); expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]); expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'}); expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']); expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'}); expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}}); expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]); expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]); expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true); } const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference']; expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}}); } finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();} }); /** * D145 (#332): the History media inspector (a recorded run's current file, `recorded-run.mjs`) shows the file's own * category with the chip rule of the Images side panel and viewer (D146). The concept has no recorded-run inspector, so * its chip is the concept viewer's own category chip (app.js a402, imageDetails(i, true)) with the recorded D146 origin * and the own category added at runtime (`withChipSource`). The chip's own place depends on what precedes it in either * inspector, so its box is compared by size only. */ const OWN_CATEGORY='Portraits'; const chipOnly=chip=>{const {left,top,source,...rest}=chip;return {...rest,source:source&&(({left,top,...own})=>own)(source)};}; const categoryChipOf=async(page,root)=>(await page.evaluate(measureInspector,root)).badges.chips.find(chip=>chip.source); async function conceptViewerChip(page,theme) { await openConcept(page,'images',theme);await page.mouse.move(0,0); await page.locator('.gallery-body > .image-grid > .media-card').first().locator('.image-open').click(); await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE); const restore=await withChipSource(page,'#viewer-details','this image',OWN_CATEGORY);await settle(page); const chip=await categoryChipOf(page,'#viewer-details'); await restore(); return chip; } /** Recorded run for the baseline's newest run, its one reported output opened in the viewer. */ async function productionRunChip(page,theme,positiveId) { await openProductionHistory(page,theme,'no-preference',positiveId); await page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click(); const run=page.getByRole('dialog',{name:'Recorded run',exact:true}); await expect(run).toBeVisible(); const view=run.getByRole('button',{name:'View current file',exact:true}); await expect(view).toBeEnabled();await view.click(); const details=page.locator('#viewer-dialog[open] #viewer-details'); await expect(details.locator('.chip-source')).toHaveText(' · this image'); await page.mouse.move(0,0);await settle(page); return categoryChipOf(page,'#viewer-dialog[open] #viewer-details'); } async function setOwnCategory(request,path,category) { const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); const root_scope=listing.images.find(item=>item.root_index===0).root_scope; const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}}); await expect(response).toBeOK(); } for(const [width,height] of SIZES)for(const theme of THEMES)test(`history media inspector category chip matches the concept ${width}x${height} ${theme} (D145)`,async({page,request},testInfo)=>{ await page.setViewportSize({width,height}); const {manifest}=testInfo; // The baseline's newest image: the output of the run its dot opens (the fixture records one run per image). const baseline=manifest.images.find(image=>image.prompt==='alpine'&&image.variant===0); await setOwnCategory(request,baseline.path,OWN_CATEGORY); const reference=await conceptViewerChip(page,theme); const actual=await productionRunChip(page,theme,manifest.prompts[0].positive_id); await captureCanonicalState(page,testInfo,`production-history-run-chip-${width}-${theme}`,'#viewer-dialog[open]'); expect.soft(reference,'concept chip').toMatchObject({tag:'span',className:'chip',text:`${OWN_CATEGORY} · this image`,hidden:null}); // D21: the hidden prefix names the scope; D146 as above. expect.soft(diffInventory({...chipOnly(reference),hidden:'Image category: '},chipOnly(actual)),'chip differences after D21, D145, D146').toEqual([]); }); // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant // dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match. test('history comparison detects mutations',async({page},testInfo)=>{ await page.setViewportSize({width:1600,height:1100}); await openConceptHistory(page,'dark','no-preference'); const reference=await conceptInventory(page); await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id); const measure=()=>inventoryOf(page,'#workspace-main'); assertHistoryMatch(reference,await measure()); const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'}); const cssMutated=await measure(); expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/); await restoreCss();await settle(page); assertHistoryMatch(reference,await measure()); await compareButton(page).evaluate(node=>node.remove()); const domMutated=await measure(); expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/); });