test(workspace): shell comparison mutation check and docs

Add mutate() to the shared concept inventory and a shell mutation check: a
nav-row min-height rule and a removed .breadcrumbs each make the comparison
fail with the changed path. Harden the resolver-failure guard test with an
injection count, make the overlay lint fail when workspace.css is missing,
mark the library.css .row-actions leftovers for PR E, and document the
concept CSS base, the overlay lint, the comparison method and the recorded
shell deviations in TESTING.md.
This commit is contained in:
Vito Sansevero
2026-10-19 22:48:39 -07:00
parent cc271838c9
commit 69d201d684
6 changed files with 151 additions and 4 deletions
+76
View File
@@ -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<n> */` 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 `map<Page>Concept(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
+31
View File
@@ -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
+33 -1
View File
@@ -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(,|$)/);
});
+8 -1
View File
@@ -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();
+1 -1
View File
@@ -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, []);
});
+2 -1
View File
@@ -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; }