testaro 78.0.2 → 78.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testaro",
3
- "version": "78.0.2",
3
+ "version": "78.0.5",
4
4
  "description": "Run 1300 web accessibility tests from 10 tools and get a standardized report",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -38,7 +38,7 @@
38
38
  "@siteimprove/alfa-act": "*",
39
39
  "@siteimprove/alfa-playwright": "*",
40
40
  "@siteimprove/alfa-rules": "*",
41
- "accessibility-checker": "*",
41
+ "accessibility-checker": ">=4.0.29",
42
42
  "aria-query": "*",
43
43
  "aslint-testaro": "*",
44
44
  "axe-playwright": "*",
package/testaro/focAll.js CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  /*
11
11
  focAll
12
- This test reports discrepancies between focusable and Tab-focused element counts. The test first counts all the visible focusable (i.e. with tabIndex 0) elements (except counting each group of radio buttons as only one focusable element). Then it repeatedly presses the Tab (or Option-Tab in webkit) key until it has reached all the elements it can and counts those elements. If the two counts differ, navigation can be made more difficult. The cause may be surprising changes in content during navigation with the Tab key, or inability to reach every focusable element (or widget, such as one radio button or tab in each group) merely by pressing the Tab key.
12
+ This test reports discrepancies between the visible focusable elements of a page and the elements actually reached with the Tab (or, in webkit, Option-Tab) key. The test first identifies and marks all the visible focusable elements, i.e. those with a nonnegative tabIndex, except elements that browsers exclude from Tab navigation (disabled elements, inert elements, and a and area elements without href attributes). Radio buttons are grouped as browsers group them: radio buttons sharing a name and a form owner belong to one group, and the group is reachable with one Tab keypress, but each radio button without a name is independently reachable. Then the test repeatedly presses the Tab (or Option-Tab) key until it has reached all the elements it can, marking each reached element. Finally, the test reports each focusable element that was never reached (for example, because keypresses cause surprising changes in content or focus) and each reached element that was not a visible focusable element when the page was loaded (for example, because it was revealed only during navigation or was invisible although Tab-focusable). Any such element can make navigation with the Tab key difficult or impossible.
13
13
  */
14
14
 
15
15
  // IMPORTS
@@ -19,16 +19,42 @@ const {getXPathCatalogIndex} = require('../procs/xPath');
19
19
  // FUNCTIONS
20
20
 
21
21
  // Runs the test and returns the result.
