scenescout 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The page-side collector and the pure geometry oracles.
3
+ *
4
+ * Split out of the engine because none of it needs Playwright: the collector
5
+ * ships to the browser as a STRING, and `geometryIssues` is a pure function
6
+ * over layout boxes. Keeping them here means the oracle rules — the part that
7
+ * has repeatedly shipped false positives — can be table-tested without
8
+ * launching a browser (see scripts/oracle-test.ts).
9
+ */
10
+ /**
11
+ * The collector's visibility rule, as page-side source. Exported so the
12
+ * file-input probe (browser.ts) can ask "would the collector have listed
13
+ * this?" with the SAME rule — two copies of it would drift.
14
+ */
15
+ export const VISIBLE_SRC = `(el) => {
16
+ const rect = el.getBoundingClientRect();
17
+ if (rect.width === 0 && rect.height === 0) return false;
18
+ const style = window.getComputedStyle(el);
19
+ return style.visibility !== "hidden" && style.display !== "none";
20
+ }`;
21
+ /**
22
+ * Page-side interactable collector. Shipped as a STRING, not a function:
23
+ * loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
24
+ * serialized functions inside the browser, where the helper doesn't exist.
25
+ * A string expression is immune to any build/loader instrumentation.
26
+ */
27
+ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
28
+ const xpathOf = (el) => {
29
+ const parts = [];
30
+ let node = el;
31
+ while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== "html") {
32
+ let index = 1;
33
+ let sibling = node.previousElementSibling;
34
+ while (sibling) {
35
+ if (sibling.tagName === node.tagName) index += 1;
36
+ sibling = sibling.previousElementSibling;
37
+ }
38
+ parts.unshift(node.tagName.toLowerCase() + "[" + index + "]");
39
+ node = node.parentElement;
40
+ }
41
+ return "/html/" + parts.join("/");
42
+ };
43
+ const visible = ${VISIBLE_SRC};
44
+ const accessibleName = (el) => {
45
+ const aria = el.getAttribute("aria-label");
46
+ if (aria) return aria.trim();
47
+ // aria-labelledby before any fallback: it is the standard way to name an
48
+ // icon-only control from adjacent text, and skipping it made exactly those
49
+ // buttons report an empty name — which then read as an a11y defect the app
50
+ // did not actually have, and made the element harder to target.
51
+ const labelledBy = el.getAttribute("aria-labelledby");
52
+ if (labelledBy) {
53
+ const named = labelledBy
54
+ .split(/\\s+/)
55
+ .map((id) => {
56
+ const n = document.getElementById(id);
57
+ return n && n.textContent ? n.textContent.trim() : "";
58
+ })
59
+ .filter(Boolean)
60
+ .join(" ");
61
+ if (named) return named.replace(/\\s+/g, " ").slice(0, 80);
62
+ }
63
+ const tag = el.tagName.toLowerCase();
64
+ if (tag === "input" || tag === "textarea") {
65
+ const id = el.getAttribute("id");
66
+ if (id) {
67
+ const label = document.querySelector('label[for="' + CSS.escape(id) + '"]');
68
+ if (label && label.textContent) return label.textContent.trim();
69
+ }
70
+ return (el.getAttribute("placeholder") || el.getAttribute("name") || el.type || "input").trim();
71
+ }
72
+ const text = el.innerText || el.textContent || "";
73
+ return text.trim().replace(/\\s+/g, " ").slice(0, 80);
74
+ };
75
+ const selector =
76
+ 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], ' +
77
+ '[role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"], [onclick], [data-testid]';
78
+ const seen = new Set();
79
+ const out = [];
80
+ // Positioning/scroll LAYER + CHROME classification, for the overlap oracle.
81
+ // Elements in different fixed/sticky/scroll contexts are STACKED by design
82
+ // (a sticky footer riding over a scrolled nav, a modal over the page); their
83
+ // document-coordinate rects "overlap" without colliding. Only elements that
84
+ // share a positioning/scroll context can genuinely collide, and overlaps
85
+ // between two pieces of fixed/sticky chrome are almost always intended
86
+ // layering — so we tag each element with its nearest layer id and whether it
87
+ // lives inside fixed/sticky chrome, and let the oracle skip the phantoms.
88
+ const ctxIds = new WeakMap();
89
+ let ctxSeq = 0;
90
+ const ctxId = (node) => {
91
+ let id = ctxIds.get(node);
92
+ if (id === undefined) { id = ++ctxSeq; ctxIds.set(node, id); }
93
+ return id;
94
+ };
95
+ const layerAndChrome = (el) => {
96
+ let chrome = false, layer = 0, first = true;
97
+ // An element that is ITSELF position:fixed is lifted out of the document
98
+ // flow and stacked over it — a modal panel, a toast, a floating bar. It is
99
+ // its own layer root, so it must not be compared against the page content
100
+ // it deliberately covers. Without this, every open dialog reported a 100%
101
+ // "overlap" against the very content it was meant to sit on top of, since
102
+ // a dialog parented directly to <body> otherwise inherited layer 0.
103
+ if (window.getComputedStyle(el).position === "fixed") layer = ctxId(el);
104
+ let node = el;
105
+ while (node && node.tagName !== "HTML" && node.tagName !== "BODY") {
106
+ const s = window.getComputedStyle(node);
107
+ const pos = s.position;
108
+ if (pos === "fixed" || pos === "sticky") chrome = true;
109
+ // The element's OWN box does not define its layer — siblings share their
110
+ // parent's context. A positioned or scroll/clip ANCESTOR does.
111
+ if (!first && layer === 0) {
112
+ const scrolls = s.overflow === "auto" || s.overflow === "scroll" ||
113
+ s.overflowY === "auto" || s.overflowY === "scroll" || s.overflowY === "hidden" || s.overflowY === "clip" ||
114
+ s.overflowX === "auto" || s.overflowX === "scroll" || s.overflowX === "hidden" || s.overflowX === "clip";
115
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute" || pos === "relative" || scrolls) layer = ctxId(node);
116
+ }
117
+ first = false;
118
+ node = node.parentElement;
119
+ }
120
+ return { layer, chrome };
121
+ };
122
+ for (const el of Array.from(document.querySelectorAll(selector))) {
123
+ if (seen.has(el) || !visible(el)) continue;
124
+ seen.add(el);
125
+ const tag = el.tagName.toLowerCase();
126
+ const explicitRole = el.getAttribute("role");
127
+ const inputType = tag === "input" ? el.type : null;
128
+ const role = explicitRole ||
129
+ (tag === "a" ? "link" : tag === "button" ? "button" : tag === "select" ? "combobox"
130
+ : tag === "textarea" ? "textbox"
131
+ : tag === "input" ? (inputType === "checkbox" ? "checkbox"
132
+ : inputType === "radio" ? "radio"
133
+ : inputType === "submit" || inputType === "button" ? "button"
134
+ // A file input is not a text field: fill() refuses it, and calling
135
+ // it a textbox sent the driver to scout_type, which could only throw.
136
+ // Its own role routes it to scout_upload instead.
137
+ : inputType === "file" ? "file"
138
+ : "textbox")
139
+ : "generic");
140
+ const rect = el.getBoundingClientRect();
141
+ // Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
142
+ // ancestor is not — the container cannot scroll, so the control exists in
143
+ // layout but no user can ever see or reach it. Out-of-flow boxes are only
144
+ // clipped by their CONTAINING-BLOCK chain: position:fixed escapes ordinary
145
+ // ancestors entirely, and position:absolute skips static ones — a dropdown
146
+ // panel deliberately escaping its clipping wrapper is NOT unreachable.
147
+ const ePos = window.getComputedStyle(el).position;
148
+ let clippedByAncestor = false;
149
+ if (ePos !== "fixed") {
150
+ let escaping = ePos === "absolute";
151
+ let anc = el.parentElement;
152
+ while (anc && anc !== document.body && anc.tagName !== "HTML") {
153
+ const as = window.getComputedStyle(anc);
154
+ if (escaping) {
155
+ const establishes = as.position !== "static" || as.transform !== "none" || as.filter !== "none" || (as.willChange || "").indexOf("transform") >= 0;
156
+ if (!establishes) { anc = anc.parentElement; continue; }
157
+ escaping = false;
158
+ }
159
+ const oy = as.overflowY, ox = as.overflowX;
160
+ const hidesY = oy === "hidden" || oy === "clip";
161
+ const hidesX = ox === "hidden" || ox === "clip";
162
+ if (hidesY || hidesX) {
163
+ const ar = anc.getBoundingClientRect();
164
+ if (ar.width > 0 && ar.height > 0) {
165
+ const outY = hidesY && (rect.bottom <= ar.top || rect.top >= ar.bottom);
166
+ const outX = hidesX && (rect.right <= ar.left || rect.left >= ar.right);
167
+ if (outY || outX) { clippedByAncestor = true; break; }
168
+ }
169
+ }
170
+ anc = anc.parentElement;
171
+ }
172
+ }
173
+ out.push({
174
+ tag,
175
+ role,
176
+ name: accessibleName(el),
177
+ testid: el.getAttribute("data-testid"),
178
+ xpath: xpathOf(el),
179
+ disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
180
+ href: tag === "a" ? el.getAttribute("href") : null,
181
+ clipped: clippedByAncestor,
182
+ ...layerAndChrome(el),
183
+ // DOCUMENT coordinates, not viewport: after scrolling, viewport-relative
184
+ // rects made every above-the-fold header element look "off-screen".
185
+ rect: {
186
+ x: Math.round(rect.x + window.scrollX),
187
+ y: Math.round(rect.y + window.scrollY),
188
+ w: Math.round(rect.width),
189
+ h: Math.round(rect.height),
190
+ },
191
+ });
192
+ if (out.length >= 150) break;
193
+ }
194
+ return out;
195
+ })()`;
196
+ /**
197
+ * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
198
+ * than the ARIA set: a hand-rolled role-less modal must still count as "an
199
+ * overlay is up", or the scroll-lock oracle files a false leaked-lock finding
200
+ * against every healthy modal that locks the page behind it.
201
+ */
202
+ export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
203
+ /**
204
+ * Deterministic geometry oracles — the checks people reach for screenshots to
205
+ * do, computed from layout boxes instead: interactables rendered fully outside
206
+ * the viewport, and heavy overlap between non-nested interactables.
207
+ */
208
+ export function geometryIssues(elements, viewport) {
209
+ const issues = [];
210
+ let clippedTotal = 0;
211
+ for (const el of elements) {
212
+ const { x, y, w, h } = el.rect;
213
+ // Rects are DOCUMENT coords: below-the-fold content is normal; unreachable
214
+ // means left/above the document origin, or absurdly far right (no page
215
+ // scrolls 3 viewports horizontally on purpose).
216
+ if (w > 0 && h > 0 && (x + w <= 0 || y + h <= 0 || x >= viewport.width * 3)) {
217
+ issues.push(`${el.ref} ${el.role} "${el.name}" is rendered outside the reachable page area (${x},${y} ${w}×${h})`);
218
+ }
219
+ // Distinct from below-the-fold: this control's own container hides it and
220
+ // cannot scroll — layout says it exists, no user can ever reach it.
221
+ if (el.clipped && w > 0 && h > 0) {
222
+ clippedTotal += 1;
223
+ if (clippedTotal <= 3) {
224
+ issues.push(`${el.ref} ${el.role} "${el.name}" is UNREACHABLE — fully clipped inside an overflow-hidden ancestor (at ${x},${y}; the container cannot scroll to reveal it)`);
225
+ }
226
+ }
227
+ }
228
+ // Cap: one transform-based carousel legitimately clips dozens of off-track
229
+ // slides; an uncapped list floods GEOMETRY and starves the overlap oracle
230
+ // (which shares this list's length budget below).
231
+ if (clippedTotal > 3) {
232
+ issues.push(`…and ${clippedTotal - 3} more controls clipped inside overflow-hidden ancestors`);
233
+ }
234
+ const overlapArea = (a, b) => {
235
+ const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
236
+ const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
237
+ return w > 0 && h > 0 ? w * h : 0;
238
+ };
239
+ outer: for (let i = 0; i < elements.length && issues.length < 8; i++) {
240
+ for (let j = i + 1; j < elements.length; j++) {
241
+ const a = elements[i];
242
+ const b = elements[j];
243
+ // Nested elements legitimately overlap (wrapper + button).
244
+ if (a.xpath.startsWith(b.xpath) || b.xpath.startsWith(a.xpath))
245
+ continue;
246
+ // Different positioning/scroll LAYERS are stacked by design (a modal over
247
+ // the page, a scrolled list panel beside another) — their document-coord
248
+ // rects "overlap" without colliding.
249
+ if ((a.layer ?? 0) !== (b.layer ?? 0))
250
+ continue;
251
+ // Two pieces of fixed/sticky CHROME overlapping is almost always intended
252
+ // layering (a sticky footer riding over the nav list it pins). Real
253
+ // collision bugs that matter live in the content flow, not the chrome.
254
+ if (a.chrome && b.chrome)
255
+ continue;
256
+ const area = overlapArea(a.rect, b.rect);
257
+ const smaller = Math.min(a.rect.w * a.rect.h, b.rect.w * b.rect.h);
258
+ if (smaller > 0 && area / smaller > 0.6) {
259
+ issues.push(`${a.ref} "${a.name}" overlaps ${b.ref} "${b.name}" (${Math.round((area / smaller) * 100)}%)`);
260
+ if (issues.length >= 8)
261
+ break outer;
262
+ }
263
+ }
264
+ }
265
+ return issues;
266
+ }