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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -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
|
+
}
|