@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. 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 };
@@ -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, '&amp;')
58
+ .replace(/</g, '&lt;')
59
+ .replace(/>/g, '&gt;')
60
+ .replace(/"/g, '&quot;')
61
+ .replace(/'/g, '&apos;');
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 };