From 846c5779467f47e9b12a78753427af5630bc5dad Mon Sep 17 00:00:00 2001 From: BenSheridanEdwards Date: Sat, 1 Aug 2026 22:24:56 +0100 Subject: [PATCH] fix(capture): sample forced states in one CDP session --- CHANGELOG.md | 9 +++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/capture.ts | 13 +++++++++++-- test/smoke.e2e.spec.ts | 8 +++++--- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b186567..f2cb1f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [4.7.4] - 2026-08-01 + +### Fixed + +- **Forced interaction states are sampled in the CDP session that applies them.** + The computed-style subtree read no longer crosses into Playwright's separate + protocol session after forcing `:hover`, `:focus`, or `:active`. This removes + intermittent phantom state deltas on unchanged controls in hydrated apps. + ## [4.7.3] - 2026-08-01 ### Fixed diff --git a/package-lock.json b/package-lock.json index 826479d..6f35241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "styleproof", - "version": "4.7.3", + "version": "4.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "styleproof", - "version": "4.7.3", + "version": "4.7.4", "license": "MIT", "dependencies": { "pngjs": "^7.0.0" diff --git a/package.json b/package.json index e89e2c6..9e9139a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "styleproof", - "version": "4.7.3", + "version": "4.7.4", "description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.", "keywords": [ "playwright", diff --git a/src/capture.ts b/src/capture.ts index 72ac8e8..3618b32 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -721,6 +721,15 @@ async function settleForcedState(client: CDPSession, selector: string): Promise< return result.value === true; } +async function snapSubtreeInSession(client: CDPSession, args: SubtreeArgs): Promise { + const { result, exceptionDetails } = await client.send('Runtime.evaluate', { + expression: `(${snapSubtree.toString()})(${JSON.stringify(args)})`, + returnByValue: true, + }); + if (exceptionDetails) throw new Error(`styleproof: forced-state snapshot failed: ${exceptionDetails.text}`); + return (result.value ?? {}) as Snap; +} + // Forced pseudo-class states on interactive elements, via CDP so no real // mouse or focus is involved and parent-state descendant rules still apply. async function captureForcedStates( @@ -758,7 +767,7 @@ async function captureForcedStates( console.warn(`styleproof: interactive element ${id} detached before forced-state capture; skipping it.`); continue; } - const baseSnap: Snap = await page.evaluate(snapSubtree, { selector, index: 0 }); + const baseSnap = await snapSubtreeInSession(client, { selector, index: 0 }); for (const [stateName, forcedPseudoClasses] of Object.entries(STATE_SETS)) { await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses }); if (!(await settleForcedState(client, selector))) { @@ -767,7 +776,7 @@ async function captureForcedStates( console.warn(`styleproof: interactive element ${id} detached during forced-state capture; skipping it.`); break; } - const forcedSnap: Snap = await page.evaluate(snapSubtree, { selector, index: 0 }); + const forcedSnap = await snapSubtreeInSession(client, { selector, index: 0 }); await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] }); await settleForcedState(client, selector); const delta = deltaBetween(baseSnap, forcedSnap); diff --git a/test/smoke.e2e.spec.ts b/test/smoke.e2e.spec.ts index d03b96b..8115649 100644 --- a/test/smoke.e2e.spec.ts +++ b/test/smoke.e2e.spec.ts @@ -81,10 +81,12 @@ test('waits for every forced pseudo-state transition before reading computed sty button:active { border-color: rgb(7, 8, 9); } ${Array.from({ length: 24 }, (_, i) => ``).join('')}`; - const first = await captureFixture(page, html); - const second = await captureFixture(page, html); + const captures = []; + for (let i = 0; i < 8; i++) captures.push(await captureFixture(page, html)); + const [first, ...replays] = captures; - expect(diffStyleMaps(first, second), 'independent forced-state sweeps are byte-equivalent').toEqual([]); + for (const replay of replays) + expect(diffStyleMaps(first, replay), 'independent forced-state sweeps are byte-equivalent').toEqual([]); for (const [path] of Object.entries(first.elements).filter(([, entry]) => entry.tag === 'button')) { expect(first.states[path]?.hover, `${path} captured :hover`).toBeTruthy(); expect(first.states[path]?.focus, `${path} captured :focus-visible`).toBeTruthy();