@vardot/varbase-e2e 2.0.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/AGENTS.md +148 -0
- package/CLAUDE.md +544 -0
- package/README.md +22 -0
- package/bin/generate-reports.js +363 -0
- package/bin/init-varbase-e2e.js +330 -0
- package/bin/postinstall.js +26 -0
- package/cucumber.js +125 -0
- package/docs/00-quick-start.md +102 -0
- package/docs/01-getting-started.md +102 -0
- package/docs/02-bbr-smart-waits.md +77 -0
- package/docs/03-selector-registry.md +95 -0
- package/docs/04-step-reference.md +801 -0
- package/docs/05-web-first-assertions.md +91 -0
- package/docs/06-network-and-dialogs.md +83 -0
- package/docs/07-auth-state.md +75 -0
- package/docs/08-clock-mocking.md +59 -0
- package/docs/09-api-testing.md +114 -0
- package/docs/10-accessibility.md +167 -0
- package/docs/11-debugging.md +129 -0
- package/docs/12-ai-agent-guide.md +338 -0
- package/docs/13-faq.md +236 -0
- package/docs/14-recipes-cookbook.md +255 -0
- package/docs/15-tag-conventions.md +76 -0
- package/docs/16-ci-cd.md +90 -0
- package/docs/README.md +125 -0
- package/docs/advanced-screenshots/then-i-save-fullscreen-screenshot.md +36 -0
- package/docs/advanced-screenshots/then-i-save-width-x-height-screenshot.md +34 -0
- package/docs/advanced-screenshots/when-i-save-screenshot-name-filename.md +45 -0
- package/docs/advanced-screenshots.md +52 -0
- package/docs/advanced-selectors.md +18 -0
- package/docs/api-step-definitions.md +45 -0
- package/docs/global-settings.md +267 -0
- package/docs/install-varbase-e2e/ddev-varbase-e2e.md +52 -0
- package/docs/install-varbase-e2e.md +34 -0
- package/docs/overview.md +40 -0
- package/docs/step-definitions/given-i-am-anonymous-user.md +67 -0
- package/docs/step-definitions/given-i-am-homepage.md +68 -0
- package/docs/step-definitions/given-i-am-specific-page.md +72 -0
- package/docs/step-definitions/then-i-should-be.md +56 -0
- package/docs/step-definitions/then-i-should-see.md +62 -0
- package/docs/step-definitions/then-item-checkbox.md +54 -0
- package/docs/step-definitions/then-item-link-should-contain-url.md +84 -0
- package/docs/step-definitions/then-item-should-contain.md +67 -0
- package/docs/step-definitions/then-radio-button-value-should-be-selected.md +68 -0
- package/docs/step-definitions/then-response-status-code-should-be-number.md +69 -0
- package/docs/step-definitions/then-value-radio-button-selected.md +62 -0
- package/docs/step-definitions/when-i-attach-file-file-name-element.md +71 -0
- package/docs/step-definitions/when-i-check-checkbox.md +60 -0
- package/docs/step-definitions/when-i-click-link.md +51 -0
- package/docs/step-definitions/when-i-click-operation-text-row.md +71 -0
- package/docs/step-definitions/when-i-fill.md +59 -0
- package/docs/step-definitions/when-i-go-homepage.md +60 -0
- package/docs/step-definitions/when-i-go-specific-page.md +67 -0
- package/docs/step-definitions/when-i-move-backward-one-page.md +49 -0
- package/docs/step-definitions/when-i-move-forward-one-page.md +58 -0
- package/docs/step-definitions/when-i-press-button.md +58 -0
- package/docs/step-definitions/when-i-reload-page.md +51 -0
- package/docs/step-definitions/when-i-scroll.md +73 -0
- package/docs/step-definitions/when-i-select-option-select-list.md +53 -0
- package/docs/step-definitions/when-i-select-radio-button-value.md +62 -0
- package/docs/step-definitions/when-i-uncheck-checkbox.md +57 -0
- package/docs/step-definitions/when-i-wait-ajax-finish.md +64 -0
- package/docs/step-definitions/when-i-wait.md +62 -0
- package/docs/step-definitions.md +82 -0
- package/docs/steps/README.md +137 -0
- package/docs/steps/a11y.md +731 -0
- package/docs/steps/action.md +181 -0
- package/docs/steps/api.md +679 -0
- package/docs/steps/assertion.md +328 -0
- package/docs/steps/auth.md +108 -0
- package/docs/steps/clock.md +171 -0
- package/docs/steps/cookie.md +293 -0
- package/docs/steps/debug.md +52 -0
- package/docs/steps/dialog.md +226 -0
- package/docs/steps/drupal-canvas.md +414 -0
- package/docs/steps/drupal-ckeditor.md +118 -0
- package/docs/steps/drupal-core.md +688 -0
- package/docs/steps/drupal-layout-builder.md +437 -0
- package/docs/steps/drupal-media.md +107 -0
- package/docs/steps/drupal-moderation.md +81 -0
- package/docs/steps/drupal-paragraphs.md +36 -0
- package/docs/steps/element.md +455 -0
- package/docs/steps/field.md +648 -0
- package/docs/steps/file-download.md +235 -0
- package/docs/steps/form.md +344 -0
- package/docs/steps/iframe.md +248 -0
- package/docs/steps/input.md +220 -0
- package/docs/steps/javascript.md +111 -0
- package/docs/steps/keyboard.md +107 -0
- package/docs/steps/link.md +217 -0
- package/docs/steps/metatag.md +99 -0
- package/docs/steps/modal.md +232 -0
- package/docs/steps/navigation.md +275 -0
- package/docs/steps/network.md +284 -0
- package/docs/steps/path.md +203 -0
- package/docs/steps/response.md +102 -0
- package/docs/steps/responsive.md +139 -0
- package/docs/steps/rest.md +140 -0
- package/docs/steps/screenshot.md +211 -0
- package/docs/steps/scroll.md +266 -0
- package/docs/steps/selectors.md +848 -0
- package/docs/steps/storage.md +254 -0
- package/docs/steps/table.md +216 -0
- package/docs/steps/varbase.md +391 -0
- package/docs/steps/video.md +111 -0
- package/docs/steps/wait.md +553 -0
- package/docs/steps/web-first.md +289 -0
- package/docs/steps/xml.md +505 -0
- package/docs/steps/yaml.md +951 -0
- package/examples/a11y.html +31 -0
- package/examples/about-us.html +11 -0
- package/examples/account.html +32 -0
- package/examples/ajax-wait-examples.html +147 -0
- package/examples/contact-us.html +10 -0
- package/examples/cookies.html +10 -0
- package/examples/date.html +12 -0
- package/examples/dynamic-examples.html +498 -0
- package/examples/element.html +11 -0
- package/examples/example-api.json +28 -0
- package/examples/existing-page.html +11 -0
- package/examples/field.html +24 -0
- package/examples/index.html +37 -0
- package/examples/keyboard.html +11 -0
- package/examples/links.html +9 -0
- package/examples/modal.html +14 -0
- package/examples/path.html +3 -0
- package/examples/table.html +13 -0
- package/examples/test--modal-dialogs.html +252 -0
- package/examples/test--then--i-should--not--see-text-in-element.html +48 -0
- package/examples/test--then--i-should--see-text-in-element.html +48 -0
- package/examples/test--then--i-should--see-text-matching-pattern.html +31 -0
- package/examples/test--then--i-should-see-text-in-table-row.html +232 -0
- package/examples/test--then--the-checkbox-checked.html +68 -0
- package/examples/test--then--the-element-should_not-contain-cssproperty.html +47 -0
- package/examples/test--then--the-link-should-contain.html +21 -0
- package/examples/test--when--i-additionally-select.html +27 -0
- package/examples/test--when--i-attach-file-to-feild.html +50 -0
- package/examples/test--when--i-check-uncheck-checkbox.html +47 -0
- package/examples/test--when--i-click-link.html +18 -0
- package/examples/test--when--i-click-text-in-table-row.html +178 -0
- package/examples/test--when--i-fill-in.html +48 -0
- package/examples/test--when--i-press-button.html +31 -0
- package/examples/test--when--i-reload-page.html +27 -0
- package/examples/test--when--i-scroll-functionality.html +177 -0
- package/examples/test--when--i-scroll-left-right-functionality.html +149 -0
- package/examples/test--when--i-select-option-from-dropdownlist.html +35 -0
- package/examples/test--when--i-select-radio-button.html +95 -0
- package/examples/test--when-i-wait-max-of-time.html +39 -0
- package/examples/test--when-i-wait-page-until-is-loaded.html +47 -0
- package/examples/test--when-i-wait-specific-time.html +39 -0
- package/examples/test-acceptable-url-path.html +11 -0
- package/examples/video-demo.html +43 -0
- package/package.json +56 -0
- package/playwright.config.ts +39 -0
- package/tests/assets/test-document.pdf +14 -0
- package/tests/assets/vardot.png +0 -0
- package/tests/features/a11y.feature +39 -0
- package/tests/features/ajax-wait-examples.feature +24 -0
- package/tests/features/api-step-definitions-individual.feature +185 -0
- package/tests/features/complete-api-testing-examples.feature +205 -0
- package/tests/features/cookie.feature +13 -0
- package/tests/features/date.feature +11 -0
- package/tests/features/dynamic-content-examples.feature +156 -0
- package/tests/features/element.feature +16 -0
- package/tests/features/field.feature +25 -0
- package/tests/features/keyboard.feature +10 -0
- package/tests/features/link.feature +11 -0
- package/tests/features/modal-advanced-example.feature +54 -0
- package/tests/features/modal-basic-example.feature +42 -0
- package/tests/features/modal-error-handling-example.feature +37 -0
- package/tests/features/modal.feature +11 -0
- package/tests/features/path.feature +16 -0
- package/tests/features/response.feature +8 -0
- package/tests/features/responsive.feature +10 -0
- package/tests/features/table.feature +18 -0
- package/tests/features/test--given--i-am-an-anonymous-user.feature +10 -0
- package/tests/features/test--given--i-am-on-homepage.feature +16 -0
- package/tests/features/test--then--i-save-screenshot.feature +33 -0
- package/tests/features/test--then--i-see-locator.feature +320 -0
- package/tests/features/test--then--i-should--not--see-text-in-element.feature +13 -0
- package/tests/features/test--then--i-should--see-text-in-element-by-attr.feature +10 -0
- package/tests/features/test--then--i-should--see-text-in-element.feature +10 -0
- package/tests/features/test--then--i-should--see-text-matching-pattern-in-element.feature +13 -0
- package/tests/features/test--then--i-should--see-text-matching-pattern.feature +18 -0
- package/tests/features/test--then--i-should-be-on-page.feature +10 -0
- package/tests/features/test--then--i-should-be-on-the-homepage.feature +10 -0
- package/tests/features/test--then--i-should-not-see-an-element-by-attr.feature +8 -0
- package/tests/features/test--then--i-should-not-see-an-element.feature +8 -0
- package/tests/features/test--then--i-should-see-an-element-by-attr.feature +8 -0
- package/tests/features/test--then--i-should-see-an-element.feature +8 -0
- package/tests/features/test--then--i-should-see-n-elements.feature +24 -0
- package/tests/features/test--then--i-should-see-text-in-table-row.feature +96 -0
- package/tests/features/test--then--print-current-url-and-response.feature +21 -0
- package/tests/features/test--then--the-checkbox-is-checked.feature +18 -0
- package/tests/features/test--then--the-checkbox-should-be-checked.feature +16 -0
- package/tests/features/test--then--the-element-should-contain-cssproperty.feature +9 -0
- package/tests/features/test--then--the-element-should-not-contain-cssproperty.feature +10 -0
- package/tests/features/test--then--the-field-should-contain-value.feature +11 -0
- package/tests/features/test--then--the-response-should-contain.feature +11 -0
- package/tests/features/test--then--the-response-status-code-should-be.feature +31 -0
- package/tests/features/test--then--the-url-should-match.feature +7 -0
- package/tests/features/test--then-the-link-should-contain-by-attribute.feature +18 -0
- package/tests/features/test--then-the-link-should-contain.feature +7 -0
- package/tests/features/test--when--i-additionally-select.feature +30 -0
- package/tests/features/test--when--i-attach-file-to-feild.feature +9 -0
- package/tests/features/test--when--i-check-checkbox.feature +21 -0
- package/tests/features/test--when--i-click-link-by-attribute.feature +21 -0
- package/tests/features/test--when--i-click-link.feature +9 -0
- package/tests/features/test--when--i-click-text-in-table-row.feature +37 -0
- package/tests/features/test--when--i-fill-in-by-attr.feature +31 -0
- package/tests/features/test--when--i-fill-in-table-by-attr.feature +19 -0
- package/tests/features/test--when--i-fill-in.feature +32 -0
- package/tests/features/test--when--i-follow.feature +22 -0
- package/tests/features/test--when--i-go-to-homepage.feature +9 -0
- package/tests/features/test--when--i-move-forward-backward-one-page.feature +14 -0
- package/tests/features/test--when--i-press-button-by-attribute.feature +18 -0
- package/tests/features/test--when--i-press-button.feature +13 -0
- package/tests/features/test--when--i-reload-page.feature +11 -0
- package/tests/features/test--when--i-scroll-left-right.feature +48 -0
- package/tests/features/test--when--i-scroll.feature +48 -0
- package/tests/features/test--when--i-select-option-from-dropdownlist.feature +21 -0
- package/tests/features/test--when--i-select-radio-button.feature +37 -0
- package/tests/features/test--when--i-wait-page-is-loaded.feature +8 -0
- package/tests/features/test--when--i-wait-time.feature +21 -0
- package/tests/features/video.feature +18 -0
- package/tests/features/wait.feature +7 -0
- package/tests/features/yaml.feature +102 -0
- package/tests/selectors/_canonical-keys.json +113 -0
- package/tests/selectors/back-end-selectors.json +19 -0
- package/tests/selectors/cms-contentful-admin.json +35 -0
- package/tests/selectors/cms-craft-admin.json +39 -0
- package/tests/selectors/cms-drupal-cms-gin.json +56 -0
- package/tests/selectors/cms-drupal-core-claro.json +50 -0
- package/tests/selectors/cms-generic-admin.json +34 -0
- package/tests/selectors/cms-ghost-admin.json +34 -0
- package/tests/selectors/cms-joomla-admin.json +41 -0
- package/tests/selectors/cms-magento2-admin.json +32 -0
- package/tests/selectors/cms-prestashop-admin.json +38 -0
- package/tests/selectors/cms-shopify-admin.json +25 -0
- package/tests/selectors/cms-strapi-admin.json +42 -0
- package/tests/selectors/cms-typo3-admin.json +41 -0
- package/tests/selectors/cms-woocommerce-front.json +50 -0
- package/tests/selectors/cms-wordpress-admin.json +31 -0
- package/tests/selectors/framework-ant-design.json +80 -0
- package/tests/selectors/framework-bootstrap.json +86 -0
- package/tests/selectors/framework-bulma.json +74 -0
- package/tests/selectors/framework-chakra.json +58 -0
- package/tests/selectors/framework-foundation.json +51 -0
- package/tests/selectors/framework-material-ui.json +72 -0
- package/tests/selectors/framework-shadcn.json +51 -0
- package/tests/selectors/framework-tailwind.json +40 -0
- package/tests/selectors/framework-vuetify.json +61 -0
- package/tests/selectors/front-end-selectors.json +16 -0
- package/tests/selectors/homepage-selectors.json +13 -0
- package/tests/step-definitions/a11y.steps.js +800 -0
- package/tests/step-definitions/action.steps.js +167 -0
- package/tests/step-definitions/api.steps.js +732 -0
- package/tests/step-definitions/assertion.steps.js +352 -0
- package/tests/step-definitions/auth.steps.js +126 -0
- package/tests/step-definitions/clock.steps.js +128 -0
- package/tests/step-definitions/cookie.steps.js +216 -0
- package/tests/step-definitions/debug.steps.js +39 -0
- package/tests/step-definitions/dialog.steps.js +193 -0
- package/tests/step-definitions/drupal-canvas.steps.js +764 -0
- package/tests/step-definitions/drupal-ckeditor.steps.js +108 -0
- package/tests/step-definitions/drupal-core.steps.js +665 -0
- package/tests/step-definitions/drupal-helpers.js +371 -0
- package/tests/step-definitions/drupal-layout-builder.steps.js +466 -0
- package/tests/step-definitions/drupal-media.steps.js +99 -0
- package/tests/step-definitions/drupal-moderation.steps.js +71 -0
- package/tests/step-definitions/drupal-paragraphs.steps.js +50 -0
- package/tests/step-definitions/element.steps.js +356 -0
- package/tests/step-definitions/field.steps.js +653 -0
- package/tests/step-definitions/file-download.steps.js +211 -0
- package/tests/step-definitions/form.steps.js +333 -0
- package/tests/step-definitions/iframe.steps.js +228 -0
- package/tests/step-definitions/input.steps.js +202 -0
- package/tests/step-definitions/javascript.steps.js +232 -0
- package/tests/step-definitions/keyboard.steps.js +112 -0
- package/tests/step-definitions/link.steps.js +142 -0
- package/tests/step-definitions/metatag.steps.js +91 -0
- package/tests/step-definitions/modal.steps.js +244 -0
- package/tests/step-definitions/navigation.steps.js +218 -0
- package/tests/step-definitions/network.steps.js +280 -0
- package/tests/step-definitions/path.steps.js +141 -0
- package/tests/step-definitions/response.steps.js +98 -0
- package/tests/step-definitions/responsive.steps.js +125 -0
- package/tests/step-definitions/rest.steps.js +111 -0
- package/tests/step-definitions/screenshot.steps.js +423 -0
- package/tests/step-definitions/scroll.steps.js +189 -0
- package/tests/step-definitions/selectors.steps.js +1003 -0
- package/tests/step-definitions/storage.steps.js +194 -0
- package/tests/step-definitions/table.steps.js +181 -0
- package/tests/step-definitions/varbase-e2e.js +897 -0
- package/tests/step-definitions/varbase.steps.js +458 -0
- package/tests/step-definitions/video.steps.js +178 -0
- package/tests/step-definitions/wait.steps.js +400 -0
- package/tests/step-definitions/web-first.steps.js +298 -0
- package/tests/step-definitions/xml.steps.js +411 -0
- package/tests/step-definitions/yaml.steps.js +913 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { friendly } = require('./varbase-e2e');
|
|
4
|
+
|
|
5
|
+
// Accessibility (a11y) step definitions.
|
|
6
|
+
//
|
|
7
|
+
// Two layers:
|
|
8
|
+
//
|
|
9
|
+
// 1. Custom JS probes — fast, dependency-free assertions for POUR
|
|
10
|
+
// fundamentals (alt text, label association, landmarks, focus,
|
|
11
|
+
// headings, skip link, ARIA validity, tabindex, title, zoom).
|
|
12
|
+
// Each probe maps to a specific WCAG 2.1 / 2.2 success criterion.
|
|
13
|
+
//
|
|
14
|
+
// 2. axe-core integration — full WCAG audit via the official Deque
|
|
15
|
+
// engine (https://github.com/dequelabs/axe-core). axe ships ~100
|
|
16
|
+
// rules covering WCAG 2.0 / 2.1 / 2.2 levels A, AA, AAA plus best-
|
|
17
|
+
// practice and experimental rules. Each violation has an `impact`
|
|
18
|
+
// field (`minor` / `moderate` / `serious` / `critical`) for triage.
|
|
19
|
+
//
|
|
20
|
+
// Standards & references:
|
|
21
|
+
// - WCAG 2.1 https://www.w3.org/TR/WCAG21/
|
|
22
|
+
// - WCAG 2.2 https://www.w3.org/TR/WCAG22/
|
|
23
|
+
// - WAI overview https://www.w3.org/WAI/
|
|
24
|
+
// - Evaluation tools https://www.w3.org/WAI/test-evaluate/tools/list/
|
|
25
|
+
// - MDN Accessibility https://developer.mozilla.org/en-US/docs/Web/Accessibility
|
|
26
|
+
// - axe-core rules https://github.com/dequelabs/axe-core/tree/develop/doc/rule-descriptions.md
|
|
27
|
+
// - Deque Labs https://github.com/dequelabs/
|
|
28
|
+
//
|
|
29
|
+
// Defaults: the audit-level steps target WCAG 2.1 / 2.2 AA — the level
|
|
30
|
+
// required by the EU Web Accessibility Directive, US Section 508, and UK
|
|
31
|
+
// PSBAR. Use `at level "AAA"` for stricter scenarios.
|
|
32
|
+
|
|
33
|
+
const { When, Then } = require('@cucumber/cucumber');
|
|
34
|
+
const assert = require('assert');
|
|
35
|
+
|
|
36
|
+
// axe-core is loaded lazily so the file works even before deps install.
|
|
37
|
+
let AxeBuilder; try { AxeBuilder = require('@axe-core/playwright').default; } catch { /* lazy */ }
|
|
38
|
+
|
|
39
|
+
const WCAG_TAGS = {
|
|
40
|
+
'A': ['wcag2a', 'wcag21a'],
|
|
41
|
+
'AA': ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],
|
|
42
|
+
'AAA': ['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa', 'wcag22aaa'],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function summariseViolations(violations) {
|
|
46
|
+
if (violations.length === 0) return 'no violations';
|
|
47
|
+
const head = violations.slice(0, 5).map((v) =>
|
|
48
|
+
` [${v.impact}] ${v.id} — ${v.help} (${v.nodes.length} node${v.nodes.length === 1 ? '' : 's'})`
|
|
49
|
+
).join('\n');
|
|
50
|
+
const more = violations.length > 5 ? `\n ... and ${violations.length - 5} more` : '';
|
|
51
|
+
return head + more;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runAxe(world, opts = {}) {
|
|
55
|
+
if (!AxeBuilder) {
|
|
56
|
+
throw friendly('axe-core a11y steps require @axe-core/playwright. Install: npm i @axe-core/playwright axe-core');
|
|
57
|
+
}
|
|
58
|
+
let builder = new AxeBuilder({ page: world.page });
|
|
59
|
+
if (opts.tags) builder = builder.withTags(opts.tags);
|
|
60
|
+
if (opts.include) builder = builder.include(opts.include);
|
|
61
|
+
if (opts.exclude) builder = builder.exclude(opts.exclude);
|
|
62
|
+
return builder.analyze();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Image alt text
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Assert every <img> on the page has an `alt` attribute.
|
|
71
|
+
*
|
|
72
|
+
* Empty `alt=""` is allowed (decorative images per WCAG); `role="presentation"`
|
|
73
|
+
* is also allowed. Only a missing `alt` attribute fails. Iterates every <img>
|
|
74
|
+
* in the document, including those nested in shadow-light DOM that the page
|
|
75
|
+
* has rendered, so it works on infinite-scroll lists and image galleries too.
|
|
76
|
+
*
|
|
77
|
+
* Example #1: Then every image should have an alt attribute
|
|
78
|
+
* Example #2: And every image should have an alt attribute
|
|
79
|
+
* Example #3: Given I am on "/photos"
|
|
80
|
+
* Then every image should have an alt attribute
|
|
81
|
+
* Example #4: When I scroll to the bottom
|
|
82
|
+
* Then every image should have an alt attribute
|
|
83
|
+
* Example #5: When I follow "Gallery"
|
|
84
|
+
* Then every image should have an alt attribute
|
|
85
|
+
*
|
|
86
|
+
*/
|
|
87
|
+
Then(/^every image should have an alt attribute$/, async function () {
|
|
88
|
+
const missing = await this.page.evaluate(() =>
|
|
89
|
+
Array.from(document.querySelectorAll('img'))
|
|
90
|
+
.filter((img) => !img.hasAttribute('alt') && img.getAttribute('role') !== 'presentation')
|
|
91
|
+
.map((img) => img.outerHTML.slice(0, 120))
|
|
92
|
+
);
|
|
93
|
+
assert.strictEqual(missing.length, 0,
|
|
94
|
+
`Found ${missing.length} <img> without alt:\n ${missing.join('\n ')}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Form label association
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Assert every form input/select/textarea has an accessible name.
|
|
103
|
+
*
|
|
104
|
+
* Pass condition: each field has at least ONE of —
|
|
105
|
+
* - `aria-label` attribute
|
|
106
|
+
* - `aria-labelledby` attribute pointing at a real element
|
|
107
|
+
* - matching `<label for="...">`
|
|
108
|
+
* - wrapping `<label>` element
|
|
109
|
+
*
|
|
110
|
+
* Submit/reset/button/image inputs and `type="hidden"` are exempt because
|
|
111
|
+
* they get their accessible name from `value` or `alt`.
|
|
112
|
+
*
|
|
113
|
+
* Example #1: Then every form field should have an accessible label
|
|
114
|
+
* Example #2: Given I am on "/register"
|
|
115
|
+
* Then every form field should have an accessible label
|
|
116
|
+
* Example #3: When I open the settings modal
|
|
117
|
+
* Then every form field should have an accessible label
|
|
118
|
+
* Example #4: And every form field should have an accessible label
|
|
119
|
+
* Example #5: Then the page should have a main landmark
|
|
120
|
+
* And every form field should have an accessible label
|
|
121
|
+
*
|
|
122
|
+
*/
|
|
123
|
+
Then(/^every form field should have an accessible label$/, async function () {
|
|
124
|
+
const missing = await this.page.evaluate(() => {
|
|
125
|
+
const fields = Array.from(document.querySelectorAll('input:not([type="hidden"]), select, textarea'));
|
|
126
|
+
return fields.filter((el) => {
|
|
127
|
+
if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) return false;
|
|
128
|
+
if (el.id && document.querySelector(`label[for="${el.id}"]`)) return false;
|
|
129
|
+
if (el.closest('label')) return false;
|
|
130
|
+
const t = el.getAttribute('type');
|
|
131
|
+
if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return false;
|
|
132
|
+
return true;
|
|
133
|
+
}).map((el) => el.outerHTML.slice(0, 120));
|
|
134
|
+
});
|
|
135
|
+
assert.strictEqual(missing.length, 0,
|
|
136
|
+
`Found ${missing.length} unlabeled form field(s):\n ${missing.join('\n ')}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Landmarks / page structure
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Assert the page has a primary `<main>` (or `[role="main"]`) landmark.
|
|
145
|
+
*
|
|
146
|
+
* Screen readers announce landmarks; missing the main landmark forces users
|
|
147
|
+
* to navigate the page linearly. WCAG 2.4.1 (Bypass Blocks) requires it.
|
|
148
|
+
*
|
|
149
|
+
* Example #1: Then the page should have a main landmark
|
|
150
|
+
* Example #2: Given I am on "/about"
|
|
151
|
+
* Then the page should have a main landmark
|
|
152
|
+
* Example #3: When I follow "Pricing"
|
|
153
|
+
* Then the page should have a main landmark
|
|
154
|
+
* Example #4: And the page should have a main landmark
|
|
155
|
+
* Example #5: Then the page should have a navigation landmark
|
|
156
|
+
* And the page should have a main landmark
|
|
157
|
+
*
|
|
158
|
+
*/
|
|
159
|
+
Then(/^the page should have a main landmark$/, async function () {
|
|
160
|
+
const count = await this.page.locator('main, [role="main"]').count();
|
|
161
|
+
assert.ok(count >= 1, 'Page is missing a <main> or [role="main"] landmark.');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Assert the page has at least one navigation landmark.
|
|
166
|
+
*
|
|
167
|
+
* Counts both `<nav>` and `[role="navigation"]`.
|
|
168
|
+
*
|
|
169
|
+
* Example #1: Then the page should have a navigation landmark
|
|
170
|
+
* Example #2: Given I am on the homepage
|
|
171
|
+
* Then the page should have a navigation landmark
|
|
172
|
+
* Example #3: Given I am on "/admin"
|
|
173
|
+
* Then the page should have a navigation landmark
|
|
174
|
+
* Example #4: And the page should have a navigation landmark
|
|
175
|
+
* Example #5: Then the page should have exactly one h1
|
|
176
|
+
* And the page should have a navigation landmark
|
|
177
|
+
*
|
|
178
|
+
*/
|
|
179
|
+
Then(/^the page should have a navigation landmark$/, async function () {
|
|
180
|
+
const count = await this.page.locator('nav, [role="navigation"]').count();
|
|
181
|
+
assert.ok(count >= 1, 'Page is missing a <nav> or [role="navigation"] landmark.');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Assert the page has exactly one `<h1>`.
|
|
186
|
+
*
|
|
187
|
+
* Multiple h1s are valid HTML5 with sectioning roots, but most assistive tech
|
|
188
|
+
* still treats the document outline as flat. One h1 per page is the safe
|
|
189
|
+
* default.
|
|
190
|
+
*
|
|
191
|
+
* Example #1: Then the page should have exactly one h1
|
|
192
|
+
* Example #2: Given I am on the homepage
|
|
193
|
+
* Then the page should have exactly one h1
|
|
194
|
+
* Example #3: When I follow "About"
|
|
195
|
+
* Then the page should have exactly one h1
|
|
196
|
+
* Example #4: And the page should have exactly one h1
|
|
197
|
+
* Example #5: Then the page should have a main landmark
|
|
198
|
+
* And the page should have exactly one h1
|
|
199
|
+
*
|
|
200
|
+
*/
|
|
201
|
+
Then(/^the page should have exactly one h1$/, async function () {
|
|
202
|
+
const count = await this.page.locator('h1').count();
|
|
203
|
+
assert.strictEqual(count, 1, `Expected 1 <h1>, found ${count}.`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Focus assertions
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Assert the currently focused element matches a CSS selector.
|
|
212
|
+
*
|
|
213
|
+
* Useful for tab-order and skip-link tests.
|
|
214
|
+
*
|
|
215
|
+
* Example #1: Then the focused element should match "#email"
|
|
216
|
+
* Example #2: When I press the "Tab" key
|
|
217
|
+
* Then the focused element should match "#email"
|
|
218
|
+
* Example #3: When I press the "Tab" key 2 times
|
|
219
|
+
* Then the focused element should match "#password"
|
|
220
|
+
* Example #4: When I focus on the element "#search"
|
|
221
|
+
* Then the focused element should match "input[type=search]"
|
|
222
|
+
* Example #5: When I follow "Skip to main content"
|
|
223
|
+
* Then the focused element should match "main h1"
|
|
224
|
+
*
|
|
225
|
+
*/
|
|
226
|
+
Then(/^the focused element should match "([^"]*)"$/, async function (selector) {
|
|
227
|
+
const matches = await this.page.evaluate((sel) => {
|
|
228
|
+
const el = document.activeElement;
|
|
229
|
+
return !!(el && el.matches && el.matches(sel));
|
|
230
|
+
}, selector);
|
|
231
|
+
assert.ok(matches, `Focused element does not match "${selector}".`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Assert the currently focused element's accessible name contains text.
|
|
236
|
+
*
|
|
237
|
+
* Resolution order: aria-label → aria-labelledby → label[for] → wrapping
|
|
238
|
+
* <label> → value/placeholder/title/textContent.
|
|
239
|
+
*
|
|
240
|
+
* Example #1: Then the focused element should be labeled "Email"
|
|
241
|
+
* Example #2: When I press the "Tab" key
|
|
242
|
+
* Then the focused element should be labeled "Email"
|
|
243
|
+
* Example #3: When I press the "Tab" key 2 times
|
|
244
|
+
* Then the focused element should be labeled "Password"
|
|
245
|
+
* Example #4: When I press the "Tab" key 3 times
|
|
246
|
+
* Then the focused element should be labeled "Sign in"
|
|
247
|
+
* Example #5: When I focus on the element "#agree"
|
|
248
|
+
* Then the focused element should be labeled "I agree"
|
|
249
|
+
*
|
|
250
|
+
*/
|
|
251
|
+
Then(/^the focused element should be labeled "([^"]*)"$/, async function (text) {
|
|
252
|
+
const name = await this.page.evaluate(() => {
|
|
253
|
+
const el = document.activeElement;
|
|
254
|
+
if (!el) return '';
|
|
255
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
256
|
+
if (ariaLabel) return ariaLabel.trim();
|
|
257
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
258
|
+
if (labelledby) {
|
|
259
|
+
const ref = document.getElementById(labelledby);
|
|
260
|
+
if (ref) return (ref.textContent || '').trim();
|
|
261
|
+
}
|
|
262
|
+
if (el.id) {
|
|
263
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
264
|
+
if (lbl) return (lbl.textContent || '').trim();
|
|
265
|
+
}
|
|
266
|
+
const wrapping = el.closest && el.closest('label');
|
|
267
|
+
if (wrapping) return (wrapping.textContent || '').trim();
|
|
268
|
+
return ((el.value || el.placeholder || el.getAttribute('title') || el.textContent || '')).trim();
|
|
269
|
+
});
|
|
270
|
+
assert.ok(name.includes(text),
|
|
271
|
+
`Expected focused element to be labeled including "${text}", got "${name}".`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Page language
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Assert the document has a `lang` attribute on `<html>`.
|
|
280
|
+
*
|
|
281
|
+
* Required by WCAG 3.1.1 — screen readers use `lang` to pick the right
|
|
282
|
+
* pronunciation engine. Missing or empty `lang` fails.
|
|
283
|
+
*
|
|
284
|
+
* Example #1: Then the page should declare a language
|
|
285
|
+
* Example #2: Given I am on the homepage
|
|
286
|
+
* Then the page should declare a language
|
|
287
|
+
* Example #3: When I follow "About"
|
|
288
|
+
* Then the page should declare a language
|
|
289
|
+
* Example #4: And the page should declare a language
|
|
290
|
+
* Example #5: Then the page should have a main landmark
|
|
291
|
+
* And the page should declare a language
|
|
292
|
+
*
|
|
293
|
+
*/
|
|
294
|
+
Then(/^the page should declare a language$/, async function () {
|
|
295
|
+
const lang = await this.page.evaluate(() => document.documentElement.getAttribute('lang') || '');
|
|
296
|
+
assert.ok(lang.length > 0, 'Page <html> is missing a "lang" attribute.');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Assert the document `lang` equals an expected value.
|
|
301
|
+
*
|
|
302
|
+
* Example #1: Then the page language should be "en"
|
|
303
|
+
* Example #2: Given I am on the homepage
|
|
304
|
+
* Then the page language should be "en"
|
|
305
|
+
* Example #3: When I switch to the French version
|
|
306
|
+
* Then the page language should be "fr"
|
|
307
|
+
* Example #4: When I switch to the Arabic version
|
|
308
|
+
* Then the page language should be "ar"
|
|
309
|
+
* Example #5: And the page language should be "en-US"
|
|
310
|
+
*
|
|
311
|
+
*/
|
|
312
|
+
Then(/^the page language should be "([^"]*)"$/, async function (expected) {
|
|
313
|
+
const lang = await this.page.evaluate(() => document.documentElement.getAttribute('lang') || '');
|
|
314
|
+
assert.strictEqual(lang, expected, `Expected page lang="${expected}", got "${lang}".`);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Heading hierarchy (WCAG 1.3.1 — Info and Relationships)
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Assert headings appear in non-skipping order (h1 → h2 → h3 — never
|
|
323
|
+
* h1 → h3). Required by WCAG 1.3.1 / 2.4.6 for screen-reader navigation.
|
|
324
|
+
*
|
|
325
|
+
* Example #1: Then the heading hierarchy should be valid
|
|
326
|
+
* Example #2: Given I am on the homepage
|
|
327
|
+
* Then the heading hierarchy should be valid
|
|
328
|
+
* Example #3: When I follow "About"
|
|
329
|
+
* Then the heading hierarchy should be valid
|
|
330
|
+
* Example #4: And the heading hierarchy should be valid
|
|
331
|
+
* Example #5: Then the page should have exactly one h1
|
|
332
|
+
* And the heading hierarchy should be valid
|
|
333
|
+
*
|
|
334
|
+
*/
|
|
335
|
+
Then(/^the heading hierarchy should be valid$/, async function () {
|
|
336
|
+
const skips = await this.page.evaluate(() => {
|
|
337
|
+
const out = [];
|
|
338
|
+
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
|
339
|
+
let prev = 0;
|
|
340
|
+
for (const h of headings) {
|
|
341
|
+
const level = parseInt(h.tagName.slice(1), 10);
|
|
342
|
+
if (prev > 0 && level - prev > 1) {
|
|
343
|
+
out.push(`<${h.tagName.toLowerCase()}>${(h.textContent || '').slice(0, 60).trim()}</${h.tagName.toLowerCase()}> (skipped from h${prev})`);
|
|
344
|
+
}
|
|
345
|
+
prev = level;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
});
|
|
349
|
+
assert.strictEqual(skips.length, 0,
|
|
350
|
+
`Heading hierarchy skips levels:\n ${skips.join('\n ')}`);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Skip link (WCAG 2.4.1 — Bypass Blocks)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Assert the page exposes a skip-link as the first focusable element. WCAG
|
|
359
|
+
* 2.4.1 requires a mechanism to bypass repeated content blocks.
|
|
360
|
+
*
|
|
361
|
+
* Detection: anchor with href="#..." that resolves to an existing target
|
|
362
|
+
* AND text matching "skip" / "main content" / "to content".
|
|
363
|
+
*
|
|
364
|
+
* Example #1: Then the page should have a skip link
|
|
365
|
+
* Example #2: Given I am on the homepage
|
|
366
|
+
* Then the page should have a skip link
|
|
367
|
+
* Example #3: When I press the key "Tab"
|
|
368
|
+
* Then the focused element should match "a[href^='#']"
|
|
369
|
+
* And the page should have a skip link
|
|
370
|
+
* Example #4: Then the page should have a skip link
|
|
371
|
+
* And the page should have a main landmark
|
|
372
|
+
* Example #5: When I follow "About"
|
|
373
|
+
* Then the page should have a skip link
|
|
374
|
+
*
|
|
375
|
+
*/
|
|
376
|
+
Then(/^the page should have a skip link$/, async function () {
|
|
377
|
+
const ok = await this.page.evaluate(() => {
|
|
378
|
+
const links = Array.from(document.querySelectorAll('a[href^="#"]'));
|
|
379
|
+
return links.some((a) => {
|
|
380
|
+
const href = a.getAttribute('href') || '';
|
|
381
|
+
const id = href.slice(1);
|
|
382
|
+
const target = id ? document.getElementById(id) : null;
|
|
383
|
+
const txt = (a.textContent || '').toLowerCase();
|
|
384
|
+
return target && (txt.includes('skip') || txt.includes('main content') || txt.includes('to content'));
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
assert.ok(ok, 'Page is missing a skip link.');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Accessible name on buttons / links (WCAG 4.1.2 — Name, Role, Value)
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Assert every <button> / role="button" has an accessible name (visible
|
|
396
|
+
* text, aria-label, or aria-labelledby).
|
|
397
|
+
*
|
|
398
|
+
* Example #1: Then every button should have an accessible name
|
|
399
|
+
* Example #2: Given I am on the homepage
|
|
400
|
+
* Then every button should have an accessible name
|
|
401
|
+
* Example #3: When I am on "/checkout"
|
|
402
|
+
* Then every button should have an accessible name
|
|
403
|
+
* Example #4: And every button should have an accessible name
|
|
404
|
+
* Example #5: Then every button should have an accessible name
|
|
405
|
+
* And every link should have an accessible name
|
|
406
|
+
*
|
|
407
|
+
*/
|
|
408
|
+
Then(/^every button should have an accessible name$/, async function () {
|
|
409
|
+
const missing = await this.page.evaluate(() => {
|
|
410
|
+
const els = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]'));
|
|
411
|
+
return els.filter((el) => {
|
|
412
|
+
if (el.hasAttribute('aria-label') && el.getAttribute('aria-label').trim()) return false;
|
|
413
|
+
if (el.hasAttribute('aria-labelledby')) return false;
|
|
414
|
+
if (el.tagName === 'INPUT') {
|
|
415
|
+
return !((el.value || '').trim());
|
|
416
|
+
}
|
|
417
|
+
return !(el.textContent || '').trim();
|
|
418
|
+
}).map((el) => el.outerHTML.slice(0, 120));
|
|
419
|
+
});
|
|
420
|
+
assert.strictEqual(missing.length, 0,
|
|
421
|
+
`Found ${missing.length} button(s) without accessible name:\n ${missing.join('\n ')}`);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Assert every <a> has an accessible name.
|
|
426
|
+
*
|
|
427
|
+
* Example #1: Then every link should have an accessible name
|
|
428
|
+
* Example #2: Given I am on the homepage
|
|
429
|
+
* Then every link should have an accessible name
|
|
430
|
+
* Example #3: When I am on "/blog"
|
|
431
|
+
* Then every link should have an accessible name
|
|
432
|
+
* Example #4: And every link should have an accessible name
|
|
433
|
+
* Example #5: Then every button should have an accessible name
|
|
434
|
+
* And every link should have an accessible name
|
|
435
|
+
*
|
|
436
|
+
*/
|
|
437
|
+
Then(/^every link should have an accessible name$/, async function () {
|
|
438
|
+
const missing = await this.page.evaluate(() => {
|
|
439
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
440
|
+
return links.filter((a) => {
|
|
441
|
+
if (a.hasAttribute('aria-label') && a.getAttribute('aria-label').trim()) return false;
|
|
442
|
+
if (a.hasAttribute('aria-labelledby')) return false;
|
|
443
|
+
if ((a.textContent || '').trim()) return false;
|
|
444
|
+
// Allow image-link if alt has text.
|
|
445
|
+
const img = a.querySelector('img[alt]');
|
|
446
|
+
if (img && (img.getAttribute('alt') || '').trim()) return false;
|
|
447
|
+
return true;
|
|
448
|
+
}).map((a) => a.outerHTML.slice(0, 120));
|
|
449
|
+
});
|
|
450
|
+
assert.strictEqual(missing.length, 0,
|
|
451
|
+
`Found ${missing.length} link(s) without accessible name:\n ${missing.join('\n ')}`);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// tabindex hygiene (WCAG 2.4.3 — Focus Order)
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Assert NO interactive element uses a positive `tabindex`. Positive values
|
|
460
|
+
* break natural focus order — WCAG 2.4.3 violation in practice.
|
|
461
|
+
*
|
|
462
|
+
* Example #1: Then no element should have a positive tabindex
|
|
463
|
+
* Example #2: Given I am on the homepage
|
|
464
|
+
* Then no element should have a positive tabindex
|
|
465
|
+
* Example #3: When I am on "/form"
|
|
466
|
+
* Then no element should have a positive tabindex
|
|
467
|
+
* Example #4: And no element should have a positive tabindex
|
|
468
|
+
* Example #5: Then no element should have a positive tabindex
|
|
469
|
+
* And the heading hierarchy should be valid
|
|
470
|
+
*
|
|
471
|
+
*/
|
|
472
|
+
Then(/^no element should have a positive tabindex$/, async function () {
|
|
473
|
+
const offenders = await this.page.evaluate(() =>
|
|
474
|
+
Array.from(document.querySelectorAll('[tabindex]'))
|
|
475
|
+
.filter((el) => parseInt(el.getAttribute('tabindex'), 10) > 0)
|
|
476
|
+
.map((el) => el.outerHTML.slice(0, 120))
|
|
477
|
+
);
|
|
478
|
+
assert.strictEqual(offenders.length, 0,
|
|
479
|
+
`Found ${offenders.length} element(s) with positive tabindex:\n ${offenders.join('\n ')}`);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// ARIA validity (WCAG 4.1.2)
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Assert every `aria-labelledby` / `aria-describedby` / `aria-controls`
|
|
488
|
+
* / `aria-owns` references an existing element id.
|
|
489
|
+
*
|
|
490
|
+
* Example #1: Then every ARIA reference should resolve
|
|
491
|
+
* Example #2: Given I am on the homepage
|
|
492
|
+
* Then every ARIA reference should resolve
|
|
493
|
+
* Example #3: When I am on "/dashboard"
|
|
494
|
+
* Then every ARIA reference should resolve
|
|
495
|
+
* Example #4: And every ARIA reference should resolve
|
|
496
|
+
* Example #5: Then every ARIA reference should resolve
|
|
497
|
+
* And every button should have an accessible name
|
|
498
|
+
*
|
|
499
|
+
*/
|
|
500
|
+
Then(/^every ARIA reference should resolve$/, async function () {
|
|
501
|
+
const broken = await this.page.evaluate(() => {
|
|
502
|
+
const attrs = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-flowto'];
|
|
503
|
+
const out = [];
|
|
504
|
+
for (const attr of attrs) {
|
|
505
|
+
const els = Array.from(document.querySelectorAll(`[${attr}]`));
|
|
506
|
+
for (const el of els) {
|
|
507
|
+
const ids = (el.getAttribute(attr) || '').split(/\s+/).filter(Boolean);
|
|
508
|
+
for (const id of ids) {
|
|
509
|
+
if (!document.getElementById(id)) {
|
|
510
|
+
out.push(`<${el.tagName.toLowerCase()} ${attr}="${el.getAttribute(attr)}"> — missing id "${id}"`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
});
|
|
517
|
+
assert.strictEqual(broken.length, 0,
|
|
518
|
+
`Found ${broken.length} broken ARIA reference(s):\n ${broken.join('\n ')}`);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Assert no element uses an invalid ARIA role. Reference: WAI-ARIA 1.2.
|
|
523
|
+
*
|
|
524
|
+
* Example #1: Then every ARIA role should be valid
|
|
525
|
+
* Example #2: Given I am on the homepage
|
|
526
|
+
* Then every ARIA role should be valid
|
|
527
|
+
* Example #3: When I am on "/dashboard"
|
|
528
|
+
* Then every ARIA role should be valid
|
|
529
|
+
* Example #4: And every ARIA role should be valid
|
|
530
|
+
* Example #5: Then every ARIA role should be valid
|
|
531
|
+
* And every ARIA reference should resolve
|
|
532
|
+
*
|
|
533
|
+
*/
|
|
534
|
+
Then(/^every ARIA role should be valid$/, async function () {
|
|
535
|
+
const VALID = new Set([
|
|
536
|
+
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote',
|
|
537
|
+
'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox',
|
|
538
|
+
'complementary', 'contentinfo', 'definition', 'deletion', 'dialog',
|
|
539
|
+
'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic',
|
|
540
|
+
'graphics-document', 'graphics-object', 'graphics-symbol', 'grid',
|
|
541
|
+
'gridcell', 'group', 'heading', 'img', 'insertion', 'link', 'list',
|
|
542
|
+
'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar',
|
|
543
|
+
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'navigation',
|
|
544
|
+
'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar',
|
|
545
|
+
'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader',
|
|
546
|
+
'scrollbar', 'search', 'searchbox', 'separator', 'slider', 'spinbutton',
|
|
547
|
+
'status', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'table',
|
|
548
|
+
'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar',
|
|
549
|
+
'tooltip', 'tree', 'treegrid', 'treeitem',
|
|
550
|
+
]);
|
|
551
|
+
const invalid = await this.page.evaluate((valid) => {
|
|
552
|
+
return Array.from(document.querySelectorAll('[role]'))
|
|
553
|
+
.filter((el) => {
|
|
554
|
+
const roles = (el.getAttribute('role') || '').split(/\s+/).filter(Boolean);
|
|
555
|
+
return roles.some((r) => !valid.includes(r));
|
|
556
|
+
})
|
|
557
|
+
.map((el) => `${el.tagName.toLowerCase()} role="${el.getAttribute('role')}"`);
|
|
558
|
+
}, [...VALID]);
|
|
559
|
+
assert.strictEqual(invalid.length, 0,
|
|
560
|
+
`Found ${invalid.length} invalid ARIA role(s):\n ${invalid.join('\n ')}`);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Forms — autocomplete, required (WCAG 1.3.5 — Identify Input Purpose)
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Assert every form field exposing `aria-required="true"` is also marked
|
|
569
|
+
* with the native `required` attribute (or vice versa). Mismatch confuses
|
|
570
|
+
* assistive tech.
|
|
571
|
+
*
|
|
572
|
+
* Example #1: Then required fields should be consistently marked
|
|
573
|
+
* Example #2: Given I am on "/signup"
|
|
574
|
+
* Then required fields should be consistently marked
|
|
575
|
+
* Example #3: When I am on "/checkout"
|
|
576
|
+
* Then required fields should be consistently marked
|
|
577
|
+
* Example #4: And required fields should be consistently marked
|
|
578
|
+
* Example #5: Then every form field should have an accessible label
|
|
579
|
+
* And required fields should be consistently marked
|
|
580
|
+
*
|
|
581
|
+
*/
|
|
582
|
+
Then(/^required fields should be consistently marked$/, async function () {
|
|
583
|
+
const mismatches = await this.page.evaluate(() => {
|
|
584
|
+
const out = [];
|
|
585
|
+
const fields = Array.from(document.querySelectorAll('input, select, textarea'));
|
|
586
|
+
for (const el of fields) {
|
|
587
|
+
const native = el.hasAttribute('required');
|
|
588
|
+
const aria = el.getAttribute('aria-required') === 'true';
|
|
589
|
+
if (native !== aria && (native || aria)) {
|
|
590
|
+
out.push(el.outerHTML.slice(0, 120));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
});
|
|
595
|
+
// Strict mismatch — any field with one but not the other is a finding.
|
|
596
|
+
assert.strictEqual(mismatches.length, 0,
|
|
597
|
+
`Found ${mismatches.length} field(s) with inconsistent required marking:\n ${mismatches.join('\n ')}`);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// Page <title> (WCAG 2.4.2 — Page Titled)
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Assert the document has a non-empty `<title>`. WCAG 2.4.2.
|
|
606
|
+
*
|
|
607
|
+
* Example #1: Then the page should have a title
|
|
608
|
+
* Example #2: Given I am on the homepage
|
|
609
|
+
* Then the page should have a title
|
|
610
|
+
* Example #3: When I am on "/about"
|
|
611
|
+
* Then the page should have a title
|
|
612
|
+
* Example #4: And the page should have a title
|
|
613
|
+
* Example #5: Then the page should have a title
|
|
614
|
+
* And the page should declare a language
|
|
615
|
+
*
|
|
616
|
+
*/
|
|
617
|
+
Then(/^the page should have a title$/, async function () {
|
|
618
|
+
const title = await this.page.title();
|
|
619
|
+
assert.ok(title && title.trim().length > 0, 'Page is missing a non-empty <title>.');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// Viewport meta (WCAG 1.4.4 — Resize Text without horizontal scroll)
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Assert the viewport meta tag does not disable user-scaling
|
|
628
|
+
* (`user-scalable=no` or `maximum-scale=1`). WCAG 1.4.4 / 1.4.10.
|
|
629
|
+
*
|
|
630
|
+
* Example #1: Then user zoom should be allowed
|
|
631
|
+
* Example #2: Given I am on the homepage
|
|
632
|
+
* Then user zoom should be allowed
|
|
633
|
+
* Example #3: When I am on "/article"
|
|
634
|
+
* Then user zoom should be allowed
|
|
635
|
+
* Example #4: And user zoom should be allowed
|
|
636
|
+
* Example #5: Then user zoom should be allowed
|
|
637
|
+
* And the page should declare a language
|
|
638
|
+
*
|
|
639
|
+
*/
|
|
640
|
+
Then(/^user zoom should be allowed$/, async function () {
|
|
641
|
+
const blocked = await this.page.evaluate(() => {
|
|
642
|
+
const m = document.querySelector('meta[name="viewport"]');
|
|
643
|
+
if (!m) return false;
|
|
644
|
+
const c = (m.getAttribute('content') || '').toLowerCase();
|
|
645
|
+
return /user-scalable\s*=\s*(no|0)/.test(c) || /maximum-scale\s*=\s*1(\.0)?(\b|$)/.test(c);
|
|
646
|
+
});
|
|
647
|
+
assert.ok(!blocked, 'Viewport meta tag disables user-scaling — fails WCAG 1.4.4 / 1.4.10.');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// ===========================================================================
|
|
651
|
+
// axe-core full WCAG audit (https://github.com/dequelabs/axe-core)
|
|
652
|
+
// ===========================================================================
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Run an axe-core audit and assert ZERO violations across the supplied WCAG
|
|
656
|
+
* level. Defaults to AA — the level mandated by most accessibility laws.
|
|
657
|
+
*
|
|
658
|
+
* Example #1: Then the page should pass an accessibility audit
|
|
659
|
+
* Example #2: Then the page should pass an accessibility audit at level "A"
|
|
660
|
+
* Example #3: Then the page should pass an accessibility audit at level "AA"
|
|
661
|
+
* Example #4: And the page should pass an accessibility audit at level "AAA"
|
|
662
|
+
* Example #5: When I am on "/dashboard"
|
|
663
|
+
* Then the page should pass an accessibility audit at level "AA"
|
|
664
|
+
*
|
|
665
|
+
*/
|
|
666
|
+
Then(/^the page should pass an accessibility audit(?: at level "(A|AA|AAA)")?$/, async function (level) {
|
|
667
|
+
const tags = WCAG_TAGS[level || 'AA'];
|
|
668
|
+
const result = await runAxe(this, { tags });
|
|
669
|
+
assert.strictEqual(result.violations.length, 0,
|
|
670
|
+
`Expected no WCAG ${level || 'AA'} violations, got ${result.violations.length}:\n${summariseViolations(result.violations)}`);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Assert NO axe violations of the given impact severity.
|
|
675
|
+
* Levels: `minor`, `moderate`, `serious`, `critical`.
|
|
676
|
+
*
|
|
677
|
+
* Use to gate merges on critical/serious issues while triaging minor regressions.
|
|
678
|
+
*
|
|
679
|
+
* Example #1: Then the page should have no critical accessibility violations
|
|
680
|
+
* Example #2: Then the page should have no serious accessibility violations
|
|
681
|
+
* Example #3: And the page should have no moderate accessibility violations
|
|
682
|
+
* Example #4: When I am on "/checkout"
|
|
683
|
+
* Then the page should have no critical accessibility violations
|
|
684
|
+
* Example #5: Then the page should have no critical accessibility violations
|
|
685
|
+
* And the page should have no serious accessibility violations
|
|
686
|
+
*
|
|
687
|
+
*/
|
|
688
|
+
Then(/^the page should have no (critical|serious|moderate|minor) accessibility violations$/, async function (impact) {
|
|
689
|
+
const result = await runAxe(this);
|
|
690
|
+
const filtered = result.violations.filter((v) => v.impact === impact);
|
|
691
|
+
assert.strictEqual(filtered.length, 0,
|
|
692
|
+
`Expected no ${impact} violations, got ${filtered.length}:\n${summariseViolations(filtered)}`);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Audit ONLY the subtree under a CSS selector. Use when third-party widgets
|
|
697
|
+
* are known-bad and you only want to gate your own components.
|
|
698
|
+
*
|
|
699
|
+
* Example #1: Then the element "main" should pass an accessibility audit
|
|
700
|
+
* Example #2: Then the element "#checkout-form" should pass an accessibility audit
|
|
701
|
+
* Example #3: And the element ".product-card" should pass an accessibility audit
|
|
702
|
+
* Example #4: Then the element "#dashboard-widget" should pass an accessibility audit
|
|
703
|
+
* Example #5: Then the element "[data-testid=signup]" should pass an accessibility audit
|
|
704
|
+
*
|
|
705
|
+
*/
|
|
706
|
+
Then(/^the element "([^"]*)" should pass an accessibility audit$/, async function (selector) {
|
|
707
|
+
const result = await runAxe(this, { include: selector, tags: WCAG_TAGS.AA });
|
|
708
|
+
assert.strictEqual(result.violations.length, 0,
|
|
709
|
+
`Expected no violations within "${selector}", got ${result.violations.length}:\n${summariseViolations(result.violations)}`);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Audit the page but EXCLUDE the subtree under a CSS selector. Skip
|
|
714
|
+
* known-bad embedded content (third-party iframes, legacy widgets, ads).
|
|
715
|
+
*
|
|
716
|
+
* Example #1: Then the page should pass an accessibility audit excluding "iframe.payment"
|
|
717
|
+
* Example #2: Then the page should pass an accessibility audit excluding "#chat-widget"
|
|
718
|
+
* Example #3: And the page should pass an accessibility audit excluding ".legacy-banner"
|
|
719
|
+
* Example #4: Then the page should pass an accessibility audit excluding "[data-third-party]"
|
|
720
|
+
* Example #5: Then the page should pass an accessibility audit excluding ".ads"
|
|
721
|
+
*
|
|
722
|
+
*/
|
|
723
|
+
Then(/^the page should pass an accessibility audit excluding "([^"]*)"$/, async function (selector) {
|
|
724
|
+
const result = await runAxe(this, { exclude: selector, tags: WCAG_TAGS.AA });
|
|
725
|
+
assert.strictEqual(result.violations.length, 0,
|
|
726
|
+
`Expected no violations excluding "${selector}", got ${result.violations.length}:\n${summariseViolations(result.violations)}`);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Assert a specific axe rule does not fire. Use for fine-grained gates —
|
|
731
|
+
* e.g. ensure color-contrast is fixed even if other rules still red.
|
|
732
|
+
*
|
|
733
|
+
* Common rule ids: `color-contrast`, `image-alt`, `label`, `link-name`,
|
|
734
|
+
* `button-name`, `heading-order`, `landmark-one-main`, `region`,
|
|
735
|
+
* `aria-valid-attr`, `tabindex`, `bypass`, `frame-title`, `meta-viewport`,
|
|
736
|
+
* `valid-lang`, `duplicate-id-aria`, `list`, `listitem`.
|
|
737
|
+
*
|
|
738
|
+
* Example #1: Then the page should not violate the accessibility rule "color-contrast"
|
|
739
|
+
* Example #2: Then the page should not violate the accessibility rule "image-alt"
|
|
740
|
+
* Example #3: And the page should not violate the accessibility rule "label"
|
|
741
|
+
* Example #4: Then the page should not violate the accessibility rule "link-name"
|
|
742
|
+
* Example #5: Then the page should not violate the accessibility rule "heading-order"
|
|
743
|
+
*
|
|
744
|
+
*/
|
|
745
|
+
Then(/^the page should not violate the accessibility rule "([^"]*)"$/, async function (ruleId) {
|
|
746
|
+
const result = await runAxe(this);
|
|
747
|
+
const hit = result.violations.find((v) => v.id === ruleId);
|
|
748
|
+
assert.ok(!hit, hit ? `Rule "${ruleId}" violated by ${hit.nodes.length} node(s):\n ${hit.nodes.slice(0, 5).map((n) => n.target.join(' ')).join('\n ')}` : '');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Print every axe violation to stdout (debug aid). Strip from CI runs.
|
|
753
|
+
*
|
|
754
|
+
* Example #1: Then I print accessibility violations
|
|
755
|
+
* Example #2: When I am on "/checkout"
|
|
756
|
+
* Then I print accessibility violations
|
|
757
|
+
* Example #3: And we print accessibility violations
|
|
758
|
+
* Example #4: Then I print accessibility violations
|
|
759
|
+
* And the page should have no critical accessibility violations
|
|
760
|
+
* Example #5: When I follow "Pricing"
|
|
761
|
+
* Then I print accessibility violations
|
|
762
|
+
*
|
|
763
|
+
*/
|
|
764
|
+
Then(/^(I |we )*print accessibility violations$/, async function () {
|
|
765
|
+
const result = await runAxe(this);
|
|
766
|
+
if (result.violations.length === 0) {
|
|
767
|
+
console.log('\n--- Accessibility violations: none ---');
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
console.log('\n--- Accessibility violations ---');
|
|
771
|
+
for (const v of result.violations) {
|
|
772
|
+
console.log(`[${v.impact}] ${v.id} — ${v.help}`);
|
|
773
|
+
console.log(` ${v.helpUrl}`);
|
|
774
|
+
for (const n of v.nodes.slice(0, 3)) {
|
|
775
|
+
console.log(` ${n.target.join(' ')}`);
|
|
776
|
+
if (n.failureSummary) console.log(` ${n.failureSummary.split('\n')[0]}`);
|
|
777
|
+
}
|
|
778
|
+
if (v.nodes.length > 3) console.log(` ... and ${v.nodes.length - 3} more node(s)`);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Assert axe finds zero violations of an exact list of rules. Use for
|
|
784
|
+
* smoke-test gating: pin the small list of rules you absolutely require to
|
|
785
|
+
* pass, run them on every page.
|
|
786
|
+
*
|
|
787
|
+
* Example #1: Then the page should pass the accessibility rules "image-alt, label"
|
|
788
|
+
* Example #2: Then the page should pass the accessibility rules "color-contrast"
|
|
789
|
+
* Example #3: And the page should pass the accessibility rules "button-name, link-name"
|
|
790
|
+
* Example #4: Then the page should pass the accessibility rules "landmark-one-main, region"
|
|
791
|
+
* Example #5: Then the page should pass the accessibility rules "aria-valid-attr, aria-valid-attr-value"
|
|
792
|
+
*
|
|
793
|
+
*/
|
|
794
|
+
Then(/^the page should pass the accessibility rules "([^"]*)"$/, async function (csv) {
|
|
795
|
+
const ids = csv.split(',').map((s) => s.trim()).filter(Boolean);
|
|
796
|
+
const result = await runAxe(this);
|
|
797
|
+
const hits = result.violations.filter((v) => ids.includes(v.id));
|
|
798
|
+
assert.strictEqual(hits.length, 0,
|
|
799
|
+
`Required rule(s) violated:\n${summariseViolations(hits)}`);
|
|
800
|
+
});
|