Skip to content

Commit 096c2ec

Browse files
committed
test(webui): measure a surface the way a reader sees it
The gate measured a surface while it was still moving. The shared dialog enters over 200ms, and a scan that runs during the entrance measures a surface the reader never sees: the dialog's own backdrop is still transparent, and a contrast finding is the difference between a settled background and a moving one. The same scan reported 166 findings with the entrance running and 17 with it settled - 149 of them were the dialog's own text, measured against a background that was not there yet. The measurement waits for the animations that are running now, which is the wait the shared-dialog measurements already use, and it gives up rather than hanging on one that never finishes. It also stops calling an item that a scroll boundary can bring back into view "outside its surface". A modal with a fixed header and a scrolling body puts what it shows below the fold, and that content is reachable by scrolling - the contract a scroll boundary states, and the contract the clipped check above already honoured. Six `models-provider-form` states failed on four attachment labels the dialog's scrolling body holds below the fold.
1 parent 6d4e810 commit 096c2ec

2 files changed

Lines changed: 57 additions & 15 deletions

File tree

‎packages/webui/test/browser/rendering-integrity.spec.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
loadManifest,
4747
manifestKey,
4848
saveManifest,
49+
settledSurface,
4950
summariseSurface,
5051
useTheme,
5152
} from "./support/rendering-integrity.mjs";
@@ -690,6 +691,7 @@ test.describe("rendering integrity of the migrated surfaces", () => {
690691
await (state.seed ?? surface.seed)(page, state);
691692
await state.open(page);
692693
expect(await appliedTheme(page), `${surface.id}/${state.id}: the theme did not apply`).toBe(theme);
694+
await settledSurface(page);
693695

694696
const facts = await captureSurface(page, surface, state);
695697
const key = manifestKey(surface.id, state.id, theme);

‎packages/webui/test/browser/support/rendering-integrity.mjs‎

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ export const THEMES = ["light", "dark"];
3939
export const EXTREMES = ["empty", "one", "maximum", "longest"];
4040

4141
/** Load the recorded measurements, or an empty manifest when none exists yet. */
42+
/**
43+
* Wait for the surface to stop moving.
44+
*
45+
* The shared dialog enters over 200ms and the surfaces fade in. A scan that
46+
* runs while an entrance is still playing measures a surface the reader never
47+
* sees: the dialog's own backdrop is still transparent, and a contrast
48+
* finding is the difference between a settled background and a moving one.
49+
* The wait is for the animations that are running now, and it gives up
50+
* rather than hanging on one that never finishes.
51+
*/
52+
export async function settledSurface(page) {
53+
await page.evaluate(async () => {
54+
const running = document.getAnimations().filter((animation) => animation.playState === "running");
55+
await Promise.race([
56+
Promise.all(running.map((animation) => animation.finished.catch(() => undefined))),
57+
new Promise((resolve) => setTimeout(resolve, 1000)),
58+
]);
59+
});
60+
}
61+
4262
export function loadManifest() {
4363
try {
4464
return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
@@ -130,17 +150,43 @@ async function measure(page, surface, state) {
130150

131151
const items = spec.itemSelector ? [...rootNode.querySelectorAll(spec.itemSelector)] : [];
132152
const itemBoxes = items.map((node) => node.getBoundingClientRect());
153+
154+
// A surface may scroll its own content: a modal with a fixed header and a
155+
// scrolling body puts what it shows below the fold, and that content is
156+
// reachable by scrolling — the contract a scroll boundary states, and the
157+
// same contract the clipped check above already honours. Only an item
158+
// that no scroll boundary can bring back into view is outside the
159+
// surface.
160+
const scrollsOn = (node, axis) => {
161+
const style = getComputedStyle(node);
162+
const value = axis === "x" ? style.overflowX : style.overflowY;
163+
return value === "auto" || value === "scroll" || value === "overlay";
164+
};
165+
// The nearest scroll boundary between the item and the root, on one
166+
// axis. A boundary inside the surface can bring the item back into
167+
// view; one outside it moves the surface along with the item, so it
168+
// cannot.
169+
const scrollBoundary = (node, axis) => {
170+
for (let current = node.parentElement; current; current = current.parentElement) {
171+
if (current === rootNode) return scrollsOn(current, axis) ? current : null;
172+
if (scrollsOn(current, axis)) return current;
173+
}
174+
return null;
175+
};
176+
const escapes = (node, box) => {
177+
const sideways = box.left < rootBox.left - 1 || box.right > rootBox.right + 1;
178+
const downwards = box.top < rootBox.top - 1 || box.bottom > rootBox.bottom + 1;
179+
if (sideways && !scrollBoundary(node, "x")) return true;
180+
if (downwards && !scrollBoundary(node, "y")) return true;
181+
return false;
182+
};
183+
133184
// Which items are outside, not just how many: a count sends the reader
134185
// back to the browser to find out what the gate already knew.
135-
const outsideItems = items
186+
const escaping = items
136187
.map((node, index) => ({ node, box: itemBoxes[index] }))
137-
.filter(
138-
({ box }) =>
139-
box.left < rootBox.left - 1 ||
140-
box.right > rootBox.right + 1 ||
141-
box.top < rootBox.top - 1 ||
142-
box.bottom > rootBox.bottom + 1,
143-
)
188+
.filter(({ node, box }) => escapes(node, box));
189+
const outsideItems = escaping
144190
.slice(0, 8)
145191
.map(
146192
({ node, box }) =>
@@ -193,13 +239,7 @@ async function measure(page, surface, state) {
193239
maxHeight: itemHeights.length ? Math.max(...itemHeights) : 0,
194240
zeroSized: itemBoxes.filter((box) => box.width <= 0 || box.height <= 0).length,
195241
outsideRootItems: outsideItems,
196-
outsideRoot: itemBoxes.filter(
197-
(box) =>
198-
box.left < rootBox.left - 1 ||
199-
box.right > rootBox.right + 1 ||
200-
box.top < rootBox.top - 1 ||
201-
box.bottom > rootBox.bottom + 1,
202-
).length,
242+
outsideRoot: escaping.length,
203243
},
204244
texts: {
205245
count: texts.length,

0 commit comments

Comments
 (0)