test(history): concept comparison for prompt history

history-concept.spec.mjs compares the Prompt history with the concept's
#prompt/alpine on the concept fixture at 1600x1100 and 390x844, both
themes and motion settings, on Chromium and touch, with its behaviors
in both documents and a mutation check. inventoryOf gains a history
section; mapHistoryConcept applies D24, D35, D45, D46, D47 and the
approved Show archived button, laying the concept out at runtime.

Corrections it demonstrated: each compared negative mounts in a
display: contents child of the concept's .compare-item (D25), and the
concept fixture now links images oldest first and counts one pair use
per image, so newest-first reads and lane run counts match the concept.
history-canonical-comparison.mjs uses the concept's selectors for
production and drops its closed polish entries.
This commit is contained in:
Vito Sansevero
2026-10-21 03:25:24 -07:00
parent bc7747db7b
commit ea36849e23
7 changed files with 492 additions and 76 deletions
+30
View File
@@ -855,6 +855,36 @@ The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair
(see "Chronological Library history"), and each lane's "N runs" is the pair's
`run_count` (see "Workspace data fields").
`tests/browser/history-concept.spec.mjs` (#249 PR F-b, F5) compares this page with the
immutable concept's `#prompt/alpine` on the concept fixture, at 1600×1100 and 390×844 in
both themes and motion settings, on Chromium and touch: the heading, the summary, the
variants panel, the comparison (Compare negatives pressed) and the shared gallery's
place and heading, all measured by `inventoryOf`'s `history` section with the concept's
selectors in both documents, with "Clean architecture" selected (the middle lane in
either order). The fixture links and records its images oldest first, so ids grow with
time as real runs do, and counts one pair use per image, which gives the concept's
"Quality baseline · 2 runs" and its run dot. `mapHistoryConcept` applies D24 (the four
recorded runs give the concept's own "4 tracked runs", asserted on both), D45 (the
visually hidden "3 of 3 saved negative variants" status, and its wrap rules applied to
the concept at runtime), D35 and D46 (the concept's lanes reordered newest first with
"1 run", laid out by the browser), D46's Baseline/Selected action names, #235's Show
archived button (added to the concept at runtime) and D47 (the gallery's body is the
Images collection, so its height is not compared). D25: each compared block mounts in a
`display: contents` child of the concept's `.compare-item`, so the item carries no
production class or margin. Behaviors are compared in both documents: a red dot changes
the negative block and the gallery, Compare toggles `aria-pressed`, All images opens a
dialog with the positive's four images, the run dot opens a dialog (D26: Recorded run
where the concept shows Repeat run), the heading send previews the selected pair, and
← Prompt library navigates. `history comparison detects mutations` injects
`.ca-tools .variant-dot { width: 20px }` and removes Compare negatives. The older
`history-canonical-comparison.mjs` stays for `workspace-visual.spec.mjs`'s recorded
executions (#209) checks; its production selectors are now the concept's.
```sh
npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
workspace-history.spec.mjs --project chromium --project touch --project firefox --project webkit
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
+82 -2
View File
@@ -152,7 +152,8 @@ export async function mutate(page,{css=null,remove=[]}={}) {
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
* caption, grid, first six cards and whatever follows the grid; history stays null until PR F.
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
@@ -441,6 +442,85 @@ export function measureInventory(scope) {
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
}
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
const spoken=node=>node.classList.contains('ca-visually-hidden');
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
function promptSectionOf(section,origin) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
return {className:classes(section),...relative(section,origin),...edges(section),
label:when(label,()=>({text:label.textContent,...font(label)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function panelHeadingOf(heading,origin) {
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
return {...layout(heading),...edges(heading),...relative(heading,origin),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
}
function laneOf(lane,timeline) {
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
before:pseudo(lane,'::before'),
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
}
function timelineOf(timeline,origin) {
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
structure:structureOf(timeline)};
}
function variantsOf(panel) {
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
}
function compareOf(section) {
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
return {...edges(section),...relative(section,main),structure:structureOf(section),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
}
function historyOf() {
const panel=main?.querySelector('section.timeline-panel');
if(!panel)return null;
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
return {
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
variants:variantsOf(panel),
compare:when(compare,compareOf),
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
};
}
return {chrome:chromeOf(),heading:headingOf(),
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
@@ -450,7 +530,7 @@ export function measureInventory(scope) {
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
continuation:when(continuation,continuationOf),
panel:when(gallery,panelOf),
library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
/**
+23 -24
View File
@@ -14,9 +14,10 @@ export const canonicalDifferences = {
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
// exact history comparison is tests/browser/history-concept.spec.mjs.
remainingApplicationPolish: [
'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
@@ -33,29 +34,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
await testInfo.attach(name, { path, contentType: 'image/png' });
}
const CONCEPT_HISTORY_SELECTORS = Object.freeze({
surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: 'article.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot:not([hidden])',
});
const RECORDED_HISTORY_SELECTORS = Object.freeze({
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
});
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-history article.surface', timeline: '.library-history .timeline', positive: '.library-history .positive-lane',
branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]',
positiveLabel: '.library-history .positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-section',
prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-history .history-gallery',
heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small',
footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
// Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors;
// production adds the recorded executions (#209), which the concept does not have.
const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS;
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
+292
View File
@@ -0,0 +1,292 @@
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,mutate,stripExact} from './concept-inventory.mjs';
// Task F5 of #249: the production Prompt history (#/library/<id>) 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 (library.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$/,'$11 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 and how many media cards it shows once loaded; 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);
const result=cards?{name,cards:await dialog.locator('.media-card').count()}:{name};
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();}
});
// 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 mutate(page,{remove:['.timeline-panel .row-actions > button[aria-pressed]:first-child']});
const domMutated=await measure();
expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/);
});
+59 -48
View File
@@ -396,56 +396,67 @@ def seed_workspace_concept(
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
now = time.time()
images, runs = [], []
for round_index in range(rounds):
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
if index >= total:
break
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = None if index < positive_links else owner["pair_ids"][variant]
if pair_id is not None:
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
slots = [
(round_index, prompt_index, prompt)
for round_index in range(rounds)
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS)
if round_index * len(CONCEPT_PROMPTS) + prompt_index < total
]
# Linked and recorded oldest first, as real runs arrive, so link and run ids
# grow with time and every newest-first read (by id or by mtime) lists the
# concept's order (app.js:180-239). The manifest keeps the concept's order.
for round_index, prompt_index, prompt in reversed(slots):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = None if index < positive_links else owner["pair_ids"][variant]
if pair_id is not None:
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
# Real pair counting: each image is one run of its pair, as the
# concept's "<name> · N runs" counts its images (app.js a598, D26).
db.record_prompt_pair_use(pair_id)
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
)
images.reverse()
runs.reverse()
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
+3 -2
View File
@@ -486,9 +486,10 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];};
content.cleanup(disposeCompareBlocks);
function compareBlock(text,label) {
const item=el('div',undefined,'compare-item');
// The concept's `.compare-item` holds the block itself; the blocks mount in a transparent child (D25).
const item=el('div',undefined,'compare-item'),mount=el('div');item.append(mount);
// D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page.
compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)}));
compareBlocks.push(mountPromptBlocks(mount,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)}));
compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'});
return item;
}
+3
View File
@@ -219,6 +219,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space
/* #249 D24: the history summary's prompt blocks sit in `article.surface` as the concept's do (app.js a598); the mount
point keeps its "Saved prompt texts" region name. */
.library-history article.surface > .prompt-inspector { display: contents; /* D24 */ }
/* #249 D25: each compared negative's block sits in the concept's `.compare-item` (app.js a600), so its mount point is
transparent and adds no margin. */
.compare-item > .prompt-inspector { display: contents; /* D25 */ }
/* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never
wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */
.card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ }