@surea11y/core 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -7
- package/LICENSE +373 -21
- package/README.md +67 -1
- package/bin/core.js +144 -19
- package/docs/API_STABILITY.md +1 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +54 -1
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +18 -0
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +42 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +55 -16
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +42 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +30 -8
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +109 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
- package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +17 -6
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -7
- package/src/checks/manual/empty-table-header-manual.js +17 -6
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -7
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
- package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +26 -20
- package/src/checks/manual/landmark-unique-manual.js +41 -15
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +16 -5
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +31 -22
- package/src/checks/manual/page-title-patterns-manual.js +78 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +241 -48
- package/src/checks/manual/scope-attr-valid-manual.js +10 -3
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +10 -3
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1127 -886
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4175 -3917
- package/src/core/dom-runner.js +720 -604
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +58 -41
- package/src/core.js +36552 -29111
- package/src/i18n/en.js +1194 -889
- package/src/i18n/fr.js +1136 -795
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +63 -43
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
package/README.md
CHANGED
|
@@ -198,6 +198,66 @@ For complete integration examples, see `docs/INTEGRATION.md`.
|
|
|
198
198
|
|
|
199
199
|
---
|
|
200
200
|
|
|
201
|
+
#### Standalone browser bundle
|
|
202
|
+
|
|
203
|
+
For a page that isn't driven by an automation framework at all — a manual
|
|
204
|
+
QA pass, a bookmarklet, a browser extension — the package also ships a
|
|
205
|
+
self-contained bundle that needs no `require`, no bundler, and no build
|
|
206
|
+
step:
|
|
207
|
+
|
|
208
|
+
```html
|
|
209
|
+
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
|
|
210
|
+
<script>
|
|
211
|
+
const result = a11ycore.runa11yCoreInPage(
|
|
212
|
+
location.href, // pageUrl
|
|
213
|
+
null, // contextSelector
|
|
214
|
+
{}, // engineOptions
|
|
215
|
+
null // runOnly
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
console.log(result.checksResults.filter(r => r.outcome === "fail"));
|
|
219
|
+
</script>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Loading `surea11y.browser.js` defines a single global, `a11ycore`, exposing
|
|
223
|
+
the same `runa11yCoreInPage` function described above — calling it runs a
|
|
224
|
+
real scan against the page it's loaded into and returns the same result
|
|
225
|
+
shape documented in [Understanding the Results](#understanding-the-results).
|
|
226
|
+
|
|
227
|
+
`contextSelector`, `engineOptions`, and `runOnly` are the same three
|
|
228
|
+
arguments described throughout this README and `docs/ENGINE_OPTIONS.md` —
|
|
229
|
+
nothing about calling the engine changes just because it's loaded this
|
|
230
|
+
way. A scan scoped to one region, filtered to a specific WCAG level, with
|
|
231
|
+
a couple of known-noisy selectors excluded, looks like this:
|
|
232
|
+
|
|
233
|
+
```html
|
|
234
|
+
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
|
|
235
|
+
<script>
|
|
236
|
+
const result = a11ycore.runa11yCoreInPage(
|
|
237
|
+
location.href,
|
|
238
|
+
"#main", // contextSelector: scan only this region
|
|
239
|
+
{
|
|
240
|
+
excludeSelectors: ["#cookie-banner", ".intercom-launcher"],
|
|
241
|
+
contrast: { mode: "auditorAssist" } // trade some false-positive protection for more findings
|
|
242
|
+
},
|
|
243
|
+
{ tags: ["wcag2a", "wcag2aa"] } // runOnly: WCAG 2.0 A/AA rules only
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
console.log(result.checksResults.filter(r => r.outcome === "fail"));
|
|
247
|
+
</script>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
This bundle is generated from the same rule sources as the rest of the
|
|
251
|
+
engine (`npm run build` regenerates it alongside `src/core.js`), so its
|
|
252
|
+
behavior never drifts from the library's. It intentionally exposes only
|
|
253
|
+
`runa11yCoreInPage` — cross-frame scanning
|
|
254
|
+
(`runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder`) requires the
|
|
255
|
+
embedded frame to also load the engine and opt in, which doesn't fit a
|
|
256
|
+
single dropped-in `<script>` tag; reach for the npm package directly if
|
|
257
|
+
you need that.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
201
261
|
## Understanding the Results
|
|
202
262
|
|
|
203
263
|
Every scan returns a structured JSON document designed for both
|
|
@@ -274,6 +334,8 @@ and progressively explore more advanced features.
|
|
|
274
334
|
| `docs/CLI.md` | CLI commands, options, exit codes and examples. |
|
|
275
335
|
| `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
|
|
276
336
|
| `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
|
|
337
|
+
| `docs/SARIF.md` | SARIF 2.1.0 report for GitHub Code Scanning and other SARIF dashboards. |
|
|
338
|
+
| `docs/CI_INTEGRATIONS.md` | GitHub Actions and Bitbucket Pipelines templates wrapping the CLI. |
|
|
277
339
|
| `docs/ENGINE_OPTIONS.md` | Configuration, filtering, policies and localization. |
|
|
278
340
|
| `docs/INTEGRATION.md` | Using surea11y with jsdom, Playwright, Puppeteer, Selenium, Cypress and other drivers. |
|
|
279
341
|
| `docs/BINDING_AUTHORS_GUIDE.md` | Building new framework integrations on top of the engine. |
|
|
@@ -341,6 +403,8 @@ The repository is organised so that the accessibility engine, rule
|
|
|
341
403
|
implementations and supporting infrastructure remain clearly separated.
|
|
342
404
|
|
|
343
405
|
```text
|
|
406
|
+
surea11y.browser.js # Generated standalone browser bundle
|
|
407
|
+
|
|
344
408
|
bin/
|
|
345
409
|
core.js # CLI entry point
|
|
346
410
|
|
|
@@ -423,10 +487,12 @@ supported versions and the preferred disclosure process.
|
|
|
423
487
|
|
|
424
488
|
## License
|
|
425
489
|
|
|
426
|
-
This project is released under the
|
|
490
|
+
This project is released under the Mozilla Public License 2.0 (MPL-2.0).
|
|
427
491
|
|
|
428
492
|
See the accompanying `LICENSE` file for the complete license text.
|
|
429
493
|
|
|
494
|
+
MPL-2.0 is file-level copyleft: it applies to `@surea11y/core`'s own source files, not to code that merely depends on it. A project that installs `@surea11y/core` as a normal package dependency and imports its public API — without copying or modifying this repository's source files — is unaffected by MPL-2.0 and may keep its own license (including a permissive one like MIT).
|
|
495
|
+
|
|
430
496
|
---
|
|
431
497
|
|
|
432
498
|
## Final Notes
|
package/bin/core.js
CHANGED
|
@@ -23,6 +23,7 @@ const path = require('path');
|
|
|
23
23
|
const pkg = require('../package.json');
|
|
24
24
|
const { buildBaselineEntries, matchBaseline } = require('../src/baseline.js');
|
|
25
25
|
const { renderHtmlReport } = require('../src/report.js');
|
|
26
|
+
const { renderSarifReport } = require('../src/sarif.js');
|
|
26
27
|
|
|
27
28
|
// Piping output to `head`/`less`/etc. closes stdout early — without this,
|
|
28
29
|
// the next write throws an unhandled EPIPE and crashes with a raw stack
|
|
@@ -45,9 +46,11 @@ Options:
|
|
|
45
46
|
--exclude-rules <ids> Comma-separated rule IDs to exclude
|
|
46
47
|
--tags <tags> Comma-separated tags to run (e.g. wcag2a,wcag2aa)
|
|
47
48
|
--context <selector> CSS selector to scope the scan to a subtree
|
|
49
|
+
--custom-rules <path> Load runtime custom rules from a JS file (repeatable)
|
|
48
50
|
--write-baseline <path> Write every current "fail" occurrence to <path>; never fails the build
|
|
49
51
|
--baseline <path> Gate only on occurrences not already recorded in <path>
|
|
50
52
|
--html <path> Write a self-contained, browsable HTML report to <path>
|
|
53
|
+
--sarif <path> Write a SARIF 2.1.0 report to <path> (for GitHub Code Scanning etc.)
|
|
51
54
|
-h, --help Show this help
|
|
52
55
|
-v, --version Show the installed version
|
|
53
56
|
|
|
@@ -63,8 +66,11 @@ Examples:
|
|
|
63
66
|
surea11y scan ./index.html --write-baseline baseline.json
|
|
64
67
|
surea11y scan ./index.html --baseline baseline.json
|
|
65
68
|
surea11y scan ./index.html --html report.html
|
|
69
|
+
surea11y scan ./index.html --baseline baseline.json --sarif results.sarif
|
|
70
|
+
surea11y scan ./index.html --custom-rules ./a11y-rules.js
|
|
66
71
|
|
|
67
|
-
See docs/BASELINE.md for the baseline/allowlist mechanism, docs/REPORT.md for the HTML report
|
|
72
|
+
See docs/BASELINE.md for the baseline/allowlist mechanism, docs/REPORT.md for the HTML report,
|
|
73
|
+
docs/SARIF.md for the SARIF report, docs/CLI.md#custom-rules for custom rules.
|
|
68
74
|
`);
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -91,6 +97,9 @@ function parseArgs(argv) {
|
|
|
91
97
|
case '--context':
|
|
92
98
|
out.context = argv[++i];
|
|
93
99
|
break;
|
|
100
|
+
case '--custom-rules':
|
|
101
|
+
(out.customRules = out.customRules || []).push(argv[++i]);
|
|
102
|
+
break;
|
|
94
103
|
case '--baseline':
|
|
95
104
|
out.baseline = argv[++i];
|
|
96
105
|
break;
|
|
@@ -100,6 +109,9 @@ function parseArgs(argv) {
|
|
|
100
109
|
case '--html':
|
|
101
110
|
out.html = argv[++i];
|
|
102
111
|
break;
|
|
112
|
+
case '--sarif':
|
|
113
|
+
out.sarif = argv[++i];
|
|
114
|
+
break;
|
|
103
115
|
case '-h':
|
|
104
116
|
case '--help':
|
|
105
117
|
out.help = true;
|
|
@@ -121,7 +133,12 @@ function isUrl(s) {
|
|
|
121
133
|
|
|
122
134
|
function formatError(err) {
|
|
123
135
|
const base = err && err.message ? err.message : String(err);
|
|
124
|
-
const cause =
|
|
136
|
+
const cause =
|
|
137
|
+
err && err.cause && err.cause.message
|
|
138
|
+
? err.cause.message
|
|
139
|
+
: err && err.cause
|
|
140
|
+
? String(err.cause)
|
|
141
|
+
: '';
|
|
125
142
|
return cause ? `${base}: ${cause}` : base;
|
|
126
143
|
}
|
|
127
144
|
|
|
@@ -141,7 +158,7 @@ async function loadHtml(target) {
|
|
|
141
158
|
return { html: fs.readFileSync(resolved, 'utf8'), url: `file://${resolved}` };
|
|
142
159
|
}
|
|
143
160
|
|
|
144
|
-
function buildEngineOptions(args) {
|
|
161
|
+
function buildEngineOptions(args, customRules) {
|
|
145
162
|
const engineOptions = {};
|
|
146
163
|
if (args.locale) engineOptions.locale = args.locale;
|
|
147
164
|
if (args.rules || args.excludeRules) {
|
|
@@ -150,9 +167,48 @@ function buildEngineOptions(args) {
|
|
|
150
167
|
if (args.excludeRules) engineOptions.rules.exclude = args.excludeRules;
|
|
151
168
|
}
|
|
152
169
|
if (args.tags) engineOptions.tags = { include: args.tags };
|
|
170
|
+
if (customRules && customRules.length) engineOptions.customRules = customRules;
|
|
153
171
|
return engineOptions;
|
|
154
172
|
}
|
|
155
173
|
|
|
174
|
+
// Loads one --custom-rules file. The CLI runs custom rules in the same
|
|
175
|
+
// process/JS realm as the scan itself (unlike a binding whose engineOptions
|
|
176
|
+
// crosses page.evaluate()'s serialization boundary), so a descriptor's
|
|
177
|
+
// runInPage/applicability may be a real function -- see the customRules
|
|
178
|
+
// section of docs/ENGINE_OPTIONS.md for the full descriptor contract this
|
|
179
|
+
// mirrors. Validated up front (not left to the engine's own silent-skip of
|
|
180
|
+
// invalid entries) so a typo in a hand-authored file fails loudly as a usage
|
|
181
|
+
// error instead of quietly producing a scan with one fewer rule than expected.
|
|
182
|
+
function loadCustomRulesFile(customRulesPath) {
|
|
183
|
+
const resolved = path.resolve(process.cwd(), customRulesPath);
|
|
184
|
+
|
|
185
|
+
let loaded;
|
|
186
|
+
try {
|
|
187
|
+
loaded = require(resolved);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new Error(`Could not load custom rules file "${customRulesPath}": ${formatError(err)}`, {
|
|
190
|
+
cause: err
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const descriptors = Array.isArray(loaded) ? loaded : [loaded];
|
|
195
|
+
|
|
196
|
+
for (const d of descriptors) {
|
|
197
|
+
const hasId = d && typeof d === 'object' && typeof d.id === 'string' && d.id.trim();
|
|
198
|
+
const hasRunInPage =
|
|
199
|
+
d &&
|
|
200
|
+
(typeof d.runInPage === 'function' ||
|
|
201
|
+
(typeof d.runInPage === 'string' && d.runInPage.trim()));
|
|
202
|
+
if (!hasId || !hasRunInPage) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Custom rules file "${customRulesPath}" must export a rule descriptor ({ id, runInPage, ... }) or an array of them. See docs/CLI.md#custom-rules.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return descriptors;
|
|
210
|
+
}
|
|
211
|
+
|
|
156
212
|
function printSummary(result, baselineMatch) {
|
|
157
213
|
const byOutcome = { pass: 0, fail: 0, cantTell: 0, notApplicable: 0 };
|
|
158
214
|
for (const r of result.checksResults) {
|
|
@@ -160,13 +216,17 @@ function printSummary(result, baselineMatch) {
|
|
|
160
216
|
}
|
|
161
217
|
|
|
162
218
|
process.stdout.write(`\nsurea11y scan: ${result.url || '(no url)'}\n`);
|
|
163
|
-
process.stdout.write(
|
|
219
|
+
process.stdout.write(
|
|
220
|
+
` pass: ${byOutcome.pass} fail: ${byOutcome.fail} cantTell: ${byOutcome.cantTell} notApplicable: ${byOutcome.notApplicable}\n\n`
|
|
221
|
+
);
|
|
164
222
|
|
|
165
223
|
const fails = result.checksResults.filter((r) => r.outcome === 'fail');
|
|
166
224
|
if (fails.length) {
|
|
167
225
|
process.stdout.write(`FAIL (${fails.length} rule(s)):\n`);
|
|
168
226
|
for (const r of fails) {
|
|
169
|
-
process.stdout.write(
|
|
227
|
+
process.stdout.write(
|
|
228
|
+
`\n ${r.ruleId} (${r.severity}, ${r.occurrences.length} occurrence(s))\n`
|
|
229
|
+
);
|
|
170
230
|
for (const occ of r.occurrences.slice(0, 5)) {
|
|
171
231
|
process.stdout.write(` - ${occ.selector || '(no selector)'}\n ${occ.summary}\n`);
|
|
172
232
|
if (occ.hint) process.stdout.write(` hint: ${occ.hint}\n`);
|
|
@@ -180,15 +240,21 @@ function printSummary(result, baselineMatch) {
|
|
|
180
240
|
|
|
181
241
|
const cantTells = result.checksResults.filter((r) => r.outcome === 'cantTell');
|
|
182
242
|
if (cantTells.length) {
|
|
183
|
-
process.stdout.write(
|
|
243
|
+
process.stdout.write(
|
|
244
|
+
`cantTell — needs human review (${cantTells.length} rule(s)): ${cantTells.map((r) => r.ruleId).join(', ')}\n\n`
|
|
245
|
+
);
|
|
184
246
|
}
|
|
185
247
|
|
|
186
248
|
if (baselineMatch) {
|
|
187
|
-
process.stdout.write(
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
`baseline: ${baselineMatch.knownCount} known, ${baselineMatch.newCount} new, ${baselineMatch.staleCount} stale (no longer detected)\n`
|
|
251
|
+
);
|
|
188
252
|
if (baselineMatch.newCount) {
|
|
189
253
|
process.stdout.write(`\nNEW (not in baseline, ${baselineMatch.newCount} occurrence(s)):\n`);
|
|
190
254
|
for (const occ of baselineMatch.newOccurrences.slice(0, 5)) {
|
|
191
|
-
process.stdout.write(
|
|
255
|
+
process.stdout.write(
|
|
256
|
+
` - ${occ.ruleId}: ${occ.selector || '(no selector)'}\n ${occ.summary}\n`
|
|
257
|
+
);
|
|
192
258
|
}
|
|
193
259
|
if (baselineMatch.newOccurrences.length > 5) {
|
|
194
260
|
process.stdout.write(` ... and ${baselineMatch.newOccurrences.length - 5} more\n`);
|
|
@@ -203,18 +269,25 @@ function loadBaselineFile(baselinePath) {
|
|
|
203
269
|
try {
|
|
204
270
|
raw = fs.readFileSync(baselinePath, 'utf8');
|
|
205
271
|
} catch (err) {
|
|
206
|
-
throw new Error(
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Could not read baseline file "${baselinePath}": ${formatError(err)}. Run with --write-baseline ${baselinePath} first to create one.`,
|
|
274
|
+
{ cause: err }
|
|
275
|
+
);
|
|
207
276
|
}
|
|
208
277
|
|
|
209
278
|
let parsed;
|
|
210
279
|
try {
|
|
211
280
|
parsed = JSON.parse(raw);
|
|
212
281
|
} catch (err) {
|
|
213
|
-
throw new Error(`Baseline file "${baselinePath}" is not valid JSON: ${formatError(err)}
|
|
282
|
+
throw new Error(`Baseline file "${baselinePath}" is not valid JSON: ${formatError(err)}`, {
|
|
283
|
+
cause: err
|
|
284
|
+
});
|
|
214
285
|
}
|
|
215
286
|
|
|
216
287
|
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) {
|
|
217
|
-
throw new Error(
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Baseline file "${baselinePath}" is not a supported baseline (expected { version: 1, entries: [...] }). Regenerate it with --write-baseline.`
|
|
290
|
+
);
|
|
218
291
|
}
|
|
219
292
|
|
|
220
293
|
return parsed;
|
|
@@ -229,7 +302,9 @@ async function runScan(args) {
|
|
|
229
302
|
}
|
|
230
303
|
|
|
231
304
|
if (args.baseline && args.writeBaseline) {
|
|
232
|
-
process.stderr.write(
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
'Error: --baseline and --write-baseline cannot be used together in the same run. See --help.\n'
|
|
307
|
+
);
|
|
233
308
|
process.exitCode = 2;
|
|
234
309
|
return;
|
|
235
310
|
}
|
|
@@ -245,6 +320,19 @@ async function runScan(args) {
|
|
|
245
320
|
}
|
|
246
321
|
}
|
|
247
322
|
|
|
323
|
+
let customRules = [];
|
|
324
|
+
if (args.customRules && args.customRules.length) {
|
|
325
|
+
try {
|
|
326
|
+
for (const customRulesPath of args.customRules) {
|
|
327
|
+
customRules = customRules.concat(loadCustomRulesFile(customRulesPath));
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
process.stderr.write(`Error: ${formatError(err)}\n`);
|
|
331
|
+
process.exitCode = 2;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
248
336
|
let html, url;
|
|
249
337
|
try {
|
|
250
338
|
({ html, url } = await loadHtml(target));
|
|
@@ -258,7 +346,9 @@ async function runScan(args) {
|
|
|
258
346
|
try {
|
|
259
347
|
({ JSDOM } = require('jsdom'));
|
|
260
348
|
} catch {
|
|
261
|
-
process.stderr.write(
|
|
349
|
+
process.stderr.write(
|
|
350
|
+
'Error: the surea11y CLI requires jsdom. Run `npm install jsdom` (it should already be a dependency of this package — this likely means a broken install).\n'
|
|
351
|
+
);
|
|
262
352
|
process.exitCode = 2;
|
|
263
353
|
return;
|
|
264
354
|
}
|
|
@@ -271,27 +361,58 @@ async function runScan(args) {
|
|
|
271
361
|
|
|
272
362
|
let result;
|
|
273
363
|
try {
|
|
274
|
-
result = runDomRulesInPage(
|
|
364
|
+
result = runDomRulesInPage(
|
|
365
|
+
url,
|
|
366
|
+
args.context || null,
|
|
367
|
+
buildEngineOptions(args, customRules),
|
|
368
|
+
null
|
|
369
|
+
);
|
|
275
370
|
} finally {
|
|
276
371
|
dom.window.close();
|
|
277
372
|
}
|
|
278
373
|
|
|
279
374
|
if (args.html) {
|
|
280
|
-
fs.writeFileSync(
|
|
375
|
+
fs.writeFileSync(
|
|
376
|
+
args.html,
|
|
377
|
+
renderHtmlReport(result, { title: `surea11y scan report — ${target}` })
|
|
378
|
+
);
|
|
281
379
|
process.stderr.write(`Wrote HTML report to: ${args.html}\n`);
|
|
282
380
|
}
|
|
283
381
|
|
|
382
|
+
if (args.sarif) {
|
|
383
|
+
fs.writeFileSync(
|
|
384
|
+
args.sarif,
|
|
385
|
+
renderSarifReport(result, {
|
|
386
|
+
toolVersion: pkg.version,
|
|
387
|
+
informationUri: (pkg.homepage || '').replace(/#.*$/, ''),
|
|
388
|
+
baselineEntries: baselineFile ? baselineFile.entries : undefined
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
process.stderr.write(`Wrote SARIF report to: ${args.sarif}\n`);
|
|
392
|
+
}
|
|
393
|
+
|
|
284
394
|
if (args.writeBaseline) {
|
|
285
395
|
const entries = buildBaselineEntries(result);
|
|
286
396
|
const payload = { version: 1, generatedAt: new Date().toISOString(), entries };
|
|
287
397
|
fs.writeFileSync(args.writeBaseline, JSON.stringify(payload, null, 2) + '\n');
|
|
288
398
|
|
|
289
399
|
if (args.json) {
|
|
290
|
-
process.stdout.write(
|
|
400
|
+
process.stdout.write(
|
|
401
|
+
JSON.stringify(
|
|
402
|
+
{
|
|
403
|
+
...result,
|
|
404
|
+
baseline: { mode: 'write', path: args.writeBaseline, entries: entries.length }
|
|
405
|
+
},
|
|
406
|
+
null,
|
|
407
|
+
2
|
|
408
|
+
) + '\n'
|
|
409
|
+
);
|
|
291
410
|
} else {
|
|
292
411
|
printSummary(result);
|
|
293
412
|
}
|
|
294
|
-
process.stderr.write(
|
|
413
|
+
process.stderr.write(
|
|
414
|
+
`Wrote ${entries.length} occurrence(s) to baseline: ${args.writeBaseline}\n`
|
|
415
|
+
);
|
|
295
416
|
process.exitCode = 0;
|
|
296
417
|
return;
|
|
297
418
|
}
|
|
@@ -300,7 +421,9 @@ async function runScan(args) {
|
|
|
300
421
|
const match = matchBaseline(result, baselineFile.entries);
|
|
301
422
|
|
|
302
423
|
if (args.json) {
|
|
303
|
-
process.stdout.write(
|
|
424
|
+
process.stdout.write(
|
|
425
|
+
JSON.stringify({ ...result, baseline: { mode: 'check', ...match } }, null, 2) + '\n'
|
|
426
|
+
);
|
|
304
427
|
} else {
|
|
305
428
|
printSummary(result, match);
|
|
306
429
|
}
|
|
@@ -333,7 +456,9 @@ async function main() {
|
|
|
333
456
|
|
|
334
457
|
const [command, ...rest] = args._;
|
|
335
458
|
if (command !== 'scan') {
|
|
336
|
-
process.stderr.write(
|
|
459
|
+
process.stderr.write(
|
|
460
|
+
`Error: unknown command "${command}". Only "scan" is supported. See --help.\n`
|
|
461
|
+
);
|
|
337
462
|
process.exitCode = 2;
|
|
338
463
|
return;
|
|
339
464
|
}
|
package/docs/API_STABILITY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# API stability & versioning
|
|
2
2
|
|
|
3
|
-
`@surea11y/core` has real downstream consumers today
|
|
3
|
+
`@surea11y/core` has real downstream consumers today: 5 first-party framework bindings (Playwright, Puppeteer, Selenium, WebdriverIO, Cypress) published to npm, plus a Jest/Vitest matcher (`@surea11y/test-matchers`, `toHaveNoA11yViolations()`) — all pinned to a `^1.1.0`-style semver range. Until now, "what counts as a breaking change" was implicit — discoverable only by reading source, not written down anywhere. This document makes that contract explicit.
|
|
4
4
|
|
|
5
5
|
## Stable fields (covered by semver)
|
|
6
6
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Binding authors' guide
|
|
2
2
|
|
|
3
|
-
Written for whoever builds the *next* framework binding on top of
|
|
3
|
+
Written for whoever builds the *next* framework binding on top of `@surea11y/core` (Puppeteer, Cypress, Selenium, WebdriverIO, whatever comes after) — not for someone consuming a binding, and not for someone calling the engine directly. If that's you, see [`INTEGRATION.md`](./INTEGRATION.md) instead.
|
|
4
4
|
|
|
5
|
-
The first real binding,
|
|
5
|
+
The first real binding, `@surea11y/playwright` (a sibling project, not part of this repo), has already worked through most of the design questions a new binding hits. This doc exists so the next one doesn't have to re-derive them — it's a checklist and a map of "what the engine already gives you for free" vs. "what every binding has to build itself," backed by what that binding actually did, not theory.
|
|
6
6
|
|
|
7
7
|
## Core-engine vs. binding-layer: know which side you're on
|
|
8
8
|
|
|
@@ -10,19 +10,19 @@ Some engine-parity features (relative to other established engines) are **engine
|
|
|
10
10
|
|
|
11
11
|
**Already engine-level, works the moment you call `runa11yCoreInPage`/`runDomRulesInPage` — no binding code needed:**
|
|
12
12
|
- All rule execution, WCAG SC mapping, composite rollups.
|
|
13
|
-
- `runOnly`/`engineOptions.rules`/`.tags`/`.tests` rule selection — including [WCAG-version filtering](./ENGINE_OPTIONS.md#filtering-by-wcag-version-21-vs-22) (`wcag21a`/`wcag22aa`-style tags): if your binding has any kind of `.withTags()`/`.options()` passthrough that forwards `runOnly`/`engineOptions` generically, WCAG-version filtering already works through it with zero extra code — just document the tag vocabulary for your users, the way
|
|
13
|
+
- `runOnly`/`engineOptions.rules`/`.tags`/`.tests` rule selection — including [WCAG-version filtering](./ENGINE_OPTIONS.md#filtering-by-wcag-version-21-vs-22) (`wcag21a`/`wcag22aa`-style tags): if your binding has any kind of `.withTags()`/`.options()` passthrough that forwards `runOnly`/`engineOptions` generically, WCAG-version filtering already works through it with zero extra code — just document the tag vocabulary for your users, the way `@surea11y/playwright`'s README does.
|
|
14
14
|
- `structuralPath` on every `fail`/`cantTell` occurrence — if your binding passes occurrences through unreshaped (don't strip fields you don't recognize), this reaches your consumers automatically.
|
|
15
15
|
- `engineOptions.customRules` — runtime rule registration. Works through a generic `engineOptions` passthrough too, **but** see the caveat below: if your binding crosses a serialization boundary (see next section), your consumers must pass `runInPage`/`applicability` as `fn.toString()` source, not a live function. Worth a dedicated `.withCustomRules([...])` convenience method for ergonomics, but not required for the feature to work.
|
|
16
|
-
- Cross-frame scanning **if** your driver reaches every frame itself already (Puppeteer/Cypress/Selenium all can, via CDP or equivalent) — you don't need `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` at all. Just call the engine once per frame your driver already gives you and merge the results yourself (see
|
|
16
|
+
- Cross-frame scanning **if** your driver reaches every frame itself already (Puppeteer/Cypress/Selenium all can, via CDP or equivalent) — you don't need `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` at all. Just call the engine once per frame your driver already gives you and merge the results yourself (see `@surea11y/playwright`'s `.frames(true)`, which does exactly this — no engine change was needed for it). The `postMessage`-based cross-frame functions exist specifically for the *no-automation-driver* case (a plain injected script) and are the wrong tool for a driver-based binding.
|
|
17
17
|
|
|
18
18
|
**Binding-layer — your binding has to build these itself, the engine won't:**
|
|
19
|
-
- **Element references.** The engine returns `selector`/`structuralPath` strings, never a live handle — it has no concept of your driver's element-reference type. Resolve `occurrences[i].selector` back to a real handle yourself (Playwright's approach: `page.evaluateHandle` instead of `page.evaluate`, then `elementHandle.$(selector)` per occurrence — see `.elementRef(true)` in
|
|
20
|
-
- **Result verbosity/reporter filtering.** The engine deliberately always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (
|
|
19
|
+
- **Element references.** The engine returns `selector`/`structuralPath` strings, never a live handle — it has no concept of your driver's element-reference type. Resolve `occurrences[i].selector` back to a real handle yourself (Playwright's approach: `page.evaluateHandle` instead of `page.evaluate`, then `elementHandle.$(selector)` per occurrence — see `.elementRef(true)` in `@surea11y/playwright`).
|
|
20
|
+
- **Result verbosity/reporter filtering.** The engine deliberately always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (`@surea11y/playwright`'s `.reportOnly(['fail','cantTell'])` is a simple array-filter over the full result — no engine change).
|
|
21
21
|
- **Formatted failure output for your framework's own assertion/reporting style** (e.g. Playwright/Jest-style multi-line failure messages). The engine's raw result is framework-agnostic on purpose; shaping it into "what shows up in a failed test's stack trace" is squarely binding territory.
|
|
22
22
|
|
|
23
23
|
## The serialization-boundary caveat
|
|
24
24
|
|
|
25
|
-
If your binding drives a *separate JS realm* (a browser page/tab is a different realm than your Node test process — this is Playwright/Puppeteer/Selenium's situation, not Cypress's, since Cypress test code already runs in-browser), anything you hand to `page.evaluate()`-equivalent gets structurally cloned/JSON-serialized. **Live functions do not survive that boundary.** This bit
|
|
25
|
+
If your binding drives a *separate JS realm* (a browser page/tab is a different realm than your Node test process — this is Playwright/Puppeteer/Selenium's situation, not Cypress's, since Cypress test code already runs in-browser), anything you hand to `page.evaluate()`-equivalent gets structurally cloned/JSON-serialized. **Live functions do not survive that boundary.** This bit `@surea11y/playwright` in two places:
|
|
26
26
|
1. `runa11yCoreInPage` itself is designed around this — it's fully self-contained (`.toString()`-serializable, no closure over outer scope) specifically so it can be reconstructed from source inside the page realm.
|
|
27
27
|
2. `engineOptions.customRules[].runInPage`/`.applicability` accept a function-source string for exactly this reason — a binding crossing this boundary must tell its consumers to pass `fn.toString()`, not `fn`. Document this prominently; it's an easy trap (a function looks like it should just work as an argument until it silently fails to serialize).
|
|
28
28
|
|
|
@@ -30,7 +30,7 @@ If your binding runs in the *same* realm as the page (a browser extension conten
|
|
|
30
30
|
|
|
31
31
|
## Things to check before shipping a new binding
|
|
32
32
|
|
|
33
|
-
A short list, derived from what the audit pass on
|
|
33
|
+
A short list, derived from what the audit pass on `@surea11y/playwright` actually found missing on a first pass (per its own `ROADMAP.md`) — worth checking explicitly rather than assuming your binding's generic passthrough covers them:
|
|
34
34
|
- [ ] Combinations of your own filtering methods behave sanely together (e.g. include+exclude on the same ID, tag-include + tag-exclude on the same tag) — these interact through the engine's `includeMode`/exclude-always-wins semantics ([`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)), test them explicitly rather than assuming.
|
|
35
35
|
- [ ] `structuralPath` and `customRules` still work correctly when combined with whatever binding-layer features you build (verbosity filtering, element refs, per-frame scanning) — a filter applied after the fact should never silently drop fields a consumer expects on a surviving occurrence.
|
|
36
36
|
- [ ] If you support cross-frame scanning via your own driver, confirm each frame's result gets the same normalization (selector/structuralPath/severity) as a single-document scan — don't let a "per-frame" code path silently skip the shared result-shaping logic.
|
|
@@ -38,4 +38,4 @@ A short list, derived from what the audit pass on `surea11y-playwright` actually
|
|
|
38
38
|
|
|
39
39
|
## A known engine-side tradeoff worth knowing about
|
|
40
40
|
|
|
41
|
-
`src/core.js` is not small (~3.1MB as of 2026-07-22) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern
|
|
41
|
+
`src/core.js` is not small (~3.1MB as of 2026-07-22) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern `@surea11y/playwright` uses), your actual browser-injected payload is unaffected by this — only your Node-side `require()` footprint grows. Worth knowing if your binding's own package size matters to your consumers.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# CI/CD pipeline integrations
|
|
2
|
+
|
|
3
|
+
Ready-to-paste templates wrapping the [CLI](./CLI.md) (`npx @surea11y/core scan ...`) in GitHub Actions and Bitbucket Pipelines. If you're calling the library directly from your own Node script instead of the CLI, see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) instead — this page is specifically about the CLI as a pipeline step.
|
|
4
|
+
|
|
5
|
+
All of these rely on the CLI's own exit codes (`0` clean, `1` at least one — or one *new*, with `--baseline` — `fail` outcome, `2` a usage/scan error) to gate the pipeline; no extra scripting is required for basic pass/fail gating.
|
|
6
|
+
|
|
7
|
+
## GitHub Actions
|
|
8
|
+
|
|
9
|
+
### Basic: gate on exit code
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
name: Accessibility scan
|
|
13
|
+
on: [pull_request]
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
a11y-scan:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: 20
|
|
23
|
+
- run: npm ci && npm run build # produce whatever static HTML you're scanning
|
|
24
|
+
- run: npx @surea11y/core scan ./dist/index.html
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This fails the job the moment any `fail` outcome is found. For an existing site with pre-existing violations, see the baseline variant below instead of disabling the step.
|
|
28
|
+
|
|
29
|
+
### With a baseline (existing site, gate only on new violations)
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# Once, locally: record every current fail occurrence, commit the file.
|
|
33
|
+
npx @surea11y/core scan ./dist/index.html --write-baseline a11y-baseline.json
|
|
34
|
+
git add a11y-baseline.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
- run: npm ci && npm run build
|
|
39
|
+
- run: npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
See [`BASELINE.md`](./BASELINE.md) for what counts as "known" vs. "new", and how to regenerate the file as violations get fixed.
|
|
43
|
+
|
|
44
|
+
### Uploading SARIF to GitHub Code Scanning
|
|
45
|
+
|
|
46
|
+
`upload-sarif` needs `security-events: write` permission and does not fail the job itself — pair it with a separate gating step (or `continue-on-error` + your own check) if you also want the build to fail on new violations.
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
name: Accessibility scan
|
|
50
|
+
on: [pull_request]
|
|
51
|
+
|
|
52
|
+
permissions:
|
|
53
|
+
contents: read
|
|
54
|
+
security-events: write
|
|
55
|
+
|
|
56
|
+
jobs:
|
|
57
|
+
a11y-scan:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/checkout@v4
|
|
61
|
+
- uses: actions/setup-node@v4
|
|
62
|
+
with:
|
|
63
|
+
node-version: 20
|
|
64
|
+
- run: npm ci && npm run build
|
|
65
|
+
- name: Scan (report, don't fail the job here)
|
|
66
|
+
run: npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json --sarif results.sarif
|
|
67
|
+
continue-on-error: true
|
|
68
|
+
id: scan
|
|
69
|
+
- name: Upload SARIF to Code Scanning
|
|
70
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
71
|
+
with:
|
|
72
|
+
sarif_file: results.sarif
|
|
73
|
+
- name: Fail the job on new violations
|
|
74
|
+
if: steps.scan.outcome == 'failure'
|
|
75
|
+
run: exit 1
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See [`SARIF.md`](./SARIF.md) for the output format and a known limitation: a scan of a **local file** in your checkout gets inline annotations in the Code Scanning UI; a scan of a **live URL** doesn't (SARIF/Code Scanning can only associate a finding with a real file in the repository).
|
|
79
|
+
|
|
80
|
+
## Bitbucket Pipelines
|
|
81
|
+
|
|
82
|
+
Bitbucket Pipelines has no built-in SARIF-consuming dashboard equivalent to GitHub Code Scanning, so the CLI's exit code (plus, optionally, `--html` as a downloadable pipeline artifact) is the practical integration point rather than `--sarif`.
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
pipelines:
|
|
86
|
+
pull-requests:
|
|
87
|
+
'**':
|
|
88
|
+
- step:
|
|
89
|
+
name: Accessibility scan
|
|
90
|
+
image: node:20
|
|
91
|
+
script:
|
|
92
|
+
- npm ci
|
|
93
|
+
- npm run build
|
|
94
|
+
- npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json --html a11y-report.html
|
|
95
|
+
artifacts:
|
|
96
|
+
- a11y-report.html
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The step fails the pipeline on the CLI's exit code exactly like any other `script` entry; `a11y-report.html` (see [`REPORT.md`](./REPORT.md)) is attached as a downloadable build artifact so a reviewer can open it without re-running the scan locally.
|
|
100
|
+
|
|
101
|
+
## Free-tier/private-repo minute limits
|
|
102
|
+
|
|
103
|
+
If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [`CLI.md`](./CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).
|