22
- exports.reporter = async (page, report) => {
22
+ exports.reporter = async (page, report, _, withItems) => {
23
23
  // Get locators of visible elements.
24
24
  const locAll = await page.locator('body *:visible');
25
- // Get the count of focusable elements.
25
+ // Mark the focusable elements and get the count of Tab stops among them.
26
26
  const focusableCount = await locAll.evaluateAll(elements => {
27
- const focusables = elements.filter(element => element.tabIndex === 0);
28
- // Count as focusable only 1 radio button per group.
29
- const radios = focusables.filter(el => el.tagName === 'INPUT' && el.type === 'radio');
30
- const radioNames = new Set(radios.map(radio => radio.name));
31
- return focusables.length - radios.length + radioNames.size;
27
+ // Get the focusable elements, i.e. those that can be reached with the Tab key.
28
+ const focusables = elements.filter(element => {
29
+ // If the element has a negative tabIndex, it is not focusable.
30
+ if (element.tabIndex < 0) {
31
+ return false;
32
+ }
33
+ // If the element or an ancestor is disabled or inert, it is not focusable.
34
+ if (element.matches(':disabled') || element.closest('[inert]')) {
35
+ return false;
36
+ }
37
+ // If the element is an a or area element without an href attribute, it is not focusable.
38
+ if (['A', 'AREA'].includes(element.tagName) && ! element.hasAttribute('href')) {
39
+ return false;
40
+ }
41
+ return true;
42
+ });
43
+ // Mark them.
44
+ focusables.forEach(element => {
45
+ element.dataset.testarofocusable = 'true';
46
+ });
47
+ // Get the radio buttons among them that browsers group, i.e. those with names.
48
+ const namedRadios = focusables.filter(
49
+ element => element.tagName === 'INPUT' && element.type === 'radio' && element.name
50
+ );
51
+ // Get the identifiers (form owner and name) of their groups.
52
+ const groupIDs = new Set(namedRadios.map(radio => {
53
+ const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
54
+ return `${formIndex}:${radio.name}`;
55
+ }));
56
+ // Return the count of Tab stops, counting each radio-button group as only 1.
57
+ return focusables.length - namedRadios.length + groupIDs.size;
32
58
  });
33
59
  /*
34
60
  Repeatedly perform a Tab or (in webkit) Opt-Tab keypress and count the focused elements.
@@ -43,7 +69,11 @@ exports.reporter = async (page, report) => {
43
69
  while (refocused < 100 && tabFocused < 2000) {
44
70
  await page.keyboard.press(keyName);
45
71
  const isNewFocus = await page.evaluate(() => {
46
- const focus = document.activeElement;
72
+ let focus = document.activeElement;
73
+ // If the focus is in a shadow DOM, get the innermost focused element.
74
+ while (focus && focus.shadowRoot && focus.shadowRoot.activeElement) {
75
+ focus = focus.shadowRoot.activeElement;
76
+ }
47
77
  if (focus === null || focus.tagName === 'BODY' || focus.dataset.testarofocused) {
48
78
  return false;
49
79
  }
@@ -59,22 +89,123 @@ exports.reporter = async (page, report) => {
59
89
  refocused++;
60
90
  }
61
91
  }
92
+ // Get the XPaths of the elements violating the rule, in both directions.
93
+ const {unreached, unexpected} = await page.evaluate(() => {
94
+ // Get all elements, including any in open shadow roots.
95
+ const allElements = [];
96
+ const addElements = root => {
97
+ root.querySelectorAll('*').forEach(element => {
98
+ allElements.push(element);
99
+ if (element.shadowRoot) {
100
+ addElements(element.shadowRoot);
101
+ }
102
+ });
103
+ };
104
+ addElements(document);
105
+ // Returns the identifier of the group of a radio button.
106
+ const getGroupID = radio => {
107
+ const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
108
+ return `${formIndex}:${radio.name}`;
109
+ };
110
+ // Get the identifiers of the radio-button groups with any reached member.
111
+ const reachedGroupIDs = new Set(
112
+ allElements
113
+ .filter(
114
+ element => element.dataset.testarofocused
115
+ && element.tagName === 'INPUT'
116
+ && element.type === 'radio'
117
+ && element.name
118
+ )
119
+ .map(getGroupID)
120
+ );
121
+ // Initialize the violation data.
122
+ const unreached = [];
123
+ const unexpected = [];
124
+ // For each element:
125
+ allElements.forEach(element => {
126
+ const wasFocusable = !! element.dataset.testarofocusable;
127
+ const wasFocused = !! element.dataset.testarofocused;
128
+ // If it was focusable but was never reached:
129
+ if (wasFocusable && ! wasFocused) {
130
+ const isGroupedRadio = element.tagName === 'INPUT'
131
+ && element.type === 'radio'
132
+ && element.name;
133
+ // If it is not a member of a radio-button group with another reached member:
134
+ if (! (isGroupedRadio && reachedGroupIDs.has(getGroupID(element)))) {
135
+ // Add its XPath to the violation data.
136
+ unreached.push(window.getXPath(element) ?? '/html');
137
+ }
138
+ }
139
+ // Otherwise, if it was reached but was not a visible focusable element:
140
+ else if (wasFocused && ! wasFocusable) {
141
+ // Get whether it is a scrollable container, which some browsers make Tab-focusable.
142
+ const styleDec = window.getComputedStyle(element);
143
+ const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
144
+ && element.scrollHeight > element.clientHeight
145
+ || ['auto', 'scroll'].includes(styleDec.overflowX)
146
+ && element.scrollWidth > element.clientWidth;
147
+ // If it is not a scrollable container made focusable by the browser alone:
148
+ if (! (isScroller && element.tabIndex < 0)) {
149
+ // Add its XPath to the violation data.
150
+ unexpected.push(window.getXPath(element) ?? '/html');
151
+ }
152
+ }
153
+ // Remove the marker attributes from the element.
154
+ delete element.dataset.testarofocusable;
155
+ delete element.dataset.testarofocused;
156
+ });
157
+ return {unreached, unexpected};
158
+ });
159
+ const count = unreached.length + unexpected.length;
62
160
  const data = {
63
161
  focusableCount,
64
162
  tabFocused,
65
- discrepancy: tabFocused - focusableCount
163
+ discrepancy: tabFocused - focusableCount,
164
+ unreachedCount: unreached.length,
165
+ unexpectedCount: unexpected.length
66
166
  };
67
- const count = Math.abs(data.discrepancy);
68
- // Return the result.
69
- return {
70
- data,
71
- totals: [0, 0, count, 0],
72
- standardInstances: count ? [{
167
+ // Initialize the standard instances.
168
+ const standardInstances = [];
169
+ // If itemization is required:
170
+ if (withItems) {
171
+ // For each element that was focusable but was never reached:
172
+ unreached.forEach(xPath => {
173
+ // Add an instance to the standard instances.
174
+ standardInstances.push({
175
+ ruleID: 'focAll',
176
+ what: 'Element is focusable but was not reached by Tab navigation',
177
+ ordinalSeverity: 2,
178
+ count: 1,
179
+ catalogIndex: getXPathCatalogIndex(report, xPath)
180
+ });
181
+ });
182
+ // For each element that was reached but was not a visible focusable element:
183
+ unexpected.forEach(xPath => {
184
+ // Add an instance to the standard instances.
185
+ standardInstances.push({
186
+ ruleID: 'focAll',
187
+ what: 'Element was reached by Tab navigation but was not a visible focusable element',
188
+ ordinalSeverity: 2,
189
+ count: 1,
190
+ catalogIndex: getXPathCatalogIndex(report, xPath)
191
+ });
192
+ });
193
+ }
194
+ // Otherwise, if there were any violations:
195
+ else if (count) {
196
+ // Add a summary instance to the standard instances.
197
+ standardInstances.push({
73
198
  ruleID: 'focAll',
74
199
  what: 'Some focusable elements are not Tab-focusable or vice versa',
75
200
  ordinalSeverity: 2,
76
201
  count,
77
202
  catalogIndex: getXPathCatalogIndex(report, '/html/body')
78
- }] : []
203
+ });
204
+ }
205
+ // Return the result.
206
+ return {
207
+ data,
208
+ totals: [0, 0, count, 0],
209
+ standardInstances
79
210
  };
80
211
  };
package/tests/htmlcs.js CHANGED
@@ -62,8 +62,19 @@ exports.reporter = async (page, report, actIndex) => {
62
62
  const script = document.createElement('script');
63
63
  script.nonce = scriptNonce;
64
64
  script.textContent = scriptText;
65
+ // HTMLCS.js is a UMD bundle. If the page exposes an AMD loader (define.amd, e.g. Wix or RequireJS) or leaked CommonJS globals (exports, module), the UMD wrapper registers HTMLCS as a module and never attaches HTMLCS_RUNNER to window, so the run() call below throws and the tool is reported prevented. Hide those loader globals for the duration of the synchronous script execution, so the wrapper falls through to its browser-global branch.
66
+ const umdDefine = window.define;
67
+ const umdExports = window.exports;
68
+ const umdModule = window.module;
69
+ window.define = undefined;
70
+ window.exports = undefined;
71
+ window.module = undefined;
65
72
  // Add the HTMLCS script to the page.
66
73
  document.head.insertAdjacentElement('beforeend', script);
74
+ // Restore the loader globals.
75
+ window.define = umdDefine;
76
+ window.exports = umdExports;
77
+ window.module = umdModule;
67
78
  // If only some rules are to be employed:
68
79
  if (rules && Array.isArray(rules) && rules.length) {
69
80
  // Redefine WCAG 2 AAA as including only them.
@@ -52,6 +52,11 @@
52
52
  "=",
53
53
  0
54
54
  ],
55
+ [
56
+ "standardResult.instances.length",
57
+ "=",
58
+ 1
59
+ ],
55
60
  [
56
61
  "standardResult.instances.0.ruleID",
57
62
  "=",
@@ -60,7 +65,7 @@
60
65
  [
61
66
  "standardResult.instances.0.what",
62
67
  "i",
63
- "vice versa"
68
+ "was not reached"
64
69
  ],
65
70
  [
66
71
  "standardResult.instances.0.ordinalSeverity",
@@ -99,6 +104,11 @@
99
104
  "=",
100
105
  0
101
106
  ],
107
+ [
108
+ "standardResult.instances.length",
109
+ "=",
110
+ 2
111
+ ],
102
112
  [
103
113
  "standardResult.instances.0.ruleID",
104
114
  "=",
@@ -107,7 +117,7 @@
107
117
  [
108
118
  "standardResult.instances.0.what",
109
119
  "i",
110
- "vice versa"
120
+ "was not a visible focusable"
111
121
  ],
112
122
  [
113
123
  "standardResult.instances.0.ordinalSeverity",
@@ -117,7 +127,12 @@
117
127
  [
118
128
  "standardResult.instances.0.count",
119
129
  "=",
120
- 2
130
+ 1
131
+ ],
132
+ [
133
+ "standardResult.instances.1.count",
134
+ "=",
135
+ 1
121
136
  ]
122
137
  ],
123
138
  "rules": [
@@ -17,7 +17,21 @@
17
17
  <body>
18
18
  <main>
19
19
  <h1>Page with full focusability</h1>
20
- <p>This page contains a link to <a href="https://en.wikipedia.org">information</a>, a <button type="button">button</button>, and a <label>text input <input type="text"></label>. All three, and no other elements, can be focused with Tab-key navigation.</p>
20
+ <p>This page contains a link to <a href="https://en.wikipedia.org">information</a>, a <button type="button">button</button>, and a <label>text input <input type="text"></label>. It also contains two forms, each containing a radio-button group, with both groups sharing one name. Each group is one Tab stop, at its checked radio button. The three controls and the two groups, and no other elements, can be focused with Tab-key navigation.</p>
21
+ <form>
22
+ <fieldset>
23
+ <legend>Beverage</legend>
24
+ <label><input type="radio" name="choice" value="coffee" checked> Coffee</label>
25
+ <label><input type="radio" name="choice" value="tea"> Tea</label>
26
+ </fieldset>
27
+ </form>
28
+ <form>
29
+ <fieldset>
30
+ <legend>Meal</legend>
31
+ <label><input type="radio" name="choice" value="lunch" checked> Lunch</label>
32
+ <label><input type="radio" name="choice" value="dinner"> Dinner</label>
33
+ </fieldset>
34
+ </form>
21
35
  </main>
22
36
  </body>
23
37
  </html>