scenescout 3.17.0 → 3.19.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 +37 -0
- package/README.md +9 -4
- package/dist/check-run.js +78 -15
- package/dist/cli.js +6 -4
- package/dist/engine/baseline.js +43 -0
- package/dist/engine/browser.js +318 -144
- package/dist/engine/check.js +116 -12
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +77 -3
- package/dist/engine/export.js +200 -1
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/flow.js +171 -4
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/export-run.js +231 -55
- package/dist/first-run.js +7 -1
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +41 -0
- package/dist/playbook.js +1 -1
- package/package.json +2 -2
- package/skills/scenescout/SKILL.md +2 -2
package/dist/engine/memory.js
CHANGED
|
@@ -7,9 +7,11 @@ import { addReading, addVerdict, MAX_TICKETS_KEPT, mergeTicketData } from "./tic
|
|
|
7
7
|
/**
|
|
8
8
|
* The element-name rule current keys are made under. 2: a link or button with
|
|
9
9
|
* no text is named by its image content (an <img>'s alt text, an <svg>'s
|
|
10
|
-
* aria-label or <title>) before its title attribute.
|
|
10
|
+
* aria-label or <title>) before its title attribute. 3: that content also
|
|
11
|
+
* counts a descendant's aria-label and an <img>'s title when its alt is empty
|
|
12
|
+
* (collector.ts CONTENT_SAYS_SRC).
|
|
11
13
|
*/
|
|
12
|
-
export const NAME_RULE =
|
|
14
|
+
export const NAME_RULE = 3;
|
|
13
15
|
/**
|
|
14
16
|
* The kinds a finding can be. One list: scout_finding's input schema, the lane
|
|
15
17
|
* report a parallel agent hands back, and the skill text all read it from
|
|
@@ -278,6 +280,20 @@ export function rekeyed(elements, aliases) {
|
|
|
278
280
|
}
|
|
279
281
|
return out;
|
|
280
282
|
}
|
|
283
|
+
/** A route's aliases for the step that leads to `rule`: key under rule - 1 → key under rule. */
|
|
284
|
+
function stepAliases(data, rule, route) {
|
|
285
|
+
return rule === 2 ? data.keyAliases?.[route] : data.ruleKeyAliases?.[String(rule)]?.[route];
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* A state's elements moved from the keys of name rule `from` to those of rule
|
|
289
|
+
* `to`, one step at a time, each through the route's aliases for that step.
|
|
290
|
+
*/
|
|
291
|
+
function keysUnderRule(elements, from, to, data, route) {
|
|
292
|
+
let out = elements;
|
|
293
|
+
for (let rule = from + 1; rule <= to; rule++)
|
|
294
|
+
out = rekeyed(out, stepAliases(data, rule, route));
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
281
297
|
/**
|
|
282
298
|
* Fold another process's memory into ours, losing nothing from either side.
|
|
283
299
|
*
|
|
@@ -300,6 +316,15 @@ export function mergeMemory(mine, theirs) {
|
|
|
300
316
|
}
|
|
301
317
|
if (Object.keys(out.keyAliases).length === 0)
|
|
302
318
|
delete out.keyAliases;
|
|
319
|
+
out.ruleKeyAliases = {};
|
|
320
|
+
for (const rule of new Set([...Object.keys(theirs.ruleKeyAliases ?? {}), ...Object.keys(mine.ruleKeyAliases ?? {})])) {
|
|
321
|
+
const step = { ...(theirs.ruleKeyAliases?.[rule] ?? {}) };
|
|
322
|
+
for (const [route, aliases] of Object.entries(mine.ruleKeyAliases?.[rule] ?? {}))
|
|
323
|
+
step[route] = { ...aliases, ...(step[route] ?? {}) };
|
|
324
|
+
out.ruleKeyAliases[rule] = step;
|
|
325
|
+
}
|
|
326
|
+
if (Object.keys(out.ruleKeyAliases).length === 0)
|
|
327
|
+
delete out.ruleKeyAliases;
|
|
303
328
|
out.states = { ...theirs.states };
|
|
304
329
|
for (const [fp, ours] of Object.entries(mine.states)) {
|
|
305
330
|
const other = theirs.states[fp];
|
|
@@ -308,10 +333,10 @@ export function mergeMemory(mine, theirs) {
|
|
|
308
333
|
continue;
|
|
309
334
|
}
|
|
310
335
|
const nameRule = Math.max(ours.nameRule ?? 0, other.nameRule ?? 0);
|
|
311
|
-
// When one side is under
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
const current = (side) =>
|
|
336
|
+
// When one side is under a later name rule than the other, the other's
|
|
337
|
+
// keys are moved to that rule's before the union, as a revisit moves them
|
|
338
|
+
// (MemoryStore visitState).
|
|
339
|
+
const current = (side) => keysUnderRule(side.elements, side.nameRule ?? 1, Math.max(nameRule, 1), out, routeIdentity(side.route));
|
|
315
340
|
const elements = { ...current(other) };
|
|
316
341
|
for (const [key, el] of Object.entries(current(ours))) {
|
|
317
342
|
const prev = elements[key];
|
|
@@ -384,6 +409,7 @@ export function mergeMemory(mine, theirs) {
|
|
|
384
409
|
const unionRecord = (a, b) => (a || b ? { ...(b ?? {}), ...(a ?? {}) } : undefined);
|
|
385
410
|
out.discoveredRoutes = unionRecord(mine.discoveredRoutes, theirs.discoveredRoutes);
|
|
386
411
|
out.attemptedRoutes = unionRecord(mine.attemptedRoutes, theirs.attemptedRoutes);
|
|
412
|
+
out.resourceRoutes = unionRecord(mine.resourceRoutes, theirs.resourceRoutes);
|
|
387
413
|
out.designElements = unionRecord(mine.designElements, theirs.designElements);
|
|
388
414
|
out.pageScores = { ...(theirs.pageScores ?? {}) };
|
|
389
415
|
for (const [route, score] of Object.entries(mine.pageScores ?? {})) {
|
|
@@ -1289,6 +1315,31 @@ export class MemoryStore {
|
|
|
1289
1315
|
get discoveredRoutes() {
|
|
1290
1316
|
return this.data.discoveredRoutes ?? {};
|
|
1291
1317
|
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Record that a route answered with a feed, a file or data (crawl.ts
|
|
1320
|
+
* isNonPageResource). A route harvested from a link joins the contract before
|
|
1321
|
+
* anything knows what it serves; this takes it back out, for every role and
|
|
1322
|
+
* every later run, so a feed linked from every page is not a gap forever.
|
|
1323
|
+
*/
|
|
1324
|
+
markResource(route, mediaType) {
|
|
1325
|
+
const map = this.data.resourceRoutes ?? {};
|
|
1326
|
+
if (map[route] !== mediaType) {
|
|
1327
|
+
map[route] = mediaType;
|
|
1328
|
+
this.data.resourceRoutes = map;
|
|
1329
|
+
this.save();
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/** A route that has since answered as a page is one again: back into the contract. */
|
|
1333
|
+
clearResource(route) {
|
|
1334
|
+
const map = this.data.resourceRoutes;
|
|
1335
|
+
if (map && route in map) {
|
|
1336
|
+
delete map[route];
|
|
1337
|
+
this.save();
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
get resourceRoutes() {
|
|
1341
|
+
return this.data.resourceRoutes ?? {};
|
|
1342
|
+
}
|
|
1292
1343
|
/**
|
|
1293
1344
|
* Record that a route was attempted but landed elsewhere (e.g. auth-redirect).
|
|
1294
1345
|
*
|
|
@@ -1827,9 +1878,10 @@ export class MemoryStore {
|
|
|
1827
1878
|
* listed keys a user cannot act on (collector inertKeys): they stay known,
|
|
1828
1879
|
* so a click on one still registers, but coverage does not count them.
|
|
1829
1880
|
* `session` names who visited, for this run's per-session coverage.
|
|
1830
|
-
* `aliases` maps
|
|
1831
|
-
*
|
|
1832
|
-
* states written before
|
|
1881
|
+
* `aliases` maps, for each step between name rules, a listed key to the
|
|
1882
|
+
* key the same control had under the rule before (KeyAliasSteps); the route
|
|
1883
|
+
* keeps them so states written before a rule changed are read under
|
|
1884
|
+
* current keys.
|
|
1833
1885
|
*/
|
|
1834
1886
|
visitState(fingerprint, url, route, elementKeys, inertKeys = [], session, aliases = {}) {
|
|
1835
1887
|
this.runStates.add(fingerprint);
|
|
@@ -1856,7 +1908,7 @@ export class MemoryStore {
|
|
|
1856
1908
|
// named like a text button beside it) leaves it unchanged. Its keys are
|
|
1857
1909
|
// moved to the current ones before it is marked current.
|
|
1858
1910
|
if ((rec.nameRule ?? 1) < NAME_RULE)
|
|
1859
|
-
rec.elements =
|
|
1911
|
+
rec.elements = keysUnderRule(rec.elements, rec.nameRule ?? 1, NAME_RULE, this.data, routeIdentity(route));
|
|
1860
1912
|
rec.nameRule = NAME_RULE;
|
|
1861
1913
|
const present = new Set(elementKeys);
|
|
1862
1914
|
const inert = new Set(inertKeys);
|
|
@@ -1888,16 +1940,18 @@ export class MemoryStore {
|
|
|
1888
1940
|
* hand out ordinals differently, and the states written under the earlier
|
|
1889
1941
|
* rule do not change, so neither should what their keys are read as.
|
|
1890
1942
|
*/
|
|
1891
|
-
learnAliases(route,
|
|
1892
|
-
const pairs = Object.entries(aliases);
|
|
1893
|
-
if (pairs.length === 0)
|
|
1894
|
-
return;
|
|
1895
|
-
const byRoute = (this.data.keyAliases ??= {});
|
|
1943
|
+
learnAliases(route, steps) {
|
|
1896
1944
|
const id = routeIdentity(route);
|
|
1897
|
-
const
|
|
1898
|
-
|
|
1899
|
-
if (
|
|
1900
|
-
|
|
1945
|
+
for (const [rule, aliases] of Object.entries(steps)) {
|
|
1946
|
+
const pairs = Object.entries(aliases);
|
|
1947
|
+
if (pairs.length === 0)
|
|
1948
|
+
continue;
|
|
1949
|
+
const byRoute = rule === "2" ? (this.data.keyAliases ??= {}) : ((this.data.ruleKeyAliases ??= {})[rule] ??= {});
|
|
1950
|
+
const known = (byRoute[id] ??= {});
|
|
1951
|
+
for (const [key, prior] of pairs)
|
|
1952
|
+
if (!Object.hasOwn(known, prior))
|
|
1953
|
+
known[prior] = key;
|
|
1954
|
+
}
|
|
1901
1955
|
}
|
|
1902
1956
|
markExercised(fingerprint, key, action) {
|
|
1903
1957
|
const rec = this.data.states[fingerprint];
|
|
@@ -2265,7 +2319,7 @@ export class MemoryStore {
|
|
|
2265
2319
|
const route = routeIdentity(rec.route);
|
|
2266
2320
|
const keys = only && !only.has(fp) ? null : bucket(listed, route);
|
|
2267
2321
|
// A state written under an earlier name rule is read under current keys.
|
|
2268
|
-
const elements = (rec.nameRule ?? 1
|
|
2322
|
+
const elements = keysUnderRule(rec.elements, rec.nameRule ?? 1, NAME_RULE, this.data, route);
|
|
2269
2323
|
for (const [key, v] of Object.entries(elements)) {
|
|
2270
2324
|
if (v.inert)
|
|
2271
2325
|
bucket(inert, route).add(key);
|
package/dist/engine/policy.js
CHANGED
|
@@ -887,6 +887,37 @@ export function matchReadPost(entries, appUrl, url) {
|
|
|
887
887
|
const segments = target.pathname.split("/").filter(Boolean);
|
|
888
888
|
return (entries.find((e) => (e.origin ?? app) === target.origin && e.segments.length === segments.length && e.segments.every((seg, i) => seg === "*" || seg === segments[i])) ?? null);
|
|
889
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* The page a script's POST was sent from, for the gap ledger's list of pages
|
|
892
|
+
* observe refused a POST on. The Referer of a request the top document sent,
|
|
893
|
+
* when it is a page of the app; otherwise the page the session drives when the
|
|
894
|
+
* request is seen. A frame's Referer is its own document, which the session
|
|
895
|
+
* never navigates to, so the caller passes none for a frame's request.
|
|
896
|
+
*
|
|
897
|
+
* The Referer comes first because the session's page URL can lag: a page's
|
|
898
|
+
* script can send its first request before the browser has told Playwright
|
|
899
|
+
* that the page committed, so the session still reports the page it came
|
|
900
|
+
* from. A Referer that is the app's bare origin is set aside for the session's
|
|
901
|
+
* page: an app whose referrer policy sends only the origin would otherwise
|
|
902
|
+
* charge every page's POST to "/". The price is that the app's root page,
|
|
903
|
+
* whose own Referer is that bare origin, still relies on the session's page
|
|
904
|
+
* URL, lag and all.
|
|
905
|
+
*/
|
|
906
|
+
export function refusedPostPage(appUrl, referer, pageUrl) {
|
|
907
|
+
if (!referer)
|
|
908
|
+
return pageUrl;
|
|
909
|
+
let from;
|
|
910
|
+
try {
|
|
911
|
+
from = new URL(referer);
|
|
912
|
+
if (from.origin !== new URL(appUrl).origin)
|
|
913
|
+
return pageUrl;
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return pageUrl;
|
|
917
|
+
}
|
|
918
|
+
const bareOrigin = from.pathname === "/" && !from.search;
|
|
919
|
+
return bareOrigin && pageUrl ? pageUrl : referer;
|
|
920
|
+
}
|
|
890
921
|
/** The longest body read for a GraphQL operation; a longer one cannot be vetted and is refused. */
|
|
891
922
|
export const MAX_READ_POST_BODY = 100_000;
|
|
892
923
|
/** A GraphQL operation that writes: `mutation` or `subscription`, then an optional name, then its variables, directives or selection. */
|
package/dist/engine/probes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ACCESSIBLE_NAME_SRC, DIALOG_LIKE_SEL } from "./collector.js";
|
|
2
2
|
import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
|
|
3
|
+
import { focusedControl } from "./design.js";
|
|
3
4
|
/**
|
|
4
5
|
* Scroll ONE named region rather than the page. The page-level heuristic
|
|
5
6
|
* picks the largest scrollable pane, so a smaller independently-scrolling
|
|
@@ -158,6 +159,12 @@ export async function probeOverlays(page) {
|
|
|
158
159
|
const s = getComputedStyle(el);
|
|
159
160
|
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
|
|
160
161
|
};
|
|
162
|
+
// A translucent background or a blur: what dims the page behind a modal.
|
|
163
|
+
const darkens = (s) => {
|
|
164
|
+
const m = (s.backgroundColor || "").match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
|
|
165
|
+
const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
|
|
166
|
+
return (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
|
|
167
|
+
};
|
|
161
168
|
// Backdrops: fixed, near-full-viewport, visually darkening/blurring.
|
|
162
169
|
const backdrops = [];
|
|
163
170
|
for (const el of document.querySelectorAll("body *")) {
|
|
@@ -166,10 +173,7 @@ export async function probeOverlays(page) {
|
|
|
166
173
|
if (s.position !== "fixed") continue;
|
|
167
174
|
const r = el.getBoundingClientRect();
|
|
168
175
|
if (r.width < vw * 0.9 || r.height < vh * 0.9) continue;
|
|
169
|
-
|
|
170
|
-
const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
|
|
171
|
-
const darkens = (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
|
|
172
|
-
if (darkens) backdrops.push(el);
|
|
176
|
+
if (darkens(s)) backdrops.push(el);
|
|
173
177
|
}
|
|
174
178
|
const hasContent = (el) => {
|
|
175
179
|
const t = (el.textContent || "").trim();
|
|
@@ -183,12 +187,47 @@ export async function probeOverlays(page) {
|
|
|
183
187
|
const dialogsAll = [...document.querySelectorAll('${DIALOG_LIKE_SEL}')].filter((d) => visible(d));
|
|
184
188
|
// Leaked modal scroll-lock: content extends past the fold, the page
|
|
185
189
|
// itself cannot scroll (overflow hidden on body/html), and NO overlay
|
|
186
|
-
// of any kind — dialog-like panel
|
|
190
|
+
// of any kind — dialog-like panel, backdrop or framed modal — is up to justify the
|
|
187
191
|
// lock; everything below the fold is unreachable and the page looks
|
|
188
192
|
// perfectly healthy otherwise.
|
|
193
|
+
// A modal can also live inside a frame: the top document then holds only
|
|
194
|
+
// a pinned wrapper around a full-viewport iframe, and the dialog and its
|
|
195
|
+
// backdrop are in the frame's document, out of reach of the selectors
|
|
196
|
+
// above. Such an iframe justifies the lock when it is visible, covers the
|
|
197
|
+
// viewport, is pinned (itself or through a fixed ancestor) and is not
|
|
198
|
+
// faded out or click-through, and, when its document can be read (same
|
|
199
|
+
// origin), something in it is open: a dialog-like panel or a dimming
|
|
200
|
+
// backdrop. A frame left mounted after its modal closed holds neither.
|
|
201
|
+
// Another site's frame cannot be read, so its geometry has to do.
|
|
202
|
+
const frameOverlay = (f) => {
|
|
203
|
+
if (!visible(f)) return false;
|
|
204
|
+
const r = f.getBoundingClientRect();
|
|
205
|
+
if (r.width < vw * 0.9 || r.height < vh * 0.9) return false;
|
|
206
|
+
let pinned = false;
|
|
207
|
+
for (let e = f; e && e !== document.body; e = e.parentElement) {
|
|
208
|
+
const s = getComputedStyle(e);
|
|
209
|
+
if (Number(s.opacity) === 0 || s.pointerEvents === "none") return false;
|
|
210
|
+
if (s.position === "fixed") pinned = true;
|
|
211
|
+
}
|
|
212
|
+
if (!pinned) return false;
|
|
213
|
+
// contentDocument is null for another site's frame.
|
|
214
|
+
const doc = f.contentDocument;
|
|
215
|
+
const win = doc && doc.defaultView;
|
|
216
|
+
if (!win) return true;
|
|
217
|
+
const fw = win.innerWidth, fh = win.innerHeight;
|
|
218
|
+
const shown = (el) => { const b = el.getBoundingClientRect(); const s = win.getComputedStyle(el); return b.width > 0 && b.height > 0 && s.visibility !== "hidden" && s.display !== "none"; };
|
|
219
|
+
if ([...doc.querySelectorAll('${DIALOG_LIKE_SEL}')].some(shown)) return true;
|
|
220
|
+
return [...doc.querySelectorAll("body *")].some((el) => {
|
|
221
|
+
const s = win.getComputedStyle(el);
|
|
222
|
+
if (s.position !== "fixed" || !shown(el)) return false;
|
|
223
|
+
const b = el.getBoundingClientRect();
|
|
224
|
+
return b.width >= fw * 0.9 && b.height >= fh * 0.9 && darkens(s);
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
const framedOverlay = [...document.querySelectorAll("iframe")].some(frameOverlay);
|
|
189
228
|
const docH = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
|
190
229
|
const ovLock = [getComputedStyle(document.documentElement).overflowY, document.body ? getComputedStyle(document.body).overflowY : ""].some((o) => o === "hidden" || o === "clip");
|
|
191
|
-
if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0) {
|
|
230
|
+
if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0 && !framedOverlay) {
|
|
192
231
|
issues.push("OVERLAY: page scrolling is DISABLED (overflow hidden on body/html) with " + Math.round(docH - vh) + "px of content below the fold and NO open dialog to justify it — likely a leaked modal scroll-lock; users cannot reach the rest of the page");
|
|
193
232
|
}
|
|
194
233
|
if (backdrops.length === 0) return issues;
|
|
@@ -249,8 +288,11 @@ export async function probeOverlays(page) {
|
|
|
249
288
|
* focusless. As Tab advances, the previous stop is naturally blurred, so
|
|
250
289
|
* each stop's focused style (captured at visit time) can be diffed against
|
|
251
290
|
* its blurred style (captured in one pass at the end) without fighting the
|
|
252
|
-
* tab order.
|
|
253
|
-
*
|
|
291
|
+
* tab order. A Tab onto a frame is followed into a same-origin frame's
|
|
292
|
+
* document and the control focused there is sampled; a press that leaves
|
|
293
|
+
* focus inside a frame it cannot read is skipped (design.ts focusedControl).
|
|
294
|
+
* Best-effort: any failure returns an empty sample set rather than failing
|
|
295
|
+
* the audit.
|
|
254
296
|
*/
|
|
255
297
|
export async function probeFocusIndicators(page) {
|
|
256
298
|
const name = page.context().browser()?.browserType().name();
|
|
@@ -261,11 +303,11 @@ export async function probeFocusIndicators(page) {
|
|
|
261
303
|
for (let i = 0; i < 15; i++) {
|
|
262
304
|
await page.keyboard.press(advanceKey);
|
|
263
305
|
const info = (await page.evaluate(`(() => {
|
|
264
|
-
const el = document
|
|
265
|
-
if (
|
|
306
|
+
const el = (${focusedControl.toString()})(document);
|
|
307
|
+
if (el === null || el === "frame") return el;
|
|
266
308
|
if (el.hasAttribute("data-scout-focus-probe")) return "wrapped";
|
|
267
309
|
el.setAttribute("data-scout-focus-probe", "${i}");
|
|
268
|
-
const s = getComputedStyle(el);
|
|
310
|
+
const s = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
269
311
|
const tid = el.getAttribute("data-testid");
|
|
270
312
|
// Named as the snapshot names it, so an icon button reads by its aria-label here too.
|
|
271
313
|
const name = (${ACCESSIBLE_NAME_SRC})(el).slice(0, 30);
|
|
@@ -273,15 +315,32 @@ export async function probeFocusIndicators(page) {
|
|
|
273
315
|
})()`));
|
|
274
316
|
if (info === null || info === "wrapped")
|
|
275
317
|
break;
|
|
318
|
+
if (info === "frame")
|
|
319
|
+
continue;
|
|
276
320
|
stops.push({ i, ...info });
|
|
277
321
|
}
|
|
278
|
-
|
|
322
|
+
// Blur from the innermost focused element out, so a control focused inside a frame loses focus too.
|
|
323
|
+
await page.evaluate(`(() => {
|
|
324
|
+
const chain = [];
|
|
325
|
+
for (let d = document; d; ) {
|
|
326
|
+
const a = d.activeElement;
|
|
327
|
+
if (!a || a === d.body || a === d.documentElement) break;
|
|
328
|
+
chain.push(a);
|
|
329
|
+
d = /^i?frame$/i.test(a.tagName) ? a.contentDocument : null;
|
|
330
|
+
}
|
|
331
|
+
for (const a of chain.reverse()) if (a.blur) a.blur();
|
|
332
|
+
})()`);
|
|
333
|
+
// The marked stops live in the page and in every same-origin frame the walk followed focus into.
|
|
279
334
|
const blurred = (await page.evaluate(`(() => {
|
|
280
335
|
const out = {};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
el.
|
|
336
|
+
const docs = [document];
|
|
337
|
+
for (let k = 0; k < docs.length; k++) {
|
|
338
|
+
for (const f of docs[k].querySelectorAll("iframe, frame")) if (f.contentDocument) docs.push(f.contentDocument);
|
|
339
|
+
for (const el of docs[k].querySelectorAll("[data-scout-focus-probe]")) {
|
|
340
|
+
const s = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
341
|
+
out[el.getAttribute("data-scout-focus-probe")] = ${styleSig};
|
|
342
|
+
el.removeAttribute("data-scout-focus-probe");
|
|
343
|
+
}
|
|
285
344
|
}
|
|
286
345
|
return out;
|
|
287
346
|
})()`));
|