From 2c12f38da992849735c1816e08e61d3218de3856 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 20:36:07 -0700 Subject: [PATCH] test(inspector): concept comparison for the panel and viewer details Compare the Images side panel and the viewer's full details with the concept (1600x1100 and 390x844, both themes): structure, tokens, geometry and controls, plus copy, the acknowledged pair send, the tag chip filter and Prompt history. mapInspectorConcept applies only D3, D20, D21 and D22; a mutation test shows the comparison notices a changed role label and a removed footer. The comparison showed the role copy and send names lacked the concept's ": " subject, so the inspector now passes its title to the prompt blocks. It also showed that a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes lack; that is recorded as D38, and the fixture's new positive_links option links the compared image to its positive alone. --- TESTING.md | 27 +- tests/browser/concept-inventory.mjs | 104 +++++++ tests/browser/full-inspector.spec.mjs | 8 +- tests/browser/inspector-concept.spec.mjs | 303 +++++++++++++++++++++ tests/browser/prompt-inspector.spec.mjs | 22 +- tests/browser/server.py | 3 + tests/browser/workspace-history.spec.mjs | 2 +- tests/browser/workspace-visual.spec.mjs | 4 +- tests/browser/workspace_concept_fixture.py | 10 +- tests/js/prompt-blocks.test.js | 14 + web/js/workspace/file-inspector.mjs | 2 +- web/js/workspace/prompt-blocks.mjs | 8 +- 12 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 tests/browser/inspector-concept.spec.mjs diff --git a/TESTING.md b/TESTING.md index 6c0b251..626ce9f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -590,6 +590,31 @@ Recorded deviations on this page (spec §8): - **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept applies its negative wording to both roles), and a confirmed `p.prompt-text` is focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable. +- **D38**: a recipe-linked image shows its recipe's own category and tags (#224), not + its positive's. The concept shows the prompt's category and tags for every image; + the concept fixture's recipes have neither, so their images show no category chip + and no tag row. `positive_links` on `/__test__/concept/reset` links the newest + images to their positive alone, so the inspector comparison sees the positive's + category ("Positive prompt category: " hidden prefix, D21) and tag filters. + +`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's +full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes: +structure, cover, heading, filename, badges, prompt blocks (role labels, actions, +text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every +offered control's name and minimum height. Each role's copy and send are named after +the prompt, as the concept's are ("Copy positive prompt: <title>"). The concept loads +all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the +same "Copy prompt" dialog in both); the pair send previews both roles in the concept +and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic` +chip sets the Images Tags filter (D21: the concept searches the word, which also +matches the untagged forest prompt's text); Prompt history goes to +`#/library/<positive id>` (the concept's `#prompt/<id>`). `mapInspectorConcept` +applies D3 (the `.file-inspector` class of the production-only rules), D20 (the +viewer's plain tag chips, laid out by the concept page itself with its chips made +plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`, +`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope +prefix) and D22 (focusable prompt text). `inspector comparison detects mutations` +changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`. Manual check (quick), after a browser refresh on Images: hover an image (it zooms); click it (the viewer reads "<title> · <filename>"; Escape closes it); press a card's @@ -599,7 +624,7 @@ saved file. ```sh npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \ - workspace-images.spec.mjs full-inspector.spec.mjs \ + workspace-images.spec.mjs full-inspector.spec.mjs inspector-concept.spec.mjs \ --project chromium --project touch --project firefox --project webkit ``` diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 09597e0..6684afb 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -344,3 +344,107 @@ export function measureInventory(scope) { panel:when(gallery,panelOf), library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; } + +/** + * The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails + * a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null + * when absent. Both are measured by one in-page function with the concept's selectors in either document. + */ +export async function inspectorOf(page) { + return {panel:await page.evaluate(measureInspector,'#image-inspector'), + viewerDetails:await page.evaluate(measureInspector,'#viewer-details')}; +} + +/** Runs in the page; self-contained. Every box is relative to the measured root. */ +export function measureInspector(selector) { + const root=document.querySelector(selector); + if(!root)return null; + const px=value=>Math.round(value); + const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; + const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden'; + const rect=node=>node.getBoundingClientRect(); + const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform'); + const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft', + 'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor'); + const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap'); + const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};}; + const classes=node=>[...node.classList].sort().join(' '); + const when=(node,measure)=>node?measure(node):null; + // Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed. + const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove()); + return copy.textContent.replace(/\s+/g,' ').trim();}; + const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null; + const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]'); + function iconOf(node) { + const svg=node.querySelector(':scope > svg.icon'); + if(!svg)return null; + return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'), + d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')}; + } + function controlOf(node) { + const style=getComputedStyle(node); + return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'), + primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node), + minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)}; + } + function chipOf(node) { + return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node), + minHeight:getComputedStyle(node).minHeight}; + } + function rowOf(row) { + return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)}; + } + function sectionOf(section) { + 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),...edges(section),...box(section), + heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})), + label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})), + actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})), + text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text), + ...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))}; + } + function factsOf(list) { + const style=getComputedStyle(list); + return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list), + items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd'); + return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})}; + } + const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`; + const panel=root.classList.contains('detail-panel'); + // The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is + // `display: contents` is transparent: its sections count as the wrapper's own children. + const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div'); + const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[]; + const sections=parts.filter(item=>item.classList.contains('prompt-section')); + const firstSection=parts.indexOf(sections[0]); + const rows=parts.filter(item=>item.classList.contains('tags')); + const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip'); + const filename=parts.find(item=>item.matches('p.small')); + const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer'); + // What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it. + const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';}; + const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node)); + return { + className:classes(root),label:root.getAttribute('aria-label'), + ...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height), + structure:{outer:[...root.children].map(describe),inner:parts.map(describe)}, + cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})), + inner:when(inner,()=>({...edges(inner),...box(inner)})), + heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading), + h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})), + filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})), + badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf), + sections:sections.map(sectionOf), + tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf), + facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf), + // Disclosures (viewer only) by their summary and frame; their content is the D20 production facts. + details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary'); + return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}), + footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})), + // The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer). + actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf), + controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'), + primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})), + }; +} diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index d54ea66..11ebd37 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -277,12 +277,12 @@ test('full inspector shows six facts and scoped provenance',async({page,request} expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px'); if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) { // The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock. - const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31); + const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31); } await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible(); - await expect(panel.getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); + await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1); // Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines. const details=await viewerDetails(page,'complete.png'); @@ -355,7 +355,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30); let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();}); await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});}); - await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden(); + await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden(); await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal); for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();} await page.evaluate(()=>{document.documentElement.style.zoom='1';}); @@ -717,7 +717,7 @@ test('slideshow interaction visibility sender and disposal clear intent',async({ for(const action of [ ()=>modal.locator('.prompt-section.positive p.prompt-text').click(), ()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}), - ()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(), + ()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).click(), async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));}, async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});}, async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();}, diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs new file mode 100644 index 0000000..2e56eaa --- /dev/null +++ b/tests/browser/inspector-concept.spec.mjs @@ -0,0 +1,303 @@ +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,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs'; + +// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i, +// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes. +// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it), which the fixture +// links to its positive prompt alone (`positive_links`, D38), so its panel shows the positive's own category and tags +// as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are artifacts only; +// there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const CARDS='.gallery-body > .image-grid > .media-card'; +const TITLE='Alpine observatory'; +const FIXTURE_IMAGES=24; +const SEED={positive_links:1}; +// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel). +const PANEL_INSET=40; + +/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */ +function withFocusablePrompts(inventory) { + const controls=[...inventory.controls]; + for(const section of inventory.sections) { + const send=section.actions.buttons.at(-1); + const index=controls.findIndex(control=>control.name===send.name); + controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'}); + } + return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))}; +} + +/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's. */ +function withCategoryScope(inventory) { + const [badge,category,...rest]=inventory.badges.chips; + return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}}; +} + +/** + * Recorded inspector deviations only (spec §8, TESTING.md D20–D22); everything else must equal production. + * `reference.viewerPlain` is the concept's own full details with its tag chips made plain at runtime (D20): the browser + * lays out what follows them, as the Images spec's removed toolbar items do. + */ +export function mapInspectorConcept(reference,{viewport}) { + const {panel,viewerPlain}=reference; + return { + panel:withCategoryScope(withFocusablePrompts({...panel, + // D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class. + className:`${panel.className} file-inspector`, + // D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer + // reachable; at 700px and below, where the concept unsticks it, it grows instead. + overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})), + // D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library). + viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})), + }; +} + +function compare(reference,actual,viewport) { + return diffInventory(mapInspectorConcept(reference,{viewport}),actual); +} + +/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ +function assertInspectorMatch(reference,actual,viewport) { + const diff=compare(reference,actual,viewport); + if(diff.length)throw new Error(`the inspector 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 panelOf=page=>page.locator('#image-inspector'); + +/** + * The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production + * has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison. + */ +async function openConceptPanel(page,theme) { + await openConcept(page,'images',theme);await page.mouse.move(0,0); + if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click'); + await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES); + await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);}); + await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE); + await settle(page); +} +async function openProductionPanel(page,theme) { + await openProduction(page,'images',theme);await page.mouse.move(0,0); + await expect(page.locator(CARDS).first()).toBeVisible(); + await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE); + await expect(panelOf(page).locator('.detail-footer')).toBeVisible(); + await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2); + await settle(page); +} + +/** The concept's tag chips in the open viewer as plain `span.chip`s (D20); returns an undo. */ +async function plainViewerChips(page) { + await page.evaluate(()=>{ + window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{ + const span=document.createElement('span');span.className='chip';span.textContent=button.textContent; + button.replaceWith(span);return [span,button]; + }); + }); + await settle(page); + return ()=>page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;}); +} + +/** The first card's viewer: its full details measured (and, in the concept, with plain chips), then closed again. */ +async function viewerOf(page,concept) { + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE); + await settle(page); + const {viewerDetails}=await inspectorOf(page); + let viewerPlain=null; + if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();} + await page.keyboard.press('Escape'); + await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0); + return {viewerDetails,viewerPlain}; +} +async function inventory(page,concept=false) { + const {panel}=await inspectorOf(page); + const {viewerDetails,viewerPlain}=await viewerOf(page,concept); + return concept?{panel,viewerDetails,viewerPlain}:{panel,viewerDetails}; +} + +/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */ +async function fakeHost(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'); + window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[]; + const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']}; + window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{ + listTargets:()=>[target], + preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}), + apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};}, + }}); + }); + return host; +} + +/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */ +async function copyOf(page) { + // An earlier toast (the concept's Load more) is not the copy's outcome. + await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page); + await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';})); + await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click(); + const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true}); + await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe(''); + const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0}; + if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);} + return result; +} + +/** The concept's send: its dialog previews both roles (app.js openSend). */ +async function conceptSendOf(page) { + await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click(); + const modal=page.locator('dialog#modal[open]'); + await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI'); + const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent])); + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0); + return previews; +} + +/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */ +async function productionSendOf(page,context) { + const host=await fakeHost(context); + try { + await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click(); + const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible(); + const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); + await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click(); + await expect(sender).toContainText('Sent to ComfyUI.'); + const applied=await host.evaluate(()=>window.applied); + await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0); + return applied; + } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();} +} + +/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */ +async function tagOf(page) { + const before=await page.locator('#result-count').textContent(); + await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click(); + await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before); + await settle(page); + return {count:await page.locator('#result-count').textContent(), + cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))}; +} + +const historyOf=async page=>{ + await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click(); + await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/); + return page.evaluate(()=>location.hash); +}; + +test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request,SEED);}); + +for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{ + test.setTimeout(180_000); + // The concept's copy and production's both use the async clipboard; Chromium asks for the permission. + if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']); + await page.setViewportSize({width,height}); + const manifest=testInfo.manifest; + const viewport={width,height}; + await openConceptPanel(page,theme); + await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`); + const reference=await inventory(page,true); + const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)}; + await openConceptPanel(page,theme);concept.history=await historyOf(page); + + await openProductionPanel(page,theme); + await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`); + const actual=await inventory(page); + const production={copy:await copyOf(page),send:await productionSendOf(page,context)}; + const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic'); + production.tag=await tagOf(page);await listing; + await page.getByRole('button',{name:'Filters',exact:true}).click(); + production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue(); + await page.keyboard.press('Escape'); + await openProductionPanel(page,theme);production.history=await historyOf(page); + + const diff=compare(reference,actual,viewport); + const path=testInfo.outputPath('inspector-geometry.json'); + await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2)); + await testInfo.attach('inspector-geometry',{path,contentType:'application/json'}); + + // The concept itself is what this comparison assumes (spec §3.3, s523–615). + const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches); + expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28); + expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static'); + expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200); + expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px'); + expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px'); + for(const section of panel.sections) { + expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'}); + expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'}); + for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'}); + } + expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color); + expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'}); + expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'}); + expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']); + expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']); + + expect.soft(diff,'differences after the recorded deviations').toEqual([]); + // Copy: the concept's toast "Prompt copied." (or its dialog), in both documents. + expect.soft(production.copy,'copy').toEqual(concept.copy); + if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false}); + // Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them. + const [positive,negative]=concept.send; + expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']); + expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]); + expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution'); + // Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images. + expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic'); + // The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic + // (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order. + const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_')); + expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts'); + expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged}); + // Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive. + expect.soft(concept.history,'concept history').toBe('#prompt/alpine'); + expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`); +}); + +// D38: a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes do +// not have, so its panel has neither (the concept shows the prompt's for every image). +test('a recipe-linked image shows only its recipe metadata (D38)',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + await openProductionPanel(page,'dark'); + await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click(); + await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry'); + await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1); + await expect(panelOf(page).locator('.detail-inner > .tags .chip')).toHaveText(['Execution recorded']); + await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); +}); + +// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the role +// label's letter spacing) and one DOM mutation (the footer removed), each applied after an exact match. +test('inspector comparison detects mutations',async({page})=>{ + test.setTimeout(120_000); + const viewport={width:1600,height:1100}; + await page.setViewportSize(viewport); + await openConceptPanel(page,'dark'); + const reference=await inventory(page,true); + await openProductionPanel(page,'dark'); + const {viewerDetails}=await inventory(page); + const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails}); + assertInspectorMatch(reference,await measure(),viewport); + + const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'}); + const cssMutated=await measure(); + expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/); + await restoreCss();await settle(page); + assertInspectorMatch(reference,await measure(),viewport); + + await mutate(page,{remove:['#image-inspector > .detail-footer']}); + const domMutated=await measure(); + expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/); +}); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index 286a95a..2c202c8 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -75,7 +75,7 @@ test('physical image inspector resolves positive and negative metadata separatel test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{ await page.goto(workspace);await select(page,'0:empty-negative.png'); await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)'); - await expect(details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toBeEnabled(); + await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled(); await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.'); await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI$/})).toHaveCount(0); }); @@ -157,7 +157,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{ const host=await fakeHost(context,page,{rejectApply:true}); try { - await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click(); + await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click(); const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/); await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled(); @@ -169,7 +169,7 @@ test('nested viewer sender Escape restores focus without closing the image and n await page.goto(workspace);await select(page,'0:shared/same.png'); await page.locator('.media-card.selected .image-open').click(); const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible(); - const send=viewer.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true});await send.click();await expect(dialog(page)).toBeVisible(); + const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible(); await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused(); await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0); }); @@ -178,7 +178,7 @@ test('empty negative send clears only the negative destination and preserves its const host=await fakeHost(context,page); try { await page.goto(workspace);await select(page,'0:empty-negative.png'); - await details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true}).click(); + await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).click(); const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''}); @@ -187,7 +187,7 @@ test('empty negative send clears only the negative destination and preserves its test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); - await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click(); + await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click(); const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.'); const host=await fakeHost(context,page); try { @@ -205,11 +205,11 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png'); const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text); await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.'); - await expect(details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0); - await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible(); + await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0); + await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Close dialog',exact:true}).click(); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); - await positive.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click(); + await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click(); await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox(); expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391); }); @@ -284,8 +284,8 @@ test('prompt role headings contain the concept label and compact real actions wi const block=role(details(page),name),heading=block.locator('.prompt-heading'); await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`)); await expect(heading.locator('span.role-label')).toHaveText(label); - await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(`Copy ${name.toLowerCase()} prompt`); - await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(`Send ${name.toLowerCase()} prompt to ComfyUI`); + await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`)); + await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`)); await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1); expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true); } @@ -301,7 +301,7 @@ test('copy toast and fallback dialog',async({page})=>{ await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});}); await page.goto(workspace);await select(page,'0:shared/same.png'); const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent(); - const copy=positive.getByRole('button',{name:'Copy positive prompt',exact:true});await copy.click(); + const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click(); await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]); await expect(positive.getByRole('status')).toHaveCount(0); await page.evaluate(()=>{window.clipboardFails=true;});await copy.click(); diff --git a/tests/browser/server.py b/tests/browser/server.py index 26066f2..e6c507d 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -17,6 +17,7 @@ from PIL.PngImagePlugin import PngInfo from tests.browser.workspace_concept_fixture import ( MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES, MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES, + MAX_POSITIVE_LINKS as CONCEPT_MAX_POSITIVE_LINKS, seed_workspace_concept, ) from tests.browser.workspace_visual_fixture import seed_workspace_visual @@ -393,6 +394,7 @@ def create_app(root: Path) -> web.Application: limits = { "extra_images": CONCEPT_MAX_EXTRA_IMAGES, "seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES, + "positive_links": CONCEPT_MAX_POSITIVE_LINKS, } if ( request.query @@ -415,6 +417,7 @@ def create_app(root: Path) -> web.Application: root, extra_images=data.get("extra_images", 0), seedless_images=data.get("seedless_images", 0), + positive_links=data.get("positive_links", 0), ) ) return web.json_response({"success": True, **manifest}) diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index d79183c..febf0b8 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -474,7 +474,7 @@ test('the inline current file names its prompts apart from the frozen run fields const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible(); await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive'); await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative'); - await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible(); + await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible(); for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1); await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0); expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 2e4652f..9278cec 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -125,11 +125,11 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive); await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative); - for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible(); + for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible(); await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible(); await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480'); const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true }); - await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click(); + await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click(); // The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused. const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true }); await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 0c5f187..2f7acb2 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -157,6 +157,9 @@ CONCEPT_MODEL = "illustrative-model.safetensors" MAX_EXTRA_IMAGES = 120 # Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks. MAX_SEEDLESS_IMAGES = 2 +# The newest images linked to their positive alone (no recipe), for the inspector's +# positive-scope category and tag chips (D38). +MAX_POSITIVE_LINKS = 6 SEEDLESS_SIZE = (1536, 1152) SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10" _TABLES = ( @@ -341,6 +344,7 @@ def seed_workspace_concept( *, extra_images: int = 0, seedless_images: int = 0, + positive_links: int = 0, ) -> dict: """Replace this server's disposable rows and files with the concept's data. @@ -350,6 +354,7 @@ def seed_workspace_concept( """ _bounded("extra_images", extra_images, MAX_EXTRA_IMAGES) _bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES) + _bounded("positive_links", positive_links, MAX_POSITIVE_LINKS) _clear(api, root) db = api.db epoch = db.get_execution_epoch() @@ -403,8 +408,9 @@ def seed_workspace_concept( os.utime(path, (stamp, stamp)) owner = prompts[prompt_index] image_id = db.link_image_to_prompt(owner["positive_id"], str(path)) - pair_id = owner["pair_ids"][variant] - db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"}) + 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}") diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index e8a239b..6157c57 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -144,6 +144,20 @@ test('a context label names nested prompt regions and actions apart from the fro }finally{scope.dispose();f.restore();} }); +test('a title names each role action after its prompt as the concept does',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}}); + const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']); + assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory', + 'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']); + const nested=new f.Element('div'); + module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}}); + assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']), + ['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']); + }finally{scope.dispose();f.restore();} +}); + const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}), ...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})}); test('prompt block emits concept markup without a source line',()=>{ diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 3a906eb..07ebf0d 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -183,7 +183,7 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl if (linked && library.category) badges.append(prefixedChip(library.metadata_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category)); if (badges.childNodes.length) inner.append(badges); const prompts = el('div'); inner.append(prompts); - const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext}); + const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title}); blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose); if (linked && library.tags.length) { // D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 9eca0da..bbda3da 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -32,11 +32,13 @@ function childScope(parentScope) { return scope; } -export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', copyFallback = openCopyFallback}) { +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) { // A nested set of blocks (the current file inside a recorded run) names its regions and actions with a // context so assistive technology never meets two identically named prompt regions in one dialog. const named = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`; const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`; + // The concept names a role's copy and send after the prompt they act on (app.js a298–302): "…: <title>". + const subject = title ? `: ${title}` : ''; if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; const scope = childScope(parentScope); container.classList.add('prompt-inspector'); @@ -92,12 +94,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role if (Object.hasOwn(roles, role)) { const actions = el('div', undefined, 'prompt-actions'); const copy = createButton('', 'icon-btn'); - copy.setAttribute('aria-label', `Copy ${action(role)}`); + copy.setAttribute('aria-label', `Copy ${action(role)}${subject}`); copy.append(createWorkspaceIcon('copy')); copy.dataset.copyRole = role; copy.disabled = !actionsEnabled; const send = createButton('', 'send icon-btn'); - send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI`); + send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI${subject}`); send.append(createWorkspaceIcon('send')); send.dataset.sendRole = role; send.disabled = !actionsEnabled;