forked from ChelseaKR/homeroom
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patha11y.mjs
More file actions
128 lines (117 loc) · 4.34 KB
/
Copy patha11y.mjs
File metadata and controls
128 lines (117 loc) · 4.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// Run axe-core over every built page, headlessly, in a jsdom DOM.
//
// This is the WCAG gate the README's standards table promises from the first school
// page. It loads each page into a real DOM implementation and runs axe-core's WCAG
// 2.0/2.1/2.2 A and AA rule sets plus the best-practice set, and exits non-zero on any
// violation. Every page is checked in both languages, because an English page that
// passes and a Spanish page that does not is the exact failure the parity commitment
// exists to prevent.
//
// It is not a substitute for a person using the pages: jsdom does no layout and paints
// no pixels, so rules that depend on rendered geometry or colour cannot fire here.
// Those are named below and in README.md under what still needs a person; colour
// contrast is measured separately, off the palette itself, in tests/test_pages.py.
//
// Usage: node tools/a11y.mjs <directory-of-html-files>
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { JSDOM, VirtualConsole } from "jsdom";
import axe from "axe-core";
const TAGS = [
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22aa",
"best-practice",
];
// Rules jsdom cannot decide. Left running they would report "incomplete", not "pass",
// and a gate that treats an unrunnable rule as a pass teaches a reader the wrong thing.
// They are listed here so the gap is on the record rather than in a silent filter.
const NEEDS_A_RENDERER = new Set([
"color-contrast", // no layout, no painted pixels; measured off the palette instead
"target-size", // SC 2.5.8 needs box geometry
]);
async function checkPage(path) {
const html = readFileSync(path, "utf8");
// "outside-only" gives an eval to inject axe with, without ever running a script that
// came out of the page. These pages ship no script, and the checker should not start
// executing one if that ever changes.
// axe probes for a canvas to decide whether it can sample colours. jsdom has none, so
// it reports that once per page. Everything else the page or axe says is forwarded.
const console_ = new VirtualConsole();
console_.forwardTo(console, { jsdomErrors: "none" });
console_.on("jsdomError", (error) => {
if (!/getContext\(\) method/.test(error.message)) {
console.error(error.message);
}
});
const dom = new JSDOM(html, {
pretendToBeVisual: true,
runScripts: "outside-only",
virtualConsole: console_,
});
const { window } = dom;
window.eval(axe.source);
const results = await window.axe.run(window.document, {
runOnly: { type: "tag", values: TAGS },
resultTypes: ["violations"],
});
dom.window.close();
return results;
}
const dir = process.argv[2];
if (!dir) {
console.error("usage: node tools/a11y.mjs <directory-of-html-files>");
process.exit(2);
}
const pages = readdirSync(dir)
.filter((name) => name.endsWith(".html"))
.sort();
if (pages.length === 0) {
console.error(`no .html files in ${dir}; build the pages first`);
process.exit(2);
}
// Both languages have to be present, or a green gate would mean nothing more than
// "the English pages are fine".
const locales = new Set(
pages.map((name) => name.split(".").at(-2)).filter(Boolean),
);
for (const required of ["en", "es"]) {
if (!locales.has(required)) {
console.error(`no ${required} pages in ${dir}; both languages must be checked`);
process.exit(2);
}
}
let failed = 0;
for (const name of pages) {
const results = await checkPage(join(dir, name));
const violations = results.violations.filter(
(v) => !NEEDS_A_RENDERER.has(v.id),
);
if (violations.length === 0) {
console.log(`ok ${name} (${TAGS.join(", ")})`);
continue;
}
failed += violations.length;
console.error(`FAIL ${name}`);
for (const v of violations) {
console.error(` [${v.impact}] ${v.id}: ${v.help}`);
console.error(` ${v.helpUrl}`);
for (const node of v.nodes.slice(0, 5)) {
console.error(` at ${node.target.join(" ")}`);
console.error(` ${node.failureSummary?.replace(/\n/g, "\n ")}`);
}
if (v.nodes.length > 5) {
console.error(` ...and ${v.nodes.length - 5} more`);
}
}
}
if (failed > 0) {
console.error(`\n${failed} accessibility violation(s)`);
process.exit(1);
}
console.log(
`\n${pages.length} page(s) in ${[...locales].sort().join(" and ")} clean ` +
`against ${TAGS.length} rule sets`,
);