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:
+76
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(,|$)/);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+1
-1
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user