automatica11y 0.0.0-stage → 0.3.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/AGENTS.md +32 -0
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/bin/automatica11y.js +4 -0
- package/package.json +50 -4
- package/skills/automatica11y/SKILL.md +23 -0
- package/skills/automatica11y-runner/SKILL.md +181 -0
- package/skills/automatica11y-runner/references/fixtures.md +59 -0
- package/src/cli.js +49 -0
- package/src/commands/audit.js +4 -0
- package/src/commands/common.js +274 -0
- package/src/commands/compare.js +4 -0
- package/src/commands/doctor.js +20 -0
- package/src/commands/guide.js +45 -0
- package/src/env/browser.js +136 -0
- package/src/env/versions.js +60 -0
- package/src/globals.d.ts +10 -0
- package/src/harness/browser.js +20 -0
- package/src/harness/bundle.js +49 -0
- package/src/harness/npm-install.js +65 -0
- package/src/harness/npm-react.js +39 -0
- package/src/harness/npm-wc.js +30 -0
- package/src/harness/shadow.js +42 -0
- package/src/harness/static-serve.js +68 -0
- package/src/harness/storybook.js +116 -0
- package/src/harness/url.js +41 -0
- package/src/plan/build-plan.js +62 -0
- package/src/plan/classify.js +185 -0
- package/src/plan/mapping.js +101 -0
- package/src/plan/resolve-npm.js +87 -0
- package/src/report/comparison.js +183 -0
- package/src/report/index.js +10 -0
- package/src/report/parts.js +334 -0
- package/src/report/single.js +16 -0
- package/src/run/audit-npm.js +279 -0
- package/src/run/fail-check.js +62 -0
- package/src/run/pool.js +21 -0
- package/src/run/run-plan.js +262 -0
- package/src/run/summary.js +49 -0
- package/src/schema.js +255 -0
- package/src/text.js +9 -0
- package/src/tiers/interactions/archetypes.js +417 -0
- package/src/tiers/interactions/helpers.js +145 -0
- package/src/tiers/interactions/index.js +107 -0
- package/src/tiers/rules/axe.js +75 -0
- package/src/tiers/rules/canvas.js +34 -0
- package/src/tiers/rules/ibm.js +121 -0
- package/src/tiers/rules/index.js +81 -0
- package/src/tiers/vsr.js +134 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interaction checks, one table per archetype. A check is a name, the WCAG criteria it speaks to,
|
|
3
|
+
* and a function that gets a context (`ctx`) and returns pass, fail, or not-applicable with a detail.
|
|
4
|
+
* The runner in index.js owns everything else: fresh page for each check, timeouts, and errors.
|
|
5
|
+
* Checks only use the two hooks (`data-a11y-trigger`, `data-a11y-root`) and ARIA roles.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
|
|
9
|
+
const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
|
|
10
|
+
const na = (detail) => ({ result: "not-applicable", detail });
|
|
11
|
+
|
|
12
|
+
const where = (s) => (s.bodyActive ? "nothing (the page body)" : `${s.activeTag}${s.activeText ? ` "${s.activeText}"` : ""}`);
|
|
13
|
+
const quote = (list) => list.map((item) => `"${item}"`).join(", ");
|
|
14
|
+
|
|
15
|
+
// ---- Shared by every archetype ----
|
|
16
|
+
|
|
17
|
+
const COMMON = [
|
|
18
|
+
{
|
|
19
|
+
name: "trigger-reachable-by-tab",
|
|
20
|
+
criteria: ["2.1.1"],
|
|
21
|
+
async run(ctx) {
|
|
22
|
+
const presses = await ctx.tabToTrigger();
|
|
23
|
+
return presses === null ? fail("Pressing Tab 12 times never put focus on the trigger.") : pass(`Tab reached the trigger after ${presses} ${presses === 1 ? "press" : "presses"}.`);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "focus-indicator-visible",
|
|
28
|
+
criteria: ["2.4.7"],
|
|
29
|
+
async run(ctx) {
|
|
30
|
+
if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't checked.");
|
|
31
|
+
const focused = await ctx.page.evaluate(() => window.__a11y.focusStyle());
|
|
32
|
+
await ctx.page.evaluate(() => window.__a11y.remember());
|
|
33
|
+
const clip = ctx.clipAround(focused.box);
|
|
34
|
+
const shotFocused = await ctx.page.screenshot({ clip });
|
|
35
|
+
await ctx.page.evaluate(() => /** @type {any} */ (window).__a11yLast?.blur());
|
|
36
|
+
await ctx.settle();
|
|
37
|
+
const unfocused = await ctx.page.evaluate(() => window.__a11y.lastStyle());
|
|
38
|
+
const changed = Object.keys(focused.style).filter((key) => focused.style[key] !== unfocused?.[key]);
|
|
39
|
+
if (changed.length) return pass(`Computed style changed on focus: ${changed.join(", ")}.`, { method: "computed-style" });
|
|
40
|
+
const shotBlurred = await ctx.page.screenshot({ clip });
|
|
41
|
+
if (!shotFocused.equals(shotBlurred)) return pass("The pixels around the trigger changed on focus, though no style property did.", { method: "screenshot" });
|
|
42
|
+
return fail("Nothing visible changed when the trigger got keyboard focus. Checked computed styles first, then a screenshot comparison.", { method: "screenshot" });
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "no-focus-trap",
|
|
47
|
+
criteria: ["2.1.2"],
|
|
48
|
+
async run(ctx) {
|
|
49
|
+
if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so focus-trap behavior wasn't checked.");
|
|
50
|
+
for (let press = 1; press <= 12; press += 1) {
|
|
51
|
+
await ctx.press("Tab");
|
|
52
|
+
const s = await ctx.snap();
|
|
53
|
+
if (!s.activeIsTrigger) return pass(`Focus moved on after ${press} more Tab ${press === 1 ? "press" : "presses"}.`);
|
|
54
|
+
}
|
|
55
|
+
return fail("Focus stayed on the trigger through 12 more Tab presses.");
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// ---- button and link ----
|
|
61
|
+
|
|
62
|
+
const activates = (key, label) => async (ctx) => {
|
|
63
|
+
const s0 = await ctx.snap();
|
|
64
|
+
if (key === "Space" && (s0.triggerTag === "a" || s0.triggerRole === "link")) return na("Links activate with Enter only.");
|
|
65
|
+
await ctx.focus();
|
|
66
|
+
await ctx.press(key);
|
|
67
|
+
const s1 = await ctx.snap();
|
|
68
|
+
return s1.clicks > s0.clicks ? pass(`${label} activated the trigger.`) : fail(`${label} did not activate the trigger.`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---- dialog and menu ----
|
|
72
|
+
|
|
73
|
+
/** Open with the keyboard and wait for the root to show. Returns the snapshot, or null. */
|
|
74
|
+
async function openWith(ctx, key = "Enter") {
|
|
75
|
+
await ctx.focus();
|
|
76
|
+
await ctx.press(key);
|
|
77
|
+
return ctx.waitFor((s) => s.rootVisible || s.expanded === "true");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function openMenu(ctx) {
|
|
81
|
+
for (const key of ["Enter", "ArrowDown"]) {
|
|
82
|
+
const s = await openWith(ctx, key);
|
|
83
|
+
if (s) return { s, key };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- The tables ----
|
|
89
|
+
|
|
90
|
+
export const ARCHETYPE_CHECKS = {
|
|
91
|
+
button: [
|
|
92
|
+
{ name: "enter-activates", criteria: ["2.1.1"], run: activates("Enter", "Enter") },
|
|
93
|
+
{ name: "space-activates", criteria: ["2.1.1"], run: activates("Space", "Space") },
|
|
94
|
+
],
|
|
95
|
+
|
|
96
|
+
link: [
|
|
97
|
+
{ name: "enter-activates", criteria: ["2.1.1"], run: activates("Enter", "Enter") },
|
|
98
|
+
{ name: "space-activates", criteria: ["2.1.1"], run: activates("Space", "Space") },
|
|
99
|
+
],
|
|
100
|
+
|
|
101
|
+
dialog: [
|
|
102
|
+
{
|
|
103
|
+
name: "focus-moves-into-dialog",
|
|
104
|
+
criteria: ["2.4.3"],
|
|
105
|
+
async run(ctx) {
|
|
106
|
+
if (!(await openWith(ctx))) return fail("The dialog didn't open when Enter was pressed on the trigger.");
|
|
107
|
+
const s = await ctx.waitFor((x) => x.activeInRoot, 800);
|
|
108
|
+
if (s) return pass("Focus moved into the dialog when it opened.");
|
|
109
|
+
return fail(`Focus stayed outside the dialog, on ${where(await ctx.snap())}.`);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "tab-stays-inside-dialog",
|
|
114
|
+
criteria: ["2.4.3", "2.1.2"],
|
|
115
|
+
async run(ctx) {
|
|
116
|
+
if (!(await openWith(ctx))) return na("The dialog didn't open with the keyboard, so Tab behavior wasn't checked.");
|
|
117
|
+
await ctx.waitFor((x) => x.activeInRoot, 600);
|
|
118
|
+
for (let press = 1; press <= 8; press += 1) {
|
|
119
|
+
await ctx.press("Tab");
|
|
120
|
+
const s = await ctx.snap();
|
|
121
|
+
if (!s.activeInRoot) {
|
|
122
|
+
const detail = `Focus left the dialog after ${press} Tab ${press === 1 ? "press" : "presses"}, onto ${where(s)}.`;
|
|
123
|
+
return s.rootModal ? fail(detail) : na(`${detail} The dialog isn't marked aria-modal, so it counts as non-modal and the check doesn't apply.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return pass("Focus stayed inside the dialog through 8 Tab presses.");
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "escape-closes",
|
|
131
|
+
criteria: ["2.1.1"],
|
|
132
|
+
async run(ctx) {
|
|
133
|
+
if (!(await openWith(ctx))) return na("The dialog didn't open with the keyboard, so Escape wasn't checked.");
|
|
134
|
+
await ctx.waitFor((x) => x.activeInRoot, 600);
|
|
135
|
+
await ctx.press("Escape");
|
|
136
|
+
const closed = await ctx.waitFor((x) => !x.rootVisible && x.expanded !== "true", 800);
|
|
137
|
+
return closed ? pass("Escape closed the dialog.") : fail("The dialog stayed open after Escape.");
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "focus-returns-to-trigger",
|
|
142
|
+
criteria: ["2.4.3"],
|
|
143
|
+
async run(ctx) {
|
|
144
|
+
if (!(await openWith(ctx))) return na("The dialog didn't open with the keyboard, so focus return wasn't checked.");
|
|
145
|
+
await ctx.waitFor((x) => x.activeInRoot, 600);
|
|
146
|
+
await ctx.press("Escape");
|
|
147
|
+
if (!(await ctx.waitFor((x) => !x.rootVisible && x.expanded !== "true", 800))) return na("Escape didn't close the dialog, so focus return wasn't checked.");
|
|
148
|
+
const back = await ctx.waitFor((x) => x.activeIsTrigger, 800);
|
|
149
|
+
return back ? pass("Focus returned to the trigger.") : fail(`Focus went to ${where(await ctx.snap())} instead of the trigger.`);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
|
|
154
|
+
menu: [
|
|
155
|
+
{
|
|
156
|
+
name: "opens-with-enter-or-arrow",
|
|
157
|
+
criteria: ["2.1.1"],
|
|
158
|
+
async run(ctx) {
|
|
159
|
+
const opened = await openMenu(ctx);
|
|
160
|
+
return opened ? pass(`${opened.key} opened the menu.`) : fail("Neither Enter nor ArrowDown opened the menu.");
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "arrow-keys-move-between-items",
|
|
165
|
+
criteria: ["2.1.1"],
|
|
166
|
+
async run(ctx) {
|
|
167
|
+
if (!(await openMenu(ctx))) return na("The menu didn't open with the keyboard, so item navigation wasn't checked.");
|
|
168
|
+
await ctx.settle(120);
|
|
169
|
+
const items = (await ctx.items('[role^="menuitem"]')).filter((i) => i.visible);
|
|
170
|
+
if (items.length < 2) return na(`The menu has ${items.length} visible menu ${items.length === 1 ? "item" : "items"}, so there is nothing to move between.`);
|
|
171
|
+
const start = items.find((i) => i.active)?.uid ?? null;
|
|
172
|
+
await ctx.press("ArrowDown");
|
|
173
|
+
const afterDown = (await ctx.items('[role^="menuitem"]')).find((i) => i.active)?.uid ?? null;
|
|
174
|
+
if (afterDown === null || afterDown === start) return fail("ArrowDown didn't move to another menu item.");
|
|
175
|
+
await ctx.press("ArrowUp");
|
|
176
|
+
const afterUp = (await ctx.items('[role^="menuitem"]')).find((i) => i.active)?.uid ?? null;
|
|
177
|
+
return afterUp === start || (start === null && afterUp !== afterDown) ? pass("ArrowDown and ArrowUp moved between menu items.") : fail("ArrowUp didn't move back to the previous menu item.");
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "escape-closes-and-returns-focus",
|
|
182
|
+
criteria: ["2.1.1", "2.4.3"],
|
|
183
|
+
async run(ctx) {
|
|
184
|
+
if (!(await openMenu(ctx))) return na("The menu didn't open with the keyboard, so Escape wasn't checked.");
|
|
185
|
+
await ctx.settle(120);
|
|
186
|
+
await ctx.press("Escape");
|
|
187
|
+
if (!(await ctx.waitFor((x) => !x.rootVisible && x.expanded !== "true", 800))) return fail("The menu stayed open after Escape.");
|
|
188
|
+
const back = await ctx.waitFor((x) => x.activeIsTrigger, 800);
|
|
189
|
+
return back ? pass("Escape closed the menu and focus returned to the trigger.") : fail(`Escape closed the menu, but focus went to ${where(await ctx.snap())} instead of the trigger.`);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
|
|
194
|
+
tabs: [
|
|
195
|
+
{
|
|
196
|
+
name: "arrow-keys-move-between-tabs",
|
|
197
|
+
criteria: ["2.1.1"],
|
|
198
|
+
async run(ctx) {
|
|
199
|
+
const tabs = (await ctx.items('[role="tab"]')).filter((t) => t.visible);
|
|
200
|
+
if (tabs.length === 0) return fail("The fixture has no elements with role=tab.");
|
|
201
|
+
if (tabs.length < 2) return na("There is only one tab, so there is nothing to move between.");
|
|
202
|
+
await ctx.focus();
|
|
203
|
+
const vertical = await ctx.page.evaluate(() => window.__a11y.queryDeep('[role="tablist"]')?.getAttribute("aria-orientation") === "vertical");
|
|
204
|
+
const [next, prev] = vertical ? ["ArrowDown", "ArrowUp"] : ["ArrowRight", "ArrowLeft"];
|
|
205
|
+
const active = async () => (await ctx.items('[role="tab"]')).find((t) => t.active)?.uid ?? null;
|
|
206
|
+
const start = await active();
|
|
207
|
+
await ctx.press(next);
|
|
208
|
+
const afterNext = await active();
|
|
209
|
+
if (afterNext === null || afterNext === start) return fail(`${next} didn't move focus to another tab.`);
|
|
210
|
+
await ctx.press(prev);
|
|
211
|
+
return (await active()) === start ? pass(`${next} and ${prev} moved focus between tabs.`) : fail(`${prev} didn't move focus back to the previous tab.`);
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "home-and-end-work",
|
|
216
|
+
criteria: ["2.1.1"],
|
|
217
|
+
async run(ctx) {
|
|
218
|
+
const tabs = (await ctx.items('[role="tab"]')).filter((t) => t.visible);
|
|
219
|
+
if (tabs.length < 2) return na("There are fewer than two tabs, so Home and End wouldn't show anything.");
|
|
220
|
+
await ctx.focus();
|
|
221
|
+
const activeIndex = async () => (await ctx.items('[role="tab"]')).filter((t) => t.visible).findIndex((t) => t.active);
|
|
222
|
+
await ctx.press("End");
|
|
223
|
+
const end = await activeIndex();
|
|
224
|
+
await ctx.press("Home");
|
|
225
|
+
const home = await activeIndex();
|
|
226
|
+
const problems = [];
|
|
227
|
+
if (end !== tabs.length - 1) problems.push("End didn't move to the last tab");
|
|
228
|
+
if (home !== 0) problems.push("Home didn't move to the first tab");
|
|
229
|
+
return problems.length ? fail(`${problems.join(". ")}.`) : pass("Home moved to the first tab and End to the last.");
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "selected-state-exposed",
|
|
234
|
+
criteria: ["4.1.2"],
|
|
235
|
+
async run(ctx) {
|
|
236
|
+
const tabs = await ctx.items('[role="tab"]');
|
|
237
|
+
if (tabs.length === 0) return fail("The fixture has no elements with role=tab.");
|
|
238
|
+
const missing = tabs.filter((t) => !t.hasSelected).length;
|
|
239
|
+
const selected = tabs.filter((t) => t.selected).length;
|
|
240
|
+
if (missing) return fail(`${missing} of ${tabs.length} tabs have no aria-selected attribute.`);
|
|
241
|
+
return selected === 1 ? pass("Every tab has aria-selected, and exactly one is true.") : fail(`${selected} tabs are aria-selected="true". A tab list should have exactly one.`);
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
|
|
246
|
+
combobox: [
|
|
247
|
+
{
|
|
248
|
+
name: "arrow-down-opens-list",
|
|
249
|
+
criteria: ["2.1.1"],
|
|
250
|
+
async run(ctx) {
|
|
251
|
+
return (await ctx.openCombo()) ? pass("ArrowDown opened the list.") : fail("ArrowDown didn't open the list.");
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "arrow-keys-change-active-option",
|
|
256
|
+
criteria: ["2.1.1"],
|
|
257
|
+
async run(ctx) {
|
|
258
|
+
if (!(await ctx.openCombo())) return na("The list didn't open with ArrowDown, so option navigation wasn't checked.");
|
|
259
|
+
const options = (await ctx.items('[role="option"]')).filter((o) => o.visible);
|
|
260
|
+
if (options.length < 2) return na("The list has fewer than two visible options.");
|
|
261
|
+
const active = async () => (await ctx.items('[role="option"]')).find((o) => o.active)?.uid ?? null;
|
|
262
|
+
const start = await active();
|
|
263
|
+
await ctx.press("ArrowDown");
|
|
264
|
+
const afterDown = await active();
|
|
265
|
+
if (afterDown === null || afterDown === start) return fail("ArrowDown didn't change the active option.");
|
|
266
|
+
await ctx.press("ArrowUp");
|
|
267
|
+
return (await active()) === start || (await active()) !== afterDown ? pass("ArrowDown and ArrowUp changed the active option.") : fail("ArrowUp didn't change the active option.");
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "enter-selects",
|
|
272
|
+
criteria: ["2.1.1"],
|
|
273
|
+
async run(ctx) {
|
|
274
|
+
if (!(await ctx.openCombo())) return na("The list didn't open with ArrowDown, so selection wasn't checked.");
|
|
275
|
+
const before = await ctx.page.evaluate(() => window.__a11y.inputValue());
|
|
276
|
+
await ctx.press("ArrowDown");
|
|
277
|
+
await ctx.press("Enter");
|
|
278
|
+
const closed = await ctx.waitFor((x) => x.expanded !== "true", 800);
|
|
279
|
+
const after = await ctx.page.evaluate(() => window.__a11y.inputValue());
|
|
280
|
+
const selected = (await ctx.items('[role="option"]')).some((o) => o.selected);
|
|
281
|
+
if (!closed) return fail("The list stayed open after Enter.");
|
|
282
|
+
return after !== before || selected ? pass("Enter chose an option and closed the list.") : fail("Enter closed the list, but nothing was chosen: the value didn't change and no option is selected.");
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: "escape-closes",
|
|
287
|
+
criteria: ["2.1.1"],
|
|
288
|
+
async run(ctx) {
|
|
289
|
+
if (!(await ctx.openCombo())) return na("The list didn't open with ArrowDown, so Escape wasn't checked.");
|
|
290
|
+
await ctx.press("Escape");
|
|
291
|
+
return (await ctx.waitFor((x) => x.expanded !== "true", 800)) ? pass("Escape closed the list.") : fail("The list stayed open after Escape.");
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
|
|
296
|
+
"form-field": [
|
|
297
|
+
{
|
|
298
|
+
name: "label-associated",
|
|
299
|
+
criteria: ["1.3.1", "3.3.2", "4.1.2"],
|
|
300
|
+
async run(ctx) {
|
|
301
|
+
const sources = await ctx.page.evaluate(() => window.__a11y.nameSources());
|
|
302
|
+
if (!sources) return fail("The fixture has no data-a11y-trigger control.");
|
|
303
|
+
return sources.length ? pass(`The control has a name from: ${sources.join(", ")}.`) : fail("The control has no label element, aria-labelledby, aria-label, or title.");
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: "error-associated-on-invalid",
|
|
308
|
+
criteria: ["3.3.1", "4.1.3"],
|
|
309
|
+
async run(ctx) {
|
|
310
|
+
const facts = await ctx.page.evaluate(() => {
|
|
311
|
+
const c = window.__a11y.queryDeep("[data-a11y-trigger]");
|
|
312
|
+
return { tag: c?.localName, type: c?.getAttribute("type") ?? "text", required: c?.hasAttribute("required"), pattern: c?.hasAttribute("pattern"), minlength: c?.hasAttribute("minlength"), valid: c?.checkValidity?.() ?? true };
|
|
313
|
+
});
|
|
314
|
+
const control = ctx.trigger;
|
|
315
|
+
const before = await ctx.page.evaluate(() => window.__a11y.errorInfo());
|
|
316
|
+
try {
|
|
317
|
+
if (facts.required) await control.fill("");
|
|
318
|
+
else if (facts.pattern || facts.minlength || ["email", "url", "tel"].includes(facts.type) || facts.tag === "input") await control.fill("x");
|
|
319
|
+
else return na("The control has no validation to trigger. Add required, a type such as email, or a pattern to the fixture.");
|
|
320
|
+
} catch {
|
|
321
|
+
return na("The control couldn't be filled, so invalid input wasn't tried.");
|
|
322
|
+
}
|
|
323
|
+
await ctx.press("Tab");
|
|
324
|
+
await ctx.settle(200);
|
|
325
|
+
/** Error text that wasn't there before the input went wrong. A hint linked all along doesn't count. */
|
|
326
|
+
const read = async () => {
|
|
327
|
+
const now = await ctx.page.evaluate(() => window.__a11y.errorInfo());
|
|
328
|
+
return { ...now, linked: now.linked.filter((t) => !before.linked.includes(t)), live: now.live.filter((t) => !before.live.includes(t)) };
|
|
329
|
+
};
|
|
330
|
+
let info = await read();
|
|
331
|
+
if (!info.invalid && !info.linked.length && !info.live.length) {
|
|
332
|
+
// Some fields only validate on submit.
|
|
333
|
+
await ctx.trigger.press("Enter").catch(() => {});
|
|
334
|
+
await ctx.settle(200);
|
|
335
|
+
info = await read();
|
|
336
|
+
}
|
|
337
|
+
if (!info.invalid && !info.linked.length && !info.live.length) return na("Invalid input didn't put the field in an invalid state, so there was no error to check.");
|
|
338
|
+
if (info.linked.length || info.live.length) {
|
|
339
|
+
const how = [info.linked.length ? `linked by aria-describedby or aria-errormessage: ${quote(info.linked)}` : null, info.live.length ? `in an alert or live region: ${quote(info.live)}` : null].filter(Boolean).join("; ");
|
|
340
|
+
return pass(`The error is ${how}.${info.ariaInvalid ? "" : " The field doesn't set aria-invalid."}`);
|
|
341
|
+
}
|
|
342
|
+
return fail("The field is invalid, but no error text is tied to it through aria-describedby or aria-errormessage, and no alert or live region shows one. If the field relies on the browser's built-in message, this check can't see it.");
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
|
|
347
|
+
accordion: [
|
|
348
|
+
{
|
|
349
|
+
name: "expanded-state-exposed",
|
|
350
|
+
criteria: ["4.1.2"],
|
|
351
|
+
async run(ctx) {
|
|
352
|
+
const s = await ctx.snap();
|
|
353
|
+
return s.expanded === null ? fail("The trigger has no aria-expanded attribute, so the expanded state isn't exposed.") : pass(`The trigger exposes aria-expanded="${s.expanded}".`);
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
{ name: "enter-toggles", criteria: ["2.1.1", "4.1.2"], run: toggles("Enter") },
|
|
357
|
+
{ name: "space-toggles", criteria: ["2.1.1", "4.1.2"], run: toggles("Space") },
|
|
358
|
+
],
|
|
359
|
+
|
|
360
|
+
tooltip: [
|
|
361
|
+
{
|
|
362
|
+
name: "appears-on-focus",
|
|
363
|
+
criteria: ["1.4.13"],
|
|
364
|
+
async run(ctx) {
|
|
365
|
+
if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so focus behavior wasn't checked.");
|
|
366
|
+
return (await ctx.waitFor((s) => s.rootVisible, 1000)) ? pass("The tooltip appeared when the trigger got keyboard focus.") : fail("The tooltip didn't appear when the trigger got keyboard focus.");
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "escape-dismisses",
|
|
371
|
+
criteria: ["1.4.13"],
|
|
372
|
+
async run(ctx) {
|
|
373
|
+
if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so Escape wasn't checked.");
|
|
374
|
+
if (!(await ctx.waitFor((s) => s.rootVisible, 1000))) return na("The tooltip didn't appear on focus, so there was nothing to dismiss.");
|
|
375
|
+
await ctx.press("Escape");
|
|
376
|
+
return (await ctx.waitFor((s) => !s.rootVisible, 800)) ? pass("Escape dismissed the tooltip.") : fail("The tooltip stayed visible after Escape.");
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "content-reachable-on-hover",
|
|
381
|
+
criteria: ["1.4.13"],
|
|
382
|
+
async run(ctx) {
|
|
383
|
+
await ctx.trigger.hover();
|
|
384
|
+
if (!(await ctx.waitFor((s) => s.rootVisible, 1000))) return na("The tooltip didn't appear on hover, so hoverable content wasn't checked.");
|
|
385
|
+
const root = ctx.page.locator("[data-a11y-root]").first();
|
|
386
|
+
const box = await root.boundingBox();
|
|
387
|
+
if (!box) return na("The tooltip has no box to move onto.");
|
|
388
|
+
await ctx.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
|
|
389
|
+
await ctx.settle(400);
|
|
390
|
+
return (await ctx.snap()).rootVisible ? pass("The tooltip stayed visible when the pointer moved onto it.") : fail("The tooltip disappeared when the pointer moved from the trigger onto it. Content shown on hover must stay reachable.");
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/** An accordion toggle check for one key. The state flips on the first press and flips back on the second. */
|
|
397
|
+
function toggles(key) {
|
|
398
|
+
return async (ctx) => {
|
|
399
|
+
const read = async () => {
|
|
400
|
+
const s = await ctx.snap();
|
|
401
|
+
return { expanded: s.expanded, shown: s.rootVisible };
|
|
402
|
+
};
|
|
403
|
+
const before = await read();
|
|
404
|
+
await ctx.focus();
|
|
405
|
+
await ctx.press(key);
|
|
406
|
+
await ctx.settle(150);
|
|
407
|
+
const after = await read();
|
|
408
|
+
const changed = (a, b) => (a.expanded !== null ? a.expanded !== b.expanded : a.shown !== b.shown);
|
|
409
|
+
if (!changed(before, after)) return fail(`${key} didn't toggle the section.`);
|
|
410
|
+
await ctx.press(key);
|
|
411
|
+
await ctx.settle(150);
|
|
412
|
+
const again = await read();
|
|
413
|
+
return changed(after, again) ? pass(`${key} expanded and collapsed the section (aria-expanded ${before.expanded} then ${after.expanded}).`) : fail(`${key} opened the section but didn't close it again.`);
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export { COMMON };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs inside the page before any page script. It sets `window.__a11y`, a small kit the interaction checks use.
|
|
3
|
+
* It reaches through open shadow roots, because fixtures often put the trigger or the options there.
|
|
4
|
+
* This function is serialized and sent to the browser, so it can't use anything from outside itself.
|
|
5
|
+
*/
|
|
6
|
+
export function installHelpers() {
|
|
7
|
+
let uidCounter = 0;
|
|
8
|
+
const uids = new WeakMap();
|
|
9
|
+
const uid = (el) => {
|
|
10
|
+
if (!el) return null;
|
|
11
|
+
if (!uids.has(el)) uids.set(el, (uidCounter += 1));
|
|
12
|
+
return uids.get(el);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** All matches in the document and in every open shadow root. */
|
|
16
|
+
const queryAllDeep = (selector, root = /** @type {Document | ShadowRoot} */ (document)) => {
|
|
17
|
+
const found = [...root.querySelectorAll(selector)];
|
|
18
|
+
for (const el of root.querySelectorAll("*")) if (el.shadowRoot) found.push(...queryAllDeep(selector, el.shadowRoot));
|
|
19
|
+
return found;
|
|
20
|
+
};
|
|
21
|
+
const queryDeep = (selector) => queryAllDeep(selector)[0] ?? null;
|
|
22
|
+
|
|
23
|
+
const deepActive = () => {
|
|
24
|
+
let active = document.activeElement;
|
|
25
|
+
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
|
|
26
|
+
return active;
|
|
27
|
+
};
|
|
28
|
+
/** True when `node` is `root` or sits inside it, across shadow boundaries. */
|
|
29
|
+
const within = (root, node) => {
|
|
30
|
+
while (node) {
|
|
31
|
+
if (node === root) return true;
|
|
32
|
+
node = node.assignedSlot || node.parentNode || node.host;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
const visible = (el) => {
|
|
37
|
+
if (!el || el.getClientRects().length === 0) return false;
|
|
38
|
+
const style = getComputedStyle(el);
|
|
39
|
+
return style.visibility !== "hidden" && style.display !== "none";
|
|
40
|
+
};
|
|
41
|
+
const byId = (el, id) => (el.getRootNode().getElementById ? el.getRootNode().getElementById(id) : document.getElementById(id));
|
|
42
|
+
const text = (el) => (el?.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80);
|
|
43
|
+
|
|
44
|
+
/** The element ARIA says is active: the focused element, or the descendant it points to. */
|
|
45
|
+
const activeTarget = () => {
|
|
46
|
+
const active = deepActive();
|
|
47
|
+
if (!active) return null;
|
|
48
|
+
const pointed = active.getAttribute?.("aria-activedescendant");
|
|
49
|
+
return (pointed && byId(active, pointed)) || active;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
window.__a11yClicks = 0;
|
|
53
|
+
document.addEventListener("click", () => (window.__a11yClicks += 1), true);
|
|
54
|
+
|
|
55
|
+
window.__a11y = {
|
|
56
|
+
uid,
|
|
57
|
+
queryDeep,
|
|
58
|
+
queryAllDeep,
|
|
59
|
+
deepActive,
|
|
60
|
+
within,
|
|
61
|
+
visible,
|
|
62
|
+
/** What the page looks like to a check, right now. */
|
|
63
|
+
snapshot() {
|
|
64
|
+
const trigger = queryDeep("[data-a11y-trigger]");
|
|
65
|
+
const root = queryDeep("[data-a11y-root]");
|
|
66
|
+
const active = deepActive();
|
|
67
|
+
const target = activeTarget();
|
|
68
|
+
return {
|
|
69
|
+
clicks: window.__a11yClicks,
|
|
70
|
+
triggerTag: trigger?.localName ?? null,
|
|
71
|
+
triggerRole: trigger?.getAttribute("role") ?? null,
|
|
72
|
+
expanded: trigger?.getAttribute("aria-expanded") ?? null,
|
|
73
|
+
rootExists: Boolean(root),
|
|
74
|
+
rootVisible: visible(root),
|
|
75
|
+
rootModal: root?.getAttribute("aria-modal") === "true" || root?.getAttribute("role") === "alertdialog",
|
|
76
|
+
activeIsTrigger: Boolean(trigger && active && within(trigger, active)),
|
|
77
|
+
activeInRoot: Boolean(root && active && within(root, active)),
|
|
78
|
+
activeUid: uid(target),
|
|
79
|
+
activeTag: active?.localName ?? null,
|
|
80
|
+
activeText: text(target),
|
|
81
|
+
activeRole: target?.getAttribute?.("role") ?? null,
|
|
82
|
+
bodyActive: !active || active === document.body,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
/** Items for a role selector, with which one is active and which are selected. */
|
|
86
|
+
items(selector) {
|
|
87
|
+
const target = activeTarget();
|
|
88
|
+
return queryAllDeep(selector).map((el) => ({
|
|
89
|
+
uid: uid(el),
|
|
90
|
+
text: text(el),
|
|
91
|
+
active: Boolean(target && (el === target || within(el, target))),
|
|
92
|
+
selected: el.getAttribute("aria-selected") === "true" || el.getAttribute("aria-checked") === "true",
|
|
93
|
+
hasSelected: el.hasAttribute("aria-selected"),
|
|
94
|
+
visible: visible(el),
|
|
95
|
+
}));
|
|
96
|
+
},
|
|
97
|
+
/** The name a control gets from its label, in the order browsers use. */
|
|
98
|
+
nameSources() {
|
|
99
|
+
const control = queryDeep("[data-a11y-trigger]");
|
|
100
|
+
if (!control) return null;
|
|
101
|
+
const sources = [];
|
|
102
|
+
if (control.labels && control.labels.length) sources.push("label element");
|
|
103
|
+
const labelledby = control.getAttribute("aria-labelledby");
|
|
104
|
+
if (labelledby && labelledby.split(/\s+/).some((id) => text(byId(control, id)))) sources.push("aria-labelledby");
|
|
105
|
+
if ((control.getAttribute("aria-label") ?? "").trim()) sources.push("aria-label");
|
|
106
|
+
if ((control.getAttribute("title") ?? "").trim()) sources.push("title");
|
|
107
|
+
return sources;
|
|
108
|
+
},
|
|
109
|
+
/** Error text tied to the control, if any. */
|
|
110
|
+
errorInfo() {
|
|
111
|
+
const control = queryDeep("[data-a11y-trigger]");
|
|
112
|
+
if (!control) return null;
|
|
113
|
+
const ids = [control.getAttribute("aria-errormessage"), ...(control.getAttribute("aria-describedby") ?? "").split(/\s+/)].filter(Boolean);
|
|
114
|
+
const linked = ids.map((id) => text(byId(control, id))).filter(Boolean);
|
|
115
|
+
const root = queryDeep("[data-a11y-root]") ?? document.body;
|
|
116
|
+
const live = [...root.querySelectorAll('[role="alert"], [aria-live="assertive"], [aria-live="polite"], [role="status"]')].map(text).filter(Boolean);
|
|
117
|
+
return { invalid: control.getAttribute("aria-invalid") === "true" || control.matches(":invalid"), ariaInvalid: control.getAttribute("aria-invalid") === "true", linked, live };
|
|
118
|
+
},
|
|
119
|
+
/** Computed style properties that can show a focus indicator. */
|
|
120
|
+
focusStyle() {
|
|
121
|
+
const el = deepActive();
|
|
122
|
+
if (!el) return null;
|
|
123
|
+
const s = getComputedStyle(el);
|
|
124
|
+
const pick = ["outlineStyle", "outlineWidth", "outlineColor", "outlineOffset", "boxShadow", "borderTopColor", "borderTopWidth", "backgroundColor", "color", "textDecorationLine"];
|
|
125
|
+
const style = Object.fromEntries(pick.map((key) => [key, s[key]]));
|
|
126
|
+
const box = el.getBoundingClientRect();
|
|
127
|
+
return { style, box: { x: box.x, y: box.y, width: box.width, height: box.height } };
|
|
128
|
+
},
|
|
129
|
+
/** Remember the focused element, so its style can be read after it loses focus. */
|
|
130
|
+
remember() {
|
|
131
|
+
window.__a11yLast = deepActive();
|
|
132
|
+
},
|
|
133
|
+
lastStyle() {
|
|
134
|
+
const el = window.__a11yLast;
|
|
135
|
+
if (!el) return null;
|
|
136
|
+
const s = getComputedStyle(el);
|
|
137
|
+
const pick = ["outlineStyle", "outlineWidth", "outlineColor", "outlineOffset", "boxShadow", "borderTopColor", "borderTopWidth", "backgroundColor", "color", "textDecorationLine"];
|
|
138
|
+
return Object.fromEntries(pick.map((key) => [key, s[key]]));
|
|
139
|
+
},
|
|
140
|
+
inputValue() {
|
|
141
|
+
const el = queryDeep("[data-a11y-trigger]");
|
|
142
|
+
return el ? (el.value ?? el.textContent ?? "").toString().trim() : null;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { VIEWPORT } from "../../harness/browser.js";
|
|
2
|
+
import { openPage } from "../../harness/url.js";
|
|
3
|
+
import { ARCHETYPE_CHECKS, COMMON } from "./archetypes.js";
|
|
4
|
+
import { installHelpers } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
const CHECK_TIMEOUT_MS = 25_000;
|
|
7
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
8
|
+
|
|
9
|
+
/** Everything a check can do, bound to one fresh page. */
|
|
10
|
+
function makeContext(page) {
|
|
11
|
+
const ctx = {
|
|
12
|
+
page,
|
|
13
|
+
trigger: page.locator("[data-a11y-trigger]").first(),
|
|
14
|
+
snap: () => page.evaluate(() => window.__a11y.snapshot()),
|
|
15
|
+
items: (selector) => page.evaluate((s) => window.__a11y.items(s), selector),
|
|
16
|
+
async settle(ms = 60) {
|
|
17
|
+
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
|
|
18
|
+
await sleep(ms);
|
|
19
|
+
},
|
|
20
|
+
async press(key) {
|
|
21
|
+
await page.keyboard.press(key);
|
|
22
|
+
await ctx.settle();
|
|
23
|
+
},
|
|
24
|
+
async focus() {
|
|
25
|
+
await ctx.trigger.focus();
|
|
26
|
+
await ctx.settle();
|
|
27
|
+
},
|
|
28
|
+
/** Poll the snapshot until `predicate` holds. Returns that snapshot, or null on timeout. */
|
|
29
|
+
async waitFor(predicate, ms = 1000) {
|
|
30
|
+
const deadline = Date.now() + ms;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const s = await ctx.snap();
|
|
33
|
+
if (predicate(s)) return s;
|
|
34
|
+
if (Date.now() > deadline) return null;
|
|
35
|
+
await sleep(50);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
/** Press Tab from the top of the page until focus lands on the trigger. Returns the presses, or null. */
|
|
39
|
+
async tabToTrigger(max = 12) {
|
|
40
|
+
await page.evaluate(() => /** @type {HTMLElement} */ (document.activeElement)?.blur?.());
|
|
41
|
+
for (let presses = 1; presses <= max; presses += 1) {
|
|
42
|
+
await page.keyboard.press("Tab");
|
|
43
|
+
await ctx.settle(30);
|
|
44
|
+
if ((await ctx.snap()).activeIsTrigger) return presses;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
},
|
|
48
|
+
async openCombo() {
|
|
49
|
+
await ctx.focus();
|
|
50
|
+
await ctx.press("ArrowDown");
|
|
51
|
+
for (let i = 0; i < 16; i += 1) {
|
|
52
|
+
const s = await ctx.snap();
|
|
53
|
+
if (s.expanded === "true" || (await ctx.items('[role="option"]')).some((o) => o.visible)) return true;
|
|
54
|
+
await sleep(50);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
},
|
|
58
|
+
/** A screenshot region around an element, kept inside the viewport. */
|
|
59
|
+
clipAround(box, margin = 8) {
|
|
60
|
+
const x = Math.max(0, Math.floor(box.x - margin));
|
|
61
|
+
const y = Math.max(0, Math.floor(box.y - margin));
|
|
62
|
+
return { x, y, width: Math.max(1, Math.min(VIEWPORT.width - x, Math.ceil(box.width + margin * 2))), height: Math.max(1, Math.min(VIEWPORT.height - y, Math.ceil(box.height + margin * 2))) };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return ctx;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run one check on a fresh page, so no check inherits another's state. */
|
|
69
|
+
export async function runCheck(browser, url, check, { timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
70
|
+
/** @type {Awaited<ReturnType<typeof openPage>> | null} */
|
|
71
|
+
let opened = null;
|
|
72
|
+
try {
|
|
73
|
+
opened = await openPage(browser, url, { beforeGoto: (page) => page.addInitScript(installHelpers), waitUntil: "load" });
|
|
74
|
+
const ctx = makeContext(opened.page);
|
|
75
|
+
await ctx.trigger.waitFor({ state: "attached", timeout: 3000 });
|
|
76
|
+
const outcome = await Promise.race([
|
|
77
|
+
check.run(ctx),
|
|
78
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`The check took longer than ${timeoutMs / 1000} seconds.`)), timeoutMs)),
|
|
79
|
+
]);
|
|
80
|
+
return { name: check.name, criteria: check.criteria, ...outcome };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return { name: check.name, criteria: check.criteria, result: "error", detail: `The check couldn't finish: ${(error instanceof Error ? error.message : String(error)).split("\n")[0]}` };
|
|
83
|
+
} finally {
|
|
84
|
+
await opened?.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run the interaction checks for one archetype against its fixture page.
|
|
90
|
+
* Each check gets a fresh page. A check that can't finish reports `error`, which counts as a gap, never as a pass.
|
|
91
|
+
* @param {import("playwright-core").Browser} browser
|
|
92
|
+
* @param {string} url
|
|
93
|
+
* @param {string} archetype
|
|
94
|
+
*/
|
|
95
|
+
export async function runInteractions(browser, url, archetype) {
|
|
96
|
+
if (archetype === "chart") return { status: "not-applicable", reason: "No interaction checks are defined for the chart archetype." };
|
|
97
|
+
const checks = [...COMMON, ...(ARCHETYPE_CHECKS[archetype] ?? [])];
|
|
98
|
+
const results = [];
|
|
99
|
+
for (const check of checks) results.push(await runCheck(browser, url, check));
|
|
100
|
+
return { status: "ran", checks: results };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a page or Storybook target says about this tier. */
|
|
104
|
+
export const NOT_APPLICABLE_FOR_PAGES = {
|
|
105
|
+
status: "not-applicable",
|
|
106
|
+
reason: "Interaction checks need an archetype fixture with trigger and root hooks. Page and Storybook targets don't have them.",
|
|
107
|
+
};
|