scenescout 3.14.1 → 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 +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- 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 +198 -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 +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- 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/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- 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 +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
package/dist/engine/collector.js
CHANGED
|
@@ -39,45 +39,172 @@ export const XPATH_OF_SRC = `(el) => {
|
|
|
39
39
|
}
|
|
40
40
|
return "/html/" + parts.join("/");
|
|
41
41
|
}`;
|
|
42
|
+
/**
|
|
43
|
+
* What an element's accessible name is read from, as page-side source: every
|
|
44
|
+
* candidate source, read from the DOM and nothing decided. PICK_NAME_SRC
|
|
45
|
+
* decides, so the order can be table-tested without a browser.
|
|
46
|
+
*
|
|
47
|
+
* A label's text is read without the control's own text: a wrapping label
|
|
48
|
+
* holds the control, and a select's options or a textarea's contents are not
|
|
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.
|
|
55
|
+
*/
|
|
56
|
+
export const NAME_FACTS_SRC = `(el) => {
|
|
57
|
+
const tag = el.tagName.toLowerCase();
|
|
58
|
+
const role = el.getAttribute("role") || "";
|
|
59
|
+
const inputType = tag === "input" ? el.type : "";
|
|
60
|
+
const byId = (id) => { const n = document.getElementById(id); return n ? n.textContent || "" : ""; };
|
|
61
|
+
const labels = [];
|
|
62
|
+
for (const label of Array.from(el.labels || [])) {
|
|
63
|
+
const own = label.contains(el) ? el.textContent || "" : "";
|
|
64
|
+
labels.push((label.textContent || "").replace(own, ""));
|
|
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
|
+
}
|
|
77
|
+
const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
|
|
78
|
+
(el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
|
|
79
|
+
return {
|
|
80
|
+
tag,
|
|
81
|
+
inputType,
|
|
82
|
+
labelledBy: (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean).map(byId).join(" "),
|
|
83
|
+
ariaLabel: el.getAttribute("aria-label"),
|
|
84
|
+
labels,
|
|
85
|
+
title: el.getAttribute("title") || "",
|
|
86
|
+
placeholder: el.getAttribute("placeholder") || "",
|
|
87
|
+
nameAttr: el.getAttribute("name") || "",
|
|
88
|
+
// Read only for button-like inputs: a text field's value is what the user typed, not its name.
|
|
89
|
+
value: inputType === "submit" || inputType === "reset" || inputType === "button" ? el.value || "" : "",
|
|
90
|
+
alt: el.getAttribute("alt") || "",
|
|
91
|
+
live,
|
|
92
|
+
text: el.innerText || el.textContent || "",
|
|
93
|
+
content,
|
|
94
|
+
};
|
|
95
|
+
}`;
|
|
96
|
+
/**
|
|
97
|
+
* The accessible name chosen from NAME_FACTS_SRC's facts, as page-side source,
|
|
98
|
+
* in the order of the accessible-name computation: aria-labelledby,
|
|
99
|
+
* aria-label, then the host language's label (an image's alt; a <label>,
|
|
100
|
+
* for= or wrapping; a button-like input's value), then title, then the
|
|
101
|
+
* placeholder. Returns the name and, for a field, where it came from when that
|
|
102
|
+
* is not a label (`from`, see NameFrom): the name falls back to the
|
|
103
|
+
* placeholder, the name attribute or the type so every field can be told apart
|
|
104
|
+
* and targeted, and the name is half of the element's coverage key, so the
|
|
105
|
+
* fallback stays.
|
|
106
|
+
*
|
|
107
|
+
* - A select is never named by its options: their text is its value, not its name.
|
|
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).
|
|
117
|
+
* - A live region (status, alert, log, timer, an aria-live region, <output>)
|
|
118
|
+
* is named by the text it announces, which is what matters after an action.
|
|
119
|
+
* - A whitespace-only aria-label ends the name with nothing, as it always has
|
|
120
|
+
* here: the field it hides is reported as unnamed.
|
|
121
|
+
*/
|
|
122
|
+
export const PICK_NAME_SRC = `(f) => {
|
|
123
|
+
const clean = (s) => (s || "").replace(/\\s+/g, " ").trim();
|
|
124
|
+
const named = (s) => ({ name: s.slice(0, 80), from: null });
|
|
125
|
+
const field = f.tag === "input" || f.tag === "textarea" || f.tag === "select";
|
|
126
|
+
if (clean(f.labelledBy)) return named(clean(f.labelledBy));
|
|
127
|
+
if (f.ariaLabel) {
|
|
128
|
+
if (clean(f.ariaLabel)) return named(clean(f.ariaLabel));
|
|
129
|
+
return { name: "", from: field ? "fallback" : null };
|
|
130
|
+
}
|
|
131
|
+
if (f.tag === "img") return named(clean(f.alt));
|
|
132
|
+
if (f.live && clean(f.text)) return named(clean(f.text));
|
|
133
|
+
const labels = f.labels.map(clean).filter(Boolean).join(" ");
|
|
134
|
+
if (labels) return named(labels);
|
|
135
|
+
if (field) {
|
|
136
|
+
const t = f.inputType;
|
|
137
|
+
if (t === "submit" || t === "reset" || t === "button") {
|
|
138
|
+
if (clean(f.value)) return named(clean(f.value));
|
|
139
|
+
// The browser's own text: what a user sees on the button.
|
|
140
|
+
if (t !== "button") return named(t === "submit" ? "Submit" : "Reset");
|
|
141
|
+
}
|
|
142
|
+
if (t === "image" && clean(f.alt)) return named(clean(f.alt));
|
|
143
|
+
if (clean(f.title)) return named(clean(f.title));
|
|
144
|
+
if (f.tag !== "select" && clean(f.placeholder)) return { name: clean(f.placeholder).slice(0, 80), from: "placeholder" };
|
|
145
|
+
return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
|
|
146
|
+
}
|
|
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) };
|
|
149
|
+
return named(clean(f.title));
|
|
150
|
+
}`;
|
|
151
|
+
/**
|
|
152
|
+
* PICK_NAME_SRC run outside a page, from the same source the page runs, so a
|
|
153
|
+
* table test exercises exactly the rule the collector ships.
|
|
154
|
+
*/
|
|
155
|
+
export function pickName(facts) {
|
|
156
|
+
return new Function(`return (${PICK_NAME_SRC});`)()(facts);
|
|
157
|
+
}
|
|
158
|
+
/** The element's name and where a field's came from, as page-side source. */
|
|
159
|
+
export const NAME_SRC = `(el) => (${PICK_NAME_SRC})((${NAME_FACTS_SRC})(el))`;
|
|
42
160
|
/**
|
|
43
161
|
* The collector's accessible name, as page-side source. Exported for the same
|
|
44
162
|
* reason as XPATH_OF_SRC: a page-side probe that names an element (forms.ts)
|
|
45
163
|
* must name it exactly as the snapshot did, or the two cannot be compared.
|
|
46
164
|
*/
|
|
47
|
-
export const ACCESSIBLE_NAME_SRC = `(el) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
65
189
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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);
|
|
78
206
|
}
|
|
79
|
-
|
|
80
|
-
return text.trim().replace(/\\s+/g, " ").slice(0, 80);
|
|
207
|
+
return { ownText, centre };
|
|
81
208
|
}`;
|
|
82
209
|
/**
|
|
83
210
|
* Anything that plausibly presents as a modal/dialog panel. Deliberately wider
|
|
@@ -87,6 +214,63 @@ export const ACCESSIBLE_NAME_SRC = `(el) => {
|
|
|
87
214
|
*/
|
|
88
215
|
export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
|
|
89
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;
|
|
90
274
|
/**
|
|
91
275
|
* Page-side interactable collector. Shipped as a STRING, not a function:
|
|
92
276
|
* loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
|
|
@@ -96,37 +280,69 @@ export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[op
|
|
|
96
280
|
export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
97
281
|
const xpathOf = ${XPATH_OF_SRC};
|
|
98
282
|
const visible = ${VISIBLE_SRC};
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
// This says what the fallback hides. A real label is aria-label,
|
|
105
|
-
// aria-labelledby, a <label> (for= or wrapping, read through el.labels),
|
|
106
|
-
// title, or for a button-like input its value or the browser's default text.
|
|
107
|
-
// "placeholder" when the placeholder is all there is, "fallback" when not even
|
|
108
|
-
// that, null when the field is labelled or is not a field.
|
|
109
|
-
const nameFrom = (el) => {
|
|
110
|
-
const tag = el.tagName.toLowerCase();
|
|
111
|
-
if (tag !== "input" && tag !== "textarea") return null;
|
|
112
|
-
if ((el.getAttribute("aria-label") || "").trim()) return null;
|
|
113
|
-
const labelledBy = (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean);
|
|
114
|
-
if (labelledBy.some((id) => { const n = document.getElementById(id); return n && (n.textContent || "").trim(); })) return null;
|
|
115
|
-
for (const label of Array.from(el.labels || [])) {
|
|
116
|
-
// A wrapping label's text includes a textarea's own content; that is not a label.
|
|
117
|
-
const own = label.contains(el) ? el.textContent || "" : "";
|
|
118
|
-
if ((label.textContent || "").replace(own, "").trim()) return null;
|
|
119
|
-
}
|
|
120
|
-
if ((el.getAttribute("title") || "").trim()) return null;
|
|
121
|
-
const type = tag === "input" ? el.type : "";
|
|
122
|
-
if (type === "submit" || type === "reset") return null;
|
|
123
|
-
if (type === "button" && (el.value || "").trim()) return null;
|
|
124
|
-
if (type === "image" && (el.getAttribute("alt") || "").trim()) return null;
|
|
125
|
-
return (el.getAttribute("placeholder") || "").trim() ? "placeholder" : "fallback";
|
|
126
|
-
};
|
|
127
|
-
const selector =
|
|
283
|
+
const nameOf = ${NAME_SRC};
|
|
284
|
+
const policyText = ${POLICY_TEXT_SRC};
|
|
285
|
+
// What the collector lists before anything else: controls, and anything the
|
|
286
|
+
// app tagged with a test id so it can be targeted.
|
|
287
|
+
const controlSelector =
|
|
128
288
|
'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], ' +
|
|
129
289
|
'[role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"], [onclick], [data-testid]';
|
|
290
|
+
// Live regions: what a page announces after an action (an error banner, a
|
|
291
|
+
// "Saved" status). Listed whether or not the app tagged them, because what
|
|
292
|
+
// the user is told must not depend on a test id. Those listed only for this
|
|
293
|
+
// are marked liveOnly: they are read, not acted on.
|
|
294
|
+
const liveSelector = '[role="alert"], [role="alertdialog"], [role="status"], [aria-live]:not([aria-live="off"]), output';
|
|
295
|
+
const selector = controlSelector + ", " + liveSelector;
|
|
296
|
+
// Whether a user can act on the element: a native control, an interactive
|
|
297
|
+
// role, a tab stop, a click handler, or a pointer cursor it sets itself (a
|
|
298
|
+
// cursor inherited from a clickable card is the card's, not its badge's).
|
|
299
|
+
// Everything else listed is here for its test id or its text, and is neither
|
|
300
|
+
// an unnamed control nor a gap in coverage.
|
|
301
|
+
const CONTROL_ROLES = /^(button|link|tab|menuitem|menuitemcheckbox|menuitemradio|checkbox|radio|switch|combobox|textbox|searchbox|slider|spinbutton|option|treeitem)$/;
|
|
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;
|
|
307
|
+
const tabindex = el.getAttribute("tabindex");
|
|
308
|
+
const tabStop = tabindex !== null && tabindex.trim() !== "" && Number(tabindex) >= 0;
|
|
309
|
+
const clickHandler = el.hasAttribute("onclick") || typeof el.onclick === "function";
|
|
310
|
+
let pointer = false;
|
|
311
|
+
if (window.getComputedStyle(el).cursor === "pointer") {
|
|
312
|
+
const parent = el.parentElement;
|
|
313
|
+
pointer = !parent || window.getComputedStyle(parent).cursor !== "pointer";
|
|
314
|
+
}
|
|
315
|
+
return { tabStop, clickHandler, pointer };
|
|
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 = [];
|
|
325
|
+
// A horizontally scrolling container a control sits outside of: the control
|
|
326
|
+
// is reachable, by a sideways scroll of that container, but nothing on screen
|
|
327
|
+
// shows it is there. Carousels (scroll-snap) page sideways by design and are
|
|
328
|
+
// left out. Returns how to name the container, or null.
|
|
329
|
+
const scrolledOutIn = (el, rect) => {
|
|
330
|
+
for (let anc = el.parentElement; anc && anc !== document.body && anc !== document.documentElement; anc = anc.parentElement) {
|
|
331
|
+
const as = window.getComputedStyle(anc);
|
|
332
|
+
if ((as.overflowX === "auto" || as.overflowX === "scroll") && anc.scrollWidth > anc.clientWidth + 1) {
|
|
333
|
+
if (as.scrollSnapType && as.scrollSnapType !== "none") return null;
|
|
334
|
+
const left = anc.getBoundingClientRect().left + anc.clientLeft;
|
|
335
|
+
const cx = rect.left + rect.width / 2;
|
|
336
|
+
if (cx >= left && cx <= left + anc.clientWidth) return null;
|
|
337
|
+
const tid = anc.getAttribute("data-testid");
|
|
338
|
+
if (tid) return "[" + tid + "]";
|
|
339
|
+
const label = (anc.getAttribute("aria-label") || "").trim().slice(0, 40);
|
|
340
|
+
return "<" + anc.tagName.toLowerCase() + (anc.id ? "#" + anc.id : "") + ">" + (label ? ' "' + label + '"' : "");
|
|
341
|
+
}
|
|
342
|
+
if (as.position === "fixed") return null;
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
};
|
|
130
346
|
const seen = new Set();
|
|
131
347
|
const out = [];
|
|
132
348
|
// Positioning/scroll LAYER + CHROME classification, for the overlap oracle.
|
|
@@ -221,6 +437,39 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
221
437
|
const text = (node.innerText || node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
|
|
222
438
|
return "<" + node.tagName.toLowerCase() + ">" + (text ? ' "' + text + '"' : "");
|
|
223
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
|
+
};
|
|
224
473
|
const coveredByPinnedChrome = (el, rect, role) => {
|
|
225
474
|
if (INTERACTIVE_ROLES.indexOf(role) === -1) return null;
|
|
226
475
|
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
@@ -229,6 +478,11 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
229
478
|
if (!ownRoot || insideScrollablePane(el)) return null;
|
|
230
479
|
const top = document.elementFromPoint(cx, cy);
|
|
231
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;
|
|
232
486
|
const coverRoot = pinnedRootOf(top);
|
|
233
487
|
if (!coverRoot || coverRoot === ownRoot || coverRoot.contains(ownRoot) || ownRoot.contains(coverRoot)) return null;
|
|
234
488
|
// A dialog's fixed wrapper often carries no role or class itself; the
|
|
@@ -240,6 +494,63 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
240
494
|
return describe(coverRoot);
|
|
241
495
|
};
|
|
242
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
|
+
|
|
243
554
|
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
244
555
|
if (seen.has(el) || !visible(el)) continue;
|
|
245
556
|
seen.add(el);
|
|
@@ -258,18 +569,35 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
258
569
|
: inputType === "file" ? "file"
|
|
259
570
|
: "textbox")
|
|
260
571
|
: tag === "img" ? "image"
|
|
572
|
+
// An aria-live region without a role announces like one: assertive
|
|
573
|
+
// interrupts as an alert does, polite waits as a status does.
|
|
574
|
+
: tag === "output" ? "status"
|
|
575
|
+
: el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off" ? (el.getAttribute("aria-live") === "assertive" ? "alert" : "status")
|
|
261
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
|
+
}
|
|
262
586
|
const rect = el.getBoundingClientRect();
|
|
587
|
+
const elStyle = window.getComputedStyle(el);
|
|
263
588
|
// Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
|
|
264
589
|
// ancestor is not — the container cannot scroll, so the control exists in
|
|
265
590
|
// layout but no user can ever see or reach it. Out-of-flow boxes are only
|
|
266
591
|
// clipped by their CONTAINING-BLOCK chain: position:fixed escapes ordinary
|
|
267
592
|
// ancestors entirely, and position:absolute skips static ones — a dropdown
|
|
268
593
|
// panel deliberately escaping its clipping wrapper is NOT unreachable.
|
|
269
|
-
|
|
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.
|
|
270
597
|
let clippedByAncestor = false;
|
|
271
|
-
if (
|
|
272
|
-
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 };
|
|
273
601
|
let anc = el.parentElement;
|
|
274
602
|
while (anc && anc !== document.body && anc.tagName !== "HTML") {
|
|
275
603
|
const as = window.getComputedStyle(anc);
|
|
@@ -279,26 +607,70 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
279
607
|
escaping = false;
|
|
280
608
|
}
|
|
281
609
|
const oy = as.overflowY, ox = as.overflowX;
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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;
|
|
290
631
|
}
|
|
291
632
|
}
|
|
292
633
|
anc = anc.parentElement;
|
|
293
634
|
}
|
|
294
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;
|
|
640
|
+
const named = nameOf(el);
|
|
641
|
+
const affords = affordancesOf(el);
|
|
642
|
+
const interactive = isInteractive(affords);
|
|
643
|
+
const checkable = tag === "input" && (inputType === "checkbox" || inputType === "radio");
|
|
295
644
|
out.push({
|
|
296
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,
|
|
297
654
|
tag,
|
|
298
655
|
role,
|
|
299
|
-
name:
|
|
300
|
-
|
|
656
|
+
name: named.name,
|
|
657
|
+
...policyText(el),
|
|
658
|
+
nameFrom: named.from,
|
|
659
|
+
...(named.prior !== undefined ? { priorName: named.prior } : {}),
|
|
301
660
|
testid: el.getAttribute("data-testid"),
|
|
661
|
+
interactive,
|
|
662
|
+
affords: affords && (affords.tabStop || affords.clickHandler || affords.pointer) ? affords : null,
|
|
663
|
+
ariaHidden: el.closest('[aria-hidden="true"]') !== null,
|
|
664
|
+
liveOnly: !el.matches(controlSelector),
|
|
665
|
+
state: {
|
|
666
|
+
pressed: el.getAttribute("aria-pressed"),
|
|
667
|
+
selected: el.getAttribute("aria-selected"),
|
|
668
|
+
checked: checkable ? String(el.checked) : el.getAttribute("aria-checked"),
|
|
669
|
+
expanded: el.getAttribute("aria-expanded"),
|
|
670
|
+
current: el.getAttribute("aria-current"),
|
|
671
|
+
},
|
|
672
|
+
// Only controls: a tagged cell or row out of view is not something the user has to reach.
|
|
673
|
+
scrolledOutIn: elStyle.position === "fixed" || clippedByAncestor || !interactive ? null : scrolledOutIn(el, rect),
|
|
302
674
|
xpath: xpathOf(el),
|
|
303
675
|
disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
|
|
304
676
|
href: tag === "a" ? el.getAttribute("href") : null,
|
|
@@ -313,9 +685,8 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
313
685
|
h: Math.round(rect.height),
|
|
314
686
|
},
|
|
315
687
|
});
|
|
316
|
-
if (out.length >= 150) break;
|
|
317
688
|
}
|
|
318
|
-
return out;
|
|
689
|
+
return cut.length > 0 ? out.concat(cut) : out;
|
|
319
690
|
})()`;
|
|
320
691
|
/**
|
|
321
692
|
* Roles that announce content rather than take input: a live region is empty
|
|
@@ -331,10 +702,70 @@ const LIVE_REGION_ROLES = new Set(["status", "alert", "log", "timer", "marquee"]
|
|
|
331
702
|
* targeted, but nothing announces them.
|
|
332
703
|
*/
|
|
333
704
|
export function missingName(el) {
|
|
705
|
+
// Listed for its test id or its text, not a control: a decorative badge, a
|
|
706
|
+
// page wrapper. Absent on elements collected before the collector said.
|
|
707
|
+
if (el.interactive === false)
|
|
708
|
+
return false;
|
|
709
|
+
// Hidden from assistive technology on purpose: nothing announces it, so it needs no name.
|
|
710
|
+
if (el.ariaHidden)
|
|
711
|
+
return false;
|
|
334
712
|
if (el.nameFrom === "fallback")
|
|
335
713
|
return true;
|
|
336
714
|
return !el.name && !LIVE_REGION_ROLES.has(el.role);
|
|
337
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* The snapshot's state markers: which filter is active, which tab is chosen,
|
|
718
|
+
* which box is ticked, which section is open. Only states that are on are
|
|
719
|
+
* shown, so a page of plain buttons stays as short as it was.
|
|
720
|
+
*/
|
|
721
|
+
export function stateFlags(state) {
|
|
722
|
+
if (!state)
|
|
723
|
+
return [];
|
|
724
|
+
const flags = [];
|
|
725
|
+
if (state.pressed === "true")
|
|
726
|
+
flags.push("pressed");
|
|
727
|
+
else if (state.pressed === "mixed")
|
|
728
|
+
flags.push("partly pressed");
|
|
729
|
+
if (state.selected === "true")
|
|
730
|
+
flags.push("selected");
|
|
731
|
+
if (state.checked === "true")
|
|
732
|
+
flags.push("checked");
|
|
733
|
+
else if (state.checked === "mixed")
|
|
734
|
+
flags.push("partly checked");
|
|
735
|
+
if (state.expanded === "true")
|
|
736
|
+
flags.push("expanded");
|
|
737
|
+
if (state.current && state.current !== "false")
|
|
738
|
+
flags.push("current");
|
|
739
|
+
return flags;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* How the snapshot diff says an element's state moved: what it gained, then
|
|
743
|
+
* what it lost. Null when the state is the same.
|
|
744
|
+
*/
|
|
745
|
+
export function stateChange(was, now) {
|
|
746
|
+
const gained = now.filter((f) => !was.includes(f));
|
|
747
|
+
const lost = was.filter((f) => !now.includes(f));
|
|
748
|
+
if (gained.length === 0 && lost.length === 0)
|
|
749
|
+
return null;
|
|
750
|
+
return [gained.length ? `now [${gained.join(", ")}]` : null, lost.length ? `no longer [${lost.join(", ")}]` : null].filter(Boolean).join(", ");
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* The elements a state's identity and coverage are kept over: all but the
|
|
754
|
+
* live regions listed only for what they say. Their text changes with every
|
|
755
|
+
* message, so in a fingerprint they would make each message a new state, and
|
|
756
|
+
* nothing in them can be exercised.
|
|
757
|
+
*/
|
|
758
|
+
export function trackedElements(elements) {
|
|
759
|
+
return elements.filter((el) => !el.liveOnly);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Keys of tracked elements a user cannot act on: listed for their test id (a
|
|
763
|
+
* wrapper, a heading, a badge). Coverage leaves them out of what there is to
|
|
764
|
+
* exercise, or the denominator counts page structure as untested controls.
|
|
765
|
+
*/
|
|
766
|
+
export function inertKeys(elements) {
|
|
767
|
+
return elements.filter((el) => !el.liveOnly && el.interactive === false).map((el) => el.key);
|
|
768
|
+
}
|
|
338
769
|
/**
|
|
339
770
|
* Whether a field's only label is its placeholder. Kept apart from missingName
|
|
340
771
|
* because browsers do announce a placeholder when nothing else names the
|
|
@@ -350,6 +781,20 @@ export function placeholderOnly(el) {
|
|
|
350
781
|
export function describeControl(el) {
|
|
351
782
|
return `${el.role}${el.testid ? ` [testid=${el.testid}]` : ` at ${el.xpath}`}`;
|
|
352
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
|
+
}
|
|
353
798
|
/** A placeholder-only field's evidence: the control, then its placeholder, capped so a paragraph of hint text stays one line. */
|
|
354
799
|
export function placeholderEvidence(el) {
|
|
355
800
|
const text = el.name.length > 80 ? `${el.name.slice(0, 79)}…` : el.name;
|
|
@@ -369,6 +814,170 @@ export function displayName(el) {
|
|
|
369
814
|
return el.name;
|
|
370
815
|
return LIVE_REGION_ROLES.has(el.role) ? "(empty live region)" : "(unnamed)";
|
|
371
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
|
+
}
|
|
372
981
|
/**
|
|
373
982
|
* Deterministic geometry oracles — the checks people reach for screenshots to
|
|
374
983
|
* do, computed from layout boxes instead: interactables rendered fully outside
|
|
@@ -381,8 +990,9 @@ export function geometryIssues(elements, viewport) {
|
|
|
381
990
|
const { x, y, w, h } = el.rect;
|
|
382
991
|
// Rects are DOCUMENT coords: below-the-fold content is normal; unreachable
|
|
383
992
|
// means left/above the document origin, or absurdly far right (no page
|
|
384
|
-
// scrolls 3 viewports horizontally on purpose).
|
|
385
|
-
|
|
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)) {
|
|
386
996
|
issues.push(`${el.ref} ${el.role} "${el.name}" is rendered outside the reachable page area (${x},${y} ${w}×${h})`);
|
|
387
997
|
}
|
|
388
998
|
// Distinct from below-the-fold: this control's own container hides it and
|
|
@@ -414,6 +1024,20 @@ export function geometryIssues(elements, viewport) {
|
|
|
414
1024
|
}
|
|
415
1025
|
if (coveredTotal > 3)
|
|
416
1026
|
issues.push(`…and ${coveredTotal - 3} more pinned controls covered by other pinned chrome`);
|
|
1027
|
+
// Controls a horizontally scrolling container holds outside its visible
|
|
1028
|
+
// width (the last column of a wide table): reachable by a sideways scroll,
|
|
1029
|
+
// but nothing on screen says they are there. One line per container, since
|
|
1030
|
+
// a table repeats its row actions; a layout choice, so worded as one.
|
|
1031
|
+
const sideways = new Map();
|
|
1032
|
+
for (const el of elements) {
|
|
1033
|
+
if (el.scrolledOutIn && el.rect.w > 0 && el.rect.h > 0)
|
|
1034
|
+
sideways.set(el.scrolledOutIn, (sideways.get(el.scrolledOutIn) ?? 0) + 1);
|
|
1035
|
+
}
|
|
1036
|
+
for (const [container, n] of [...sideways].slice(0, 2)) {
|
|
1037
|
+
issues.push(`${n} control${n === 1 ? " is" : "s are"} scrolled out of view inside a horizontally scrolling container ${container} — only a sideways scroll of it shows ${n === 1 ? "it" : "them"}; worth a look at this width, not necessarily a defect`);
|
|
1038
|
+
}
|
|
1039
|
+
if (sideways.size > 2)
|
|
1040
|
+
issues.push(`…and ${sideways.size - 2} more horizontally scrolling containers holding controls out of view`);
|
|
417
1041
|
const overlapArea = (a, b) => {
|
|
418
1042
|
const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
419
1043
|
const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
@@ -436,6 +1060,15 @@ export function geometryIssues(elements, viewport) {
|
|
|
436
1060
|
// collision bugs that matter live in the content flow, not the chrome.
|
|
437
1061
|
if (a.chrome && b.chrome)
|
|
438
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;
|
|
439
1072
|
const area = overlapArea(a.rect, b.rect);
|
|
440
1073
|
const smaller = Math.min(a.rect.w * a.rect.h, b.rect.w * b.rect.h);
|
|
441
1074
|
if (smaller > 0 && area / smaller > 0.6) {
|
|
@@ -478,6 +1111,146 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
|
|
|
478
1111
|
}
|
|
479
1112
|
return { images, total };
|
|
480
1113
|
})()`;
|
|
1114
|
+
/**
|
|
1115
|
+
* What the page's main region holds besides controls, read in the page: its
|
|
1116
|
+
* first heading, its paragraphs and how much static text it has. A main area
|
|
1117
|
+
* with a heading and an empty-state sentence lists no controls, and without
|
|
1118
|
+
* this it reads exactly like a main area that rendered nothing.
|
|
1119
|
+
*
|
|
1120
|
+
* The region is the `<main>` or `[role=main]` landmark; without one, the body
|
|
1121
|
+
* less its banner, navigation, complementary and footer regions. Static text
|
|
1122
|
+
* is visible text outside controls (links, buttons, fields, options), so a
|
|
1123
|
+
* page of nothing but a nav does not count as having content. The text walk
|
|
1124
|
+
* stops after 5000 text nodes.
|
|
1125
|
+
*/
|
|
1126
|
+
export const MAIN_REGION_SCRIPT = `(() => {
|
|
1127
|
+
const shown = (n) => !!n && (n.offsetWidth > 0 || n.offsetHeight > 0 || n.getClientRects().length > 0) && window.getComputedStyle(n).visibility !== "hidden";
|
|
1128
|
+
const landmarks = Array.from(document.querySelectorAll('main, [role="main"]')).filter(shown);
|
|
1129
|
+
const landmark = landmarks[0] || null;
|
|
1130
|
+
const region = landmark || document.body;
|
|
1131
|
+
if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0, states: [], rest: 0, text: "" };
|
|
1132
|
+
const CHROME = 'header, nav, footer, aside, [role="banner"], [role="navigation"], [role="contentinfo"], [role="complementary"]';
|
|
1133
|
+
const outside = (n) => !landmark && !!n.closest(CHROME);
|
|
1134
|
+
const CONTROL = 'a[href], button, input, select, textarea, option, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"]';
|
|
1135
|
+
const inRegion = (sel) => Array.from(region.querySelectorAll(sel)).filter((n) => shown(n) && !outside(n));
|
|
1136
|
+
const headings = inRegion('h1, h2, h3, h4, h5, h6, [role="heading"]').filter((n) => (n.textContent || "").trim());
|
|
1137
|
+
const h = headings.find((n) => n.tagName === "H1") || headings[0] || null;
|
|
1138
|
+
const level = h ? (/^H[1-6]$/.test(h.tagName) ? Number(h.tagName[1]) : Number(h.getAttribute("aria-level") || 2)) : 0;
|
|
1139
|
+
const heading = h ? { level, text: (h.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 60) } : null;
|
|
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
|
+
}
|
|
1156
|
+
let chars = 0;
|
|
1157
|
+
let rest = 0;
|
|
1158
|
+
let text = "";
|
|
1159
|
+
let seen = 0;
|
|
1160
|
+
const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
|
|
1161
|
+
for (let t = walker.nextNode(); t && seen < 5000; t = walker.nextNode()) {
|
|
1162
|
+
seen += 1;
|
|
1163
|
+
const parent = t.parentElement;
|
|
1164
|
+
if (!parent || parent.closest('script, style, noscript, template, [aria-hidden="true"]') || parent.closest(CONTROL) || outside(parent) || !shown(parent)) continue;
|
|
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);
|
|
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;
|
|
1173
|
+
return {
|
|
1174
|
+
landmark: !!landmark,
|
|
1175
|
+
heading,
|
|
1176
|
+
paragraphs,
|
|
1177
|
+
chars,
|
|
1178
|
+
controls: controls.length,
|
|
1179
|
+
media: media.length,
|
|
1180
|
+
states,
|
|
1181
|
+
rest,
|
|
1182
|
+
text,
|
|
1183
|
+
};
|
|
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
|
+
}
|
|
1229
|
+
/** Nothing at all in the region: no heading, no text, no control, no image or embed. */
|
|
1230
|
+
function mainIsEmpty(m) {
|
|
1231
|
+
return !m.heading && m.chars === 0 && m.controls === 0 && m.media === 0;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The snapshot's one line on the main region, e.g.
|
|
1235
|
+
* `main: h1 "Orders" · 2 paragraphs · 180 chars of static text`, or
|
|
1236
|
+
* `main: EMPTY`. Named `content` when the page has no main landmark.
|
|
1237
|
+
*/
|
|
1238
|
+
export function mainRegionLine(m) {
|
|
1239
|
+
const where = m.landmark ? "main" : "content (no main landmark)";
|
|
1240
|
+
if (mainIsEmpty(m))
|
|
1241
|
+
return `${where}: EMPTY`;
|
|
1242
|
+
const parts = [
|
|
1243
|
+
m.heading ? `h${m.heading.level} "${m.heading.text}"` : null,
|
|
1244
|
+
m.paragraphs > 0 ? `${m.paragraphs} paragraph${m.paragraphs === 1 ? "" : "s"}` : null,
|
|
1245
|
+
m.chars > 0 ? `${m.chars} chars of static text` : "no static text",
|
|
1246
|
+
].filter(Boolean);
|
|
1247
|
+
return `${where}: ${parts.join(" · ")}`;
|
|
1248
|
+
}
|
|
1249
|
+
/** The same in a crawl line's few words: `main 180 chars`, or `main EMPTY`. */
|
|
1250
|
+
export function mainRegionTag(m) {
|
|
1251
|
+
const where = m.landmark ? "main" : "content";
|
|
1252
|
+
return mainIsEmpty(m) ? `${where} EMPTY` : `${where} ${m.chars} chars`;
|
|
1253
|
+
}
|
|
481
1254
|
/**
|
|
482
1255
|
* An element's coverage key inside a frame: the frame's origin (another site)
|
|
483
1256
|
* or path (the app's own) before the element's own key, so a "Submit" in an
|