@surea11y/core 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +8 -7
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +2 -2
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +26 -11
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- 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 +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- 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.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -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 +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- 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-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 +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- 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 +109 -5
- 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/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/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 +14285 -2219
- 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/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 +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- 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/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 };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The mapping of a standard whose requirements are linked to rules one by one,
|
|
7
|
+
* as a profile made with `npm run profile:new` describes it: what each result
|
|
8
|
+
* names, one rollup per requirement, and the checks the build runs on the
|
|
9
|
+
* tables. A profile hands it its tables and puts what it returns in its
|
|
10
|
+
* registry entry (ENTRY SHAPE in src/coverage/standards.js), so the code is
|
|
11
|
+
* core's, tested once, and a fix reaches every profile that uses it.
|
|
12
|
+
*
|
|
13
|
+
* The tables:
|
|
14
|
+
*
|
|
15
|
+
* - versions: [{ version, wcagVersion }], oldest first: the standard's version
|
|
16
|
+
* and the WCAG version it is built on ('2.0', '2.1' or '2.2').
|
|
17
|
+
* - requirements[version][id]: { title, wcagSc }. `id` is the standard's own
|
|
18
|
+
* number ('1.2', 'B4'...), `title` its wording, and `wcagSc` the WCAG
|
|
19
|
+
* criteria it corresponds to, each one a criterion of that WCAG version.
|
|
20
|
+
* - ruleMap[version][ruleId]: { requirements: [id, ...], note }, the
|
|
21
|
+
* requirements a rule checks: a core rule or one of the profile's own.
|
|
22
|
+
*
|
|
23
|
+
* A standard whose mapping needs more (tests grouped into criteria, related
|
|
24
|
+
* to WCAG many to many, say) writes its own functions instead.
|
|
25
|
+
*
|
|
26
|
+
* It loads no engine code, so a profile's own files may require it
|
|
27
|
+
* (profiles/README.md, "What a profile may use").
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { WCAG_VERSIONS, wcagCriterion, wcagTags } = require('./wcag.js');
|
|
31
|
+
|
|
32
|
+
// Requirement ids in their natural order: '1.2' before '1.10'.
|
|
33
|
+
function compareIds(a, b) {
|
|
34
|
+
return String(a).localeCompare(String(b), 'en', { numeric: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* { mappingsFor, composites, validate, wcagTagsOf } for the registry entry of
|
|
39
|
+
* a standard named `standard`, whose rules and rollups carry `tag`.
|
|
40
|
+
*/
|
|
41
|
+
function ruleMappedStandard({ standard, tag, versions, requirements, ruleMap }) {
|
|
42
|
+
function requirementsOf(version, ruleId) {
|
|
43
|
+
const row = (ruleMap[version] || {})[ruleId];
|
|
44
|
+
return row && Array.isArray(row.requirements) ? row.requirements : [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entry(version, id) {
|
|
48
|
+
const req = requirements[version][id];
|
|
49
|
+
return {
|
|
50
|
+
standard,
|
|
51
|
+
version,
|
|
52
|
+
requirement: id,
|
|
53
|
+
title: req.title,
|
|
54
|
+
wcagSc: Array.isArray(req.wcagSc) ? req.wcagSc.slice() : []
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The entries for a rule ({ id }) or, given `checksIds`, for a rollup: the
|
|
59
|
+
// requirements its rules check, oldest version first.
|
|
60
|
+
function mappingsFor({ id, checksIds }) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const { version } of versions) {
|
|
63
|
+
const ids = new Set();
|
|
64
|
+
for (const ruleId of Array.isArray(checksIds) ? checksIds : [id]) {
|
|
65
|
+
for (const req of requirementsOf(version, ruleId)) ids.add(req);
|
|
66
|
+
}
|
|
67
|
+
for (const req of [...ids].sort(compareIds)) out.push(entry(version, req));
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One rollup per requirement a rule checks, grouping those rules. They carry
|
|
73
|
+
// the standard's tag, so only a run that asks for it produces them.
|
|
74
|
+
function composites() {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const { version } of versions) {
|
|
77
|
+
const table = ruleMap[version] || {};
|
|
78
|
+
for (const id of Object.keys(requirements[version] || {}).sort(compareIds)) {
|
|
79
|
+
const ruleIds = Object.keys(table)
|
|
80
|
+
.filter((ruleId) => requirementsOf(version, ruleId).includes(id))
|
|
81
|
+
.sort();
|
|
82
|
+
if (!ruleIds.length) continue;
|
|
83
|
+
out.push({
|
|
84
|
+
id: `${tag}-${version}-${id}`,
|
|
85
|
+
checksIds: ruleIds,
|
|
86
|
+
meta: {
|
|
87
|
+
title: requirements[version][id].title,
|
|
88
|
+
description: '',
|
|
89
|
+
wcagSc: [],
|
|
90
|
+
level: null,
|
|
91
|
+
standard,
|
|
92
|
+
version,
|
|
93
|
+
criterion: id,
|
|
94
|
+
tags: [tag],
|
|
95
|
+
standardMappings: [entry(version, id)]
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Problems with the tables, given every rule ([{ ruleId, wcagSc }]). The
|
|
104
|
+
// build fails on any.
|
|
105
|
+
function validate(rules) {
|
|
106
|
+
const known = new Set(rules.map((r) => r.ruleId));
|
|
107
|
+
const problems = [];
|
|
108
|
+
const listed = versions.map((v) => v.version);
|
|
109
|
+
for (const { version, wcagVersion } of versions) {
|
|
110
|
+
if (!requirements[version]) {
|
|
111
|
+
problems.push(`version ${version} has no requirements table`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!WCAG_VERSIONS.includes(wcagVersion)) {
|
|
115
|
+
problems.push(`version ${version}: wcagVersion must be one of ${WCAG_VERSIONS.join(', ')}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// A requirement corresponds to criteria of the WCAG version it is built on.
|
|
119
|
+
for (const [id, req] of Object.entries(requirements[version])) {
|
|
120
|
+
for (const sc of (req && req.wcagSc) || []) {
|
|
121
|
+
if (!wcagCriterion(sc, wcagVersion)) {
|
|
122
|
+
problems.push(`${version} ${id}: WCAG ${wcagVersion} has no criterion ${sc}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const [version, table] of Object.entries(ruleMap)) {
|
|
128
|
+
if (!listed.includes(version)) {
|
|
129
|
+
problems.push(`the rule map names version ${version}, which versions does not list`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const [ruleId, row] of Object.entries(table)) {
|
|
133
|
+
if (!known.has(ruleId)) problems.push(`${version} ${ruleId}: no such rule`);
|
|
134
|
+
const reqs = row && Array.isArray(row.requirements) ? row.requirements : null;
|
|
135
|
+
if (!reqs) {
|
|
136
|
+
problems.push(`${version} ${ruleId}: requirements must be a list`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const id of reqs) {
|
|
140
|
+
if (!requirements[version] || !requirements[version][id]) {
|
|
141
|
+
problems.push(`${version} ${ruleId}: no requirement ${id}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (new Set(reqs).size !== reqs.length) {
|
|
145
|
+
problems.push(`${version} ${ruleId}: a requirement is listed twice`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return problems;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The WCAG A and AA tags of the WCAG version a version is built on, for its
|
|
153
|
+
// profile in the registry entry.
|
|
154
|
+
function wcagTagsOf(version) {
|
|
155
|
+
const v = versions.find((x) => x.version === version);
|
|
156
|
+
if (!v) throw new Error(`${standard} has no version ${version}`);
|
|
157
|
+
return wcagTags(v.wcagVersion);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { mappingsFor, composites, validate, wcagTagsOf };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { ruleMappedStandard };
|