Skip to content

Commit 0199c6e

Browse files
Programcodeclaude
andcommitted
test(agentic): clip text along its containing block and by real clip areas
Codex review round 4: a fixed or absolute box escapes the overflow of ancestors outside its containing block, a clip-path inset or a zero-height clip can hide a whole box, and two guard cases passed even with clipping removed. Clip only along the containing-block chain, compute clip and inset areas, and rebuild every case so its text crosses the screen edge before clipping. Removing clipping fails 7 cases; removing the containing-block rule fails the fixed-escape case. Text cut short inside the screen stays out of scope: the judge checks it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8a5e455 commit 0199c6e

3 files changed

Lines changed: 135 additions & 25 deletions

File tree

‎agentic/edge-guard.e2e.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,25 @@ import { agentic, expect, textPastTheEdge } from "./support/fixtures";
44
// The rules of the fixtures' text-past-the-edge guard, on injected layouts:
55
// text that shows up to the screen edge and continues fails; text clipped
66
// away, cut short inside the screen, or in a sideways scroller does not.
7+
// Each layout puts text across the screen edge before clipping, so a case
8+
// passes only through the rule it names.
9+
const LONG = "Long words past the edge ".repeat(30);
710
const CASES: [string, string, boolean][] = [
8-
["nowrap text clipped by an overflow-hidden box at the edge", `<div style="overflow:hidden;width:100vw"><p style="white-space:nowrap;width:100%">${"Clipped words ".repeat(40)}</p></div>`, true],
9-
["text overflowing the page with no clip", `<p style="white-space:nowrap;position:relative;left:200px">${"Overflowing words ".repeat(20)}</p>`, true],
11+
["nowrap text clipped by an overflow-hidden box at the edge", `<div style="overflow:hidden;width:100vw"><p style="white-space:nowrap;width:100%">${LONG}</p></div>`, true],
12+
["text overflowing the page with no clip", `<p style="white-space:nowrap;position:relative;left:200px">${LONG}</p>`, true],
1013
["hero column wider than the screen", `<div style="overflow-x:hidden"><div style="width:calc(100vw + 120px);margin-left:16px"><p>${"A paragraph of wrapping words ".repeat(20)}</p></div></div>`, true],
1114
["clip-path box wider than the screen", `<div style="width:calc(100vw + 200px);clip-path:inset(0)"><h2 style="white-space:nowrap;margin-left:calc(100vw - 30px)">A heading past the edge</h2></div>`, true],
1215
["one-axis clip leaves the other axis visible", `<div style="width:100px;overflow-x:visible;overflow-y:clip"><h2 style="white-space:nowrap;margin-left:calc(100vw - 30px)">A heading past the edge</h2></div>`, true],
13-
["ellipsis inside a header", `<header style="display:flex;width:100%"><span style="margin-left:152px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">This House would replace school exams with continuous assessment</span></header>`, false],
14-
["wide box holding short text", `<div style="overflow:hidden"><p style="width:600px">Hello</p></div>`, false],
15-
["node panned fully outside a clipped canvas", `<div style="overflow:hidden;width:200px;height:200px;position:relative"><div style="position:absolute;left:900px;top:10px">Panned node</div></div>`, false],
16-
["drawer translated off canvas", `<div style="position:fixed;top:0;left:0;transform:translateX(-100%);width:300px">Drawer link</div>`, false],
17-
["sr-only label", `<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap">Screen reader text that is long</span>`, false],
18-
["table that scrolls sideways", `<div style="overflow-x:auto;width:300px"><p style="white-space:nowrap">${"Wide table cell ".repeat(40)}</p></div>`, false],
16+
["fixed text escapes a clipping ancestor outside its containing block", `<div style="overflow:hidden;width:200px"><h2 style="position:fixed;top:0;left:calc(100vw - 30px);white-space:nowrap">A fixed heading past the edge</h2></div>`, true],
17+
["fixed text inside a transformed clipping ancestor", `<div style="overflow:hidden;width:200px;height:100px;transform:translateZ(0)"><h2 style="position:fixed;top:0;left:calc(100vw - 30px);white-space:nowrap">A fixed heading past the edge</h2></div>`, false],
18+
["ellipsis inside a header", `<header style="display:flex;width:100%"><span style="margin-left:152px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${LONG}</span></header>`, false],
19+
["wide box holding short text", `<div style="overflow:hidden"><p style="width:calc(100vw + 200px)">Hello</p></div>`, false],
20+
["node panned out of a clipped canvas", `<div style="overflow:hidden;width:200px;height:200px;position:relative"><div style="position:absolute;left:calc(100vw - 30px);top:10px;white-space:nowrap">${LONG}</div></div>`, false],
21+
["drawer translated off canvas", `<div style="position:fixed;top:0;left:0;transform:translateX(-100%);width:300px;overflow:hidden;white-space:nowrap">${LONG}</div>`, false],
22+
["zero-area clip label", `<span style="position:absolute;left:calc(100vw - 30px);clip:rect(0px,0px,0px,0px);white-space:nowrap">${LONG}</span>`, false],
23+
["zero-height clip with a width", `<span style="position:absolute;left:calc(100vw - 30px);clip:rect(0px,100px,0px,0px);white-space:nowrap">${LONG}</span>`, false],
24+
["clip-path that hides the whole box", `<p style="white-space:nowrap;margin-left:calc(100vw - 30px);clip-path:inset(0 100% 0 0)">${LONG}</p>`, false],
25+
["table that scrolls sideways", `<div style="overflow-x:auto;width:300px"><p style="white-space:nowrap">${LONG}</p></div>`, false],
1926
];
2027
for (const [name, html, expectFlag] of CASES) {
2128
test(`edge guard: ${name}`, { skip: agentic.skip, tags: ["guards"] }, async ({ app, browser }) => {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
{
2+
"schemaVersion": "trace-1",
3+
"createdAt": "2026-10-06T11:24:00.157Z",
4+
"payload": {
5+
"actions": [
6+
{
7+
"name": "tap",
8+
"summary": "tap switch \"Toggle Plastic bag levy study\"",
9+
"target": {
10+
"role": "switch",
11+
"name": "Toggle Plastic bag levy study"
12+
}
13+
}
14+
],
15+
"executor": {
16+
"name": "e2e-default-agent",
17+
"version": "2"
18+
},
19+
"recordedFor": {
20+
"testId": "agentic/workspace.e2e.ts::a%20student%20adds%20a%20source%20to%20the%20debate%27s%20Library%20and%20pauses%20it",
21+
"targetId": "mobile",
22+
"instructionDigest": "9d91feda70935783ebcf15c1b620bce9da676e8396f3bf892a209539bebb7dbb",
23+
"paramsDigest": "5389157aca16270d663ba59055313812cc7ac445013bac8109e739f151f394e3",
24+
"callIndex": 0,
25+
"agent": "default"
26+
},
27+
"summary": "Paused “Plastic bag levy study” by turning off its library switch. The source is no longer checked, Active sources is 0, and the page shows Grounding Disabled.",
28+
"startPath": "/app/library",
29+
"endPath": "/app/library",
30+
"endAnchors": [
31+
{
32+
"role": "heading",
33+
"name": "Grounding Disabled"
34+
},
35+
{
36+
"text": "AI responses are not grounded"
37+
},
38+
{
39+
"role": "switch",
40+
"name": "Toggle Plastic bag levy study"
41+
}
42+
],
43+
"goneAnchors": [
44+
{
45+
"role": "button",
46+
"name": "Close"
47+
},
48+
{
49+
"text": "Source Added"
50+
},
51+
{
52+
"text": "Your source is now available for grounding."
53+
},
54+
{
55+
"role": "heading",
56+
"name": "Grounding Active"
57+
},
58+
{
59+
"text": "AI responses use your sources"
60+
},
61+
{
62+
"role": "listitem",
63+
"name": "Close Source Added Your source is now available for grounding."
64+
},
65+
{
66+
"role": "switch",
67+
"name": "Toggle Plastic bag levy study",
68+
"states": [
69+
"checked"
70+
]
71+
}
72+
],
73+
"endWaitMs": 13336
74+
}
75+
}

‎agentic/support/fixtures.ts‎

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,17 @@ export async function pageErrors(browser: Browser): Promise<string[]> {
8181
* Visible text that crosses the left or right edge of the screen. A wrapper
8282
* with `overflow-x: hidden` keeps the page from scrolling sideways while it
8383
* cuts such text off, so the page-width check alone misses it. Each text node
84-
* is measured, not its element's box, and clipped through every ancestor that
85-
* clips: text clipped away entirely (a drawer off canvas, an `sr-only` label)
86-
* or cut short inside the screen (an ellipsis) does not count, and text that
87-
* shows right up to the edge and continues past it does.
88-
* Text in a container that scrolls sideways on purpose (a table, a carousel)
89-
* or pans (the Flowchart canvas) is exempt.
84+
* is measured, not its element's box, and clipped the way the browser clips
85+
* it: by the ancestors on its containing-block chain, and by `clip` and
86+
* `clip-path`. Text clipped away entirely (a drawer off canvas, an `sr-only`
87+
* label) does not count; text that shows right up to the edge and continues
88+
* past it does. Text in a container that scrolls sideways on purpose (a table,
89+
* a carousel) or pans (the Flowchart canvas) is exempt.
90+
*
91+
* Text cut short inside the screen is out of scope on purpose. An ellipsis,
92+
* a scroll area, a carousel and a line clamp all clip inside the page by
93+
* design, so a geometric rule would fail them; the page sweep's judge checks
94+
* for text that is cut off.
9095
*/
9196
export async function textPastTheEdge(browser: Browser): Promise<string[]> {
9297
return browser.evaluate(() => {
@@ -109,21 +114,44 @@ export async function textPastTheEdge(browser: Browser): Promise<string[]> {
109114
let top = box.top;
110115
let bottom = box.bottom;
111116
let exempt = false;
117+
const cut = (area: { left: number; right: number; top: number; bottom: number }, x: boolean, y: boolean) => {
118+
if (x) { left = Math.max(left, area.left); right = Math.min(right, area.right); }
119+
if (y) { top = Math.max(top, area.top); bottom = Math.min(bottom, area.bottom); }
120+
};
121+
// A fixed or absolute box escapes the overflow of ancestors outside its
122+
// containing block, so only ancestors on that chain clip it.
123+
const containsFixed = (style: CSSStyleDeclaration) =>
124+
style.transform !== "none" || style.perspective !== "none" || style.filter !== "none" ||
125+
/paint|layout|strict|content/.test(style.contain) || /transform|perspective|filter/.test(style.willChange);
126+
let escaping: "fixed" | "absolute" | null = null;
112127
for (let node: Element | null = el; node && node !== document.documentElement; node = node.parentElement) {
113128
const style = getComputedStyle(node);
114-
if ((style.overflowX === "auto" || style.overflowX === "scroll") && node.scrollWidth > node.clientWidth) { exempt = true; break; }
115-
// `clip` and `clip-path` cut to the box at most; a zero-area `clip` hides it (`sr-only`).
116-
const clipsToBox = style.clipPath !== "none" || style.clip !== "auto";
117-
if (style.clip !== "auto" && /^rect\(0px,? 0px,? 0px,? 0px\)$/.test(style.clip)) { left = right; break; }
118-
const clip = node.getBoundingClientRect();
119-
if (clipsToBox || style.overflowX !== "visible") {
120-
left = Math.max(left, clip.left);
121-
right = Math.min(right, clip.right);
129+
const onChain = escaping === null
130+
|| (escaping === "absolute" && (style.position !== "static" || containsFixed(style)))
131+
|| (escaping === "fixed" && containsFixed(style));
132+
if (onChain) {
133+
escaping = null;
134+
if ((style.overflowX === "auto" || style.overflowX === "scroll") && node.scrollWidth > node.clientWidth) { exempt = true; break; }
135+
const rect = node.getBoundingClientRect();
136+
cut(rect, style.overflowX !== "visible", style.overflowY !== "visible");
122137
}
123-
if (clipsToBox || style.overflowY !== "visible") {
124-
top = Math.max(top, clip.top);
125-
bottom = Math.min(bottom, clip.bottom);
138+
// `clip` and `clip-path` apply to the element itself, wherever it sits.
139+
const rect = node.getBoundingClientRect();
140+
const clipRect = /^rect\((.+)\)$/.exec(style.clip);
141+
if (clipRect) {
142+
const [t, r, b, l] = clipRect[1].split(/,?\s+/).map((v, i) => v === "auto" ? [0, rect.width, rect.height, 0][i] : parseFloat(v));
143+
cut({ left: rect.left + l, right: rect.left + r, top: rect.top + t, bottom: rect.top + b }, true, true);
126144
}
145+
const inset = /^inset\(([^)]*?)(?:\s+round[^)]*)?\)$/.exec(style.clipPath);
146+
if (inset) {
147+
const v = inset[1].trim().split(/\s+/).map((part, i) => part.endsWith("%") ? parseFloat(part) / 100 * (i % 2 ? rect.width : rect.height) : parseFloat(part));
148+
const [t, r = t, b = t, l = r] = v;
149+
cut({ left: rect.left + l, right: rect.right - r, top: rect.top + t, bottom: rect.bottom - b }, true, true);
150+
} else if (style.clipPath !== "none") {
151+
cut(rect, true, true);
152+
}
153+
if (style.position === "fixed") escaping = "fixed";
154+
else if (style.position === "absolute" && escaping === null) escaping = "absolute";
127155
}
128156
if (exempt || right - left < 1 || bottom - top < 1) continue;
129157
// Cut short by its own box inside the screen, like an ellipsis on a

0 commit comments

Comments
 (0)