diff --git a/TESTING.md b/TESTING.md index d249e9d..ce81b09 100644 --- a/TESTING.md +++ b/TESTING.md @@ -305,6 +305,82 @@ Use the disposable fixture and managed-host prerequisites above. A maintainer live check needs a ComfyUI restart after adding the Python route, then opening `/prompt_manager/workspace`; the mockup preview server is a separate design tool. +### Concept shell and comparison method (#249) + +**CSS base and drift check.** `web/js/workspace/concept.css` is a byte-identical copy +of the approved concept's `docs/concepts/workspace/style.css`. Never edit the copy; +it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`. +`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails +on a missing copy or names the first differing byte. `workspace.html` loads, in order, +the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own +PRs retire, and the production overlay `workspace.css` last. +`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no +other page or canvas script loads workspace CSS. + +**Overlay decision-marker lint.** `lintOverlay` in `tools/vendor/sync-concept-css.mjs` +fails any `workspace.css` rule whose selector is also a concept selector unless the +rule carries a `/* D */` comment, before its selector or inside its block. The lint +test fails if `workspace.css` is missing. The match is by exact selector, so a narrower +selector over a concept rule (for example `.ca-tools dialog.prompt-sender`) passes the +lint. Such rules, and the page-stylesheet leftovers such as `library.css` +`.row-actions`, name the PR that retires them, by convention. + +**Comparison method.** Production must equal the concept except for the recorded +deviations in the #249 design (spec §8, D-numbers). It is never compared against +blessed production screenshots. +- The fixture is `tests/browser/workspace_concept_fixture.py`, reset through + `POST /__test__/concept/reset`. It holds the concept's six prompts and pairs, and + 24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts + are therefore the concept's own: 24, 6 and 15. +- The shared inventory is `tests/browser/concept-inventory.mjs`. One self-contained + in-page measurer runs the same concept selectors in the immutable concept + (`/__test__/canonical/workspace`) and in production. An absent element becomes + `null`, so the diff names its path (for example `chrome.breadcrumbs`) instead of + failing the measurement. +- Each page's `mapConcept(reference)` applies only the recorded deviations, each + commented with its D-number. `diffInventory` must then report nothing. +- Sizes, offsets and heights that follow from a removed element are derived from the + concept's own measurements, never copied from production. +- `mutate(page, {css, remove})` appends a stylesheet and detaches elements, and returns + an undo. Each comparison has a mutation check proving it fails. For the shell, a + `.sidebar nav a { min-height: 30px }` rule must fail at `chrome.navs.0.rows.0.minHeight` + and `.height`, and removing `.breadcrumbs` must fail at `chrome.breadcrumbs`. +- Paired concept/production captures are attached as test artifacts. There are no PNG + baselines. + +`tests/browser/shell-concept.spec.mjs` compares the shell: the sidebar, topbar, page +heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and +reduced motion, on Images, Prompt library and Tags. The heading is compared in full on +Tags; on Images and Library only its frame and h1 are compared, because their copy and +actions belong to their own PRs. The spec also compares the hover state of the first +non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of +the first nav link and heading button. + +Recorded shell deviations: +- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI + palette, then the OS. +- **D5**: no Activity button or Activity row until an activity center exists. +- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is + System plus the production Overview hub (the concept's `activity` glyph, no count). + The connection reads "Your library, on your server" with a neutral muted dot. +- **D29**: no prototype copy. There is no "Interactive concept" badge and no + "What changes & why" button. The footer reads "ComfyAssets · Local tools. Your + library.", and the title has no " concept" suffix. +- **D31**: at `max-width: 950px` the overlay caps the one-column shell at + `minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content. +- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation + slice. +- **Brand name**: not a deviation. The brand link's accessible name matches the + concept's, mark included, because production's `.brand-mark` is not `aria-hidden` + either. The inventory compares `brand.name` and `brand.mark.hidden`. + +```sh +npm run --prefix tools/vendor check +npm test --prefix tools/vendor +npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \ + --project chromium --project touch --project firefox --project webkit +``` + ## Prompt sending and cross-pack compatibility `tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 7e67119..cc20579 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -56,6 +56,37 @@ export async function hoverStateOf(page,locator) { return state; } +/** + * Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as + * the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector + * is detached. Returns an async undo that restores the stylesheet and the removed elements in place. + */ +export async function mutate(page,{css=null,remove=[]}={}) { + const token=await page.evaluate(({css,remove})=>{ + const token=`mutation-${Math.random().toString(36).slice(2)}`; + const undo=[]; + if(css) { + const style=document.createElement('style');style.dataset.mutation=token;style.textContent=css; + document.head.append(style);undo.push(()=>style.remove()); + } + for(const selector of remove) { + const nodes=[...document.querySelectorAll(selector)]; + if(!nodes.length)throw new Error(`mutate: nothing matches ${selector}`); + for(const node of nodes) { + const parent=node.parentNode,next=node.nextSibling; + node.remove();undo.push(()=>parent.insertBefore(node,next)); + } + } + (window.__conceptMutations??=new Map()).set(token,undo); + return token; + },{css,remove}); + return ()=>page.evaluate(token=>{ + const undo=window.__conceptMutations?.get(token); + if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded'); + undo.reverse().forEach(step=>step());window.__conceptMutations.delete(token); + },token); +} + /** * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history, * noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index 9711365..6cb5197 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs'; // Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the // concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes, @@ -114,6 +114,12 @@ function compare(route,reference,actual) { ]; } +/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ +function assertShellMatches(route,reference,actual) { + const diff=compare(route,reference,actual); + if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`); +} + /** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */ async function ringsOf(page,mainSelector) { // Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links. @@ -218,3 +224,29 @@ test('the concept fixture renders the concept inspector facts',async({page})=>{ // D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way. await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible(); }); + +// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the +// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match, +// and each must make the comparison fail with the path of what changed. +for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{ + await page.setViewportSize({width,height}); + await openConcept(page,'tags','dark');await page.mouse.move(0,0); + const reference=await inventoryOf(page,'#main'); + await openProduction(page,'tags','dark');await page.mouse.move(0,0); + const measure=()=>inventoryOf(page,'#workspace-main'); + const clean=await measure(); + assertShellMatches('tags',reference,clean); + + const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'}); + const cssMutated=await measure(); + expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/); + expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/); + await restoreCss(); + assertShellMatches('tags',reference,await measure()); + + await mutate(page,{remove:['.breadcrumbs']}); + const domMutated=await measure(); + expect(domMutated.chrome.breadcrumbs).toBeNull(); + // The missing section is reported by its own path (A6: absent sections become null), not by a measuring error. + expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 65df3fd..2c0e2be 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -663,11 +663,17 @@ test('the ComfyUI link points to the root of a non-standard proxy mount', async test('a resolver failure for the ComfyUI link never stops the shell from mounting', async ({ page }) => { await serveUnderProxyMount(page); // Wrap the shared client as it loads so only the topbar's canvas-root lookup throws (the sender reads the same root). + // Every engine's stack names the calling module's URL; each injected failure is counted so a renamed module or a + // changed call site fails this test on the count instead of silently testing nothing. await page.addInitScript(() => { let client; + window.__resolverFailures = { injected: 0 }; Object.defineProperty(window, 'ApiClient', { configurable: true, get: () => client, set(value) { client = { ...value, resolveUrl: (url, ...rest) => { - if (url === '/prompt_manager/' && /topbar\.mjs/.test(new Error().stack)) throw new Error('resolver unavailable'); + if (url === '/prompt_manager/' && /\/workspace\/topbar\.mjs\b/.test(String(new Error().stack))) { + window.__resolverFailures.injected += 1; + throw new Error('resolver unavailable'); + } return value.resolveUrl(url, ...rest); } }; } }); @@ -677,6 +683,7 @@ test('a resolver failure for the ComfyUI link never stops the shell from mountin await page.goto(`${PROXY_MOUNT}${workspace}#/overview`); await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); await expect(sidebarLinks(page)).toHaveCount(routes.length); + expect(await page.evaluate(() => window.__resolverFailures.injected), 'the topbar lookup was the one that failed').toBe(1); expect(warnings.filter(text => text.includes('the ComfyUI link falls back to the page mount'))).toHaveLength(1); await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', `${PROXY_MOUNT}/`); await sidebarLink(page, 'Prompt library').click(); diff --git a/tests/vendor/concept-css.test.mjs b/tests/vendor/concept-css.test.mjs index 3ad8d45..1de1bce 100644 --- a/tests/vendor/concept-css.test.mjs +++ b/tests/vendor/concept-css.test.mjs @@ -57,7 +57,7 @@ test('overlay lint requires a decision marker on concept selectors', () => { test('overlay rules on concept selectors carry a decision marker', () => { const overlay = join(root, 'web/js/workspace/workspace.css'); - if (!existsSync(overlay)) return; + assert.ok(existsSync(overlay), 'web/js/workspace/workspace.css is missing'); const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(overlay, 'utf8')); assert.deepEqual(violations, []); }); diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index a1b71bc..84c759f 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -123,7 +123,8 @@ main.library-main { --library-negative: #ff8c98; } .history-run-marker[aria-pressed="true"] { outline-color: Highlight; } } -/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5). */ +/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5). + These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */ .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } .library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }