scenescout 3.15.0 → 3.16.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 +75 -0
- package/README.md +64 -15
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +190 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +104 -43
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +182 -39
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +4 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +567 -57
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +49 -16
package/dist/engine/collector.js
CHANGED
|
@@ -47,6 +47,11 @@ export const XPATH_OF_SRC = `(el) => {
|
|
|
47
47
|
* A label's text is read without the control's own text: a wrapping label
|
|
48
48
|
* holds the control, and a select's options or a textarea's contents are not
|
|
49
49
|
* its label.
|
|
50
|
+
*
|
|
51
|
+
* `content` is what the element's image content says, which the text cannot
|
|
52
|
+
* hold: the alt text of the first descendant <img>, else the aria-label or
|
|
53
|
+
* <title> of a descendant <svg> or role="img", in document order, skipping
|
|
54
|
+
* anything inside an aria-hidden part of the element.
|
|
50
55
|
*/
|
|
51
56
|
export const NAME_FACTS_SRC = `(el) => {
|
|
52
57
|
const tag = el.tagName.toLowerCase();
|
|
@@ -58,6 +63,17 @@ export const NAME_FACTS_SRC = `(el) => {
|
|
|
58
63
|
const own = label.contains(el) ? el.textContent || "" : "";
|
|
59
64
|
labels.push((label.textContent || "").replace(own, ""));
|
|
60
65
|
}
|
|
66
|
+
let content = "";
|
|
67
|
+
for (const d of el.querySelectorAll("img, svg, [role='img']")) {
|
|
68
|
+
const hidden = d.closest('[aria-hidden="true"]');
|
|
69
|
+
if (hidden && el.contains(hidden)) continue;
|
|
70
|
+
const title = d.tagName.toLowerCase() === "svg" ? d.querySelector(":scope > title") : null;
|
|
71
|
+
const said = (d.tagName === "IMG" ? d.getAttribute("alt") : d.getAttribute("aria-label") || (title ? title.textContent : "")) || "";
|
|
72
|
+
if (said.trim()) {
|
|
73
|
+
content = said;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
61
77
|
const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
|
|
62
78
|
(el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
|
|
63
79
|
return {
|
|
@@ -74,6 +90,7 @@ export const NAME_FACTS_SRC = `(el) => {
|
|
|
74
90
|
alt: el.getAttribute("alt") || "",
|
|
75
91
|
live,
|
|
76
92
|
text: el.innerText || el.textContent || "",
|
|
93
|
+
content,
|
|
77
94
|
};
|
|
78
95
|
}`;
|
|
79
96
|
/**
|
|
@@ -88,8 +105,15 @@ export const NAME_FACTS_SRC = `(el) => {
|
|
|
88
105
|
* fallback stays.
|
|
89
106
|
*
|
|
90
107
|
* - A select is never named by its options: their text is its value, not its name.
|
|
91
|
-
* - Other elements are named by their text,
|
|
92
|
-
* (
|
|
108
|
+
* - Other elements are named by their text; with none, by their image content
|
|
109
|
+
* (a link holding only a logo with alt text, a button holding only an svg
|
|
110
|
+
* with a <title>); and with neither, by title (an icon-only button with a
|
|
111
|
+
* tooltip). Text and image content are not joined: a control with both is
|
|
112
|
+
* named by its text, as it was before image content counted.
|
|
113
|
+
* - A name taken from image content carries `prior`, the name the rule gave
|
|
114
|
+
* before image content counted (the title, or nothing). The name is half of
|
|
115
|
+
* the coverage key, so the key under the earlier rule is kept beside the new
|
|
116
|
+
* one and coverage recorded under it carries over (memory.ts keyAliases).
|
|
93
117
|
* - A live region (status, alert, log, timer, an aria-live region, <output>)
|
|
94
118
|
* is named by the text it announces, which is what matters after an action.
|
|
95
119
|
* - A whitespace-only aria-label ends the name with nothing, as it always has
|
|
@@ -121,6 +145,7 @@ export const PICK_NAME_SRC = `(f) => {
|
|
|
121
145
|
return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
|
|
122
146
|
}
|
|
123
147
|
if (clean(f.text)) return named(clean(f.text));
|
|
148
|
+
if (clean(f.content)) return { ...named(clean(f.content)), prior: clean(f.title).slice(0, 80) };
|
|
124
149
|
return named(clean(f.title));
|
|
125
150
|
}`;
|
|
126
151
|
/**
|
|
@@ -138,6 +163,49 @@ export const NAME_SRC = `(el) => (${PICK_NAME_SRC})((${NAME_FACTS_SRC})(el))`;
|
|
|
138
163
|
* must name it exactly as the snapshot did, or the two cannot be compared.
|
|
139
164
|
*/
|
|
140
165
|
export const ACCESSIBLE_NAME_SRC = `(el) => (${NAME_SRC})(el).name`;
|
|
166
|
+
/**
|
|
167
|
+
* What the label policy reads of an element besides its name (policy.ts
|
|
168
|
+
* destructiveLabelOf), as page-side source shared by the snapshot and the
|
|
169
|
+
* live re-check before an action, so the two cannot judge differently.
|
|
170
|
+
*
|
|
171
|
+
* `ownText`: the label the element is given (aria-label, aria-labelledby),
|
|
172
|
+
* then its text without the text of the controls inside it.
|
|
173
|
+
* `centre`: the labels of the controls inside it whose boxes cover the
|
|
174
|
+
* element's centre point, which is where a click on the element lands. Boxes,
|
|
175
|
+
* not a hit test, so it reads the same whether or not the element is scrolled
|
|
176
|
+
* into view, and every covering control is named, not only the topmost.
|
|
177
|
+
*/
|
|
178
|
+
export const POLICY_TEXT_SRC = `(el) => {
|
|
179
|
+
const CONTROL = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], ' +
|
|
180
|
+
'[role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="combobox"], [role="listbox"], [onclick]';
|
|
181
|
+
const parts = [];
|
|
182
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
183
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
184
|
+
const owner = node.parentElement && node.parentElement.closest(CONTROL);
|
|
185
|
+
if (owner && owner !== el && el.contains(owner)) continue;
|
|
186
|
+
const t = (node.textContent || "").trim();
|
|
187
|
+
if (t) parts.push(t);
|
|
188
|
+
if (parts.join(" ").length > 400) break;
|
|
189
|
+
}
|
|
190
|
+
// A label the element is given (aria-label, aria-labelledby) is its own: an icon-only control's only name.
|
|
191
|
+
const given = [el.getAttribute("aria-label") || ""].concat((el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean).map((id) => {
|
|
192
|
+
const n = document.getElementById(id);
|
|
193
|
+
return n ? n.textContent || "" : "";
|
|
194
|
+
})).join(" ").trim();
|
|
195
|
+
const ownText = (given + " " + parts.join(" ")).replace(/\\s+/g, " ").trim().slice(0, 400);
|
|
196
|
+
const r = el.getBoundingClientRect();
|
|
197
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
198
|
+
const centre = [];
|
|
199
|
+
for (const c of Array.from(el.querySelectorAll(CONTROL))) {
|
|
200
|
+
const b = c.getBoundingClientRect();
|
|
201
|
+
if (b.width === 0 || b.height === 0 || cx < b.left || cx > b.right || cy < b.top || cy > b.bottom) continue;
|
|
202
|
+
const label = (c.getAttribute("aria-label") || c.innerText || c.textContent || c.getAttribute("value") || "").trim().replace(/\\s+/g, " ").slice(0, 120);
|
|
203
|
+
if (label && centre.length < 10) centre.push(label);
|
|
204
|
+
const tid = c.getAttribute("data-testid");
|
|
205
|
+
if (tid && centre.length < 10) centre.push(tid);
|
|
206
|
+
}
|
|
207
|
+
return { ownText, centre };
|
|
208
|
+
}`;
|
|
141
209
|
/**
|
|
142
210
|
* Anything that plausibly presents as a modal/dialog panel. Deliberately wider
|
|
143
211
|
* than the ARIA set: a hand-rolled role-less modal must still count as "an
|
|
@@ -146,6 +214,63 @@ export const ACCESSIBLE_NAME_SRC = `(el) => (${NAME_SRC})(el).name`;
|
|
|
146
214
|
*/
|
|
147
215
|
export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
|
|
148
216
|
// Declared above the collector script because that script interpolates it.
|
|
217
|
+
/**
|
|
218
|
+
* The accessible name of a control that pages a clipping container: next and
|
|
219
|
+
* previous arrows, numbered slide or page buttons, scroll-left and
|
|
220
|
+
* scroll-right. Content clipped inside a container such a control sits beside
|
|
221
|
+
* is revealed by it, so it is not unreachable. The page-side walk carries a
|
|
222
|
+
* literal copy (no value is spliced into page code); oracle-test holds the two
|
|
223
|
+
* equal.
|
|
224
|
+
*/
|
|
225
|
+
export const PAGER_NAME = /^(?:[‹›«»<>←→⟨⟩❮❯]|(?:go to |show )?(?:next|previous|prev)(?:\s+\w+)?|(?:go to |show )?(?:slide|page|image|item|step)\s*\d+.*|scroll (?:left|right|up|down)\b.*)$/i;
|
|
226
|
+
/** Whether a control's name says it pages a container (see PAGER_NAME). */
|
|
227
|
+
export function isPagerName(name) {
|
|
228
|
+
return PAGER_NAME.test(name.trim());
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* An href that points at a place in this same document: "#main". Not a bare
|
|
232
|
+
* "#", and not a hash route ("#/reports", "#!/reports"), which is navigation.
|
|
233
|
+
* The collector carries a literal copy; oracle-test holds the two equal.
|
|
234
|
+
*/
|
|
235
|
+
export const IN_PAGE_ANCHOR = /^#(?![/!])./;
|
|
236
|
+
/**
|
|
237
|
+
* Style properties that can bring a box parked off the page back into it. A
|
|
238
|
+
* :focus rule that sets one of these is how a skip link is revealed; a :focus
|
|
239
|
+
* rule that only draws an outline is not. The collector carries a literal
|
|
240
|
+
* copy; oracle-test holds the two equal.
|
|
241
|
+
*/
|
|
242
|
+
export const FOCUS_MOVES_PROPS = [
|
|
243
|
+
"position",
|
|
244
|
+
"top",
|
|
245
|
+
"left",
|
|
246
|
+
"right",
|
|
247
|
+
"bottom",
|
|
248
|
+
"inset",
|
|
249
|
+
"transform",
|
|
250
|
+
"translate",
|
|
251
|
+
"clip",
|
|
252
|
+
"clip-path",
|
|
253
|
+
"margin",
|
|
254
|
+
"margin-top",
|
|
255
|
+
"margin-left",
|
|
256
|
+
"width",
|
|
257
|
+
"height",
|
|
258
|
+
];
|
|
259
|
+
/** The most elements a snapshot lists in full; past it, what is left is counted (cutSummary). */
|
|
260
|
+
export const COLLECTOR_CAP = 150;
|
|
261
|
+
/** Pager and "load more" controls listed past the cap, so a lane can still reach the rest of a long list. */
|
|
262
|
+
export const MAX_KEPT_PAST_CAP = 10;
|
|
263
|
+
/** How many elements past the cap are looked at, to keep them or count them, before the rest goes uncounted. */
|
|
264
|
+
export const MAX_SCANNED_PAST_CAP = 2000;
|
|
265
|
+
/**
|
|
266
|
+
* A control that pages a list or loads more of it, by its name: "Next",
|
|
267
|
+
* "Previous page", "Page 3", "»", "Load more", "Show 20 more", "More results".
|
|
268
|
+
* Narrower than PAGER_NAME, which also takes "Item 4" and "Step 2" for a
|
|
269
|
+
* carousel's slides: past the cap, those are the list itself.
|
|
270
|
+
*/
|
|
271
|
+
export const LIST_PAGER_NAME = /^(?:[‹›«»<>←→]|(?:go to )?(?:next|previous|prev|first|last)(?: page)?|(?:go to )?page \d+|(?:load|show|see|view) (?:\d+ )?more\b.*|more results)$/i;
|
|
272
|
+
/** A test id that names a pager or a "load more" control: "pager-next", "pagination", "load-more", "next-page". */
|
|
273
|
+
export const PAGER_TESTID = /(?:^|[-_.:])(?:pager|pagination|paginator|load-?more|show-?more|(?:next|prev|previous)-?page|page-?(?:next|prev|previous))(?:$|[-_.:])/i;
|
|
149
274
|
/**
|
|
150
275
|
* Page-side interactable collector. Shipped as a STRING, not a function:
|
|
151
276
|
* loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
|
|
@@ -156,6 +281,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
156
281
|
const xpathOf = ${XPATH_OF_SRC};
|
|
157
282
|
const visible = ${VISIBLE_SRC};
|
|
158
283
|
const nameOf = ${NAME_SRC};
|
|
284
|
+
const policyText = ${POLICY_TEXT_SRC};
|
|
159
285
|
// What the collector lists before anything else: controls, and anything the
|
|
160
286
|
// app tagged with a test id so it can be targeted.
|
|
161
287
|
const controlSelector =
|
|
@@ -173,18 +299,29 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
173
299
|
// Everything else listed is here for its test id or its text, and is neither
|
|
174
300
|
// an unnamed control nor a gap in coverage.
|
|
175
301
|
const CONTROL_ROLES = /^(button|link|tab|menuitem|menuitemcheckbox|menuitemradio|checkbox|radio|switch|combobox|textbox|searchbox|slider|spinbutton|option|treeitem)$/;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
302
|
+
// For an element that is neither a native control nor in a control role:
|
|
303
|
+
// what alone makes it actionable (affordanceFlags). Null for a control.
|
|
304
|
+
const affordancesOf = (el) => {
|
|
305
|
+
if (el.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"], [contenteditable=""]')) return null;
|
|
306
|
+
if (CONTROL_ROLES.test(el.getAttribute("role") || "")) return null;
|
|
179
307
|
const tabindex = el.getAttribute("tabindex");
|
|
180
|
-
|
|
181
|
-
|
|
308
|
+
const tabStop = tabindex !== null && tabindex.trim() !== "" && Number(tabindex) >= 0;
|
|
309
|
+
const clickHandler = el.hasAttribute("onclick") || typeof el.onclick === "function";
|
|
310
|
+
let pointer = false;
|
|
182
311
|
if (window.getComputedStyle(el).cursor === "pointer") {
|
|
183
312
|
const parent = el.parentElement;
|
|
184
|
-
|
|
313
|
+
pointer = !parent || window.getComputedStyle(parent).cursor !== "pointer";
|
|
185
314
|
}
|
|
186
|
-
return
|
|
315
|
+
return { tabStop, clickHandler, pointer };
|
|
187
316
|
};
|
|
317
|
+
const isInteractive = (aff) => aff === null || aff.tabStop || aff.clickHandler || aff.pointer;
|
|
318
|
+
// Past the element cap, a control that pages the list or loads more of it
|
|
319
|
+
// is still listed (up to MAX_KEPT_PAST_CAP), and the rest is only counted.
|
|
320
|
+
const LIST_PAGER = ${String(LIST_PAGER_NAME)};
|
|
321
|
+
const PAGER_TID = ${String(PAGER_TESTID)};
|
|
322
|
+
let keptPastCap = 0;
|
|
323
|
+
let scannedPastCap = 0;
|
|
324
|
+
const cut = [];
|
|
188
325
|
// A horizontally scrolling container a control sits outside of: the control
|
|
189
326
|
// is reachable, by a sideways scroll of that container, but nothing on screen
|
|
190
327
|
// shows it is there. Carousels (scroll-snap) page sideways by design and are
|
|
@@ -300,6 +437,39 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
300
437
|
const text = (node.innerText || node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
|
|
301
438
|
return "<" + node.tagName.toLowerCase() + ">" + (text ? ' "' + text + '"' : "");
|
|
302
439
|
};
|
|
440
|
+
/** A link to a place in this same document (a skip link's shape): "#main", never a bare "#" or a hash route ("#/reports"). */
|
|
441
|
+
const inPageAnchor = (node) => node.tagName === "A" && /^#(?![/!])./.test(node.getAttribute("href") || "");
|
|
442
|
+
// The page's :focus rules that move a box (FOCUS_MOVES_PROPS), as selectors
|
|
443
|
+
// with the :focus part removed: an element they match is shown when it takes
|
|
444
|
+
// focus. Read once, only when an element sits off the page.
|
|
445
|
+
let focusMoveSelectors = null;
|
|
446
|
+
const FOCUS_MOVES = ["position", "top", "left", "right", "bottom", "inset", "transform", "translate", "clip", "clip-path", "margin", "margin-top", "margin-left", "width", "height"];
|
|
447
|
+
const readFocusRules = (rules, out) => {
|
|
448
|
+
for (const rule of Array.from(rules || [])) {
|
|
449
|
+
if (out.length >= 500) return;
|
|
450
|
+
if (rule.cssRules && !rule.selectorText) { readFocusRules(rule.cssRules, out); continue; }
|
|
451
|
+
const sel = rule.selectorText || "";
|
|
452
|
+
if (!/:focus/.test(sel) || !rule.style) continue;
|
|
453
|
+
const props = Array.from(rule.style);
|
|
454
|
+
if (!props.some((p) => FOCUS_MOVES.indexOf(p) >= 0 || /^(inset|margin)-/.test(p))) continue;
|
|
455
|
+
for (const part of sel.split(",")) {
|
|
456
|
+
if (/:focus/.test(part)) out.push(part.replace(/:focus(-visible|-within)?/g, "").trim() || "*");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const focusMoves = (el) => {
|
|
461
|
+
if (focusMoveSelectors === null) {
|
|
462
|
+
focusMoveSelectors = [];
|
|
463
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
464
|
+
// Reading another origin's stylesheet throws by design; its rules stay unknown.
|
|
465
|
+
try { readFocusRules(sheet.cssRules, focusMoveSelectors); } catch (e) { continue; }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return focusMoveSelectors.some((sel) => {
|
|
469
|
+
// Removing :focus can leave an invalid selector (":not()"), which matches nothing.
|
|
470
|
+
try { return el.matches(sel); } catch (e) { return false; }
|
|
471
|
+
});
|
|
472
|
+
};
|
|
303
473
|
const coveredByPinnedChrome = (el, rect, role) => {
|
|
304
474
|
if (INTERACTIVE_ROLES.indexOf(role) === -1) return null;
|
|
305
475
|
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
@@ -308,6 +478,11 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
308
478
|
if (!ownRoot || insideScrollablePane(el)) return null;
|
|
309
479
|
const top = document.elementFromPoint(cx, cy);
|
|
310
480
|
if (!top || top === el || el.contains(top) || top.contains(el)) return null;
|
|
481
|
+
// A skip link is pinned out of sight until it takes focus, then shown over
|
|
482
|
+
// the header. While focused it covers whatever is under it, by design, and
|
|
483
|
+
// it leaves again as soon as focus moves on.
|
|
484
|
+
const active = document.activeElement;
|
|
485
|
+
if (active && inPageAnchor(active) && (top === active || active.contains(top))) return null;
|
|
311
486
|
const coverRoot = pinnedRootOf(top);
|
|
312
487
|
if (!coverRoot || coverRoot === ownRoot || coverRoot.contains(ownRoot) || ownRoot.contains(coverRoot)) return null;
|
|
313
488
|
// A dialog's fixed wrapper often carries no role or class itself; the
|
|
@@ -319,6 +494,63 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
319
494
|
return describe(coverRoot);
|
|
320
495
|
};
|
|
321
496
|
|
|
497
|
+
const PAGER = /^(?:[‹›«»<>←→⟨⟩❮❯]|(?:go to |show )?(?:next|previous|prev)(?:\\s+\\w+)?|(?:go to |show )?(?:slide|page|image|item|step)\\s*\\d+.*|scroll (?:left|right|up|down)\\b.*)$/i;
|
|
498
|
+
// Per container and axis: a carousel clips every slide but one, and they share the answer.
|
|
499
|
+
const pagedClips = new Map();
|
|
500
|
+
const revealedByControl = (clip, axis) => {
|
|
501
|
+
const key = pagedClips.get(clip) || {};
|
|
502
|
+
if (key[axis] === undefined) { key[axis] = pagesClip(clip, axis); pagedClips.set(clip, key); }
|
|
503
|
+
return key[axis];
|
|
504
|
+
};
|
|
505
|
+
// Content laid out as slides along the clipped axis: two or more children
|
|
506
|
+
// (of the clip, or of its single track) the clip's own size on that axis,
|
|
507
|
+
// at least one starting outside it. A table wider than its card, or a list
|
|
508
|
+
// longer than it, is not; a "Next page" in its footer pages rows, not them.
|
|
509
|
+
const slidesIn = (clip, axis) => {
|
|
510
|
+
const cr = clip.getBoundingClientRect();
|
|
511
|
+
const start = axis === "x" ? cr.left : cr.top;
|
|
512
|
+
const span = axis === "x" ? cr.width : cr.height;
|
|
513
|
+
const track = clip.children.length === 1 ? clip.children[0] : clip;
|
|
514
|
+
let size = 0, outside = 0;
|
|
515
|
+
for (const kid of Array.from(track.children).slice(0, 50)) {
|
|
516
|
+
const r = kid.getBoundingClientRect();
|
|
517
|
+
const s = axis === "x" ? r.width : r.height;
|
|
518
|
+
if (s < span * 0.9 || s > span * 1.1) continue;
|
|
519
|
+
size += 1;
|
|
520
|
+
const at = axis === "x" ? r.left : r.top;
|
|
521
|
+
if (at >= start + span - 1 || at + s <= start + 1) outside += 1;
|
|
522
|
+
}
|
|
523
|
+
return size >= 2 && outside >= 1;
|
|
524
|
+
};
|
|
525
|
+
/**
|
|
526
|
+
* Whether a control in or beside a clipping container pages it: a visible
|
|
527
|
+
* control within two levels above the container that names the container
|
|
528
|
+
* (or something in it) in aria-controls, or, when the container holds
|
|
529
|
+
* slides on the clipped axis, one whose name says next, previous, or a
|
|
530
|
+
* numbered slide or page.
|
|
531
|
+
*/
|
|
532
|
+
const pagesClip = (clip, axis) => {
|
|
533
|
+
let region = clip;
|
|
534
|
+
for (let i = 0; i < 2 && region.parentElement && region.parentElement !== document.body; i++) region = region.parentElement;
|
|
535
|
+
const cr = clip.getBoundingClientRect();
|
|
536
|
+
let slides = null;
|
|
537
|
+
const controls = region.querySelectorAll('button, [role="button"], a[href], [role="tab"], input[type="button"]');
|
|
538
|
+
for (const c of Array.from(controls).slice(0, 200)) {
|
|
539
|
+
if (!visible(c)) continue;
|
|
540
|
+
// A control inside the clip must itself be showing: a button on another
|
|
541
|
+
// hidden slide (the clipped element among them) pages nothing.
|
|
542
|
+
if (clip.contains(c)) {
|
|
543
|
+
const r = c.getBoundingClientRect();
|
|
544
|
+
if (r.bottom <= cr.top || r.top >= cr.bottom || r.right <= cr.left || r.left >= cr.right) continue;
|
|
545
|
+
}
|
|
546
|
+
const controlled = (c.getAttribute("aria-controls") || "").split(/\\s+/).filter(Boolean);
|
|
547
|
+
if (controlled.some((id) => { const t = document.getElementById(id); return t && (t === clip || clip.contains(t) || t.contains(clip)); })) return true;
|
|
548
|
+
const name = (c.getAttribute("aria-label") || c.getAttribute("title") || c.textContent || "").trim().replace(/\\s+/g, " ");
|
|
549
|
+
if (PAGER.test(name) && (slides === null ? (slides = slidesIn(clip, axis)) : slides)) return true;
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
};
|
|
553
|
+
|
|
322
554
|
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
323
555
|
if (seen.has(el) || !visible(el)) continue;
|
|
324
556
|
seen.add(el);
|
|
@@ -342,17 +574,30 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
342
574
|
: tag === "output" ? "status"
|
|
343
575
|
: el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off" ? (el.getAttribute("aria-live") === "assertive" ? "alert" : "status")
|
|
344
576
|
: "generic");
|
|
577
|
+
if (out.length >= ${COLLECTOR_CAP}) {
|
|
578
|
+
// Past the cap only a bounded number of elements is looked at at all.
|
|
579
|
+
if (++scannedPastCap > ${MAX_SCANNED_PAST_CAP}) { cut.push({ cut: true, uncounted: true }); break; }
|
|
580
|
+
const tid = el.getAttribute("data-testid");
|
|
581
|
+
const said = (el.getAttribute("aria-label") || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 60);
|
|
582
|
+
const pages = LIST_PAGER.test(said) || (tid !== null && PAGER_TID.test(tid));
|
|
583
|
+
if (!pages || keptPastCap >= ${MAX_KEPT_PAST_CAP}) { cut.push({ cut: true, role, testid: tid }); continue; }
|
|
584
|
+
keptPastCap += 1;
|
|
585
|
+
}
|
|
345
586
|
const rect = el.getBoundingClientRect();
|
|
587
|
+
const elStyle = window.getComputedStyle(el);
|
|
346
588
|
// Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
|
|
347
589
|
// ancestor is not — the container cannot scroll, so the control exists in
|
|
348
590
|
// layout but no user can ever see or reach it. Out-of-flow boxes are only
|
|
349
591
|
// clipped by their CONTAINING-BLOCK chain: position:fixed escapes ordinary
|
|
350
592
|
// ancestors entirely, and position:absolute skips static ones — a dropdown
|
|
351
593
|
// panel deliberately escaping its clipping wrapper is NOT unreachable.
|
|
352
|
-
|
|
594
|
+
// A scrolling ancestor reveals what it holds: past one, what the user can
|
|
595
|
+
// see is that scroller's box, so it is the scroller's box (not the
|
|
596
|
+
// element's) that the clipping ancestors above it are tested against.
|
|
353
597
|
let clippedByAncestor = false;
|
|
354
|
-
if (
|
|
355
|
-
let escaping =
|
|
598
|
+
if (elStyle.position !== "fixed") {
|
|
599
|
+
let escaping = elStyle.position === "absolute";
|
|
600
|
+
const box = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right };
|
|
356
601
|
let anc = el.parentElement;
|
|
357
602
|
while (anc && anc !== document.body && anc.tagName !== "HTML") {
|
|
358
603
|
const as = window.getComputedStyle(anc);
|
|
@@ -362,30 +607,59 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
362
607
|
escaping = false;
|
|
363
608
|
}
|
|
364
609
|
const oy = as.overflowY, ox = as.overflowX;
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
610
|
+
const ar = anc.getBoundingClientRect();
|
|
611
|
+
if (ar.width > 0 && ar.height > 0) {
|
|
612
|
+
let hidden = null;
|
|
613
|
+
let scrolls = false;
|
|
614
|
+
// A scroller reveals anything it holds on that axis. Not tested
|
|
615
|
+
// against its scroll range: reversed flex columns (chat logs) and
|
|
616
|
+
// right-to-left text scroll to negative offsets.
|
|
617
|
+
if (oy === "auto" || oy === "scroll") { box.top = ar.top; box.bottom = ar.bottom; scrolls = true; }
|
|
618
|
+
else if ((oy === "hidden" || oy === "clip") && (box.bottom <= ar.top || box.top >= ar.bottom)) hidden = "y";
|
|
619
|
+
if (ox === "auto" || ox === "scroll") { box.left = ar.left; box.right = ar.right; scrolls = true; }
|
|
620
|
+
else if ((ox === "hidden" || ox === "clip") && (box.right <= ar.left || box.left >= ar.right)) hidden = "x";
|
|
621
|
+
if (hidden) {
|
|
622
|
+
// A pager beside the container (next/previous, numbered slides, or
|
|
623
|
+
// a control naming it in aria-controls) reveals what it clips.
|
|
624
|
+
if (!revealedByControl(anc, hidden)) clippedByAncestor = true;
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
// Above a scroller, clipping follows the SCROLLER's containing chain.
|
|
628
|
+
if (scrolls) {
|
|
629
|
+
if (as.position === "fixed") break;
|
|
630
|
+
if (as.position === "absolute") escaping = true;
|
|
373
631
|
}
|
|
374
632
|
}
|
|
375
633
|
anc = anc.parentElement;
|
|
376
634
|
}
|
|
377
635
|
}
|
|
636
|
+
// Parked above or left of the page: can taking focus bring it back?
|
|
637
|
+
const docX = rect.x + window.scrollX, docY = rect.y + window.scrollY;
|
|
638
|
+
const offPage = docX + rect.width <= 0 || docY + rect.height <= 0;
|
|
639
|
+
const focusable = el.tabIndex >= 0 && el.disabled !== true;
|
|
378
640
|
const named = nameOf(el);
|
|
379
|
-
const
|
|
641
|
+
const affords = affordancesOf(el);
|
|
642
|
+
const interactive = isInteractive(affords);
|
|
380
643
|
const checkable = tag === "input" && (inputType === "checkbox" || inputType === "radio");
|
|
381
644
|
out.push({
|
|
382
645
|
coveredBy: coveredByPinnedChrome(el, rect, role),
|
|
646
|
+
focusable,
|
|
647
|
+
focusMoves: offPage && focusable ? focusMoves(el) : false,
|
|
648
|
+
// pointer-events:none lets every click through: it cannot take one meant for something else.
|
|
649
|
+
passThrough: elStyle.pointerEvents === "none",
|
|
650
|
+
// A text field's side padding, where an adornment (a clear button, an icon) is meant to sit.
|
|
651
|
+
fieldPad: tag === "textarea" || (tag === "input" && /^(textbox|combobox|searchbox|spinbutton)$/.test(role))
|
|
652
|
+
? { l: parseFloat(elStyle.paddingLeft) || 0, r: parseFloat(elStyle.paddingRight) || 0 }
|
|
653
|
+
: null,
|
|
383
654
|
tag,
|
|
384
655
|
role,
|
|
385
656
|
name: named.name,
|
|
657
|
+
...policyText(el),
|
|
386
658
|
nameFrom: named.from,
|
|
659
|
+
...(named.prior !== undefined ? { priorName: named.prior } : {}),
|
|
387
660
|
testid: el.getAttribute("data-testid"),
|
|
388
661
|
interactive,
|
|
662
|
+
affords: affords && (affords.tabStop || affords.clickHandler || affords.pointer) ? affords : null,
|
|
389
663
|
ariaHidden: el.closest('[aria-hidden="true"]') !== null,
|
|
390
664
|
liveOnly: !el.matches(controlSelector),
|
|
391
665
|
state: {
|
|
@@ -396,7 +670,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
396
670
|
current: el.getAttribute("aria-current"),
|
|
397
671
|
},
|
|
398
672
|
// Only controls: a tagged cell or row out of view is not something the user has to reach.
|
|
399
|
-
scrolledOutIn:
|
|
673
|
+
scrolledOutIn: elStyle.position === "fixed" || clippedByAncestor || !interactive ? null : scrolledOutIn(el, rect),
|
|
400
674
|
xpath: xpathOf(el),
|
|
401
675
|
disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
|
|
402
676
|
href: tag === "a" ? el.getAttribute("href") : null,
|
|
@@ -411,9 +685,8 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
411
685
|
h: Math.round(rect.height),
|
|
412
686
|
},
|
|
413
687
|
});
|
|
414
|
-
if (out.length >= 150) break;
|
|
415
688
|
}
|
|
416
|
-
return out;
|
|
689
|
+
return cut.length > 0 ? out.concat(cut) : out;
|
|
417
690
|
})()`;
|
|
418
691
|
/**
|
|
419
692
|
* Roles that announce content rather than take input: a live region is empty
|
|
@@ -508,6 +781,20 @@ export function placeholderOnly(el) {
|
|
|
508
781
|
export function describeControl(el) {
|
|
509
782
|
return `${el.role}${el.testid ? ` [testid=${el.testid}]` : ` at ${el.xpath}`}`;
|
|
510
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* What a forced click's note says about the element on top of its target:
|
|
786
|
+
* `covered by status "Could not save…" [testid=toast-error]`. With
|
|
787
|
+
* `afterBlock`, the write policy refused a request after the tester last
|
|
788
|
+
* looked at the page, so the covering element may be the app's response to
|
|
789
|
+
* the engine's own refusal (an error toast) and in no snapshot.
|
|
790
|
+
*/
|
|
791
|
+
export function describeCover(cover, afterBlock) {
|
|
792
|
+
const text = cover.text.replace(/\s+/g, " ").trim();
|
|
793
|
+
const shown = text.length > 60 ? `${text.slice(0, 59)}…` : text;
|
|
794
|
+
const who = cover.role ?? `<${cover.tag}>`;
|
|
795
|
+
return (`covered by ${who}${shown ? ` ${JSON.stringify(shown)}` : ""}${cover.testid ? ` [testid=${cover.testid}]` : ""}` +
|
|
796
|
+
(afterBlock ? " (after a write-policy block since the last snapshot: it may be the app's response to the engine's own refusal)" : ""));
|
|
797
|
+
}
|
|
511
798
|
/** A placeholder-only field's evidence: the control, then its placeholder, capped so a paragraph of hint text stays one line. */
|
|
512
799
|
export function placeholderEvidence(el) {
|
|
513
800
|
const text = el.name.length > 80 ? `${el.name.slice(0, 79)}…` : el.name;
|
|
@@ -527,6 +814,170 @@ export function displayName(el) {
|
|
|
527
814
|
return el.name;
|
|
528
815
|
return LIVE_REGION_ROLES.has(el.role) ? "(empty live region)" : "(unnamed)";
|
|
529
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* The snapshot's flags for an element listed without a control's role that a
|
|
819
|
+
* user can still act on: "clickable" for a click handler or its own pointer
|
|
820
|
+
* cursor, "focusable" for a tab stop. A row a click opens then reads apart
|
|
821
|
+
* from a wrapper listed only for its test id, which gets neither.
|
|
822
|
+
*/
|
|
823
|
+
export function affordanceFlags(el) {
|
|
824
|
+
const a = el.affords;
|
|
825
|
+
if (!a)
|
|
826
|
+
return [];
|
|
827
|
+
const flags = [];
|
|
828
|
+
if (a.clickHandler || a.pointer)
|
|
829
|
+
flags.push("clickable");
|
|
830
|
+
if (a.tabStop)
|
|
831
|
+
flags.push("focusable");
|
|
832
|
+
return flags;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* A test id as a family: a trailing number or id after a separator becomes
|
|
836
|
+
* "*", so "row-12" and "row-13" read as "row-*" while "pager-next" stays itself.
|
|
837
|
+
*/
|
|
838
|
+
export function testidFamily(testid) {
|
|
839
|
+
return testid.replace(/([-_.:])(?:\d+|[0-9a-f]{8}(?:-?[0-9a-f]{4}){3}-?[0-9a-f]{12}|[0-9a-f]{12,})$/i, "$1*");
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* What a truncated snapshot left out, by role and test-id family, largest
|
|
843
|
+
* group first: "38 link [row-*], 1 button [pager-next], 12 generic". At most
|
|
844
|
+
* `groups` groups are named; the rest is counted together. "and more past
|
|
845
|
+
* those, not counted" when the collector stopped looking.
|
|
846
|
+
*/
|
|
847
|
+
export function cutSummary(cut, groups = 8) {
|
|
848
|
+
const counts = new Map();
|
|
849
|
+
let uncounted = false;
|
|
850
|
+
for (const c of cut) {
|
|
851
|
+
if (c.uncounted) {
|
|
852
|
+
uncounted = true;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const label = `${c.role ?? "element"}${c.testid ? ` [${testidFamily(c.testid)}]` : ""}`;
|
|
856
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
857
|
+
}
|
|
858
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
859
|
+
const named = sorted.slice(0, groups).map(([label, n]) => `${n} ${label}`);
|
|
860
|
+
const rest = sorted.slice(groups).reduce((sum, [, n]) => sum + n, 0);
|
|
861
|
+
if (rest > 0)
|
|
862
|
+
named.push(`${rest} other`);
|
|
863
|
+
if (uncounted)
|
|
864
|
+
named.push("and more past those, not counted");
|
|
865
|
+
return named.join(", ");
|
|
866
|
+
}
|
|
867
|
+
/** A key without the ordinal the collector adds to the second and later elements sharing it ("tid:row~3" → "tid:row"). */
|
|
868
|
+
export function keyFamily(key) {
|
|
869
|
+
return key.replace(/~\d+$/, "");
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Which element of the previous snapshot each current element is, as that
|
|
873
|
+
* snapshot's key, or null for an element new since. Gives the ref an element
|
|
874
|
+
* keeps, and what the diff calls added, removed or changed.
|
|
875
|
+
*
|
|
876
|
+
* An element alone under its key is the one that held the key before, so its
|
|
877
|
+
* new text reads as a relabel. Elements sharing a key (rows tagged with one
|
|
878
|
+
* test id) are told apart by their own text and href, not their position: a
|
|
879
|
+
* filtered list then reads as the rows it lost and gained, where by position
|
|
880
|
+
* the first row "became" another record. A row that kept its link but changed
|
|
881
|
+
* its text is still matched by the link. `byPosition` keeps the position for
|
|
882
|
+
* elements whose text is meant to change, such as live regions.
|
|
883
|
+
*/
|
|
884
|
+
export function matchPrevious(prev, current) {
|
|
885
|
+
const prevByFamily = new Map();
|
|
886
|
+
for (const key of prev.keys()) {
|
|
887
|
+
const fam = keyFamily(key);
|
|
888
|
+
const list = prevByFamily.get(fam);
|
|
889
|
+
if (list)
|
|
890
|
+
list.push(key);
|
|
891
|
+
else
|
|
892
|
+
prevByFamily.set(fam, [key]);
|
|
893
|
+
}
|
|
894
|
+
const curByFamily = new Map();
|
|
895
|
+
current.forEach((el, i) => {
|
|
896
|
+
const fam = keyFamily(el.key);
|
|
897
|
+
const list = curByFamily.get(fam);
|
|
898
|
+
if (list)
|
|
899
|
+
list.push(i);
|
|
900
|
+
else
|
|
901
|
+
curByFamily.set(fam, [i]);
|
|
902
|
+
});
|
|
903
|
+
const out = current.map(() => null);
|
|
904
|
+
for (const [fam, idxs] of curByFamily) {
|
|
905
|
+
const prevKeys = prevByFamily.get(fam) ?? [];
|
|
906
|
+
const single = prevKeys.length <= 1 && idxs.length <= 1;
|
|
907
|
+
if (single || idxs.some((i) => current[i].byPosition)) {
|
|
908
|
+
for (const i of idxs)
|
|
909
|
+
out[i] = prev.has(current[i].key) ? current[i].key : null;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const free = new Set(prevKeys);
|
|
913
|
+
const take = (i, same) => {
|
|
914
|
+
for (const k of free) {
|
|
915
|
+
if (same(prev.get(k))) {
|
|
916
|
+
out[i] = k;
|
|
917
|
+
free.delete(k);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
for (const i of idxs)
|
|
923
|
+
take(i, (p) => p.label === current[i].name && (p.href ?? null) === (current[i].href ?? null));
|
|
924
|
+
for (const i of idxs)
|
|
925
|
+
if (out[i] === null && current[i].href)
|
|
926
|
+
take(i, (p) => !!p.href && p.href === current[i].href);
|
|
927
|
+
}
|
|
928
|
+
return out;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Whether a box parked off the page comes back when it takes focus: a skip
|
|
932
|
+
* link. Either a link into this same document (the skip-link shape), or any
|
|
933
|
+
* focusable element a :focus rule moves. It must be reachable by Tab, or
|
|
934
|
+
* nothing ever brings it back.
|
|
935
|
+
*/
|
|
936
|
+
export function revealedOnFocus(el) {
|
|
937
|
+
if (!el.focusable)
|
|
938
|
+
return false;
|
|
939
|
+
return IN_PAGE_ANCHOR.test(el.href ?? "") || el.focusMoves === true;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Whether `other` is an adornment of the text field `field`: a clear button or
|
|
943
|
+
* icon lying wholly inside the padding the field reserves at its left or right
|
|
944
|
+
* edge. Text never runs under that padding, so nothing collides. An element
|
|
945
|
+
* that reaches into the text area is a real collision and is not an adornment.
|
|
946
|
+
*/
|
|
947
|
+
export function isFieldAdornment(field, other) {
|
|
948
|
+
const pad = field.fieldPad;
|
|
949
|
+
if (!pad)
|
|
950
|
+
return false;
|
|
951
|
+
const f = field.rect;
|
|
952
|
+
const r = other.rect;
|
|
953
|
+
const t = 4; // borders, and rounding to whole pixels
|
|
954
|
+
if (r.y < f.y - t || r.y + r.h > f.y + f.h + t)
|
|
955
|
+
return false;
|
|
956
|
+
const inLeft = pad.l > 0 && r.x >= f.x - t && r.x + r.w <= f.x + pad.l + t;
|
|
957
|
+
const inRight = pad.r > 0 && r.x >= f.x + f.w - pad.r - t && r.x + r.w <= f.x + f.w + t;
|
|
958
|
+
return inLeft || inRight;
|
|
959
|
+
}
|
|
960
|
+
/** Roles of the controls a click is aimed at; anything else with pointer-events:none is decoration. */
|
|
961
|
+
const CONTROL_ROLES = new Set([
|
|
962
|
+
"button",
|
|
963
|
+
"link",
|
|
964
|
+
"textbox",
|
|
965
|
+
"combobox",
|
|
966
|
+
"checkbox",
|
|
967
|
+
"radio",
|
|
968
|
+
"switch",
|
|
969
|
+
"tab",
|
|
970
|
+
"menuitem",
|
|
971
|
+
"file",
|
|
972
|
+
"searchbox",
|
|
973
|
+
"spinbutton",
|
|
974
|
+
"slider",
|
|
975
|
+
"option",
|
|
976
|
+
]);
|
|
977
|
+
/** Whether `overlay` is a decorative layer over `other` that lets clicks through to it. */
|
|
978
|
+
export function isPassThroughOverlay(overlay, other) {
|
|
979
|
+
return overlay.passThrough === true && !CONTROL_ROLES.has(overlay.role) && overlay.rect.w * overlay.rect.h >= other.rect.w * other.rect.h;
|
|
980
|
+
}
|
|
530
981
|
/**
|
|
531
982
|
* Deterministic geometry oracles — the checks people reach for screenshots to
|
|
532
983
|
* do, computed from layout boxes instead: interactables rendered fully outside
|
|
@@ -539,8 +990,9 @@ export function geometryIssues(elements, viewport) {
|
|
|
539
990
|
const { x, y, w, h } = el.rect;
|
|
540
991
|
// Rects are DOCUMENT coords: below-the-fold content is normal; unreachable
|
|
541
992
|
// means left/above the document origin, or absurdly far right (no page
|
|
542
|
-
// scrolls 3 viewports horizontally on purpose).
|
|
543
|
-
|
|
993
|
+
// scrolls 3 viewports horizontally on purpose). A skip link parked there
|
|
994
|
+
// until it takes focus is the accessibility pattern working.
|
|
995
|
+
if (w > 0 && h > 0 && (x + w <= 0 || y + h <= 0 || x >= viewport.width * 3) && !revealedOnFocus(el)) {
|
|
544
996
|
issues.push(`${el.ref} ${el.role} "${el.name}" is rendered outside the reachable page area (${x},${y} ${w}×${h})`);
|
|
545
997
|
}
|
|
546
998
|
// Distinct from below-the-fold: this control's own container hides it and
|
|
@@ -608,6 +1060,15 @@ export function geometryIssues(elements, viewport) {
|
|
|
608
1060
|
// collision bugs that matter live in the content flow, not the chrome.
|
|
609
1061
|
if (a.chrome && b.chrome)
|
|
610
1062
|
continue;
|
|
1063
|
+
// An overlay with pointer-events:none cannot take a click meant for what
|
|
1064
|
+
// it lies over. Only a non-control at least as large as the other counts:
|
|
1065
|
+
// design systems also set it on disabled buttons, and a disabled button
|
|
1066
|
+
// drawn over another is still a collision.
|
|
1067
|
+
if (isPassThroughOverlay(a, b) || isPassThroughOverlay(b, a))
|
|
1068
|
+
continue;
|
|
1069
|
+
// A clear button or icon inside a text field's reserved padding.
|
|
1070
|
+
if (isFieldAdornment(a, b) || isFieldAdornment(b, a))
|
|
1071
|
+
continue;
|
|
611
1072
|
const area = overlapArea(a.rect, b.rect);
|
|
612
1073
|
const smaller = Math.min(a.rect.w * a.rect.h, b.rect.w * b.rect.h);
|
|
613
1074
|
if (smaller > 0 && area / smaller > 0.6) {
|
|
@@ -667,7 +1128,7 @@ export const MAIN_REGION_SCRIPT = `(() => {
|
|
|
667
1128
|
const landmarks = Array.from(document.querySelectorAll('main, [role="main"]')).filter(shown);
|
|
668
1129
|
const landmark = landmarks[0] || null;
|
|
669
1130
|
const region = landmark || document.body;
|
|
670
|
-
if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0 };
|
|
1131
|
+
if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0, states: [], rest: 0, text: "" };
|
|
671
1132
|
const CHROME = 'header, nav, footer, aside, [role="banner"], [role="navigation"], [role="contentinfo"], [role="complementary"]';
|
|
672
1133
|
const outside = (n) => !landmark && !!n.closest(CHROME);
|
|
673
1134
|
const CONTROL = 'a[href], button, input, select, textarea, option, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"]';
|
|
@@ -677,24 +1138,94 @@ export const MAIN_REGION_SCRIPT = `(() => {
|
|
|
677
1138
|
const level = h ? (/^H[1-6]$/.test(h.tagName) ? Number(h.tagName[1]) : Number(h.getAttribute("aria-level") || 2)) : 0;
|
|
678
1139
|
const heading = h ? { level, text: (h.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 60) } : null;
|
|
679
1140
|
const paragraphs = inRegion("p").filter((n) => (n.textContent || "").trim()).length;
|
|
1141
|
+
// Status regions: an alert, a live status, a busy or progress marker. What lies outside
|
|
1142
|
+
// them is the page's own content; a main area with none is showing only its state.
|
|
1143
|
+
const STATE = '[role="alert"], [role="status"], [aria-busy="true"], [role="progressbar"]';
|
|
1144
|
+
const stateOf = (n) => {
|
|
1145
|
+
const s = n.closest(STATE);
|
|
1146
|
+
return s && (s === region || region.contains(s)) ? s : null;
|
|
1147
|
+
};
|
|
1148
|
+
const states = [];
|
|
1149
|
+
for (const s of [region, ...inRegion(STATE)]) {
|
|
1150
|
+
if (!s.matches(STATE)) continue;
|
|
1151
|
+
const role = s.getAttribute("role");
|
|
1152
|
+
const kind = role === "alert" || role === "status" || role === "progressbar" ? role : "busy";
|
|
1153
|
+
if (!states.includes(kind)) states.push(kind);
|
|
1154
|
+
if (s.getAttribute("aria-busy") === "true" && !states.includes("busy")) states.push("busy");
|
|
1155
|
+
}
|
|
680
1156
|
let chars = 0;
|
|
1157
|
+
let rest = 0;
|
|
1158
|
+
let text = "";
|
|
681
1159
|
let seen = 0;
|
|
682
1160
|
const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
|
|
683
1161
|
for (let t = walker.nextNode(); t && seen < 5000; t = walker.nextNode()) {
|
|
684
1162
|
seen += 1;
|
|
685
1163
|
const parent = t.parentElement;
|
|
686
1164
|
if (!parent || parent.closest('script, style, noscript, template, [aria-hidden="true"]') || parent.closest(CONTROL) || outside(parent) || !shown(parent)) continue;
|
|
687
|
-
|
|
1165
|
+
const words = (t.textContent || "").replace(/\\s+/g, " ").trim();
|
|
1166
|
+
chars += words.length;
|
|
1167
|
+
if (!stateOf(parent)) rest += words.length;
|
|
1168
|
+
if (words && text.length < 160) text = (text ? text + " " + words : words).slice(0, 160);
|
|
688
1169
|
}
|
|
1170
|
+
const controls = inRegion(CONTROL);
|
|
1171
|
+
const media = inRegion("img, svg, video, canvas, iframe, object, embed");
|
|
1172
|
+
rest += [...headings, ...controls, ...media].filter((n) => !stateOf(n)).length;
|
|
689
1173
|
return {
|
|
690
1174
|
landmark: !!landmark,
|
|
691
1175
|
heading,
|
|
692
1176
|
paragraphs,
|
|
693
1177
|
chars,
|
|
694
|
-
controls:
|
|
695
|
-
media:
|
|
1178
|
+
controls: controls.length,
|
|
1179
|
+
media: media.length,
|
|
1180
|
+
states,
|
|
1181
|
+
rest,
|
|
1182
|
+
text,
|
|
696
1183
|
};
|
|
697
1184
|
})()`;
|
|
1185
|
+
/**
|
|
1186
|
+
* Whether text says only that something is on its way, e.g. "Loading…",
|
|
1187
|
+
* "Loading orders...", "Please wait", once or repeated. Each sentence must
|
|
1188
|
+
* open with the loading words and stay short.
|
|
1189
|
+
*/
|
|
1190
|
+
function isLoadingText(text) {
|
|
1191
|
+
const parts = text
|
|
1192
|
+
.split(/\.{1,3}|…/)
|
|
1193
|
+
.map((p) => p.trim())
|
|
1194
|
+
.filter(Boolean);
|
|
1195
|
+
return parts.length > 0 && parts.every((p) => /^(?:loading|please wait)\b[^!?]{0,30}$/i.test(p));
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* What a main area holding nothing but its state is showing, or null when it
|
|
1199
|
+
* holds content of its own. A client-rendered app answers 200 for a path it
|
|
1200
|
+
* does not have and draws its not-found or error view; a route still waiting
|
|
1201
|
+
* on data after the page settled shows only a placeholder. Both read as a
|
|
1202
|
+
* healthy page to a status code and an element count.
|
|
1203
|
+
*
|
|
1204
|
+
* - `error`: everything in the area is inside a `role="alert"` region that says something.
|
|
1205
|
+
* - `loading`: everything is inside an `aria-busy="true"` region or a
|
|
1206
|
+
* progress bar, or inside a live status whose only words are a loading
|
|
1207
|
+
* message or that holds only a spinner, or (with no such marker at all)
|
|
1208
|
+
* the area's only words are a loading message.
|
|
1209
|
+
*
|
|
1210
|
+
* A live status saying anything else ("No orders yet") is an empty state,
|
|
1211
|
+
* which is content, and so is an alert beside a heading or a control.
|
|
1212
|
+
*/
|
|
1213
|
+
export function mainState(m) {
|
|
1214
|
+
const states = m.states ?? [];
|
|
1215
|
+
const text = (m.text ?? "").trim();
|
|
1216
|
+
if (states.length === 0) {
|
|
1217
|
+
return !m.heading && m.controls === 0 && m.media === 0 && isLoadingText(text) ? "loading" : null;
|
|
1218
|
+
}
|
|
1219
|
+
if ((m.rest ?? 1) > 0)
|
|
1220
|
+
return null;
|
|
1221
|
+
// An alert with nothing in it is a live region waiting for a message, not a message.
|
|
1222
|
+
if (states.includes("alert") && text !== "")
|
|
1223
|
+
return "error";
|
|
1224
|
+
if (states.includes("busy") || states.includes("progressbar"))
|
|
1225
|
+
return "loading";
|
|
1226
|
+
// A spinner inside a status, or a status saying it is loading; an empty one alone is just an empty main area.
|
|
1227
|
+
return states.includes("status") && (isLoadingText(text) || (text === "" && m.media > 0)) ? "loading" : null;
|
|
1228
|
+
}
|
|
698
1229
|
/** Nothing at all in the region: no heading, no text, no control, no image or embed. */
|
|
699
1230
|
function mainIsEmpty(m) {
|
|
700
1231
|
return !m.heading && m.chars === 0 && m.controls === 0 && m.media === 0;
|