@setsquare/review-sdk 0.0.1 → 0.1.1
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/NOTICE +3 -2
- package/README.md +287 -3
- package/dist/autoscan.d.ts +23 -1
- package/dist/autoscan.d.ts.map +1 -1
- package/dist/autoscan.js +220 -15
- package/dist/autoscan.js.map +1 -1
- package/dist/brand.d.ts +51 -2
- package/dist/brand.d.ts.map +1 -1
- package/dist/brand.js +560 -5
- package/dist/brand.js.map +1 -1
- package/dist/checks/context-change.d.ts +96 -0
- package/dist/checks/context-change.d.ts.map +1 -0
- package/dist/checks/context-change.js +655 -0
- package/dist/checks/context-change.js.map +1 -0
- package/dist/checks/focus.d.ts +154 -0
- package/dist/checks/focus.d.ts.map +1 -0
- package/dist/checks/focus.js +1123 -0
- package/dist/checks/focus.js.map +1 -0
- package/dist/checks/heuristics.d.ts +117 -0
- package/dist/checks/heuristics.d.ts.map +1 -0
- package/dist/checks/heuristics.js +961 -0
- package/dist/checks/heuristics.js.map +1 -0
- package/dist/checks/keyboard.d.ts +106 -0
- package/dist/checks/keyboard.d.ts.map +1 -0
- package/dist/checks/keyboard.js +452 -0
- package/dist/checks/keyboard.js.map +1 -0
- package/dist/checks/reflow.d.ts +75 -0
- package/dist/checks/reflow.d.ts.map +1 -0
- package/dist/checks/reflow.js +285 -0
- package/dist/checks/reflow.js.map +1 -0
- package/dist/checks/rule-pack.d.ts +1 -1
- package/dist/checks/rule-pack.d.ts.map +1 -1
- package/dist/checks/rule-pack.js +350 -45
- package/dist/checks/rule-pack.js.map +1 -1
- package/dist/checks/shortcuts.d.ts +71 -0
- package/dist/checks/shortcuts.d.ts.map +1 -0
- package/dist/checks/shortcuts.js +196 -0
- package/dist/checks/shortcuts.js.map +1 -0
- package/dist/checks/versions.d.ts.map +1 -1
- package/dist/checks/versions.js +33 -1
- package/dist/checks/versions.js.map +1 -1
- package/dist/contract.d.ts +32 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/covers.d.ts +56 -0
- package/dist/covers.d.ts.map +1 -0
- package/dist/covers.js +135 -0
- package/dist/covers.js.map +1 -0
- package/dist/dedupe.d.ts +23 -0
- package/dist/dedupe.d.ts.map +1 -1
- package/dist/dedupe.js +96 -2
- package/dist/dedupe.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +144 -0
- package/dist/document.js.map +1 -1
- package/dist/envelope.d.ts +8 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +106 -16
- package/dist/envelope.js.map +1 -1
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +33 -4
- package/dist/fixture.js.map +1 -1
- package/dist/html-report.d.ts +24 -7
- package/dist/html-report.d.ts.map +1 -1
- package/dist/html-report.js +608 -136
- package/dist/html-report.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts.map +1 -1
- package/dist/insights.js +9 -12
- package/dist/insights.js.map +1 -1
- package/dist/links.d.ts +43 -0
- package/dist/links.d.ts.map +1 -1
- package/dist/links.js +43 -0
- package/dist/links.js.map +1 -1
- package/dist/observe.d.ts +8 -0
- package/dist/observe.d.ts.map +1 -1
- package/dist/observe.js +45 -1
- package/dist/observe.js.map +1 -1
- package/dist/options.d.ts +53 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +3 -0
- package/dist/options.js.map +1 -1
- package/dist/reading-level.d.ts +22 -1
- package/dist/reading-level.d.ts.map +1 -1
- package/dist/reading-level.js +122 -19
- package/dist/reading-level.js.map +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +31 -15
- package/dist/reporter.js.map +1 -1
- package/dist/scan.d.ts +36 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +16 -2
- package/dist/scan.js.map +1 -1
- package/dist/screens.d.ts +55 -4
- package/dist/screens.d.ts.map +1 -1
- package/dist/screens.js +243 -43
- package/dist/screens.js.map +1 -1
- package/dist/settle.d.ts +35 -0
- package/dist/settle.d.ts.map +1 -1
- package/dist/settle.js +43 -2
- package/dist/settle.js.map +1 -1
- package/dist/summary.d.ts +35 -0
- package/dist/summary.d.ts.map +1 -1
- package/dist/summary.js +248 -28
- package/dist/summary.js.map +1 -1
- package/dist/unscannable-reasons.d.ts +30 -0
- package/dist/unscannable-reasons.d.ts.map +1 -0
- package/dist/unscannable-reasons.js +53 -0
- package/dist/unscannable-reasons.js.map +1 -0
- package/dist/viewports.d.ts +105 -0
- package/dist/viewports.d.ts.map +1 -0
- package/dist/viewports.js +134 -0
- package/dist/viewports.js.map +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { ON_FOCUS_HELP_URL, ON_INPUT_HELP_URL } from '../links.js';
|
|
2
|
+
import { sanitiseSelectors } from '../sanitise.js';
|
|
3
|
+
/**
|
|
4
|
+
* WCAG 3.2.1 On Focus (A) and 3.2.2 On Input (A): does touching a control
|
|
5
|
+
* change the context without being asked to?
|
|
6
|
+
*
|
|
7
|
+
* "Change of context" is normatively defined — user agent, viewport, focus, or
|
|
8
|
+
* content that changes the meaning of the page — and the first three are
|
|
9
|
+
* observable. The fourth is a judgement, so a large DOM mutation is
|
|
10
|
+
* needs-review rather than a violation.
|
|
11
|
+
*
|
|
12
|
+
* ---------------------------------------------------------------------------
|
|
13
|
+
* **The two halves have very different side effects, so they are gated
|
|
14
|
+
* differently. That is the decision this check exists around.**
|
|
15
|
+
*
|
|
16
|
+
* **3.2.1 is on by default.** It needs nothing the teardown focus walk does not
|
|
17
|
+
* already do — focusing every focusable element — so it adds no new class of
|
|
18
|
+
* side effect to a run that is already focusing them.
|
|
19
|
+
*
|
|
20
|
+
* **3.2.2 is opt-in, off by default** (`onInput: true`). It has to CHANGE a
|
|
21
|
+
* control's value, and a change event is where an application saves. A
|
|
22
|
+
* customer's page at teardown can be an authenticated application whose select
|
|
23
|
+
* has an autosave handler behind it, and mutating their data is a worse failure
|
|
24
|
+
* than any finding this rule could produce — worse, in particular, than being a
|
|
25
|
+
* flake, because a flake is visible and a silent write is not. So it is a
|
|
26
|
+
* choice somebody makes, and while it is off the criterion reports as "a check
|
|
27
|
+
* that did not run here", which is exactly what it is.
|
|
28
|
+
*
|
|
29
|
+
* **Text fields are never typed into, at any setting.** Only `<select>`,
|
|
30
|
+
* checkboxes and radios are touched, and their value is put back. Typing into a
|
|
31
|
+
* text field is a different risk class again: validation requests, autosave,
|
|
32
|
+
* character counters, and a half-filled form left behind.
|
|
33
|
+
* ---------------------------------------------------------------------------
|
|
34
|
+
*/
|
|
35
|
+
export const SETSQUARE_CONTEXT_CHECK_VERSION = 'setsquare-context@1';
|
|
36
|
+
export const ON_FOCUS_RULE_ID = 'setsquare-on-focus';
|
|
37
|
+
export const ON_INPUT_RULE_ID = 'setsquare-on-input';
|
|
38
|
+
/** As in the focus walk: a ceiling so a large page cannot cost a scan. */
|
|
39
|
+
const MAX_ELEMENTS = 120;
|
|
40
|
+
const MAX_CONTROLS = 40;
|
|
41
|
+
/**
|
|
42
|
+
* A DOM mutation big enough to be worth a person's attention.
|
|
43
|
+
*
|
|
44
|
+
* Not a violation at any size: "content that changes the meaning of the page"
|
|
45
|
+
* is the normative fourth kind of context change, and meaning is exactly what
|
|
46
|
+
* a node count cannot see. Twenty nodes is a threshold for asking, not for
|
|
47
|
+
* asserting.
|
|
48
|
+
*/
|
|
49
|
+
const LARGE_MUTATION_NODES = 20;
|
|
50
|
+
/**
|
|
51
|
+
* How long to wait after changing a control before deciding nothing happened.
|
|
52
|
+
*
|
|
53
|
+
* A navigation is not synchronous with the `change` event that caused it: the
|
|
54
|
+
* request is issued after the handler returns. Without this pause the request
|
|
55
|
+
* from one control arrived while the NEXT one was being measured, and the
|
|
56
|
+
* finding named the wrong element — `select#jump`'s navigation was reported
|
|
57
|
+
* against the checkbox below it. Fifty milliseconds, times at most forty
|
|
58
|
+
* controls, on an opt-in check.
|
|
59
|
+
*/
|
|
60
|
+
const CHANGE_SETTLE_MS = 50;
|
|
61
|
+
/* ------------------------------------------------------------------ 3.2.1 */
|
|
62
|
+
/**
|
|
63
|
+
* Focus every focusable element and watch for an unrequested change of context.
|
|
64
|
+
*
|
|
65
|
+
* Runs entirely in the page, in one evaluate, because a round trip per element
|
|
66
|
+
* over a hundred and twenty of them at the end of every test is a cost nobody
|
|
67
|
+
* agreed to. That bounds what it can see, and the boundary is stated in the
|
|
68
|
+
* finding rather than papered over: `window.open` and `location.assign` /
|
|
69
|
+
* `location.replace` are patched and recorded, but `location.href = …` is a
|
|
70
|
+
* native setter on a non-configurable object and cannot be intercepted. A page
|
|
71
|
+
* that navigates that way on focus destroys the execution context, which
|
|
72
|
+
* surfaces as no evidence at all rather than as a pass.
|
|
73
|
+
*/
|
|
74
|
+
export async function runOnFocus(page) {
|
|
75
|
+
try {
|
|
76
|
+
const walk = await page.evaluate(focusContextWalk, {
|
|
77
|
+
maxElements: MAX_ELEMENTS,
|
|
78
|
+
largeMutation: LARGE_MUTATION_NODES,
|
|
79
|
+
});
|
|
80
|
+
return onFocusEvidence(walk);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function focusContextWalk({ maxElements, largeMutation, }) {
|
|
87
|
+
const step = (el) => {
|
|
88
|
+
const tag = el.tagName.toLowerCase();
|
|
89
|
+
if (el.id)
|
|
90
|
+
return `${tag}#${el.id}`;
|
|
91
|
+
if (el.classList.length > 0)
|
|
92
|
+
return `${tag}.${el.classList[0]}`;
|
|
93
|
+
const parent = el.parentElement;
|
|
94
|
+
if (!parent)
|
|
95
|
+
return tag;
|
|
96
|
+
return `${tag}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`;
|
|
97
|
+
};
|
|
98
|
+
const describe = (el) => {
|
|
99
|
+
const parts = [];
|
|
100
|
+
let node = el;
|
|
101
|
+
for (let depth = 0; node && depth < 3; depth += 1) {
|
|
102
|
+
parts.unshift(step(node));
|
|
103
|
+
if (node.id || node.classList.length > 0)
|
|
104
|
+
break;
|
|
105
|
+
node = node.parentElement;
|
|
106
|
+
if (!node || node === document.body)
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
return parts.join(' > ');
|
|
110
|
+
};
|
|
111
|
+
const changes = [];
|
|
112
|
+
/*
|
|
113
|
+
* Patched, not merely watched. A check that let a page open a window or
|
|
114
|
+
* navigate in order to observe it doing so would have destroyed the run to
|
|
115
|
+
* report on it — and `window.open` in particular leaves a real window behind
|
|
116
|
+
* for the rest of the session.
|
|
117
|
+
*/
|
|
118
|
+
let opened;
|
|
119
|
+
let navigated;
|
|
120
|
+
const realOpen = window.open;
|
|
121
|
+
const realAssign = window.location.assign;
|
|
122
|
+
const realReplace = window.location.replace;
|
|
123
|
+
window.open = ((url) => {
|
|
124
|
+
opened = String(url ?? '');
|
|
125
|
+
return null;
|
|
126
|
+
});
|
|
127
|
+
window.location.assign = (url) => {
|
|
128
|
+
navigated = String(url);
|
|
129
|
+
};
|
|
130
|
+
window.location.replace = (url) => {
|
|
131
|
+
navigated = String(url);
|
|
132
|
+
};
|
|
133
|
+
let mutated = 0;
|
|
134
|
+
const observer = new MutationObserver((records) => {
|
|
135
|
+
for (const record of records) {
|
|
136
|
+
mutated += record.addedNodes.length + record.removedNodes.length;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
140
|
+
const FOCUSABLE = 'a[href],button,input,select,textarea,summary,[tabindex],[contenteditable="true"]';
|
|
141
|
+
const candidates = Array.prototype.slice
|
|
142
|
+
.call(document.querySelectorAll(FOCUSABLE))
|
|
143
|
+
.filter((el) => {
|
|
144
|
+
if (el.hasAttribute('disabled'))
|
|
145
|
+
return false;
|
|
146
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
147
|
+
return false;
|
|
148
|
+
if (el.tabIndex < 0)
|
|
149
|
+
return false;
|
|
150
|
+
/*
|
|
151
|
+
* A CONTROL IN A SKIPPED OR INERT SUBTREE CANNOT BE FOCUSED, AND ITS OWN
|
|
152
|
+
* COMPUTED STYLE WILL NOT SAY SO.
|
|
153
|
+
*
|
|
154
|
+
* The case that made this necessary is the collapsed disclosure panel
|
|
155
|
+
* every component library ships. React Aria's renders as
|
|
156
|
+
* `hidden="until-found"`, which the UA styles with
|
|
157
|
+
* `content-visibility: hidden` rather than `display: none` — so the panel
|
|
158
|
+
* is in the layout, its descendants report a stale 67x44 box from the
|
|
159
|
+
* last time they were laid out, `display` and `visibility` on the link
|
|
160
|
+
* itself both read as perfectly ordinary, and the browser refuses focus
|
|
161
|
+
* anyway because the contents are skipped. Every check below this line
|
|
162
|
+
* passes and the element is still not focusable.
|
|
163
|
+
*
|
|
164
|
+
* `checkVisibility` is the only thing that answers this without walking
|
|
165
|
+
* every ancestor's computed style, and it is guarded because a customer's
|
|
166
|
+
* browser may predate it — the walk is still correct without it, thanks
|
|
167
|
+
* to the unchanged-focus guard below, just wasteful.
|
|
168
|
+
*/
|
|
169
|
+
const cannotBeSeen = typeof el.checkVisibility === 'function' &&
|
|
170
|
+
!el.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true });
|
|
171
|
+
if (cannotBeSeen)
|
|
172
|
+
return false;
|
|
173
|
+
if (el.closest('[inert]'))
|
|
174
|
+
return false;
|
|
175
|
+
const cs = window.getComputedStyle(el);
|
|
176
|
+
if (cs.visibility === 'hidden' || cs.display === 'none')
|
|
177
|
+
return false;
|
|
178
|
+
const rect = el.getBoundingClientRect();
|
|
179
|
+
return rect.width > 0 && rect.height > 0;
|
|
180
|
+
});
|
|
181
|
+
let checked = 0;
|
|
182
|
+
for (const el of candidates.slice(0, maxElements)) {
|
|
183
|
+
opened = undefined;
|
|
184
|
+
navigated = undefined;
|
|
185
|
+
mutated = 0;
|
|
186
|
+
const focusedBefore = document.activeElement;
|
|
187
|
+
el.focus();
|
|
188
|
+
if (document.activeElement !== el) {
|
|
189
|
+
const landed = document.activeElement;
|
|
190
|
+
/*
|
|
191
|
+
* FOCUS THAT NEVER MOVED IS NOT FOCUS THAT MOVED SOMEWHERE.
|
|
192
|
+
*
|
|
193
|
+
* If the active element is the same one it was before we called
|
|
194
|
+
* `focus()`, the element refused focus and the browser left the previous
|
|
195
|
+
* control exactly where it was. The walk then reads its OWN last step as
|
|
196
|
+
* this step's outcome — "focusing X moved focus to Y", where Y is
|
|
197
|
+
* whatever the walk happened to focus a moment earlier.
|
|
198
|
+
*
|
|
199
|
+
* The body guard below was written for this and does not cover it,
|
|
200
|
+
* because the previous element is only the body on the first iteration.
|
|
201
|
+
* Every iteration after that inherits a real control.
|
|
202
|
+
*
|
|
203
|
+
* Found by scanning this repository's own marketing site at 768 and 390
|
|
204
|
+
* CSS pixels: below the nav's breakpoint the collapsed disclosure panel
|
|
205
|
+
* holds five controls that are laid out and unfocusable, and every one of
|
|
206
|
+
* them was reported as a level A failure reading "when a.ss-nav-link
|
|
207
|
+
* receives focus, focus moves to button#react-aria-…" — the disclosure
|
|
208
|
+
* trigger, which is simply the control the walk had focused on the step
|
|
209
|
+
* before. Ninety serious findings across eighteen surfaces, none of them
|
|
210
|
+
* real, and none of them visible from a desktop-only run. Any responsive
|
|
211
|
+
* site with a collapsing nav would have had the same.
|
|
212
|
+
*
|
|
213
|
+
* The filter above now excludes those controls before they are reached,
|
|
214
|
+
* so this guard is the backstop rather than the fix. It stays because the
|
|
215
|
+
* filter cannot be exhaustive: "the browser declined to move focus" is
|
|
216
|
+
* the general condition, and any future way of making an element
|
|
217
|
+
* unfocusable lands here.
|
|
218
|
+
*/
|
|
219
|
+
if (landed === focusedBefore)
|
|
220
|
+
continue;
|
|
221
|
+
/*
|
|
222
|
+
* Focus landing on the BODY means the element refused focus, not that
|
|
223
|
+
* something took it away.
|
|
224
|
+
*
|
|
225
|
+
* A control that is present and laid out but not focusable at this
|
|
226
|
+
* moment — a disclosure trigger hidden behind a media query, a button
|
|
227
|
+
* inside an `inert` subtree — leaves `document.activeElement` at the
|
|
228
|
+
* body. That is not a change of context; there was no context change
|
|
229
|
+
* because there was no focus. Reporting it as one is how our own
|
|
230
|
+
* marketing site produced a level A violation reading "when
|
|
231
|
+
* button.shrink-0 receives focus, focus moves to button.shrink-0", which
|
|
232
|
+
* is a check describing its own mechanics.
|
|
233
|
+
*/
|
|
234
|
+
if (!landed || landed === document.body || !(landed instanceof Element))
|
|
235
|
+
continue;
|
|
236
|
+
/*
|
|
237
|
+
* Focus landing INSIDE the same component is not a change of context
|
|
238
|
+
* either. A composite widget moving focus to its own inner control — a
|
|
239
|
+
* button that delegates to a nested element, a listbox that focuses its
|
|
240
|
+
* active option — is the recommended pattern, not a defect. Only focus
|
|
241
|
+
* LEAVING the component is what 3.2.1 is about.
|
|
242
|
+
*/
|
|
243
|
+
if (el.contains(landed) || landed.contains(el)) {
|
|
244
|
+
checked += 1;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
/*
|
|
248
|
+
* Focus moving to a SIBLING inside a composite widget is roving
|
|
249
|
+
* tabindex, which is the recommended pattern rather than a defect.
|
|
250
|
+
*
|
|
251
|
+
* A radiogroup, a toolbar, a tablist: Tab enters the group once and
|
|
252
|
+
* arrow keys move within it, so focusing a member that is not the active
|
|
253
|
+
* one hands focus to the one that is. From outside it looks like the
|
|
254
|
+
* control stealing focus; from a keyboard user's point of view nothing
|
|
255
|
+
* happened at all, because they could never have focused the member we
|
|
256
|
+
* reached for.
|
|
257
|
+
*
|
|
258
|
+
* This is what our own theme toggle does, and it produced a level A
|
|
259
|
+
* violation on every page of this repository's marketing site — the
|
|
260
|
+
* finding read "when button.shrink-0 receives focus, focus moves to
|
|
261
|
+
* button.shrink-0", two different radios with the same shape.
|
|
262
|
+
*/
|
|
263
|
+
const COMPOSITE = new Set([
|
|
264
|
+
'radiogroup',
|
|
265
|
+
'toolbar',
|
|
266
|
+
'tablist',
|
|
267
|
+
'listbox',
|
|
268
|
+
'menu',
|
|
269
|
+
'menubar',
|
|
270
|
+
'tree',
|
|
271
|
+
'treegrid',
|
|
272
|
+
'grid',
|
|
273
|
+
]);
|
|
274
|
+
let insideOneWidget = false;
|
|
275
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
276
|
+
if (!node.contains(landed))
|
|
277
|
+
continue;
|
|
278
|
+
const role = node.getAttribute('role');
|
|
279
|
+
if (role && COMPOSITE.has(role))
|
|
280
|
+
insideOneWidget = true;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
if (insideOneWidget) {
|
|
284
|
+
checked += 1;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// Focus was somewhere else the instant we asked for it here. That is the
|
|
288
|
+
// commonest real 3.2.1 failure: a handler that moves focus on focus.
|
|
289
|
+
changes.push({ selector: describe(el), kind: 'focus-moved', detail: describe(landed) });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
checked += 1;
|
|
293
|
+
// Let a microtask-scheduled handler run before deciding nothing happened.
|
|
294
|
+
observer.takeRecords().forEach((record) => {
|
|
295
|
+
mutated += record.addedNodes.length + record.removedNodes.length;
|
|
296
|
+
});
|
|
297
|
+
if (opened !== undefined) {
|
|
298
|
+
changes.push({ selector: describe(el), kind: 'window-opened', detail: opened });
|
|
299
|
+
}
|
|
300
|
+
else if (navigated !== undefined) {
|
|
301
|
+
changes.push({ selector: describe(el), kind: 'navigated', detail: navigated });
|
|
302
|
+
}
|
|
303
|
+
else if (mutated >= largeMutation) {
|
|
304
|
+
changes.push({ selector: describe(el), kind: 'mutation', nodes: mutated });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
observer.disconnect();
|
|
308
|
+
window.open = realOpen;
|
|
309
|
+
window.location.assign = realAssign;
|
|
310
|
+
window.location.replace = realReplace;
|
|
311
|
+
return { checked, found: candidates.length, truncated: candidates.length > maxElements, changes };
|
|
312
|
+
}
|
|
313
|
+
/* ------------------------------------------------------------------ 3.2.2 */
|
|
314
|
+
/**
|
|
315
|
+
* Change each `<select>`, checkbox and radio, and watch for a change of
|
|
316
|
+
* context. Opt-in; see the decision at the top of this file.
|
|
317
|
+
*
|
|
318
|
+
* Driven from Node rather than from one evaluate, unlike 3.2.1, because the
|
|
319
|
+
* failure this exists to catch — a select whose handler does
|
|
320
|
+
* `location.href = this.value` — navigates through a native setter that cannot
|
|
321
|
+
* be patched from inside the page. Playwright can see the navigation request
|
|
322
|
+
* and abort it before it commits, which is both how we detect it and how we
|
|
323
|
+
* stop it destroying the run. There are rarely more than a handful of such
|
|
324
|
+
* controls on a page, so the round trips are affordable in a way a
|
|
325
|
+
* hundred-and-twenty-element focus walk is not.
|
|
326
|
+
*
|
|
327
|
+
* Three guards, in this order, and the first is the one that matters most:
|
|
328
|
+
*
|
|
329
|
+
* 1. **Nothing is ever submitted.** A capture-phase listener cancels every
|
|
330
|
+
* `submit` event and records that it happened — so an attempted submit is
|
|
331
|
+
* the finding AND the thing that did not occur. A submit triggered by our
|
|
332
|
+
* own input would be worse than any finding: it writes.
|
|
333
|
+
* 2. **No navigation commits.** Navigation requests are aborted at the route
|
|
334
|
+
* level. The attempt is what we report.
|
|
335
|
+
* 3. **Every value is put back**, in a `finally`, whatever happened.
|
|
336
|
+
*/
|
|
337
|
+
export async function runOnInput(page) {
|
|
338
|
+
let unroute = false;
|
|
339
|
+
try {
|
|
340
|
+
const controls = await page.evaluate(collectControls, { max: MAX_CONTROLS });
|
|
341
|
+
if (controls.found === 0) {
|
|
342
|
+
return { inapplicable: { ruleId: ON_INPUT_RULE_ID, wcag: ON_INPUT_WCAG } };
|
|
343
|
+
}
|
|
344
|
+
let navigatedTo;
|
|
345
|
+
/*
|
|
346
|
+
* Answered with 204, not aborted.
|
|
347
|
+
*
|
|
348
|
+
* `route.abort()` on a main-frame navigation does not leave the page where
|
|
349
|
+
* it was: Chromium commits an error document, the execution context is
|
|
350
|
+
* destroyed, and the whole check dies reporting nothing — which is what the
|
|
351
|
+
* first version did, landing on `chrome-error://chromewebdata/` and
|
|
352
|
+
* throwing. A 204 No Content is the browser's own "there is nothing to
|
|
353
|
+
* navigate to": the request is made, we see it, and the current document
|
|
354
|
+
* stays exactly where it is.
|
|
355
|
+
*/
|
|
356
|
+
await page.route('**/*', (route) => {
|
|
357
|
+
const request = route.request();
|
|
358
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
|
359
|
+
navigatedTo = request.url();
|
|
360
|
+
return route.fulfill({ status: 204, body: '' });
|
|
361
|
+
}
|
|
362
|
+
return route.continue();
|
|
363
|
+
});
|
|
364
|
+
unroute = true;
|
|
365
|
+
const changes = [];
|
|
366
|
+
let checked = 0;
|
|
367
|
+
for (const control of controls.controls) {
|
|
368
|
+
navigatedTo = undefined;
|
|
369
|
+
const result = await page.evaluate(changeControl, {
|
|
370
|
+
selector: control.selector,
|
|
371
|
+
largeMutation: LARGE_MUTATION_NODES,
|
|
372
|
+
});
|
|
373
|
+
if (!result)
|
|
374
|
+
continue;
|
|
375
|
+
checked += 1;
|
|
376
|
+
// A navigation is issued after the handler returns, not during it.
|
|
377
|
+
await page.waitForTimeout(CHANGE_SETTLE_MS);
|
|
378
|
+
if (navigatedTo || result.submitted || result.navigated) {
|
|
379
|
+
changes.push({
|
|
380
|
+
selector: control.selector,
|
|
381
|
+
kind: 'navigated',
|
|
382
|
+
detail: navigatedTo ?? result.navigated ?? 'a form submission',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
else if (result.opened !== undefined) {
|
|
386
|
+
changes.push({ selector: control.selector, kind: 'window-opened', detail: result.opened });
|
|
387
|
+
}
|
|
388
|
+
else if (result.focusMovedTo) {
|
|
389
|
+
changes.push({
|
|
390
|
+
selector: control.selector,
|
|
391
|
+
kind: 'focus-moved',
|
|
392
|
+
detail: result.focusMovedTo,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
else if (result.mutated >= LARGE_MUTATION_NODES) {
|
|
396
|
+
changes.push({ selector: control.selector, kind: 'mutation', nodes: result.mutated });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return onInputEvidence({
|
|
400
|
+
checked,
|
|
401
|
+
found: controls.found,
|
|
402
|
+
truncated: controls.found > MAX_CONTROLS,
|
|
403
|
+
changes,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
if (unroute)
|
|
411
|
+
await page.unroute('**/*').catch(() => { });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The controls this check will touch, and no others.
|
|
416
|
+
*
|
|
417
|
+
* `<select>`, checkbox and radio only. A text field is never typed into at any
|
|
418
|
+
* setting — validation requests, autosave, character counters, and a half-filled
|
|
419
|
+
* form left behind are a different risk class from flipping a checkbox back.
|
|
420
|
+
*/
|
|
421
|
+
function collectControls({ max }) {
|
|
422
|
+
const step = (el) => {
|
|
423
|
+
const tag = el.tagName.toLowerCase();
|
|
424
|
+
if (el.id)
|
|
425
|
+
return `${tag}#${el.id}`;
|
|
426
|
+
if (el.classList.length > 0)
|
|
427
|
+
return `${tag}.${el.classList[0]}`;
|
|
428
|
+
const parent = el.parentElement;
|
|
429
|
+
if (!parent)
|
|
430
|
+
return tag;
|
|
431
|
+
return `${tag}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`;
|
|
432
|
+
};
|
|
433
|
+
const describe = (el) => {
|
|
434
|
+
const parts = [];
|
|
435
|
+
let node = el;
|
|
436
|
+
for (let depth = 0; node && depth < 3; depth += 1) {
|
|
437
|
+
parts.unshift(step(node));
|
|
438
|
+
if (node.id || node.classList.length > 0)
|
|
439
|
+
break;
|
|
440
|
+
node = node.parentElement;
|
|
441
|
+
if (!node || node === document.body)
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
return parts.join(' > ');
|
|
445
|
+
};
|
|
446
|
+
const all = Array.prototype.slice.call(document.querySelectorAll('select, input[type="checkbox"], input[type="radio"]'));
|
|
447
|
+
const usable = all.filter((el) => {
|
|
448
|
+
if (el.hasAttribute('disabled'))
|
|
449
|
+
return false;
|
|
450
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
451
|
+
return false;
|
|
452
|
+
const cs = window.getComputedStyle(el);
|
|
453
|
+
if (cs.visibility === 'hidden' || cs.display === 'none')
|
|
454
|
+
return false;
|
|
455
|
+
if (el instanceof HTMLSelectElement)
|
|
456
|
+
return el.options.length > 1;
|
|
457
|
+
return true;
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
found: usable.length,
|
|
461
|
+
controls: usable.slice(0, max).map((el) => ({ selector: describe(el) })),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Change one control, observe, and put it back.
|
|
466
|
+
*
|
|
467
|
+
* Returns null when the selector no longer resolves — a previous control's
|
|
468
|
+
* handler may have re-rendered the page, and asserting against an element that
|
|
469
|
+
* is gone would be asserting about nothing.
|
|
470
|
+
*/
|
|
471
|
+
function changeControl({ selector, largeMutation }) {
|
|
472
|
+
const el = document.querySelector(selector);
|
|
473
|
+
if (!el)
|
|
474
|
+
return null;
|
|
475
|
+
const describe = (node) => {
|
|
476
|
+
const tag = node.tagName.toLowerCase();
|
|
477
|
+
if (node.id)
|
|
478
|
+
return `${tag}#${node.id}`;
|
|
479
|
+
if (node.classList.length > 0)
|
|
480
|
+
return `${tag}.${node.classList[0]}`;
|
|
481
|
+
return tag;
|
|
482
|
+
};
|
|
483
|
+
let submitted = false;
|
|
484
|
+
const blockSubmit = (event) => {
|
|
485
|
+
submitted = true;
|
|
486
|
+
event.preventDefault();
|
|
487
|
+
event.stopImmediatePropagation();
|
|
488
|
+
};
|
|
489
|
+
document.addEventListener('submit', blockSubmit, true);
|
|
490
|
+
let opened;
|
|
491
|
+
let navigated;
|
|
492
|
+
const realOpen = window.open;
|
|
493
|
+
const realAssign = window.location.assign;
|
|
494
|
+
const realReplace = window.location.replace;
|
|
495
|
+
const realSubmit = HTMLFormElement.prototype.submit;
|
|
496
|
+
window.open = ((url) => {
|
|
497
|
+
opened = String(url ?? '');
|
|
498
|
+
return null;
|
|
499
|
+
});
|
|
500
|
+
window.location.assign = (url) => {
|
|
501
|
+
navigated = String(url);
|
|
502
|
+
};
|
|
503
|
+
window.location.replace = (url) => {
|
|
504
|
+
navigated = String(url);
|
|
505
|
+
};
|
|
506
|
+
// `form.submit()` bypasses the submit EVENT entirely, so the listener above
|
|
507
|
+
// would never see it. Patched rather than trusted.
|
|
508
|
+
HTMLFormElement.prototype.submit = function patched() {
|
|
509
|
+
submitted = true;
|
|
510
|
+
};
|
|
511
|
+
let mutated = 0;
|
|
512
|
+
const observer = new MutationObserver((records) => {
|
|
513
|
+
for (const record of records) {
|
|
514
|
+
mutated += record.addedNodes.length + record.removedNodes.length;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
518
|
+
const before = document.activeElement;
|
|
519
|
+
const restore = [];
|
|
520
|
+
try {
|
|
521
|
+
if (el instanceof HTMLSelectElement) {
|
|
522
|
+
const original = el.selectedIndex;
|
|
523
|
+
restore.push(() => {
|
|
524
|
+
el.selectedIndex = original;
|
|
525
|
+
});
|
|
526
|
+
// The next option, or the previous one when we are already on the last.
|
|
527
|
+
el.selectedIndex = original === el.options.length - 1 ? original - 1 : original + 1;
|
|
528
|
+
}
|
|
529
|
+
else if (el instanceof HTMLInputElement) {
|
|
530
|
+
const original = el.checked;
|
|
531
|
+
restore.push(() => {
|
|
532
|
+
el.checked = original;
|
|
533
|
+
});
|
|
534
|
+
el.checked = !original;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
540
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
541
|
+
observer.takeRecords().forEach((record) => {
|
|
542
|
+
mutated += record.addedNodes.length + record.removedNodes.length;
|
|
543
|
+
});
|
|
544
|
+
const landed = document.activeElement;
|
|
545
|
+
const focusMovedTo = landed !== before && landed instanceof Element && landed !== el
|
|
546
|
+
? describe(landed)
|
|
547
|
+
: undefined;
|
|
548
|
+
return {
|
|
549
|
+
mutated,
|
|
550
|
+
submitted,
|
|
551
|
+
...(opened !== undefined ? { opened } : {}),
|
|
552
|
+
...(navigated !== undefined ? { navigated } : {}),
|
|
553
|
+
...(focusMovedTo ? { focusMovedTo } : {}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
for (const undo of restore)
|
|
558
|
+
undo();
|
|
559
|
+
observer.disconnect();
|
|
560
|
+
document.removeEventListener('submit', blockSubmit, true);
|
|
561
|
+
window.open = realOpen;
|
|
562
|
+
window.location.assign = realAssign;
|
|
563
|
+
window.location.replace = realReplace;
|
|
564
|
+
HTMLFormElement.prototype.submit = realSubmit;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const ON_FOCUS_WCAG = { criteria: ['3.2.1'], level: 'A', versionTags: [] };
|
|
568
|
+
const ON_INPUT_WCAG = { criteria: ['3.2.2'], level: 'A', versionTags: [] };
|
|
569
|
+
export function onFocusEvidence(walk) {
|
|
570
|
+
return contextEvidence(walk, {
|
|
571
|
+
ruleId: ON_FOCUS_RULE_ID,
|
|
572
|
+
wcag: ON_FOCUS_WCAG,
|
|
573
|
+
helpUrl: ON_FOCUS_HELP_URL,
|
|
574
|
+
verb: 'receives focus',
|
|
575
|
+
fix: 'Fix: move focus, open windows and navigate on activation — a click, Enter or Space — never on focus alone.',
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
export function onInputEvidence(walk) {
|
|
579
|
+
return contextEvidence(walk, {
|
|
580
|
+
ruleId: ON_INPUT_RULE_ID,
|
|
581
|
+
wcag: ON_INPUT_WCAG,
|
|
582
|
+
helpUrl: ON_INPUT_HELP_URL,
|
|
583
|
+
verb: 'has its value changed',
|
|
584
|
+
fix: 'Fix: put the navigation behind a button the user presses, and leave the control to set a value. A select that navigates on change is the classic form of this failure.',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* One verdict shape for both criteria, because they differ only in what was
|
|
589
|
+
* touched. Keeping them in one function is what stops the two findings drifting
|
|
590
|
+
* into two vocabularies for one idea.
|
|
591
|
+
*/
|
|
592
|
+
function contextEvidence(walk, wording) {
|
|
593
|
+
if (walk.checked === 0 && walk.changes.length === 0) {
|
|
594
|
+
return { inapplicable: { ruleId: wording.ruleId, wcag: wording.wcag } };
|
|
595
|
+
}
|
|
596
|
+
const definite = walk.changes.filter((row) => row.kind !== 'mutation');
|
|
597
|
+
const judged = walk.changes.filter((row) => row.kind === 'mutation');
|
|
598
|
+
if (definite.length > 0) {
|
|
599
|
+
const first = definite[0];
|
|
600
|
+
const what = first.kind === 'focus-moved'
|
|
601
|
+
? `focus moves to ${first.detail ?? 'another element'}`
|
|
602
|
+
: first.kind === 'window-opened'
|
|
603
|
+
? `a new window is opened (${first.detail || 'no url given'})`
|
|
604
|
+
: `the page navigates to ${first.detail}`;
|
|
605
|
+
return {
|
|
606
|
+
finding: {
|
|
607
|
+
ruleId: wording.ruleId,
|
|
608
|
+
status: 'violation',
|
|
609
|
+
impact: 'serious',
|
|
610
|
+
wcag: wording.wcag,
|
|
611
|
+
helpUrl: wording.helpUrl,
|
|
612
|
+
evidence: {
|
|
613
|
+
failureSummary: `When ${first.selector} ${wording.verb}, ${what} — a change of context nobody asked ` +
|
|
614
|
+
`for. ${definite.length} control(s) affected. ${wording.fix}` +
|
|
615
|
+
bounds(walk),
|
|
616
|
+
},
|
|
617
|
+
targets: {
|
|
618
|
+
nodeCount: definite.length,
|
|
619
|
+
...sanitiseSelectors(definite.map((row) => row.selector)),
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (judged.length > 0) {
|
|
625
|
+
const first = judged[0];
|
|
626
|
+
return {
|
|
627
|
+
finding: {
|
|
628
|
+
ruleId: wording.ruleId,
|
|
629
|
+
status: 'incomplete',
|
|
630
|
+
impact: 'moderate',
|
|
631
|
+
wcag: wording.wcag,
|
|
632
|
+
helpUrl: wording.helpUrl,
|
|
633
|
+
evidence: {
|
|
634
|
+
failureSummary: `When ${first.selector} ${wording.verb}, ${first.nodes} node(s) are added or removed. ` +
|
|
635
|
+
`The fourth kind of context change is "content that changes the meaning of the page", ` +
|
|
636
|
+
`and meaning is exactly what a node count cannot see — a revealed hint is fine, a ` +
|
|
637
|
+
`replaced page is not. ${judged.length} control(s) affected; a person decides.` +
|
|
638
|
+
bounds(walk),
|
|
639
|
+
},
|
|
640
|
+
targets: {
|
|
641
|
+
nodeCount: judged.length,
|
|
642
|
+
...sanitiseSelectors(judged.map((row) => row.selector)),
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return { pass: { ruleId: wording.ruleId, nodeCount: walk.checked, wcag: wording.wcag } };
|
|
648
|
+
}
|
|
649
|
+
function bounds(walk) {
|
|
650
|
+
return walk.truncated
|
|
651
|
+
? ` The walk stopped after ${walk.checked} of ${walk.found} controls, so this is not the whole page.`
|
|
652
|
+
: '';
|
|
653
|
+
}
|
|
654
|
+
export { MAX_CONTROLS };
|
|
655
|
+
//# sourceMappingURL=context-change.js.map
|