@surea11y/core 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Canonical WCAG-version-introduction map for Success Criteria.
|
|
7
|
+
*
|
|
8
|
+
* PURPOSE
|
|
9
|
+
* -------
|
|
10
|
+
* WCAG 2.1 and 2.2 are each a superset of the version before them -- they add new
|
|
11
|
+
* Success Criteria, never remove or renumber existing ones, with the sole exception of
|
|
12
|
+
* 4.1.1 Parsing, removed in 2.2. This module lists exactly which SCs were newly
|
|
13
|
+
* introduced in each version; every SC not listed here is WCAG 2.0 baseline. The one
|
|
14
|
+
* removed SC is listed separately below, since a rule mapped to it is valid for a
|
|
15
|
+
* 2.0/2.1 conformance target and must not count against a 2.2 one.
|
|
16
|
+
*
|
|
17
|
+
* This is the single source of truth used by the rule-authoring consistency test
|
|
18
|
+
* (`tests/coverage/wcag-version-tags.test.js`) to verify every rule's `meta.tags`
|
|
19
|
+
* carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` prefix for its `wcagSc`.
|
|
20
|
+
*
|
|
21
|
+
* `src/core/dom-runner.js`'s `buildCompositeDef` needs the identical list to compute
|
|
22
|
+
* composite tags, but has to keep its own literal copy rather than import this module --
|
|
23
|
+
* `runCore` is inlined into `core.js` via `Function.prototype.toString()`, so it cannot
|
|
24
|
+
* reference anything outside its own function body at runtime. If you change the lists
|
|
25
|
+
* below, update that literal too.
|
|
26
|
+
*
|
|
27
|
+
* SOURCE: W3C WCAG 2.1 and 2.2 "Success Criteria" tables (each SC's own "Introduced in
|
|
28
|
+
* version" note). Each SC belongs to exactly one origin version, which is the model
|
|
29
|
+
* this file encodes.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// SCs newly introduced in WCAG 2.1 (not present in WCAG 2.0).
|
|
33
|
+
const WCAG21_NEW_SCS = [
|
|
34
|
+
'1.3.4', // Orientation (AA)
|
|
35
|
+
'1.3.5', // Identify Input Purpose (AA)
|
|
36
|
+
'1.3.6', // Identify Purpose (AAA)
|
|
37
|
+
'1.4.10', // Reflow (AA)
|
|
38
|
+
'1.4.11', // Non-text Contrast (AA)
|
|
39
|
+
'1.4.12', // Text Spacing (AA)
|
|
40
|
+
'1.4.13', // Content on Hover or Focus (AA)
|
|
41
|
+
'2.1.4', // Character Key Shortcuts (A)
|
|
42
|
+
'2.2.6', // Timeouts (AAA)
|
|
43
|
+
'2.3.3', // Animation from Interactions (AAA)
|
|
44
|
+
'2.5.1', // Pointer Gestures (A)
|
|
45
|
+
'2.5.2', // Pointer Cancellation (A)
|
|
46
|
+
'2.5.3', // Label in Name (A)
|
|
47
|
+
'2.5.4', // Motion Actuation (A)
|
|
48
|
+
'2.5.5', // Target Size -- Enhanced (AAA)
|
|
49
|
+
'2.5.6', // Concurrent Input Mechanisms (AAA)
|
|
50
|
+
'4.1.3' // Status Messages (AA)
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// SCs newly introduced in WCAG 2.2 (not present in WCAG 2.0 or 2.1).
|
|
54
|
+
const WCAG22_NEW_SCS = [
|
|
55
|
+
'2.4.11', // Focus Not Obscured (Minimum) (AA)
|
|
56
|
+
'2.4.12', // Focus Not Obscured (Enhanced) (AAA)
|
|
57
|
+
'2.4.13', // Focus Appearance (AAA)
|
|
58
|
+
'2.5.7', // Dragging Movements (AA)
|
|
59
|
+
'2.5.8', // Target Size (Minimum) (AA)
|
|
60
|
+
'3.2.6', // Consistent Help (A)
|
|
61
|
+
'3.3.7', // Redundant Entry (A)
|
|
62
|
+
'3.3.8', // Accessible Authentication (Minimum) (AA)
|
|
63
|
+
'3.3.9' // Accessible Authentication (Enhanced) (AAA)
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// The only SC ever removed from WCAG: 4.1.1 Parsing, dropped in 2.2 because browsers
|
|
67
|
+
// recover from the malformed markup it was written for. A rule mapped to it carries
|
|
68
|
+
// the `wcag22-removed` tag alongside its `wcag2a` origin tag, so a 2.2 conformance run
|
|
69
|
+
// can exclude it while a 2.0/2.1 run keeps it (see docs/ENGINE_OPTIONS.md).
|
|
70
|
+
const WCAG22_REMOVED_SCS = ['4.1.1'];
|
|
71
|
+
|
|
72
|
+
// The version an SC was removed in, or null if it is still current.
|
|
73
|
+
function removedInVersion(sc) {
|
|
74
|
+
return WCAG22_REMOVED_SCS.includes(String(sc || '').trim()) ? '2.2' : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function introducedInVersion(sc) {
|
|
78
|
+
const s = String(sc || '').trim();
|
|
79
|
+
if (WCAG22_NEW_SCS.includes(s)) return '2.2';
|
|
80
|
+
if (WCAG21_NEW_SCS.includes(s)) return '2.1';
|
|
81
|
+
return '2.0';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Given a rule/composite's wcagSc list, returns the runOnly.tags prefix
|
|
85
|
+
// ('wcag2' | 'wcag21' | 'wcag22') it should be tagged with -- a 2.2-origin SC wins
|
|
86
|
+
// over a 2.1-origin one if a rule/composite ever spans both (rare; none do today).
|
|
87
|
+
function versionTagPrefixForScs(wcagScList) {
|
|
88
|
+
const list = Array.isArray(wcagScList) ? wcagScList.map(String) : [];
|
|
89
|
+
if (list.some((sc) => WCAG22_NEW_SCS.includes(sc))) return 'wcag22';
|
|
90
|
+
if (list.some((sc) => WCAG21_NEW_SCS.includes(sc))) return 'wcag21';
|
|
91
|
+
return 'wcag2';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
WCAG21_NEW_SCS,
|
|
96
|
+
WCAG22_NEW_SCS,
|
|
97
|
+
WCAG22_REMOVED_SCS,
|
|
98
|
+
introducedInVersion,
|
|
99
|
+
removedInVersion,
|
|
100
|
+
versionTagPrefixForScs
|
|
101
|
+
};
|
package/src/earl.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders scan results as an EARL 1.0 report in JSON-LD (docs/EARL.md), the
|
|
7
|
+
* format the W3C ACT Rules community group accepts as an implementation
|
|
8
|
+
* report and the interchange format most accessibility tooling reads.
|
|
9
|
+
*
|
|
10
|
+
* The graph is grouped by TestSubject rather than being a flat list of
|
|
11
|
+
* assertions: one entry per scanned URL, each carrying the assertions made
|
|
12
|
+
* about it. That is what the ACT earl-context expects, and it is why this
|
|
13
|
+
* takes an array of results as readily as one -- a report covering many pages
|
|
14
|
+
* is the normal case, not an extension.
|
|
15
|
+
*
|
|
16
|
+
* Every rule that ran becomes an assertion, including `pass` and
|
|
17
|
+
* `notApplicable`. That is the opposite of the SARIF and HTML reporters,
|
|
18
|
+
* which carry violations only: an implementation report is a claim about what
|
|
19
|
+
* the engine decided everywhere, and a rule that was silent because it found
|
|
20
|
+
* nothing applicable is evidence, not noise.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const EARL_CONTEXT = 'https://www.w3.org/WAI/content-assets/wcag-act-rules/earl-context.json';
|
|
24
|
+
|
|
25
|
+
// The engine's four outcomes onto EARL's vocabulary. `earl:untested` has no
|
|
26
|
+
// counterpart here: a rule that did not run produces no result to assert on.
|
|
27
|
+
const OUTCOME_TO_EARL = {
|
|
28
|
+
pass: 'earl:passed',
|
|
29
|
+
fail: 'earl:failed',
|
|
30
|
+
cantTell: 'earl:cantTell',
|
|
31
|
+
notApplicable: 'earl:inapplicable'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A Success Criterion's own id, as WCAG publishes it: the slug of its title.
|
|
36
|
+
* Derived rather than tabulated so a rule added with a new SC needs no second
|
|
37
|
+
* edit here, and pinned by a test that lists every slug the catalog produces.
|
|
38
|
+
*/
|
|
39
|
+
function scSlug(title) {
|
|
40
|
+
return String(title || '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[(),.:]/g, '')
|
|
43
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
44
|
+
.replace(/^-+|-+$/g, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `normativeMappings` also carries Understanding-document references and
|
|
49
|
+
* non-WCAG standards, which share `standard: 'WCAG'` and a `requirement` with
|
|
50
|
+
* the real thing. A Success Criterion is the entry that states a conformance
|
|
51
|
+
* level and claims no other document type.
|
|
52
|
+
*/
|
|
53
|
+
function wcagCriteria(check) {
|
|
54
|
+
const mappings = (check && check.meta && check.meta.normativeMappings) || [];
|
|
55
|
+
const slugs = new Set();
|
|
56
|
+
|
|
57
|
+
for (const m of mappings) {
|
|
58
|
+
if (!m || m.standard !== 'WCAG' || m.type || !m.conformanceLevel) continue;
|
|
59
|
+
const slug = scSlug(m.title);
|
|
60
|
+
if (slug) slugs.add(`WCAG2:${slug}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [...slugs].sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertionFor(check, assertor, mode) {
|
|
67
|
+
const outcome = OUTCOME_TO_EARL[check.outcome];
|
|
68
|
+
if (!outcome) return null;
|
|
69
|
+
|
|
70
|
+
const test = { title: check.ruleId };
|
|
71
|
+
const isPartOf = wcagCriteria(check);
|
|
72
|
+
if (isPartOf.length) test.isPartOf = isPartOf;
|
|
73
|
+
|
|
74
|
+
const assertion = {
|
|
75
|
+
'@type': 'Assertion',
|
|
76
|
+
test,
|
|
77
|
+
result: { outcome }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (assertor) assertion.assertedBy = assertor;
|
|
81
|
+
if (mode) assertion.mode = mode;
|
|
82
|
+
|
|
83
|
+
return assertion;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeAssertor(options) {
|
|
87
|
+
if (options.assertor === null) return null;
|
|
88
|
+
|
|
89
|
+
const supplied = options.assertor || {};
|
|
90
|
+
const assertor = { '@type': 'Assertor', name: supplied.name || 'surea11y' };
|
|
91
|
+
const revision = supplied.version || supplied.revision;
|
|
92
|
+
if (revision) assertor.release = { '@type': 'Version', revision: String(revision) };
|
|
93
|
+
return assertor;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {object|object[]} results one scan result, or several to report together
|
|
98
|
+
* @param {object} [options]
|
|
99
|
+
* @param {object|null} [options.assertor] `{ name, version }`; null omits it
|
|
100
|
+
* @param {string} [options.mode] an EARL test mode, e.g. `'earl:automatic'`
|
|
101
|
+
* @returns {object} the JSON-LD document
|
|
102
|
+
*/
|
|
103
|
+
function renderEarlReport(results, options = {}) {
|
|
104
|
+
const list = (Array.isArray(results) ? results : [results]).filter(
|
|
105
|
+
(r) => r && typeof r === 'object'
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const assertor = normalizeAssertor(options);
|
|
109
|
+
const mode = typeof options.mode === 'string' && options.mode ? options.mode : null;
|
|
110
|
+
|
|
111
|
+
// Several results for one URL merge into a single subject: a caller scanning
|
|
112
|
+
// the same page under different engineOptions still describes one resource,
|
|
113
|
+
// and the context has no way to express two subjects with the same source.
|
|
114
|
+
const bySource = new Map();
|
|
115
|
+
|
|
116
|
+
for (const result of list) {
|
|
117
|
+
const source = typeof result.url === 'string' && result.url ? result.url : 'about:blank';
|
|
118
|
+
const checks = Array.isArray(result.checksResults) ? result.checksResults : [];
|
|
119
|
+
|
|
120
|
+
if (!bySource.has(source)) bySource.set(source, new Map());
|
|
121
|
+
const assertions = bySource.get(source);
|
|
122
|
+
|
|
123
|
+
for (const check of checks) {
|
|
124
|
+
if (!check || typeof check.ruleId !== 'string') continue;
|
|
125
|
+
const assertion = assertionFor(check, assertor, mode);
|
|
126
|
+
if (assertion) assertions.set(check.ruleId, assertion);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const graph = [...bySource.keys()]
|
|
131
|
+
.sort()
|
|
132
|
+
.map((source) => ({
|
|
133
|
+
'@type': 'TestSubject',
|
|
134
|
+
source,
|
|
135
|
+
assertions: [...bySource.get(source).keys()]
|
|
136
|
+
.sort()
|
|
137
|
+
.map((ruleId) => bySource.get(source).get(ruleId))
|
|
138
|
+
}))
|
|
139
|
+
.filter((subject) => subject.assertions.length);
|
|
140
|
+
|
|
141
|
+
return { '@context': EARL_CONTEXT, '@graph': graph };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { renderEarlReport, EARL_CONTEXT, OUTCOME_TO_EARL, scSlug };
|
package/src/en301549.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The EN 301 549 chapter 9 clause for each WCAG Success Criterion, per
|
|
7
|
+
* version of the standard (docs/WCAG_CONFORMANCE.md#en-301-549). The same
|
|
8
|
+
* table the engine uses to add EN 301 549 entries to every result's
|
|
9
|
+
* `meta.normativeMappings`, published so tools can ask the reverse question:
|
|
10
|
+
* which criteria a given version requires, and which of them a scan never
|
|
11
|
+
* covered.
|
|
12
|
+
*
|
|
13
|
+
* The objects are frozen: they are the engine's own table, not copies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
EN301549_VERSIONS,
|
|
18
|
+
EN301549_CLAUSES,
|
|
19
|
+
en301549ClausesForSc
|
|
20
|
+
} = require('./coverage/en301549-map.js');
|
|
21
|
+
|
|
22
|
+
function deepFreeze(value) {
|
|
23
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
24
|
+
Object.freeze(value);
|
|
25
|
+
for (const v of Object.values(value)) deepFreeze(v);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
deepFreeze(EN301549_VERSIONS);
|
|
31
|
+
deepFreeze(EN301549_CLAUSES);
|
|
32
|
+
|
|
33
|
+
module.exports = { EN301549_VERSIONS, EN301549_CLAUSES, en301549ClausesForSc };
|
package/src/junit.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders one scan result as a JUnit XML report (docs/JUNIT.md), the test
|
|
7
|
+
* report format CI dashboards read natively: GitLab's merge request widget,
|
|
8
|
+
* Azure DevOps' Tests tab, Jenkins, CircleCI.
|
|
9
|
+
*
|
|
10
|
+
* One <testsuite> per WCAG Success Criterion, one <testcase> per rule mapped
|
|
11
|
+
* to it. A criterion is the unit people track, and a rule rather than an
|
|
12
|
+
* occurrence keeps test counts stable when the same defect repeats. Suites
|
|
13
|
+
* come from each rule's own WCAG mappings rather than from the composites, so
|
|
14
|
+
* every rule that ran is reported even when composites were excluded; a rule
|
|
15
|
+
* mapped to no criterion goes into a final "Other checks" suite, and a rule
|
|
16
|
+
* mapped to two criteria appears in both.
|
|
17
|
+
*
|
|
18
|
+
* JUnit has no "could not tell". A cantTell rule is <skipped> by default:
|
|
19
|
+
* surfaced for review, never turning a build red, the same line the SARIF
|
|
20
|
+
* reporter draws with "warning". `cantTellAs: 'failure'` gates on it instead.
|
|
21
|
+
* notApplicable rules are left out unless `includeNotApplicable` is set, since
|
|
22
|
+
* a page has hundreds of them and none says anything.
|
|
23
|
+
*
|
|
24
|
+
* `baselineEntries` (docs/BASELINE.md) drops fail occurrences already
|
|
25
|
+
* recorded there, as SARIF does. A rule whose every failure is known is
|
|
26
|
+
* <skipped> with the count, not reported as passing: it did not pass.
|
|
27
|
+
*
|
|
28
|
+
* No timings are invented. `time` is "0" throughout, so the same scan always
|
|
29
|
+
* renders byte-identical XML, and `timestamp` appears only when the result
|
|
30
|
+
* carries one (the engine has no clock of its own).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { computeBaselineKey, getReasonCode } = require('./baseline.js');
|
|
34
|
+
const { NORMATIVE_STANDARDS, standardOfEntry } = require('./coverage/standards.js');
|
|
35
|
+
|
|
36
|
+
const OTHER_SUITE = 'Other checks';
|
|
37
|
+
|
|
38
|
+
// XML 1.0 forbids most control characters, and lone surrogates, outright,
|
|
39
|
+
// escaped or not, and markup scraped from a page can carry them.
|
|
40
|
+
function isXmlChar(codePoint) {
|
|
41
|
+
return (
|
|
42
|
+
codePoint === 0x9 ||
|
|
43
|
+
codePoint === 0xa ||
|
|
44
|
+
codePoint === 0xd ||
|
|
45
|
+
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
|
46
|
+
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
|
47
|
+
codePoint >= 0x10000
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function xmlText(value) {
|
|
52
|
+
let text = '';
|
|
53
|
+
for (const ch of String(value == null ? '' : value)) {
|
|
54
|
+
if (isXmlChar(ch.codePointAt(0))) text += ch;
|
|
55
|
+
}
|
|
56
|
+
return text
|
|
57
|
+
.replace(/&/g, '&')
|
|
58
|
+
.replace(/</g, '<')
|
|
59
|
+
.replace(/>/g, '>')
|
|
60
|
+
.replace(/"/g, '"')
|
|
61
|
+
.replace(/'/g, ''');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compareCriteria(a, b) {
|
|
65
|
+
const pa = a.split('.').map(Number);
|
|
66
|
+
const pb = b.split('.').map(Number);
|
|
67
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
68
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
69
|
+
if (diff) return diff;
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Same test as src/sarif.js and src/report.js.
|
|
75
|
+
function isWcagCriterion(m) {
|
|
76
|
+
return !!(m && m.requirement && (m.standard == null || m.standard === 'WCAG') && !m.type);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mappingsOf(result) {
|
|
80
|
+
return (result && result.meta && result.meta.normativeMappings) || [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getOccurrenceOutcome(check, occurrence) {
|
|
84
|
+
const occurrenceOutcome =
|
|
85
|
+
occurrence &&
|
|
86
|
+
(occurrence.occurrenceOutcome === 'fail' || occurrence.occurrenceOutcome === 'cantTell'
|
|
87
|
+
? occurrence.occurrenceOutcome
|
|
88
|
+
: occurrence.outcome === 'fail' || occurrence.outcome === 'cantTell'
|
|
89
|
+
? occurrence.outcome
|
|
90
|
+
: null);
|
|
91
|
+
if (occurrenceOutcome) return occurrenceOutcome;
|
|
92
|
+
return check && (check.outcome === 'fail' || check.outcome === 'cantTell') ? check.outcome : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildRemainingBaselineMap(baselineEntries) {
|
|
96
|
+
const remaining = new Map();
|
|
97
|
+
for (const entry of Array.isArray(baselineEntries) ? baselineEntries : []) {
|
|
98
|
+
if (!entry) continue;
|
|
99
|
+
const key = computeBaselineKey(
|
|
100
|
+
entry.ruleId,
|
|
101
|
+
entry.reasonCode || 'DEFAULT',
|
|
102
|
+
typeof entry.html === 'string' ? entry.html : ''
|
|
103
|
+
);
|
|
104
|
+
remaining.set(key, (remaining.get(key) || 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
return remaining;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// What one rule contributes, decided once so that a rule appearing in two
|
|
110
|
+
// suites consumes its baseline entries only once.
|
|
111
|
+
function classify(check, remaining, options) {
|
|
112
|
+
const occurrences = Array.isArray(check.occurrences) ? check.occurrences.filter(Boolean) : [];
|
|
113
|
+
const failing = [];
|
|
114
|
+
const undecided = [];
|
|
115
|
+
let baselined = 0;
|
|
116
|
+
|
|
117
|
+
if (check.outcome === 'fail' || check.outcome === 'cantTell') {
|
|
118
|
+
for (const occurrence of occurrences) {
|
|
119
|
+
const outcome = getOccurrenceOutcome(check, occurrence);
|
|
120
|
+
if (outcome === 'fail') {
|
|
121
|
+
const html = typeof occurrence.html === 'string' ? occurrence.html : '';
|
|
122
|
+
const key = computeBaselineKey(check.ruleId, getReasonCode(occurrence), html);
|
|
123
|
+
const left = remaining.get(key) || 0;
|
|
124
|
+
if (left > 0) {
|
|
125
|
+
remaining.set(key, left - 1);
|
|
126
|
+
baselined++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
failing.push(occurrence);
|
|
130
|
+
} else if (outcome === 'cantTell') {
|
|
131
|
+
undecided.push(occurrence);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let status;
|
|
137
|
+
if (failing.length) status = 'failure';
|
|
138
|
+
else if (undecided.length) status = options.cantTellAs === 'failure' ? 'failure' : 'skipped';
|
|
139
|
+
else if (baselined) status = 'skipped';
|
|
140
|
+
else if (check.outcome === 'notApplicable') status = 'notApplicable';
|
|
141
|
+
else if (check.outcome === 'cantTell')
|
|
142
|
+
status = options.cantTellAs === 'failure' ? 'failure' : 'skipped';
|
|
143
|
+
else status = 'passed';
|
|
144
|
+
|
|
145
|
+
return { check, status, failing, undecided, baselined };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function describeOccurrence(occurrence) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
const message = [occurrence.summary, occurrence.hint].filter(Boolean).join(' ');
|
|
151
|
+
lines.push(`- ${message || '(no message)'}`);
|
|
152
|
+
if (occurrence.selector) lines.push(` selector: ${occurrence.selector}`);
|
|
153
|
+
if (typeof occurrence.html === 'string' && occurrence.html) {
|
|
154
|
+
lines.push(` html: ${occurrence.html}`);
|
|
155
|
+
}
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function plural(n, word) {
|
|
160
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function needReview(n) {
|
|
164
|
+
return `${plural(n, 'occurrence')} ${n === 1 ? 'needs' : 'need'} manual review`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderTestcase(entry, classname, indent) {
|
|
168
|
+
const { check, status, failing, undecided, baselined } = entry;
|
|
169
|
+
const open = `${indent}<testcase classname="${xmlText(classname)}" name="${xmlText(check.ruleId)}" time="0"`;
|
|
170
|
+
const inner = [];
|
|
171
|
+
|
|
172
|
+
if (status === 'failure' && failing.length) {
|
|
173
|
+
const message = `${plural(failing.length, 'failing occurrence')}: ${failing[0].summary || check.ruleId}`;
|
|
174
|
+
inner.push(
|
|
175
|
+
`${indent} <failure type="fail" message="${xmlText(message)}">${xmlText(failing.map(describeOccurrence).join('\n'))}</failure>`
|
|
176
|
+
);
|
|
177
|
+
} else if (status === 'failure') {
|
|
178
|
+
const message = undecided.length ? needReview(undecided.length) : 'Needs manual review';
|
|
179
|
+
inner.push(
|
|
180
|
+
`${indent} <failure type="cantTell" message="${xmlText(message)}">${xmlText(undecided.map(describeOccurrence).join('\n'))}</failure>`
|
|
181
|
+
);
|
|
182
|
+
} else if (status === 'skipped') {
|
|
183
|
+
const parts = [];
|
|
184
|
+
if (undecided.length) parts.push(needReview(undecided.length));
|
|
185
|
+
else if (check.outcome === 'cantTell' && !baselined) parts.push('Needs manual review');
|
|
186
|
+
if (baselined) parts.push(`${plural(baselined, 'known failure')} recorded in the baseline`);
|
|
187
|
+
inner.push(`${indent} <skipped message="${xmlText(parts.join('; '))}"/>`);
|
|
188
|
+
} else if (status === 'notApplicable') {
|
|
189
|
+
inner.push(`${indent} <skipped message="Not applicable"/>`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Undecided occurrences on a rule that also failed, or that were skipped,
|
|
193
|
+
// are still worth reading; they go where dashboards show test output.
|
|
194
|
+
if (undecided.length && !(status === 'failure' && !failing.length)) {
|
|
195
|
+
inner.push(
|
|
196
|
+
`${indent} <system-out>${xmlText(['Needs manual review:', ...undecided.map(describeOccurrence)].join('\n'))}</system-out>`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return inner.length ? `${open}>\n${inner.join('\n')}\n${indent}</testcase>` : `${open}/>`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function countStatus(entries, status) {
|
|
204
|
+
return entries.filter((e) => e.status === status).length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderJunitReport(result, options = {}) {
|
|
208
|
+
const opts = {
|
|
209
|
+
cantTellAs: options.cantTellAs === 'failure' ? 'failure' : 'skipped',
|
|
210
|
+
includeNotApplicable: options.includeNotApplicable === true
|
|
211
|
+
};
|
|
212
|
+
const remaining = buildRemainingBaselineMap(options.baselineEntries);
|
|
213
|
+
const engine = (result && result.engine) || {};
|
|
214
|
+
|
|
215
|
+
// Composite title and outcome per criterion, where the composite ran.
|
|
216
|
+
const composites = new Map();
|
|
217
|
+
for (const composite of (result && result.rulesResults) || []) {
|
|
218
|
+
const sc = mappingsOf(composite).find(isWcagCriterion);
|
|
219
|
+
if (sc && !composites.has(sc.requirement)) composites.set(sc.requirement, composite);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const suites = new Map(); // criterion (or OTHER_SUITE) -> { entries, level, standards }
|
|
223
|
+
const checks = ((result && result.checksResults) || []).filter((c) => c && c.ruleId);
|
|
224
|
+
for (const check of checks) {
|
|
225
|
+
const entry = classify(check, remaining, opts);
|
|
226
|
+
if (entry.status === 'notApplicable' && !opts.includeNotApplicable) continue;
|
|
227
|
+
|
|
228
|
+
const mappings = mappingsOf(check);
|
|
229
|
+
const criteria = mappings.filter(isWcagCriterion);
|
|
230
|
+
const keys = criteria.length
|
|
231
|
+
? [...new Set(criteria.map((m) => String(m.requirement)))]
|
|
232
|
+
: [OTHER_SUITE];
|
|
233
|
+
for (const key of keys) {
|
|
234
|
+
if (!suites.has(key)) suites.set(key, { entries: [], level: null, standards: new Map() });
|
|
235
|
+
const suite = suites.get(key);
|
|
236
|
+
suite.entries.push(entry);
|
|
237
|
+
const own = criteria.find((m) => String(m.requirement) === key);
|
|
238
|
+
if (own && !suite.level) suite.level = own.conformanceLevel || own.level || null;
|
|
239
|
+
// Another standard's entry goes under the criteria it corresponds to
|
|
240
|
+
// (its `wcagSc`); one that names none (no field, or an empty list, for a
|
|
241
|
+
// requirement outside WCAG) belongs to every criterion of its rule, and
|
|
242
|
+
// a rule with no criterion keeps all of its entries.
|
|
243
|
+
for (const m of mappings) {
|
|
244
|
+
const standard = standardOfEntry(m);
|
|
245
|
+
if (!standard) continue;
|
|
246
|
+
const named = Array.isArray(m.wcagSc) && m.wcagSc.length ? m.wcagSc.map(String) : null;
|
|
247
|
+
if (key !== OTHER_SUITE && named && !named.includes(key)) continue;
|
|
248
|
+
if (!suite.standards.has(standard.key)) suite.standards.set(standard.key, new Set());
|
|
249
|
+
suite.standards.get(standard.key).add(String(m.requirement));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const keys = [...suites.keys()].sort((a, b) => {
|
|
255
|
+
if (a === OTHER_SUITE) return 1;
|
|
256
|
+
if (b === OTHER_SUITE) return -1;
|
|
257
|
+
return compareCriteria(a, b);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const runProperties = [
|
|
261
|
+
['engine', engine.tag],
|
|
262
|
+
['schemaVersion', engine.schemaVersion],
|
|
263
|
+
['wcagVersion', engine.wcagVersion],
|
|
264
|
+
['profile', engine.profile],
|
|
265
|
+
['optInRules', Array.isArray(engine.optInRules) ? engine.optInRules.join(',') : null],
|
|
266
|
+
['locale', engine.locale && engine.locale.resolved],
|
|
267
|
+
['url', result && result.url]
|
|
268
|
+
].filter(([, v]) => v != null && v !== '');
|
|
269
|
+
|
|
270
|
+
const totals = { tests: 0, failures: 0, skipped: 0 };
|
|
271
|
+
const suiteXml = keys.map((key) => {
|
|
272
|
+
const suite = suites.get(key);
|
|
273
|
+
const entries = suite.entries
|
|
274
|
+
.slice()
|
|
275
|
+
.sort((a, b) => a.check.ruleId.localeCompare(b.check.ruleId));
|
|
276
|
+
const composite = key === OTHER_SUITE ? null : composites.get(key);
|
|
277
|
+
const name =
|
|
278
|
+
key === OTHER_SUITE
|
|
279
|
+
? OTHER_SUITE
|
|
280
|
+
: `WCAG ${key}${composite && composite.title ? ` ${composite.title}` : ''}`;
|
|
281
|
+
const classname = key === OTHER_SUITE ? 'other' : `wcag-${key}`;
|
|
282
|
+
const failures = countStatus(entries, 'failure');
|
|
283
|
+
const skipped = countStatus(entries, 'skipped') + countStatus(entries, 'notApplicable');
|
|
284
|
+
totals.tests += entries.length;
|
|
285
|
+
totals.failures += failures;
|
|
286
|
+
totals.skipped += skipped;
|
|
287
|
+
|
|
288
|
+
const properties = [
|
|
289
|
+
...(key === OTHER_SUITE ? [] : [['wcagCriterion', key]]),
|
|
290
|
+
...(suite.level ? [['wcagLevel', suite.level]] : []),
|
|
291
|
+
...NORMATIVE_STANDARDS.flatMap((standard) =>
|
|
292
|
+
[...(suite.standards.get(standard.key) || [])]
|
|
293
|
+
.sort((a, b) => compareCriteria(a, b) || a.localeCompare(b))
|
|
294
|
+
.map((requirement) => [standard.key, requirement])
|
|
295
|
+
),
|
|
296
|
+
...(composite && composite.outcome ? [['criterionOutcome', composite.outcome]] : []),
|
|
297
|
+
...runProperties
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
const timestamp = result && result.timestamp ? ` timestamp="${xmlText(result.timestamp)}"` : '';
|
|
301
|
+
return [
|
|
302
|
+
` <testsuite name="${xmlText(name)}" tests="${entries.length}" failures="${failures}" errors="0" skipped="${skipped}" time="0"${timestamp}>`,
|
|
303
|
+
' <properties>',
|
|
304
|
+
...properties.map(([k, v]) => ` <property name="${xmlText(k)}" value="${xmlText(v)}"/>`),
|
|
305
|
+
' </properties>',
|
|
306
|
+
...entries.map((entry) => renderTestcase(entry, classname, ' ')),
|
|
307
|
+
' </testsuite>'
|
|
308
|
+
].join('\n');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const name = typeof options.name === 'string' && options.name ? options.name : 'surea11y';
|
|
312
|
+
return [
|
|
313
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
314
|
+
`<testsuites name="${xmlText(name)}" tests="${totals.tests}" failures="${totals.failures}" errors="0" skipped="${totals.skipped}" time="0">`,
|
|
315
|
+
...suiteXml,
|
|
316
|
+
'</testsuites>',
|
|
317
|
+
''
|
|
318
|
+
].join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = { renderJunitReport };
|