@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.
Files changed (301) hide show
  1. package/AGENTS.md +148 -0
  2. package/CLAUDE.md +544 -0
  3. package/README.md +22 -0
  4. package/bin/generate-reports.js +363 -0
  5. package/bin/init-varbase-e2e.js +330 -0
  6. package/bin/postinstall.js +26 -0
  7. package/cucumber.js +125 -0
  8. package/docs/00-quick-start.md +102 -0
  9. package/docs/01-getting-started.md +102 -0
  10. package/docs/02-bbr-smart-waits.md +77 -0
  11. package/docs/03-selector-registry.md +95 -0
  12. package/docs/04-step-reference.md +801 -0
  13. package/docs/05-web-first-assertions.md +91 -0
  14. package/docs/06-network-and-dialogs.md +83 -0
  15. package/docs/07-auth-state.md +75 -0
  16. package/docs/08-clock-mocking.md +59 -0
  17. package/docs/09-api-testing.md +114 -0
  18. package/docs/10-accessibility.md +167 -0
  19. package/docs/11-debugging.md +129 -0
  20. package/docs/12-ai-agent-guide.md +338 -0
  21. package/docs/13-faq.md +236 -0
  22. package/docs/14-recipes-cookbook.md +255 -0
  23. package/docs/15-tag-conventions.md +76 -0
  24. package/docs/16-ci-cd.md +90 -0
  25. package/docs/README.md +125 -0
  26. package/docs/advanced-screenshots/then-i-save-fullscreen-screenshot.md +36 -0
  27. package/docs/advanced-screenshots/then-i-save-width-x-height-screenshot.md +34 -0
  28. package/docs/advanced-screenshots/when-i-save-screenshot-name-filename.md +45 -0
  29. package/docs/advanced-screenshots.md +52 -0
  30. package/docs/advanced-selectors.md +18 -0
  31. package/docs/api-step-definitions.md +45 -0
  32. package/docs/global-settings.md +267 -0
  33. package/docs/install-varbase-e2e/ddev-varbase-e2e.md +52 -0
  34. package/docs/install-varbase-e2e.md +34 -0
  35. package/docs/overview.md +40 -0
  36. package/docs/step-definitions/given-i-am-anonymous-user.md +67 -0
  37. package/docs/step-definitions/given-i-am-homepage.md +68 -0
  38. package/docs/step-definitions/given-i-am-specific-page.md +72 -0
  39. package/docs/step-definitions/then-i-should-be.md +56 -0
  40. package/docs/step-definitions/then-i-should-see.md +62 -0
  41. package/docs/step-definitions/then-item-checkbox.md +54 -0
  42. package/docs/step-definitions/then-item-link-should-contain-url.md +84 -0
  43. package/docs/step-definitions/then-item-should-contain.md +67 -0
  44. package/docs/step-definitions/then-radio-button-value-should-be-selected.md +68 -0
  45. package/docs/step-definitions/then-response-status-code-should-be-number.md +69 -0
  46. package/docs/step-definitions/then-value-radio-button-selected.md +62 -0
  47. package/docs/step-definitions/when-i-attach-file-file-name-element.md +71 -0
  48. package/docs/step-definitions/when-i-check-checkbox.md +60 -0
  49. package/docs/step-definitions/when-i-click-link.md +51 -0
  50. package/docs/step-definitions/when-i-click-operation-text-row.md +71 -0
  51. package/docs/step-definitions/when-i-fill.md +59 -0
  52. package/docs/step-definitions/when-i-go-homepage.md +60 -0
  53. package/docs/step-definitions/when-i-go-specific-page.md +67 -0
  54. package/docs/step-definitions/when-i-move-backward-one-page.md +49 -0
  55. package/docs/step-definitions/when-i-move-forward-one-page.md +58 -0
  56. package/docs/step-definitions/when-i-press-button.md +58 -0
  57. package/docs/step-definitions/when-i-reload-page.md +51 -0
  58. package/docs/step-definitions/when-i-scroll.md +73 -0
  59. package/docs/step-definitions/when-i-select-option-select-list.md +53 -0
  60. package/docs/step-definitions/when-i-select-radio-button-value.md +62 -0
  61. package/docs/step-definitions/when-i-uncheck-checkbox.md +57 -0
  62. package/docs/step-definitions/when-i-wait-ajax-finish.md +64 -0
  63. package/docs/step-definitions/when-i-wait.md +62 -0
  64. package/docs/step-definitions.md +82 -0
  65. package/docs/steps/README.md +137 -0
  66. package/docs/steps/a11y.md +731 -0
  67. package/docs/steps/action.md +181 -0
  68. package/docs/steps/api.md +679 -0
  69. package/docs/steps/assertion.md +328 -0
  70. package/docs/steps/auth.md +108 -0
  71. package/docs/steps/clock.md +171 -0
  72. package/docs/steps/cookie.md +293 -0
  73. package/docs/steps/debug.md +52 -0
  74. package/docs/steps/dialog.md +226 -0
  75. package/docs/steps/drupal-canvas.md +414 -0
  76. package/docs/steps/drupal-ckeditor.md +118 -0
  77. package/docs/steps/drupal-core.md +688 -0
  78. package/docs/steps/drupal-layout-builder.md +437 -0
  79. package/docs/steps/drupal-media.md +107 -0
  80. package/docs/steps/drupal-moderation.md +81 -0
  81. package/docs/steps/drupal-paragraphs.md +36 -0
  82. package/docs/steps/element.md +455 -0
  83. package/docs/steps/field.md +648 -0
  84. package/docs/steps/file-download.md +235 -0
  85. package/docs/steps/form.md +344 -0
  86. package/docs/steps/iframe.md +248 -0
  87. package/docs/steps/input.md +220 -0
  88. package/docs/steps/javascript.md +111 -0
  89. package/docs/steps/keyboard.md +107 -0
  90. package/docs/steps/link.md +217 -0
  91. package/docs/steps/metatag.md +99 -0
  92. package/docs/steps/modal.md +232 -0
  93. package/docs/steps/navigation.md +275 -0
  94. package/docs/steps/network.md +284 -0
  95. package/docs/steps/path.md +203 -0
  96. package/docs/steps/response.md +102 -0
  97. package/docs/steps/responsive.md +139 -0
  98. package/docs/steps/rest.md +140 -0
  99. package/docs/steps/screenshot.md +211 -0
  100. package/docs/steps/scroll.md +266 -0
  101. package/docs/steps/selectors.md +848 -0
  102. package/docs/steps/storage.md +254 -0
  103. package/docs/steps/table.md +216 -0
  104. package/docs/steps/varbase.md +391 -0
  105. package/docs/steps/video.md +111 -0
  106. package/docs/steps/wait.md +553 -0
  107. package/docs/steps/web-first.md +289 -0
  108. package/docs/steps/xml.md +505 -0
  109. package/docs/steps/yaml.md +951 -0
  110. package/examples/a11y.html +31 -0
  111. package/examples/about-us.html +11 -0
  112. package/examples/account.html +32 -0
  113. package/examples/ajax-wait-examples.html +147 -0
  114. package/examples/contact-us.html +10 -0
  115. package/examples/cookies.html +10 -0
  116. package/examples/date.html +12 -0
  117. package/examples/dynamic-examples.html +498 -0
  118. package/examples/element.html +11 -0
  119. package/examples/example-api.json +28 -0
  120. package/examples/existing-page.html +11 -0
  121. package/examples/field.html +24 -0
  122. package/examples/index.html +37 -0
  123. package/examples/keyboard.html +11 -0
  124. package/examples/links.html +9 -0
  125. package/examples/modal.html +14 -0
  126. package/examples/path.html +3 -0
  127. package/examples/table.html +13 -0
  128. package/examples/test--modal-dialogs.html +252 -0
  129. package/examples/test--then--i-should--not--see-text-in-element.html +48 -0
  130. package/examples/test--then--i-should--see-text-in-element.html +48 -0
  131. package/examples/test--then--i-should--see-text-matching-pattern.html +31 -0
  132. package/examples/test--then--i-should-see-text-in-table-row.html +232 -0
  133. package/examples/test--then--the-checkbox-checked.html +68 -0
  134. package/examples/test--then--the-element-should_not-contain-cssproperty.html +47 -0
  135. package/examples/test--then--the-link-should-contain.html +21 -0
  136. package/examples/test--when--i-additionally-select.html +27 -0
  137. package/examples/test--when--i-attach-file-to-feild.html +50 -0
  138. package/examples/test--when--i-check-uncheck-checkbox.html +47 -0
  139. package/examples/test--when--i-click-link.html +18 -0
  140. package/examples/test--when--i-click-text-in-table-row.html +178 -0
  141. package/examples/test--when--i-fill-in.html +48 -0
  142. package/examples/test--when--i-press-button.html +31 -0
  143. package/examples/test--when--i-reload-page.html +27 -0
  144. package/examples/test--when--i-scroll-functionality.html +177 -0
  145. package/examples/test--when--i-scroll-left-right-functionality.html +149 -0
  146. package/examples/test--when--i-select-option-from-dropdownlist.html +35 -0
  147. package/examples/test--when--i-select-radio-button.html +95 -0
  148. package/examples/test--when-i-wait-max-of-time.html +39 -0
  149. package/examples/test--when-i-wait-page-until-is-loaded.html +47 -0
  150. package/examples/test--when-i-wait-specific-time.html +39 -0
  151. package/examples/test-acceptable-url-path.html +11 -0
  152. package/examples/video-demo.html +43 -0
  153. package/package.json +56 -0
  154. package/playwright.config.ts +39 -0
  155. package/tests/assets/test-document.pdf +14 -0
  156. package/tests/assets/vardot.png +0 -0
  157. package/tests/features/a11y.feature +39 -0
  158. package/tests/features/ajax-wait-examples.feature +24 -0
  159. package/tests/features/api-step-definitions-individual.feature +185 -0
  160. package/tests/features/complete-api-testing-examples.feature +205 -0
  161. package/tests/features/cookie.feature +13 -0
  162. package/tests/features/date.feature +11 -0
  163. package/tests/features/dynamic-content-examples.feature +156 -0
  164. package/tests/features/element.feature +16 -0
  165. package/tests/features/field.feature +25 -0
  166. package/tests/features/keyboard.feature +10 -0
  167. package/tests/features/link.feature +11 -0
  168. package/tests/features/modal-advanced-example.feature +54 -0
  169. package/tests/features/modal-basic-example.feature +42 -0
  170. package/tests/features/modal-error-handling-example.feature +37 -0
  171. package/tests/features/modal.feature +11 -0
  172. package/tests/features/path.feature +16 -0
  173. package/tests/features/response.feature +8 -0
  174. package/tests/features/responsive.feature +10 -0
  175. package/tests/features/table.feature +18 -0
  176. package/tests/features/test--given--i-am-an-anonymous-user.feature +10 -0
  177. package/tests/features/test--given--i-am-on-homepage.feature +16 -0
  178. package/tests/features/test--then--i-save-screenshot.feature +33 -0
  179. package/tests/features/test--then--i-see-locator.feature +320 -0
  180. package/tests/features/test--then--i-should--not--see-text-in-element.feature +13 -0
  181. package/tests/features/test--then--i-should--see-text-in-element-by-attr.feature +10 -0
  182. package/tests/features/test--then--i-should--see-text-in-element.feature +10 -0
  183. package/tests/features/test--then--i-should--see-text-matching-pattern-in-element.feature +13 -0
  184. package/tests/features/test--then--i-should--see-text-matching-pattern.feature +18 -0
  185. package/tests/features/test--then--i-should-be-on-page.feature +10 -0
  186. package/tests/features/test--then--i-should-be-on-the-homepage.feature +10 -0
  187. package/tests/features/test--then--i-should-not-see-an-element-by-attr.feature +8 -0
  188. package/tests/features/test--then--i-should-not-see-an-element.feature +8 -0
  189. package/tests/features/test--then--i-should-see-an-element-by-attr.feature +8 -0
  190. package/tests/features/test--then--i-should-see-an-element.feature +8 -0
  191. package/tests/features/test--then--i-should-see-n-elements.feature +24 -0
  192. package/tests/features/test--then--i-should-see-text-in-table-row.feature +96 -0
  193. package/tests/features/test--then--print-current-url-and-response.feature +21 -0
  194. package/tests/features/test--then--the-checkbox-is-checked.feature +18 -0
  195. package/tests/features/test--then--the-checkbox-should-be-checked.feature +16 -0
  196. package/tests/features/test--then--the-element-should-contain-cssproperty.feature +9 -0
  197. package/tests/features/test--then--the-element-should-not-contain-cssproperty.feature +10 -0
  198. package/tests/features/test--then--the-field-should-contain-value.feature +11 -0
  199. package/tests/features/test--then--the-response-should-contain.feature +11 -0
  200. package/tests/features/test--then--the-response-status-code-should-be.feature +31 -0
  201. package/tests/features/test--then--the-url-should-match.feature +7 -0
  202. package/tests/features/test--then-the-link-should-contain-by-attribute.feature +18 -0
  203. package/tests/features/test--then-the-link-should-contain.feature +7 -0
  204. package/tests/features/test--when--i-additionally-select.feature +30 -0
  205. package/tests/features/test--when--i-attach-file-to-feild.feature +9 -0
  206. package/tests/features/test--when--i-check-checkbox.feature +21 -0
  207. package/tests/features/test--when--i-click-link-by-attribute.feature +21 -0
  208. package/tests/features/test--when--i-click-link.feature +9 -0
  209. package/tests/features/test--when--i-click-text-in-table-row.feature +37 -0
  210. package/tests/features/test--when--i-fill-in-by-attr.feature +31 -0
  211. package/tests/features/test--when--i-fill-in-table-by-attr.feature +19 -0
  212. package/tests/features/test--when--i-fill-in.feature +32 -0
  213. package/tests/features/test--when--i-follow.feature +22 -0
  214. package/tests/features/test--when--i-go-to-homepage.feature +9 -0
  215. package/tests/features/test--when--i-move-forward-backward-one-page.feature +14 -0
  216. package/tests/features/test--when--i-press-button-by-attribute.feature +18 -0
  217. package/tests/features/test--when--i-press-button.feature +13 -0
  218. package/tests/features/test--when--i-reload-page.feature +11 -0
  219. package/tests/features/test--when--i-scroll-left-right.feature +48 -0
  220. package/tests/features/test--when--i-scroll.feature +48 -0
  221. package/tests/features/test--when--i-select-option-from-dropdownlist.feature +21 -0
  222. package/tests/features/test--when--i-select-radio-button.feature +37 -0
  223. package/tests/features/test--when--i-wait-page-is-loaded.feature +8 -0
  224. package/tests/features/test--when--i-wait-time.feature +21 -0
  225. package/tests/features/video.feature +18 -0
  226. package/tests/features/wait.feature +7 -0
  227. package/tests/features/yaml.feature +102 -0
  228. package/tests/selectors/_canonical-keys.json +113 -0
  229. package/tests/selectors/back-end-selectors.json +19 -0
  230. package/tests/selectors/cms-contentful-admin.json +35 -0
  231. package/tests/selectors/cms-craft-admin.json +39 -0
  232. package/tests/selectors/cms-drupal-cms-gin.json +56 -0
  233. package/tests/selectors/cms-drupal-core-claro.json +50 -0
  234. package/tests/selectors/cms-generic-admin.json +34 -0
  235. package/tests/selectors/cms-ghost-admin.json +34 -0
  236. package/tests/selectors/cms-joomla-admin.json +41 -0
  237. package/tests/selectors/cms-magento2-admin.json +32 -0
  238. package/tests/selectors/cms-prestashop-admin.json +38 -0
  239. package/tests/selectors/cms-shopify-admin.json +25 -0
  240. package/tests/selectors/cms-strapi-admin.json +42 -0
  241. package/tests/selectors/cms-typo3-admin.json +41 -0
  242. package/tests/selectors/cms-woocommerce-front.json +50 -0
  243. package/tests/selectors/cms-wordpress-admin.json +31 -0
  244. package/tests/selectors/framework-ant-design.json +80 -0
  245. package/tests/selectors/framework-bootstrap.json +86 -0
  246. package/tests/selectors/framework-bulma.json +74 -0
  247. package/tests/selectors/framework-chakra.json +58 -0
  248. package/tests/selectors/framework-foundation.json +51 -0
  249. package/tests/selectors/framework-material-ui.json +72 -0
  250. package/tests/selectors/framework-shadcn.json +51 -0
  251. package/tests/selectors/framework-tailwind.json +40 -0
  252. package/tests/selectors/framework-vuetify.json +61 -0
  253. package/tests/selectors/front-end-selectors.json +16 -0
  254. package/tests/selectors/homepage-selectors.json +13 -0
  255. package/tests/step-definitions/a11y.steps.js +800 -0
  256. package/tests/step-definitions/action.steps.js +167 -0
  257. package/tests/step-definitions/api.steps.js +732 -0
  258. package/tests/step-definitions/assertion.steps.js +352 -0
  259. package/tests/step-definitions/auth.steps.js +126 -0
  260. package/tests/step-definitions/clock.steps.js +128 -0
  261. package/tests/step-definitions/cookie.steps.js +216 -0
  262. package/tests/step-definitions/debug.steps.js +39 -0
  263. package/tests/step-definitions/dialog.steps.js +193 -0
  264. package/tests/step-definitions/drupal-canvas.steps.js +764 -0
  265. package/tests/step-definitions/drupal-ckeditor.steps.js +108 -0
  266. package/tests/step-definitions/drupal-core.steps.js +665 -0
  267. package/tests/step-definitions/drupal-helpers.js +371 -0
  268. package/tests/step-definitions/drupal-layout-builder.steps.js +466 -0
  269. package/tests/step-definitions/drupal-media.steps.js +99 -0
  270. package/tests/step-definitions/drupal-moderation.steps.js +71 -0
  271. package/tests/step-definitions/drupal-paragraphs.steps.js +50 -0
  272. package/tests/step-definitions/element.steps.js +356 -0
  273. package/tests/step-definitions/field.steps.js +653 -0
  274. package/tests/step-definitions/file-download.steps.js +211 -0
  275. package/tests/step-definitions/form.steps.js +333 -0
  276. package/tests/step-definitions/iframe.steps.js +228 -0
  277. package/tests/step-definitions/input.steps.js +202 -0
  278. package/tests/step-definitions/javascript.steps.js +232 -0
  279. package/tests/step-definitions/keyboard.steps.js +112 -0
  280. package/tests/step-definitions/link.steps.js +142 -0
  281. package/tests/step-definitions/metatag.steps.js +91 -0
  282. package/tests/step-definitions/modal.steps.js +244 -0
  283. package/tests/step-definitions/navigation.steps.js +218 -0
  284. package/tests/step-definitions/network.steps.js +280 -0
  285. package/tests/step-definitions/path.steps.js +141 -0
  286. package/tests/step-definitions/response.steps.js +98 -0
  287. package/tests/step-definitions/responsive.steps.js +125 -0
  288. package/tests/step-definitions/rest.steps.js +111 -0
  289. package/tests/step-definitions/screenshot.steps.js +423 -0
  290. package/tests/step-definitions/scroll.steps.js +189 -0
  291. package/tests/step-definitions/selectors.steps.js +1003 -0
  292. package/tests/step-definitions/storage.steps.js +194 -0
  293. package/tests/step-definitions/table.steps.js +181 -0
  294. package/tests/step-definitions/varbase-e2e.js +897 -0
  295. package/tests/step-definitions/varbase.steps.js +458 -0
  296. package/tests/step-definitions/video.steps.js +178 -0
  297. package/tests/step-definitions/wait.steps.js +400 -0
  298. package/tests/step-definitions/web-first.steps.js +298 -0
  299. package/tests/step-definitions/xml.steps.js +411 -0
  300. package/tests/step-definitions/yaml.steps.js +913 -0
  301. 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
+ });