The page workflows started one global webServer and ran with one worker. Each worker now owns its own tests/browser/server.py on an OS-chosen port, with its own temp root and database, through a shared `test` in tests/browser/fixtures.mjs. baseURL, and so page, request and browser.newContext, point at that worker's server. A server that dies fails the running test with its stderr tail and the next test gets a fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI, else a quarter of the CPUs up to 8. Chromium workflows in the pinned image: 510 tests in 606 s with one worker on main, 511 (one new isolation spec) in 119 s with eight.
307 lines
20 KiB
JavaScript
307 lines
20 KiB
JavaScript
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 as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} 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,
|
||
// 1600x1100 and 390x844, normal and reduced motion. Paired captures are artifacts only; there are no PNG baselines.
|
||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||
|
||
// Routes whose shell is compared. The concept and production share route names for these three pages.
|
||
const ROUTES=Object.freeze([['images','Images'],['library','Prompt library'],['tags','Tags']]);
|
||
// The heading's content (description copy, actions) belongs to each page's own PR (C1, E1, F1) and is compared there;
|
||
// Tags & categories is already rebuilt (#245), so its heading is compared in full.
|
||
const FULL_HEADING=new Set(['tags']);
|
||
|
||
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
|
||
|
||
const removedItem=item=>String(item.className).includes('concept-badge');
|
||
|
||
/**
|
||
* The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as
|
||
* the comparison fixture has none. The concept's own CSS then lays out its button without the count.
|
||
*/
|
||
async function openConcept(page,route,theme,motion) {
|
||
await openConceptPage(page,route,theme,motion);
|
||
await mutate(page,{css:'#activity-count { display: none }'});
|
||
}
|
||
|
||
/** 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);
|
||
// 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 its shown items with `space-between`; the hidden pill (D53) takes no place.
|
||
// 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,
|
||
// D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are.
|
||
flexWrap:actions.fullWidth?'wrap':actions.flexWrap,
|
||
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. */
|
||
function remainingFooter(footer,spans) {
|
||
const content=footer.flexDirection==='column'
|
||
?spans.reduce((sum,span)=>sum+span.height,0)+parseFloat(footer.rowGap)*(spans.length-1)
|
||
:Math.max(...spans.map(span=>span.height));
|
||
return `${parseFloat(footer.paddingTop)+parseFloat(footer.paddingBottom)+content}px`;
|
||
}
|
||
|
||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||
// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph.
|
||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||
|
||
/**
|
||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||
*/
|
||
function savedNav(nav) {
|
||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||
}
|
||
|
||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||
function mapShellConcept(reference) {
|
||
const {chrome}=reference;
|
||
const navs=chrome.navs
|
||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||
.filter(nav=>nav.id!=='collections-nav')
|
||
.map(nav=>{
|
||
if(nav.id==='saved-nav')return savedNav(nav);
|
||
if(nav.id!=='system-nav')return nav;
|
||
const system=rowNamed(nav,'System');
|
||
return {...nav,rows:[
|
||
// D52: the Activity row counts running jobs only, so with none it has no count.
|
||
{...rowNamed(nav,'Activity'),count:null},
|
||
// D28: no Design review (prototype page).
|
||
system,
|
||
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
|
||
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
|
||
{...system,text:'Overview',current:null,gapAfter:null,icon:{...system.icon,d:OVERVIEW_GLYPH}},
|
||
]};
|
||
});
|
||
return {...reference,chrome:{...chrome,
|
||
// D29: no prototype suffix in the document title.
|
||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||
navs,
|
||
connection:{...chrome.connection,
|
||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||
small:'Your library, on your server',
|
||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||
// D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running
|
||
// job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow
|
||
// from the concept's own item sizes.
|
||
topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=>
|
||
item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}),
|
||
footer:(()=>{
|
||
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
|
||
// (a prototype review page); the footer then holds only its span.
|
||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||
})(),
|
||
},
|
||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||
}
|
||
|
||
/** 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,description,actions,...frame}=heading;
|
||
const {text:copy,...descriptionTokens}=description??{};
|
||
const {buttons,stacked,...actionTokens}=actions??{};
|
||
return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null};
|
||
}
|
||
|
||
function compare(route,reference,actual) {
|
||
const expected=mapShellConcept(reference);
|
||
const heading=FULL_HEADING.has(route);
|
||
return [
|
||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||
];
|
||
}
|
||
|
||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||
function assertShellMatches(route,reference,actual) {
|
||
const diff=compare(route,reference,actual);
|
||
if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||
}
|
||
|
||
/** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */
|
||
async function ringsOf(page,mainSelector) {
|
||
// Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links.
|
||
await page.locator('#page-title').focus();await page.keyboard.press('Tab');
|
||
const button=page.locator(`${mainSelector} .heading-actions button`).first();
|
||
await expect(button).toBeFocused();
|
||
const headingButton=await focusRingOf(button);
|
||
const link=page.locator('#primary-nav a').first();await link.focus();
|
||
const navLink=await focusRingOf(link);
|
||
await link.blur();
|
||
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)=>{
|
||
await page.setViewportSize({width,height});const result={};
|
||
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
|
||
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
||
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;
|
||
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,{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) {
|
||
// The toolkit's button ring (2px offset) wins over the concept's 3px for buttons, in the concept too (#245).
|
||
expect.soft(referenceRings.headingButton,`${key} concept heading-button ring`).toMatchObject({focused:true,visible:true,outlineStyle:'solid',outlineWidth:'2px'});
|
||
expect.soft(referenceRings.navLink,`${key} concept nav-link ring`).toMatchObject({focused:true,outlineStyle:'solid',outlineWidth:'2px',outlineOffset:'3px'});
|
||
expect.soft(actualRings,`${key} focus rings`).toEqual(referenceRings);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
|
||
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
|
||
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
|
||
await page.setViewportSize({width,height});
|
||
const save=async(action,mainSelector)=>{
|
||
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
|
||
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
|
||
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
|
||
await dialog.getByRole('button',{name:action,exact:true}).click();
|
||
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
|
||
await page.mouse.move(0,0);
|
||
return inventoryOf(page,mainSelector);
|
||
};
|
||
await openConcept(page,'images','dark');
|
||
const reference=await save('Apply in prototype','#main');
|
||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||
await openProduction(page,'images','dark');
|
||
const actual=await save('Save','#workspace-main');
|
||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||
});
|
||
|
||
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 #image-inspector .meta-grid');
|
||
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: execution evidence is the concept's "Execution recorded" badge.
|
||
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
|
||
});
|
||
|
||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||
// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match,
|
||
// and each must make the comparison fail with the path of what changed.
|
||
for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{
|
||
await page.setViewportSize({width,height});
|
||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||
const reference=await inventoryOf(page,'#main');
|
||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||
const clean=await measure();
|
||
assertShellMatches('tags',reference,clean);
|
||
|
||
const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'});
|
||
const cssMutated=await measure();
|
||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/);
|
||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/);
|
||
await restoreCss();
|
||
assertShellMatches('tags',reference,await measure());
|
||
|
||
await mutate(page,{remove:['.breadcrumbs']});
|
||
const domMutated=await measure();
|
||
expect(domMutated.chrome.breadcrumbs).toBeNull();
|
||
// The missing section is reported by its own path (A6: absent sections become null), not by a measuring error.
|
||
expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/);
|
||
});
|