diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index ee52ac5..7e67119 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -46,6 +46,16 @@ export async function focusRingOf(locator) { outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};}); } +/** Computed colors of an element while the pointer rests on it; the pointer is parked again afterwards. */ +export async function hoverStateOf(page,locator) { + await locator.hover(); + const state=await locator.evaluate(node=>{const style=getComputedStyle(node);return {hovered:node.matches(':hover'), + color:style.color,backgroundColor:style.backgroundColor,borderTopColor:style.borderTopColor, + textDecorationLine:style.textDecorationLine,boxShadow:style.boxShadow};}); + await page.mouse.move(0,0); + return state; +} + /** * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history, * noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet @@ -96,72 +106,88 @@ export function measureInventory(scope) { gapAfter,icon:iconOf(link), count:count?{text:count.textContent,shown:shown(count),...font(count),...css(count,'marginInlineStart')}:null}; } - function chromeOf() { - if(!sidebar||!workspace)return null; - const shell=document.querySelector('.app-shell'); - const brand=sidebar.querySelector('.brand'),mark=brand.querySelector('.brand-mark'),small=brand.querySelector('small'); - const bottom=sidebar.querySelector('.sidebar-bottom'),connection=sidebar.querySelector('.connection'); - const topbar=workspace.querySelector('.topbar'),crumbs=topbar.querySelector('.breadcrumbs'),actions=topbar.querySelector('.top-actions'); - const footer=workspace.querySelector('.app-footer'); + // Every subsection is null when its element is missing, so a removed node is reported by its path + // (for example `chrome.breadcrumbs`) instead of failing the measurement. + const when=(node,measure)=>node?measure(node):null; + // The accessible text of a node: its text without aria-hidden descendants. + const accessibleText=node=>textOf(node,'[aria-hidden="true"]'); + function itemOf(node,actions) { + const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)}; + if(!base.shown)return base; + const box=relative(node,actions); + return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node), + left:box.left,top:box.top,width:box.width,height:box.height, + count:node.querySelector('.count')?.textContent??null, + select:when(node.querySelector('select'),select=>({options:[...select.options].map(option=>[option.value,option.textContent]), + label:select.getAttribute('aria-label'),labelled:select.labels?.[0]===node,...font(select),...edges(select), + minHeight:getComputedStyle(select).minHeight,width:px(rect(select).width),height:px(rect(select).height)})), + hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null, + whiteSpace:getComputedStyle(node).whiteSpace}; + } + function sidebarOf() { + return when(sidebar,()=>({...layout(sidebar),...edges(sidebar), + ...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width), + fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1})); + } + function bottomOf(bottom) { + // In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is only + // the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead. const sidebarStyle=getComputedStyle(sidebar); - const columns=getComputedStyle(shell).gridTemplateColumns.split(' '); - const item=node=>{ - const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)}; - if(!base.shown)return base; - const box=relative(node,actions); - const select=node.querySelector('select'); - return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node),width:box.width,height:box.height,top:box.top, - count:node.querySelector('.count')?.textContent??null, - select:select?{options:[...select.options].map(option=>[option.value,option.textContent]),label:select.getAttribute('aria-label'), - labelled:select.labels?.[0]===node,...font(select),...edges(select),minHeight:getComputedStyle(select).minHeight, - width:px(rect(select).width),height:px(rect(select).height)}:null, - hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null, - whiteSpace:getComputedStyle(node).whiteSpace}; - }; + const pinned=sidebarStyle.display==='flex'; + const {marginTop,...rest}=edges(bottom); + return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop, + bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null}; + } + function topActionsOf(actions,topbar) { + const style=getComputedStyle(topbar); + return {...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height), + fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)))<=1, + rightInset:px(rect(topbar).right-rect(actions).right), + items:[...actions.children].map(node=>itemOf(node,actions))}; + } + function chromeOf() { + if(!sidebar&&!workspace)return null; + const shell=document.querySelector('.app-shell'); + const brand=sidebar?.querySelector('.brand'); + const topbar=workspace?.querySelector('.topbar'); + const footer=workspace?.querySelector('.app-footer'); return { - title:document.title.replace(/ · Prompt Manager.*$/,''), - skip:{text:document.querySelector('a.skip')?.textContent.trim()??null,...css(document.querySelector('a.skip'),'position','top','left')}, + title:document.title, + skip:when(document.querySelector('a.skip'),skip=>({text:skip.textContent.trim(),...css(skip,'position','top','left')})), body:{...font(document.body),backgroundColor:getComputedStyle(document.body).backgroundColor, tokens:Object.fromEntries(['--ca-text','--ca-muted','--ca-focus','--ca-accent','--ca-field','--ca-surface','--ca-line', '--line','--page','--sidebar','--soft-blue','--green'].map(name=>[name,getComputedStyle(document.body).getPropertyValue(name).trim()]))}, - shell:{display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null, - minHeight:getComputedStyle(shell).minHeight}, - sidebar:{...layout(sidebar),...edges(sidebar),...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width), - fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1}, - brand:{text:textOf(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''),...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand), - mark:{text:textOf(mark),...font(mark),width:px(rect(mark).width),height:px(rect(mark).height)}, - small:{text:small.textContent,...font(small),...css(small,'display','marginTop')}}, - labels:[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})), - navs:[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return { + shell:when(shell,()=>{const columns=getComputedStyle(shell).gridTemplateColumns.split(' '); + return {display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null, + minHeight:getComputedStyle(shell).minHeight};}), + sidebar:sidebarOf(), + brand:when(brand,()=>({text:textOf(brand),name:accessibleText(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''), + ...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand), + mark:when(brand.querySelector('.brand-mark'),mark=>({text:textOf(mark),hidden:mark.getAttribute('aria-hidden'),...font(mark), + width:px(rect(mark).width),height:px(rect(mark).height)})), + small:when(brand.querySelector('small'),small=>({text:small.textContent,...font(small),...css(small,'display','marginTop')}))})), + labels:sidebar?[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})):null, + navs:sidebar?[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return { id:nav.id,label:nav.getAttribute('aria-label'),shown:shown(nav),...layout(nav),...edges(nav), - width:px(rect(nav).width),rows:links.map(navRow)};}), - bottom:(()=>{ - // In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is - // only the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead. - const pinned=sidebarStyle.display==='flex'; - const {marginTop,...rest}=edges(bottom); - return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop, - bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null}; - })(), - connection:{shown:shown(connection),text:textOf(connection,'small'),small:connection.querySelector('small')?.textContent??null, - ...layout(connection),...edges(connection),...font(connection), - smallFont:font(connection.querySelector('small')), - dot:(dot=>({...css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop')}))(connection.querySelector('i'))}, - topbar:{...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace), - fullWidth:px(rect(topbar).width)===px(rect(workspace).width)}, - breadcrumbs:{shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs), + width:px(rect(nav).width),rows:links.map(navRow)};}):null, + bottom:when(sidebar?.querySelector('.sidebar-bottom'),bottomOf), + connection:when(sidebar?.querySelector('.connection'),connection=>({shown:shown(connection),text:textOf(connection,'small'), + small:connection.querySelector('small')?.textContent??null,...layout(connection),...edges(connection),...font(connection), + smallFont:when(connection.querySelector('small'),font), + dot:when(connection.querySelector('i'),dot=>css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop'))})), + topbar:when(topbar,()=>({...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace), + fullWidth:px(rect(topbar).width)===px(rect(workspace).width)})), + breadcrumbs:when(topbar?.querySelector('.breadcrumbs'),crumbs=>({shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs), parts:[...crumbs.querySelectorAll('span')].map(span=>({text:span.textContent,...font(span)})), - ...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})}, - topActions:{...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height), - fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(getComputedStyle(topbar).paddingLeft)-parseFloat(getComputedStyle(topbar).paddingRight)))<=1, - rightInset:px(rect(topbar).right-rect(actions).right), - items:[...actions.children].map(item)}, - main:{tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex,...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')}, - footer:footer?{...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width), + ...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})})), + topActions:when(topbar?.querySelector('.top-actions'),actions=>topActionsOf(actions,topbar)), + main:when(main,()=>({tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex, + ...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')})), + footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width), fullWidth:px(rect(footer).width)===px(rect(workspace).width), spans:[...footer.querySelectorAll(':scope > span')].map(span=>({text:span.textContent.replace(/\s+/g,' ').trim(),...font(span),height:px(rect(span).height)})), buttons:[...footer.querySelectorAll('button')].map(button=>({text:button.textContent.trim(),...font(button),...edges(button), - minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))}:null, + minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))})), }; } function headingOf() { @@ -169,13 +195,13 @@ export function measureInventory(scope) { if(!heading)return null; const copy=heading.firstElementChild,h1=copy?.querySelector('h1'),description=copy?.querySelector('p'),actions=heading.querySelector('.heading-actions'); return {...layout(heading),...edges(heading),height:px(rect(heading).height), - copy:{tag:copy.tagName.toLowerCase(),...layout(copy)}, - h1:{id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)}, - description:description?{className:description.className,text:description.textContent,...font(description),...edges(description)}:null, - actions:actions?{...layout(actions),...edges(actions), - fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:rect(actions).top>=rect(copy).bottom-0.5, + copy:when(copy,()=>({tag:copy.tagName.toLowerCase(),...layout(copy)})), + h1:when(h1,()=>({id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)})), + description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})), + actions:when(actions,()=>({...layout(actions),...edges(actions), + fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null, buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'), - ...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}:null}; + ...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))}; } return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null, library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index 5394fb2..9711365 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf} from './concept-inventory.mjs'; // Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the // concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes, @@ -18,17 +18,30 @@ const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text); const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity')); +/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */ +function packedLefts(items,width,justify,gap) { + const total=items.reduce((sum,item)=>sum+item.width,0)+gap*(items.length-1); + let start=0,spacing=gap; + if(justify==='flex-end'||justify==='end')start=width-total; + else if(justify==='center')start=(width-total)/2; + else if(justify==='space-between'&&items.length>1)spacing=gap+(width-total)/(items.length-1); + let x=start; + return items.map(item=>{const left=Math.round(x);x+=item.width+spacing;return left;}); +} + /** The top-actions row once removed items are gone: a centered flex row, so its size and item offsets follow. */ function remainingTopActions(actions) { const kept=actions.items.filter(item=>!removedItem(item)); const shown=kept.filter(item=>item.shown); const height=Math.max(...shown.map(item=>item.height)); const gap=parseFloat(actions.columnGap); - const width=shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1); - return {...actions,height, - // A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items. - width:actions.fullWidth?actions.width:width, - items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2)}:item)}; + // A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items. + const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1); + // At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with + // Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes. + const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]])); + return {...actions,height,width, + items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)}; } /** The footer once its button is gone: paddings plus the remaining spans along the footer's flex direction. */ @@ -57,6 +70,8 @@ function mapShellConcept(reference) { ]}; }); return {...reference,chrome:{...chrome, + // D29: no prototype suffix in the document title. + title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'), // D28: only the WORKSPACE group label remains. labels:chrome.labels.filter(label=>label.text==='WORKSPACE'), navs, @@ -80,14 +95,13 @@ function mapShellConcept(reference) { buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}}; } -/** Layout and typography of the heading without its page-owned copy and actions. */ +/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */ function headingFrame(heading) { if(!heading)return heading; - const {height,h1,description,actions,...frame}=heading; - const {text,...h1Tokens}=h1; + const {height,description,actions,...frame}=heading; const {text:copy,...descriptionTokens}=description??{}; const {buttons,stacked,...actionTokens}=actions??{}; - return {...frame,h1:h1Tokens,description:description?descriptionTokens:null,actions:actions?actionTokens:null}; + return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null}; } function compare(route,reference,actual) { @@ -113,6 +127,22 @@ async function ringsOf(page,mainSelector) { return {navLink,headingButton}; } +/** Hover colors of the first non-current WORKSPACE row, the Theme select and the ComfyUI link. */ +async function hoversOf(page) { + return { + navRow:await hoverStateOf(page,page.locator('#primary-nav a:not([aria-current])').first()), + theme:await hoverStateOf(page,page.getByRole('combobox',{name:'Theme',exact:true})), + canvasLink:await hoverStateOf(page,page.locator('.topbar .canvas-link')), + }; +} + +/** A viewport capture with the footer (and, at 1600, the sidebar's bottom group) in view. */ +async function captureFooter(page,testInfo,name) { + await page.locator('.app-footer').scrollIntoViewIfNeeded(); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await captureCanonicalState(page,testInfo,name); +} + test.beforeEach(async({request})=>{await seedConcept(request);}); for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{ @@ -122,21 +152,32 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry const reference=await inventoryOf(page,'#main'); if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`); const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null; + const referenceHovers=await hoversOf(page); + if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`); await openProduction(page,route,theme,motion);await page.mouse.move(0,0); const actual=await inventoryOf(page,'#workspace-main'); if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`); const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null; - result[`${motion} ${route}`]={breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings}; + const actualHovers=await hoversOf(page); + if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`); + result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings, + referenceHovers,actualHovers}; } const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); await testInfo.attach('shell-geometry',{path,contentType:'application/json'}); - for(const [key,{breadcrumb,reference,actual,diff,referenceRings,actualRings}] of Object.entries(result)) { + for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) { // The concept itself is what this comparison assumes: its sample counts and its shell controls. expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`) .toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]); expect.soft(reference.chrome.topActions.items.map(item=>item.className||item.tag),`${key} concept top actions`) .toEqual(['concept-badge','button','theme-label','canvas-link']); expect.soft(reference.chrome.breadcrumbs.parts.at(-1).text,`${key} concept breadcrumb`).toBe(breadcrumb); + expect.soft(reference.chrome.title,`${key} concept title`).toBe(`${breadcrumb} · Prompt Manager concept`); + expect.soft(actual.chrome.title,`${key} production title`).toBe(`${breadcrumb} · Prompt Manager`); + expect.soft(reference.heading.h1.text,`${key} concept page title`).toBe(route==='tags'?'Tags & categories':breadcrumb); + // The concept's own nav-row hover (style.css:183-186) is what production is held to. + expect.soft(referenceHovers.navRow,`${key} concept nav-row hover`).toMatchObject({hovered:true,color:reference.chrome.body.color}); + expect.soft(actualHovers,`${key} hover states`).toEqual(referenceHovers); expect.soft(diff,`${key} differences after the recorded deviations`).toEqual([]); expect.soft(actual.noPageScroll,`${key} no page-level horizontal scroll`).toBe(true); if(referenceRings) { @@ -147,3 +188,33 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry } } }); + +test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + await openConcept(page,'tags','dark');await page.mouse.move(0,0); + const reference=mapShellConcept(await inventoryOf(page,'#main')); + await openProduction(page,'tags','dark');await page.mouse.move(0,0); + expect(diffInventory(reference.chrome,(await inventoryOf(page,'#workspace-main')).chrome)).toEqual([]); + await page.locator('.breadcrumbs').evaluate(node=>node.remove()); + await page.locator('.connection small').evaluate(node=>node.remove()); + const actual=await inventoryOf(page,'#workspace-main'); + expect(actual.chrome.breadcrumbs).toBeNull(); + expect(actual.chrome.connection.smallFont).toBeNull(); + const paths=diffInventory(reference.chrome,actual.chrome).map(item=>item.path); + expect(paths).toContain('breadcrumbs'); + expect(paths).toContain('connection.smallFont'); + expect(paths).toContain('connection.small'); +}); + +test('the concept fixture renders the concept inspector facts',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + await openProduction(page,'images','dark'); + // The first image is the concept's first sample, selected into the panel on first load (D19). + const facts=page.locator('#workspace-main .generation-facts'); + await expect(facts).toBeVisible(); + const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()]))); + // app.js:404-408: the concept's facts for alpine_00001.png. + expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'}); + // D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way. + await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible(); +}); diff --git a/web/workspace.html b/web/workspace.html index c136436..b8dc814 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -21,7 +21,7 @@ Skip to workspace