From 123c283e0b6afbb999f976ea4293cf3ad0744fe4 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:39:45 +0100 Subject: [PATCH 1/2] --computed: diff links' and controls' :hover and :focus-visible styles After the resting walk, the first 500 links and controls per page are forced into :hover, then :focus+:focus-visible, over CDP (all at once), and their subtrees are read again under `key:hover` / `key:focus-visible`. A state only reports what differs from resting. The snapshot also reads outline and text-decoration-line (SNAP_PROPS); pixel mode's PROPS, and so accepted fingerprints, are unchanged. Fixes #51 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/output.md | 15 +++- skills/renderdiff/SKILL.md | 4 +- skills/renderdiff/reference.md | 5 +- src/cli.ts | 3 +- src/computed.ts | 25 ++++-- src/locate.ts | 150 ++++++++++++++++++++++++++++----- src/probe.ts | 4 +- src/run.ts | 12 +-- tests/unit/computed.test.ts | 29 ++++++- tests/visual/computed.test.ts | 58 ++++++++++++- 11 files changed, 263 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a3439c4..14142af 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ renderdiff [options] | `--rerun-failed` | Rerun only what the last run in `--out` didn't find clean or accepted, keeping the rest. | | `--cache-right` | Replay the right side's network traffic from the last `--cache-right` run in `--out`, when it's under an hour old and covers every shot; otherwise load it live and record it. Keeps `/.cache/` when the out dir is wiped. See **Accepted differences and reruns**. | | `--no-recheck` | Don't reload a differing shot's right side to find content that varies per load. Off anyway under `--cache-right`. | -| `--computed` | Also diff the computed style of every rendered element and its `::before`/`::after`, and say per route whether the markup is identical apart from asset hashes and origin. Pixels still decide the score and exit code. See [docs/output.md](docs/output.md#computed-styles---computed). | +| `--computed` | Also diff the computed style of every rendered element and its `::before`/`::after`, and links' and controls' `:hover`/`:focus-visible`, and say per route whether the markup is identical apart from asset hashes and origin. Pixels still decide the score and exit code. See [docs/output.md](docs/output.md#computed-styles---computed). | | `--aria` | Also diff each route's ARIA snapshot (Playwright's accessibility tree) per width: left lines `+`, right `-`, asset hashes and origin normalized, masked elements left out. Pixels still decide. See [docs/output.md](docs/output.md#aria-snapshots---aria). | | `--sitemap[=]` | Add the paths in the right side's sitemap. Default `/sitemap.xml`; give another URL or path with `=`. | | `--dry-run` | Print the URL pairs and exit. No browser. | diff --git a/docs/output.md b/docs/output.md index d869232..217e673 100644 --- a/docs/output.md +++ b/docs/output.md @@ -166,7 +166,8 @@ DOM and iframes aren't searched. ## Computed styles (`--computed`) With `--computed`, after the shot renderdiff reads the computed style of every rendered -element on both sides: the properties `dom` compares, plus `content`, and `::before` and +element on both sides: the properties `dom` compares, plus `outline`, +`text-decoration-line` and `content`, and `::before` and `::after` wherever they generate content. Masked subtrees and `script`/`style`-like tags are skipped; a `display: none` element is read, its subtree isn't. Script-driven animations are finished first. The summary gets one block, after the rows: @@ -232,7 +233,17 @@ computed: 2 findings; dom identical on 2 of 3 route(s), differs /pricing (1), so 50,000 elements is reported as failed, with why (`computed: null`, `computedError`), and the pixel result stands. Pixels still decide the score and the exit code. -`:hover`/`:focus` states aren't snapshotted. +Links and controls (`a[href]`, `button`, `input`, `select`, `textarea`, `summary`, +`[tabindex]`) are also read in `:hover` and `:focus-visible`, with their subtrees: + +- The key gets the state: `body > nav > a[href="#x"]:hover > span.icon`. `:focus-visible` + is keyboard focus, forced as `:focus` plus `:focus-visible`. +- Only what differs from resting is a finding: a resting change isn't repeated per state. +- The first 500 per page are read; `states:` in the summary says where there were more + (`computed.unchecked` in results.json). One with more than 50 descendants is skipped. +- All of them are forced at once, so a rule relating two of them + (`ul:has(a:hover) a:not(:hover)`) isn't seen, nor `:hover` on an ancestor (`li:hover` + menus), `:active`, `:focus-within` or script hover handlers. ## ARIA snapshots (`--aria`) diff --git a/skills/renderdiff/SKILL.md b/skills/renderdiff/SKILL.md index dbb2ed9..63a5300 100644 --- a/skills/renderdiff/SKILL.md +++ b/skills/renderdiff/SKILL.md @@ -19,7 +19,7 @@ the § named at each step. | Change | Run | | --- | --- | -| CSS only, markup untouched (refactor, dead-CSS removal, token swap) | pixels + `--computed`. Expect `dom identical` on every route: then each `computed:` finding is a real style change, even on a clean route. `dom differs`: often a wrapper's class changed, re-keying its subtree (§ `--computed`). Hover, focus and open menus aren't shot without a `before` hook, and dead-CSS removal breaks exactly those. | +| CSS only, markup untouched (refactor, dead-CSS removal, token swap) | pixels + `--computed`. Expect `dom identical` on every route: then each `computed:` finding is a real style change, even on a clean route. `dom differs`: often a wrapper's class changed, re-keying its subtree (§ `--computed`). Hover menus and open menus aren't shot without a `before` hook, and dead-CSS removal breaks exactly those. | | Markup changes (framework, template or component migration) | pixels, plus `--aria`: `aria: identical` means a screen reader gets the same page. `--computed` findings would only be hints. | | Dependency or build upgrade | pixels, plus `--computed` if the markup should be identical. | | One component | pixels with `--selector ` or `'/route @sel'`. | @@ -71,7 +71,7 @@ unaccepted difference. § Exit codes. 2. **What you saw**: per route and width, element and property ("/pricing @1440: `.cta` margin-left 24px→16px"). 3. **Not verified**: errors, unstable or failed-asset rows, skipped widths, masks, accepts, - hooks, unshot states (hover, open menus), unopened regions, unlisted pages, and the + hooks, unshot states (menus opened by hover or click; `:active`), unopened regions, unlisted pages, and the reference (base build or production). 4. **Call**: parity, regression, or expected change, and why. diff --git a/skills/renderdiff/reference.md b/skills/renderdiff/reference.md index 71dd509..80d7f3d 100644 --- a/skills/renderdiff/reference.md +++ b/skills/renderdiff/reference.md @@ -30,7 +30,8 @@ results.json · Gotchas. - **Baseline when the change is already made:** serve the base build twice, on two ports, and diff it against itself; whatever differs varies per load. - **States:** only the loaded state is shot. Hover, focus, open menus and dialogs need a - `before` hook (§ Scoping); dead-CSS removal often breaks exactly those. + `before` hook (§ Scoping); dead-CSS removal often breaks exactly those. `--computed` diffs + links' and controls' hover and focus styles without shooting them. ## Output @@ -168,7 +169,7 @@ computed: 2 findings; dom identical on 2 of 3 route(s), differs /pricing (1), so `none`. An `auto` margin reads as `auto`, not the px it resolved to, which varies with layout. - A finding on a clean route is a change you can't see in this state: a hidden element - (closed menu), another breakpoint, `:hover`/`:focus` (not snapshotted). Decide it; a + (closed menu), another breakpoint, hover on non-interactive elements, open menus. Decide it; a `before` hook can open the state. - Fails per shot (reported, pixels stand) past 15s or 50,000 elements. Not walked: SVG internals, shadow DOM, iframes, masked and `display: none` subtrees. diff --git a/src/cli.ts b/src/cli.ts index bdd9283..a2970d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,7 +250,8 @@ const HELP = ` --no-recheck Don't reload a differing shot's right side to find content that varies per load. Off anyway under --cache-right. --computed Also diff every element's computed style and ::before/::after, - and say whether the markup is identical. Pixels still decide. + and links' and controls' :hover/:focus-visible, and say whether + the markup is identical. Pixels still decide. --aria Also diff each page's accessibility tree (Playwright's ARIA snapshot) line by line. Pixels still decide. --sitemap[=] Add the paths in the right side's sitemap (one index level, files diff --git a/src/computed.ts b/src/computed.ts index a15584f..05a0097 100644 --- a/src/computed.ts +++ b/src/computed.ts @@ -1,12 +1,12 @@ import { textDiff } from "./compare.ts"; import { name } from "./config.ts"; -import { PROPS } from "./locate.ts"; +import { MAX_STATES, SNAP_PROPS } from "./locate.ts"; import type { ComparedResult, RouteResult } from "./report.ts"; /** `--computed`: every rendered element's computed style on both sides, diffed by a stable key. */ /** - * Selector path -> its opening tag with own text, `PROPS` then `content` computed, and its + * Selector path -> its opening tag with own text, `SNAP_PROPS` computed, and its * specified width and height (`auto`, `100%`, `200px`) where the browser says. */ export type Snapshot = Record; @@ -28,6 +28,8 @@ export interface ComputedDiff { truncated: number; /** Elements only that side has, outermost only, at most `MAX_UNMATCHED` per side. */ unmatched: { left: string[]; right: string[] }; + /** Links and controls past `MAX_STATES` on a side, not forced into `:hover`/`:focus-visible`. */ + unchecked?: number; } /** One change, with every route and width it was seen at. */ @@ -47,7 +49,7 @@ const MAX_CHANGES = 500; const MAX_ARIA_LINES = 200; const ARIA_LINES_SHOWN = 10; const MAX_UNMATCHED = 20; -const FIELDS = [...PROPS, "content"]; +const FIELDS = SNAP_PROPS; const INHERITED = new Set([ "font-family", @@ -65,6 +67,8 @@ const DISPLAY = FIELDS.indexOf("display"); const PSEUDO = /::(before|after)$/; const isPseudo = (key: string) => PSEUDO.test(key); +const STATE = /:(hover|focus-visible)(?= > |::|$)/; +const isState = (key: string) => STATE.test(key); const parentOf = (key: string) => { if (isPseudo(key)) return key.replace(PSEUDO, ""); @@ -121,6 +125,9 @@ export function collapseInherited !follows(k)).map(parentOf)); return changes.filter((c) => { + // A state only reports what differs from resting. + if (isState(c.key) && raw.has(`${c.key.replace(STATE, "")}\0${c.prop}\0${c.values.join("\0")}`)) + return false; const p = parentOf(c.key); if (INHERITED.has(c.prop)) return !raw.has(`${p}\0${c.prop}\0${c.values.join("\0")}`); if (c.prop === "width") return !wide.has(p) || set(c.key, 0); @@ -161,7 +168,7 @@ export function diffSnapshots( const keys = new Set([...Object.keys(left), ...Object.keys(right)]); let dom = 0; for (const k of keys) { - if (isPseudo(k) || html(left, k) === html(right, k)) continue; + if (isPseudo(k) || isState(k) || html(left, k) === html(right, k)) continue; if (!left[k] && hiddenIn(left, k)) continue; if (!right[k] && hiddenIn(right, k)) continue; dom++; @@ -196,7 +203,9 @@ export function diffSnapshots( } const only = (a: Snapshot, b: Snapshot) => { - const ks = Object.keys(a).filter((k) => !isPseudo(k) && !b[k] && !hiddenIn(b, k)); + const ks = Object.keys(a).filter( + (k) => !isPseudo(k) && !isState(k) && !b[k] && !hiddenIn(b, k), + ); const set = new Set(ks); return ks.filter((k) => !set.has(parentOf(k))).slice(0, MAX_UNMATCHED); }; @@ -280,6 +289,12 @@ export function fmtComputed(results: RouteResult[], routes: string[]): string[] ` truncated: only the first ${MAX_CHANGES} changes kept on ` + cut.map((r) => `${name(r)}@${r.width} (+${r.computed!.truncated})`).join(" "), ); + const capped = computedOf(rows).filter((r) => r.computed!.unchecked); + if (capped.length) + out.push( + ` states: only the first ${MAX_STATES} links and controls forced on ` + + capped.map((r) => `${name(r)}@${r.width} (+${r.computed!.unchecked})`).join(" "), + ); const unmatched = routes.flatMap((route) => { const r = computedOf(rows).find((x) => name(x) === route && x.computed!.dom); if (!r) return []; diff --git a/src/locate.ts b/src/locate.ts index af8fe74..6841ddc 100644 --- a/src/locate.ts +++ b/src/locate.ts @@ -72,6 +72,16 @@ export const PROPS = [ "height", ]; +/** `--computed` reads these too; pixel mode's `changed`, and so accepted fingerprints, don't. */ +export const SNAP_PROPS = [...PROPS, "outline", "text-decoration-line", "content"]; + +/** Links and controls forced into each state, per page per side. */ +export const MAX_STATES = 500; +const STATES = [ + [":hover", ["hover"]], + [":focus-visible", ["focus", "focus-visible"]], +] as const; + interface Seen { sel: string; tag: string; @@ -110,10 +120,29 @@ interface ProbeArgs { walk?: number; /** Where the walk starts, keyed `:scope`; the body, keyed `body`, when null. */ root?: Node | null; + /** Of `walk`, already used by an earlier pass. */ + spent?: number; + /** Keep up to this many links and controls on `globalThis.__renderdiffStates`, returning their keys. */ + collect?: number; + /** Walk only the kept elements, keyed `states[i] + state`, instead of the page. */ + state?: string; + states?: string[]; } /** Runs in the page. At scroll 0 a client rect is page px, below the fold too. */ -function probe({ boxes, find, masked, props, perSpot, walk, root }: ProbeArgs) { +function probe({ + boxes, + find, + masked, + props, + perSpot, + walk, + root, + spent, + collect, + state, + states, +}: ProbeArgs) { const MAX_SCAN = 50_000; const HASHED = /\d{3,}|^(css|sc|jsx|svelte)-|__[\w-]{5,}$|^_/; const TEST = ["data-testid", "data-test", "data-test-id", "data-cy", "data-qa"]; @@ -184,9 +213,18 @@ function probe({ boxes, find, masked, props, perSpot, walk, root }: ProbeArgs) { }; // A resolved auto margin's px depends on the layout path: a grid item's reads 0px. - const styles = (e: Element, cs: CSSStyleDeclaration, m = e.computedStyleMap?.()) => + const styles = ( + e: Element, + cs: CSSStyleDeclaration, + m: StylePropertyMapReadOnly | null | undefined = e.computedStyleMap?.(), + ) => props.map((p) => - p.startsWith("margin-") && String(m?.get(p)) === "auto" ? "auto" : cs.getPropertyValue(p), + p.startsWith("margin-") && String(m?.get(p)) === "auto" + ? "auto" + : // Unset, it reads ` none 3px`, which would repeat every color change. + p === "outline" && cs.getPropertyValue("outline-style") === "none" + ? "none" + : cs.getPropertyValue(p), ); const seen = (e: Element) => { @@ -261,7 +299,11 @@ function probe({ boxes, find, masked, props, perSpot, walk, root }: ProbeArgs) { const snap: Record = {}; const SKIP = new Set(["script", "style", "link", "meta", "template", "noscript"]); let n = 0; - const end = performance.now() + walk; + const end = performance.now() + walk - (spent ?? 0); + const g = globalThis as { __renderdiffStates?: Element[] }; + const kept: Element[] = []; + const keys: string[] = []; + let unchecked = 0; // Generated class names change with the CSS, so they aren't markup changes. const html = (e: Element) => { const attrs = Array.from(e.attributes, (a) => { @@ -293,9 +335,20 @@ function probe({ boxes, find, masked, props, perSpot, walk, root }: ProbeArgs) { ...(m ? { size: [String(m.get("width")), String(m.get("height"))] } : {}), }; if (cs.display === "none") return; + // ponytail: skips containers like
, which would re-walk the page per state + if ( + collect && + e.matches("a[href], button, input, select, textarea, summary, [tabindex]") && + e.getElementsByTagName("*").length <= 50 + ) { + if (kept.length < collect) { + kept.push(e); + keys.push(key); + } else unchecked++; + } for (const pseudo of ["::before", "::after"]) { const ps = generated(e, pseudo); - if (ps) snap[key + pseudo] = { html: "", styles: props.map((p) => ps.getPropertyValue(p)) }; + if (ps) snap[key + pseudo] = { html: "", styles: styles(e, ps, null) }; } // Indexed among every child, as :nth-of-type counts, so the key works with querySelector. const ofType = new Map(); @@ -319,9 +372,13 @@ function probe({ boxes, find, masked, props, perSpot, walk, root }: ProbeArgs) { visit(c, `${key} > ${count.get(g)! > 1 ? `${g}:nth-of-type(${k})` : g}`); }); }; - const start = (root as Element | null) ?? document.body; - if (start) visit(start, root ? ":scope" : "body"); - return { picks: [], found: [], snap }; + if (state) g.__renderdiffStates!.forEach((e, i) => visit(e, states![i]! + state)); + else { + const start = (root as Element | null) ?? document.body; + if (start) visit(start, root ? ":scope" : "body"); + } + if (collect) g.__renderdiffStates = kept; + return { picks: [], found: [], snap, states: keys, unchecked }; } const els = document.body?.querySelectorAll("*") ?? []; for (let i = 0; i < els.length && i < MAX_SCAN; i++) { @@ -459,8 +516,22 @@ const moved = (a: Box, b: Box) => a.x !== b.x || a.y !== b.y || a.w !== b.w || a const handles = async (ls: Locator[]) => (await Promise.all(ls.map((l) => l.elementHandles()))).flat(); +const walker = async (page: Page, masks: Locator[], budget: number, root: Locator | null) => { + const args = { + root: root && (await root.elementHandle()), + boxes: [], + find: [], + masked: await handles(masks), + props: SNAP_PROPS, + perSpot: 0, + walk: budget, + }; + return (more: Pick = {}) => + page.evaluate(probe, { ...args, ...more }); +}; + /** - * Every rendered element's computed `PROPS` and `content`, and its `::before`/`::after` when + * Every rendered element's computed `SNAP_PROPS`, and its `::before`/`::after` when * generated, keyed by a selector path; masked and `display: none` subtrees are skipped. * Finishes the page's animations, so it runs after `locate`. */ @@ -470,16 +541,57 @@ export async function snapshot( budget: number, root: Locator | null, ): Promise { - const { snap } = await page.evaluate(probe, { - root: root && (await root.elementHandle()), - boxes: [], - find: [], - masked: await handles(masks), - props: [...PROPS, "content"], - perSpot: 0, - walk: budget, - }); - return snap!; + return (await (await walker(page, masks, budget, root))()).snap!; +} + +/** + * `snapshot`, plus the first `MAX_STATES` links and controls and their subtrees forced into + * each of `STATES` at once, keyed `…a[href="#x"]:hover > span`. Forcing all at once misses + * rules relating two of them, and never puts an ancestor in `:hover`. Leaves the page resting. + */ +export async function snapshotStates( + page: Page, + masks: Locator[], + budget: number, + root: Locator | null, +): Promise<{ snap: Snapshot; unchecked: number }> { + const t0 = Date.now(); + const run = await walker(page, masks, budget, root); + const { snap, states, unchecked } = await run({ collect: MAX_STATES }); + const client = await page.context().newCDPSession(page); + try { + await Promise.all([client.send("DOM.enable"), client.send("CSS.enable")]); + await client.send("DOM.getDocument", { depth: 0 }); + const { result } = await client.send("Runtime.evaluate", { + expression: "globalThis.__renderdiffStates", + }); + const { result: own } = await client.send("Runtime.getProperties", { + objectId: result.objectId!, + ownProperties: true, + }); + const nodeIds = await Promise.all( + own + .filter((p) => /^\d+$/.test(p.name)) + .map((p) => + client.send("DOM.requestNode", { objectId: p.value!.objectId! }).then((r) => r.nodeId), + ), + ); + for (const [state, forced] of STATES) { + // A new force replaces the last; detaching clears them all. + await Promise.all( + nodeIds.map((nodeId) => + client.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: [...forced] }), + ), + ); + Object.assign(snap!, (await run({ spent: Date.now() - t0, state, states })).snap); + } + } finally { + await client + .send("Runtime.evaluate", { expression: "delete globalThis.__renderdiffStates" }) + .catch(() => {}); + await client.detach().catch(() => {}); + } + return { snap: snap!, unchecked: unchecked! }; } /** diff --git a/src/probe.ts b/src/probe.ts index f128b96..0d519e6 100644 --- a/src/probe.ts +++ b/src/probe.ts @@ -2,11 +2,11 @@ import type { Page } from "playwright"; import { prepare } from "./capture.ts"; import { collapseInherited, pageError, SHOWN, type Snapshot } from "./computed.ts"; import { CONTEXT_OPTIONS, masksFor, type ResolvedConfig, type Route } from "./config.ts"; -import { PROPS, snapshot } from "./locate.ts"; +import { SNAP_PROPS, snapshot } from "./locate.ts"; import { COMPUTED_TIMEOUT, launch, shoot } from "./run.ts"; // ponytail: x/y also list the siblings a grown box pushes down; drop a y a previous sibling's height explains if it gets noisy -const FIELDS = [...PROPS, "content", "x", "y"]; +const FIELDS = [...SNAP_PROPS, "x", "y"]; interface Change { key: string; diff --git a/src/run.ts b/src/run.ts index 1f2cde7..a1fd2d9 100644 --- a/src/run.ts +++ b/src/run.ts @@ -10,7 +10,7 @@ import { CACHE_DIR, cacheFile, replayable, REPLAY_TTL, saveRecording, shotId } f import { cancelUnstable, capture, HookError, prepare, step, type Shot } from "./capture.ts"; import { compare, crops, type Band, type Box, type Region } from "./compare.ts"; import { diffAria, diffSnapshots, fmtAria, fmtComputed, pageError } from "./computed.ts"; -import { locate, snapshot, type ElementDiff } from "./locate.ts"; +import { locate, snapshotStates, type ElementDiff } from "./locate.ts"; import { CONTEXT_OPTIONS, label, @@ -465,7 +465,7 @@ export async function run({ ]); named?.forEach((els, i) => (spots[i]!.target.elements = els)); const snap = (page: Page, side: "left" | "right") => - snapshot( + snapshotStates( page, masksFor(cfg, page, route), COMPUTED_TIMEOUT, @@ -476,9 +476,11 @@ export async function run({ const computed = cfg.computed ? await Promise.race([ Promise.all([snap(pages.left, "left"), snap(pages.right, "right")]).then( - ([l, r]) => ({ - computed: diffSnapshots(l, r, [pages.left.url(), pages.right.url()]), - }), + ([l, r]) => { + const d = diffSnapshots(l.snap, r.snap, [pages.left.url(), pages.right.url()]); + const unchecked = Math.max(l.unchecked, r.unchecked); + return { computed: unchecked ? { ...d, unchecked } : d }; + }, (e: Error) => ({ computed: null, computedError: e.message }), ), sleep(COMPUTED_TIMEOUT + 5_000, null, { ref: false }).then(() => ({ diff --git a/tests/unit/computed.test.ts b/tests/unit/computed.test.ts index 728ebf6..d1f1d4a 100644 --- a/tests/unit/computed.test.ts +++ b/tests/unit/computed.test.ts @@ -15,10 +15,10 @@ import { pageError, type Snapshot, } from "../../src/computed.ts"; -import { PROPS } from "../../src/locate.ts"; +import { SNAP_PROPS } from "../../src/locate.ts"; import type { ComparedResult, RouteResult } from "../../src/report.ts"; -const FIELDS = [...PROPS, "content"]; +const FIELDS = SNAP_PROPS; const el = (html: string, styles: Record = {}) => ({ html, styles: FIELDS.map((p) => styles[p] ?? (p === "content" ? "normal" : "x")), @@ -37,6 +37,25 @@ describe("diffSnapshots", () => { assert.deepEqual(d.changes, [{ key: "body", prop: "font-family", values: ["Inter", "Arial"] }]); }); + test("a state only one side forced is no markup change; one matching resting isn't repeated", () => { + const a = (color: string): Snapshot => ({ + body: el(""), + "body > a": el("", { color }), + "body > a:hover": el("", { color }), + "body > a:focus-visible": el("", { color, outline: "red solid 2px" }), + }); + const left = { ...a("red"), "body > b:hover": el("") }; + const d = diffSnapshots(left, a("blue")); + assert.equal(d.dom, 0); + assert.deepEqual(d.unmatched, { left: [], right: [] }); + assert.deepEqual(d.changes, [{ key: "body > a", prop: "color", values: ["red", "blue"] }]); + const right = a("red"); + right["body > a:hover"] = el("", { color: "blue" }); + assert.deepEqual(diffSnapshots(a("red"), right).changes, [ + { key: "body > a:hover", prop: "color", values: ["red", "blue"] }, + ]); + }); + test("a descendant that changed differently from its parent is kept", () => { const changes = collapseInherited([ { key: "body", prop: "color", values: ["red", "blue"] }, @@ -289,13 +308,15 @@ describe("groupFindings", () => { ...results, shot("/d", 1440, { computed: null, computedError: "more than 50000 elements" }), shot("/e", 1440, { computed: { ...diff(), truncated: 7 } }), + shot("/f", 1440, { computed: { ...diff(), changes: [], unchecked: 312 } }), ], - ["/a", "/b", "/c", "/d", "/e"], + ["/a", "/b", "/c", "/d", "/e", "/f"], ), [ - "computed: 1 finding; dom identical on 3 of 4 route(s), differs /b (1), so findings there are hints; snapshot failed on /d@1440 (more than 50000 elements)", + "computed: 1 finding; dom identical on 4 of 5 route(s), differs /b (1), so findings there are hints; snapshot failed on /d@1440 (more than 50000 elements)", " body > a margin-left 24px→16px /a@1440,375 /b@1440 /c@1440 +1 routes", " truncated: only the first 500 changes kept on /e@1440 (+7)", + " states: only the first 500 links and controls forced on /f@1440 (+312)", ], ); }); diff --git a/tests/visual/computed.test.ts b/tests/visual/computed.test.ts index 86305a0..c11cd5b 100644 --- a/tests/visual/computed.test.ts +++ b/tests/visual/computed.test.ts @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { chromium, type Browser, type Page } from "playwright"; import { diffSnapshots } from "../../src/computed.ts"; -import { locate, PROPS, snapshot } from "../../src/locate.ts"; +import { locate, PROPS, snapshot, snapshotStates } from "../../src/locate.ts"; import type { Shot } from "../../src/capture.ts"; import type { Region } from "../../src/compare.ts"; @@ -134,3 +134,59 @@ test("pixel mode's selectors keep the classes in source order", async () => { await Promise.all(pages.map((p) => p.close())); assert.equal(els?.[0]?.selector, "div.zeta.alpha"); }); + +async function states(body: string, css: [string, string]) { + const [l, r] = await Promise.all([page(body, css[0]), page(body, css[1])]); + const s = await Promise.all([l, r].map((p) => snapshotStates(p, [], BUDGET, null))); + await Promise.all([l.close(), r.close()]); + return diffSnapshots(s[0]!.snap, s[1]!.snap); +} + +const LINK = ``; +const A = `body > nav > a[href="#x"]`; +const changed = (d: ReturnType) => d.changes.map((c) => [c.key, c.prop]); + +test("a hover colour one side drops is a finding on the link's :hover key alone", async () => { + const d = await states(LINK, ["a:hover { color: red }", ""]); + assert.equal(d.dom, 0); + assert.deepEqual(d.unmatched, { left: [], right: [] }); + assert.deepEqual(changed(d), [ + [`${A}:hover`, "color"], + [`${A}:hover`, "border-top-color"], + [`${A}:hover > span.icon`, "border-top-color"], + ]); +}); + +test("a dropped focus ring shows on :focus-visible as outline", async () => { + const d = await states(LINK, ["a:focus-visible { outline: 2px solid blue }", ""]); + assert.deepEqual(changed(d), [[`${A}:focus-visible`, "outline"]]); +}); + +test("a rule on a hovered link's child is keyed under the :hover", async () => { + const d = await states(LINK, ["a:hover .icon { color: red }", ""]); + assert.deepEqual(changed(d), [ + [`${A}:hover > span.icon`, "color"], + [`${A}:hover > span.icon`, "border-top-color"], + ]); +}); + +test("same states are no change, and a resting change isn't repeated per state", async () => { + const css = "a:hover { color: red } a:focus-visible { outline: 1px solid }"; + assert.deepEqual(changed(await states(LINK, [css, css])), []); + const d = await states(LINK, ["a { text-decoration: underline }", "a { text-decoration: none }"]); + assert.deepEqual(changed(d), [[A, "text-decoration-line"]]); +}); + +test("past 500 links the rest are counted, and the page is left resting", async () => { + const p = await page( + `x${"y".repeat(500)}`, + "a:hover { color: red }", + ); + const before = await snapshot(p, [], BUDGET, null); + const { snap, unchecked } = await snapshotStates(p, [], BUDGET, null); + assert.equal(unchecked, 1); + assert.ok(snap[`body > a[href="#x"]:hover`]); + assert.equal(await p.evaluate(() => "__renderdiffStates" in globalThis), false); + assert.deepEqual(await snapshot(p, [], BUDGET, null), before); + await p.close(); +}); From 9e3e338b1fd501a5983c1b99eaa30468649c8941 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:51:23 +0100 Subject: [PATCH 2/2] Open the states' CDP session before keeping the page global; assert forcing in the cap test The global was set before the session existed, so a failed newCDPSession left it on the page. The cap test now checks the forced :hover value, so it fails when forcing doesn't happen. Co-Authored-By: Claude Opus 5.5 --- skills/renderdiff/SKILL.md | 4 ++-- src/locate.ts | 4 ++-- tests/visual/computed.test.ts | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/skills/renderdiff/SKILL.md b/skills/renderdiff/SKILL.md index 63a5300..9aaa891 100644 --- a/skills/renderdiff/SKILL.md +++ b/skills/renderdiff/SKILL.md @@ -71,8 +71,8 @@ unaccepted difference. § Exit codes. 2. **What you saw**: per route and width, element and property ("/pricing @1440: `.cta` margin-left 24px→16px"). 3. **Not verified**: errors, unstable or failed-asset rows, skipped widths, masks, accepts, - hooks, unshot states (menus opened by hover or click; `:active`), unopened regions, unlisted pages, and the - reference (base build or production). + hooks, unshot states (hover on non-interactive elements, open menus, `:active`), unopened + regions, unlisted pages, and the reference (base build or production). 4. **Call**: parity, regression, or expected change, and why. A score without looking is not a verdict: 99.6% can be a missing nav link. diff --git a/src/locate.ts b/src/locate.ts index 6841ddc..8204917 100644 --- a/src/locate.ts +++ b/src/locate.ts @@ -557,9 +557,9 @@ export async function snapshotStates( ): Promise<{ snap: Snapshot; unchecked: number }> { const t0 = Date.now(); const run = await walker(page, masks, budget, root); - const { snap, states, unchecked } = await run({ collect: MAX_STATES }); const client = await page.context().newCDPSession(page); try { + const { snap, states, unchecked } = await run({ collect: MAX_STATES }); await Promise.all([client.send("DOM.enable"), client.send("CSS.enable")]); await client.send("DOM.getDocument", { depth: 0 }); const { result } = await client.send("Runtime.evaluate", { @@ -585,13 +585,13 @@ export async function snapshotStates( ); Object.assign(snap!, (await run({ spent: Date.now() - t0, state, states })).snap); } + return { snap: snap!, unchecked: unchecked! }; } finally { await client .send("Runtime.evaluate", { expression: "delete globalThis.__renderdiffStates" }) .catch(() => {}); await client.detach().catch(() => {}); } - return { snap: snap!, unchecked: unchecked! }; } /** diff --git a/tests/visual/computed.test.ts b/tests/visual/computed.test.ts index c11cd5b..2c0f61d 100644 --- a/tests/visual/computed.test.ts +++ b/tests/visual/computed.test.ts @@ -185,7 +185,9 @@ test("past 500 links the rest are counted, and the page is left resting", async const before = await snapshot(p, [], BUDGET, null); const { snap, unchecked } = await snapshotStates(p, [], BUDGET, null); assert.equal(unchecked, 1); - assert.ok(snap[`body > a[href="#x"]:hover`]); + const color = PROPS.indexOf("color"); + assert.equal(snap[`body > a[href="#x"]:hover`]?.styles[color], "rgb(255, 0, 0)"); + assert.notEqual(snap[`body > a[href="#x"]`]?.styles[color], "rgb(255, 0, 0)"); assert.equal(await p.evaluate(() => "__renderdiffStates" in globalThis), false); assert.deepEqual(await snapshot(p, [], BUDGET, null), before); await p.close();