@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,897 @@
1
+ 'use strict';
2
+
3
+ const { setWorldConstructor, World, Before, After, BeforeStep, AfterStep, setDefaultTimeout } = require('@cucumber/cucumber');
4
+ const playwright = require('playwright');
5
+ const playwrightConfig = require(require('path').join(process.cwd(), 'playwright.config'));
6
+ const path = require('path');
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Output filter — strip noisy `✔ Before # ...` / `✔ After # ...` hook lines
10
+ // from cucumber-js's failure dump so tester-facing output stays focused on
11
+ // the failing step + hint. Disable with VARBASE_E2E_FILTER_HOOK_LINES=off.
12
+ // ---------------------------------------------------------------------------
13
+ if (process.env.VARBASE_E2E_FILTER_HOOK_LINES !== 'off') {
14
+ const HOOK_LINE = /^\s*[✔✖✗⚠?-]\s+(?:Before|After|BeforeStep|AfterStep)\b.*$/;
15
+ const wrap = (stream) => {
16
+ const orig = stream.write.bind(stream);
17
+ stream.write = (chunk, encoding, cb) => {
18
+ if (typeof chunk === 'string' || Buffer.isBuffer(chunk)) {
19
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
20
+ const filtered = text
21
+ .split('\n')
22
+ .filter((line) => !HOOK_LINE.test(line.replace(/\x1b\[[0-9;]*m/g, '')))
23
+ .join('\n');
24
+ return orig(filtered, encoding, cb);
25
+ }
26
+ return orig(chunk, encoding, cb);
27
+ };
28
+ };
29
+ wrap(process.stdout);
30
+ wrap(process.stderr);
31
+ }
32
+
33
+ // ===========================================================================
34
+ // Internal helpers (BBR smart settle, modal probes, selector + text utilities).
35
+ // Imported by every *.steps.js file via require('./varbase-e2e'). Defined here
36
+ // rather than in a shared sub-module so there is a single, canonical entry
37
+ // point — `tests/step-definitions/varbase-e2e.js`.
38
+ // ===========================================================================
39
+
40
+ /**
41
+ * Shared shape of every Cucumber `this` (the World) at step time.
42
+ *
43
+ * @typedef {Object} VarbaseE2eWorld
44
+ * @property {import('playwright').Page} [page] - Active page (set in Before hook).
45
+ * @property {import('playwright').BrowserContext} [context] - Active browser context.
46
+ * @property {import('playwright').Browser} [browser] - Active browser instance.
47
+ * @property {import('playwright').FrameLocator} [frame] - Active iframe scope (set by iframe steps).
48
+ * @property {Object<string, string>} [__selectorsCss] - Named CSS selectors (registry).
49
+ * @property {Object<string, string>} [__selectorsXpath] - Named XPath selectors (registry).
50
+ * @property {Object} [parameters] - cucumber.js worldParameters.
51
+ */
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // BBR-style smart settle
55
+ //
56
+ // Behaviour-Based Robotics (BBR) principle: react to the environment, not
57
+ // the clock. smartSettle returns when the page is at the *edge* of activity,
58
+ // bounded by a budget so a runaway page cannot stall the run.
59
+ //
60
+ // The probe checks:
61
+ // 1. <body> attached
62
+ // 2. DOMContentLoaded fired
63
+ // 3. Playwright "networkidle" (500ms with no in-flight network)
64
+ // 4. window.__varbaseE2eAjaxCount === 0 (custom fetch/XHR counter)
65
+ // 5. window.__varbaseE2ePendingTimers === 0 (custom setTimeout counter)
66
+ // 6. Date.now() - window.__varbaseE2eLastMutation >= quietMs (DOM-quiet)
67
+ //
68
+ // Steps 4/5/6 are evaluated atomically via a single waitForFunction, so a
69
+ // late-firing setTimeout that mutates the DOM re-arms the wait correctly.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Wait for the page to reach an "edge" of activity — DOM ready, network
74
+ * idle, no pending AJAX or timers, and no DOM mutations for ≥250 ms.
75
+ *
76
+ * @param {import('playwright').Page} page - Page being probed.
77
+ * @param {number} [timeout=10000] - Total budget in ms.
78
+ * @returns {Promise<void>}
79
+ */
80
+ async function smartSettle(page, timeout) {
81
+ const total = typeof timeout === 'number' && timeout > 0 ? timeout : 10000;
82
+ const deadline = Date.now() + total;
83
+ const remaining = () => Math.max(0, deadline - Date.now());
84
+
85
+ try { await page.waitForSelector('body', { state: 'attached', timeout: remaining() }); } catch { /* keep going */ }
86
+ try { await page.waitForLoadState('domcontentloaded', { timeout: remaining() }); } catch { /* keep going */ }
87
+ try { await page.waitForLoadState('networkidle', { timeout: remaining() }); } catch { /* keep going */ }
88
+ try {
89
+ await page.waitForFunction(
90
+ (quietMs) => {
91
+ const ajax = window.__varbaseE2eAjaxCount;
92
+ const timers = window.__varbaseE2ePendingTimers;
93
+ const last = window.__varbaseE2eLastMutation;
94
+ if (typeof ajax === 'number' && ajax > 0) return false;
95
+ if (typeof timers === 'number' && timers > 0) return false;
96
+ if (typeof last === 'number' && Date.now() - last < quietMs) return false;
97
+ return true;
98
+ },
99
+ 250,
100
+ { timeout: remaining(), polling: 50 }
101
+ );
102
+ } catch { /* keep going */ }
103
+ }
104
+
105
+ /**
106
+ * Convenience alias for {@link smartSettle}. Kept for readability inside
107
+ * navigation steps where "wait for page to load" reads more naturally.
108
+ *
109
+ * @param {import('playwright').Page} page
110
+ * @param {number} [timeout]
111
+ * @returns {Promise<void>}
112
+ */
113
+ async function waitForPageLoad(page, timeout) {
114
+ await smartSettle(page, timeout);
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Modal helpers — use the named "modal" selector from the registry, falling
119
+ // back to ARIA role + native <dialog>.
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * Resolve the CSS selector that targets a modal dialog. Prefers a named
123
+ * `modal` selector from the registry; otherwise falls back to ARIA + native.
124
+ *
125
+ * @param {VarbaseE2eWorld} world
126
+ * @returns {string}
127
+ */
128
+ function getModalSelector(world) {
129
+ if (world && world.__selectorsCss && world.__selectorsCss['modal']) {
130
+ return world.__selectorsCss['modal'];
131
+ }
132
+ return '[role="dialog"], dialog';
133
+ }
134
+
135
+ /**
136
+ * Locator pointing at modal dialogs on the given page.
137
+ *
138
+ * @param {import('playwright').Page} page
139
+ * @param {VarbaseE2eWorld} world
140
+ * @returns {import('playwright').Locator}
141
+ */
142
+ function getModalLocator(page, world) {
143
+ return page.locator(getModalSelector(world));
144
+ }
145
+
146
+ /**
147
+ * Wait until a modal is visible or hidden. Visibility is derived from
148
+ * computed styles (display + visibility + opacity), which is more
149
+ * reliable than `offsetParent` for fixed-position modal containers.
150
+ *
151
+ * @param {import('playwright').Page} page
152
+ * @param {'visible'|'hidden'} state
153
+ * @param {number} [timeout=10000]
154
+ * @param {VarbaseE2eWorld} world
155
+ * @returns {Promise<void>}
156
+ */
157
+ async function waitForModalState(page, state, timeout, world) {
158
+ const sel = getModalSelector(world);
159
+ const total = typeof timeout === 'number' && timeout > 0 ? timeout : 10000;
160
+ if (state === 'visible') {
161
+ await page.waitForFunction(
162
+ (s) => Array.from(document.querySelectorAll(s)).some((el) => {
163
+ const cs = window.getComputedStyle(el);
164
+ return cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0';
165
+ }),
166
+ sel, { timeout: total }
167
+ );
168
+ } else {
169
+ await page.waitForFunction(
170
+ (s) => !Array.from(document.querySelectorAll(s)).some((el) => {
171
+ const cs = window.getComputedStyle(el);
172
+ return cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0';
173
+ }),
174
+ sel, { timeout: total }
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Return the first visible modal locator. Throws a friendly error with
181
+ * remediation hints when no modal is on screen.
182
+ *
183
+ * @param {import('playwright').Page} page
184
+ * @param {VarbaseE2eWorld} world
185
+ * @returns {Promise<import('playwright').Locator>}
186
+ */
187
+ async function findVisibleModal(page, world) {
188
+ const all = getModalLocator(page, world);
189
+ const count = await all.count();
190
+ for (let i = 0; i < count; i++) {
191
+ if (await all.nth(i).isVisible()) return all.nth(i);
192
+ }
193
+ const sel = getModalSelector(world);
194
+ throw friendly(
195
+ `No visible modal found.\n` +
196
+ ` Selector tried: ${sel}\n` +
197
+ ` Hint: did you "wait for the modal to appear" first?\n` +
198
+ ` Or: register a custom "modal" CSS selector in tests/selectors/<preset>.json.`
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Quick check — true when any element matching the modal selector is
204
+ * visible per the same computed-style probe as `waitForModalState`.
205
+ *
206
+ * @param {import('playwright').Page} page
207
+ * @param {VarbaseE2eWorld} world
208
+ * @returns {Promise<boolean>}
209
+ */
210
+ async function isAnyModalVisible(page, world) {
211
+ const sel = getModalSelector(world);
212
+ return page.evaluate((s) =>
213
+ Array.from(document.querySelectorAll(s)).some((el) => {
214
+ const cs = window.getComputedStyle(el);
215
+ return cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0';
216
+ }),
217
+ sel
218
+ );
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Resilient CSS selector builder.
223
+ // Honors id (#x), class (.x), or attribute selectors ([x]) verbatim.
224
+ // Otherwise tries id / class / name / data-testid / data-test-id / data-test
225
+ // / data-cy / aria-label / value / placeholder / title in priority order.
226
+ // ---------------------------------------------------------------------------
227
+ /**
228
+ * Compose a CSS selector that tries multiple addressable attributes.
229
+ * Verbatim selectors (`#id`, `.class`, `[attr]`) pass through untouched;
230
+ * bare names fan out across `name`, `data-testid`, `aria-label`, etc.
231
+ *
232
+ * @param {string} attrValue - Value to match (e.g. `"submit-btn"`).
233
+ * @param {string} [attr] - Optional explicit attribute name; when given, returns `[attr="value" i]`.
234
+ * @returns {string} CSS selector union.
235
+ */
236
+ function buildSelector(attrValue, attr) {
237
+ const hasASpace = attrValue.indexOf(' ');
238
+ if ((attrValue.startsWith('#') || attrValue.startsWith('.') || attrValue.startsWith('[')) && hasASpace === -1) {
239
+ return attrValue;
240
+ }
241
+ if (!attr && hasASpace === -1) {
242
+ return (
243
+ attrValue +
244
+ ',#' + attrValue +
245
+ ',.' + attrValue +
246
+ ',[name="' + attrValue + '"]' +
247
+ ',[data-testid="' + attrValue + '"]' +
248
+ ',[data-test-id="' + attrValue + '"]' +
249
+ ',[data-test="' + attrValue + '"]' +
250
+ ',[data-cy="' + attrValue + '"]' +
251
+ ',[aria-label="' + attrValue + '"]' +
252
+ ',[value="' + attrValue + '"]' +
253
+ ',[placeholder="' + attrValue + '"]' +
254
+ ',[title="' + attrValue + '"]'
255
+ );
256
+ }
257
+ if (!attr && hasASpace > -1) {
258
+ return '[value="' + attrValue + '"],[placeholder="' + attrValue + '"],[aria-label="' + attrValue + '"],[title="' + attrValue + '"]';
259
+ }
260
+ return '[' + attr + '="' + attrValue + '" i]';
261
+ }
262
+
263
+ // Navigate to a URL with friendly errors. Tolerates Firefox / WebKit
264
+ // empty-response edge cases so a redirect to about:blank or a 204 does
265
+ // not crash the suite. All other failures rethrow with extra context
266
+ // (URL + likely cause).
267
+ /**
268
+ * Navigate to a URL with friendly error messages for common failures
269
+ * (server down, DNS, empty response). All other errors rethrow with
270
+ * preserved stack + extra context.
271
+ *
272
+ * @param {import('playwright').Page} page
273
+ * @param {string} url
274
+ * @returns {Promise<void>}
275
+ */
276
+ async function gotoUrl(page, url) {
277
+ try {
278
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
279
+ } catch (e) {
280
+ const msg = e.message || '';
281
+ // Empty response: harmless, swallow.
282
+ if (/NS_ERROR_NET_EMPTY_RESPONSE|net::ERR_EMPTY_RESPONSE/.test(msg)) return;
283
+ // Connection refused: server probably not running.
284
+ if (/ECONNREFUSED|net::ERR_CONNECTION_REFUSED/.test(msg)) {
285
+ throw friendly(
286
+ `Could not reach "${url}".\n` +
287
+ ` ${msg.split('\n')[0]}\n` +
288
+ ` Hint: is your dev server running? Check LAUNCH_URL env var.`
289
+ );
290
+ }
291
+ // DNS failure.
292
+ if (/ERR_NAME_NOT_RESOLVED|NS_ERROR_UNKNOWN_HOST/.test(msg)) {
293
+ throw friendly(
294
+ `Could not resolve host for "${url}".\n` +
295
+ ` ${msg.split('\n')[0]}\n` +
296
+ ` Hint: double-check the hostname in LAUNCH_URL or your "Given I am on" path.`
297
+ );
298
+ }
299
+ // Anything else — strip the stack trace too.
300
+ throw friendly(`Failed to navigate to "${url}":\n ${e.message}`);
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Fill a form field located by label, placeholder, or `[name]`. Resolution
306
+ * order: label → placeholder → name. Throws a friendly error when none
307
+ * match, suggesting the by-attr variant.
308
+ *
309
+ * @param {import('playwright').Page} page
310
+ * @param {string} field - Label / placeholder / name.
311
+ * @param {string} value - Value to type.
312
+ * @returns {Promise<void>}
313
+ */
314
+ async function fillField(page, field, value) {
315
+ const byLabel = page.getByLabel(field, { exact: true });
316
+ if (await byLabel.count() > 0) {
317
+ await byLabel.fill(value);
318
+ return;
319
+ }
320
+ const byPlaceholder = page.getByPlaceholder(field, { exact: true });
321
+ if (await byPlaceholder.count() > 0) {
322
+ await byPlaceholder.fill(value);
323
+ return;
324
+ }
325
+ const byName = page.locator(`[name="${field}"]`).first();
326
+ if (await byName.count() > 0) {
327
+ await byName.fill(value);
328
+ return;
329
+ }
330
+ throw friendly(
331
+ `Could not find a field for "${field}".\n` +
332
+ ` Tried: getByLabel(exact), getByPlaceholder(exact), [name="${field}"].\n` +
333
+ ` Hints: check the label/placeholder text matches exactly (case-sensitive),\n` +
334
+ ` or use the attribute variant: 'I fill in "${field}" with "${value}" by attr'.`
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Read text from a locator. Falls back to `textContent` when the element
340
+ * is not an `<input>` / `<textarea>`.
341
+ *
342
+ * @param {import('playwright').Locator} locator
343
+ * @returns {Promise<string>}
344
+ */
345
+ async function getLocatorText(locator) {
346
+ try {
347
+ return await locator.inputValue();
348
+ } catch {
349
+ return await locator.textContent() || '';
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Left-pad a number with zeros to the requested width.
355
+ *
356
+ * @param {number} n
357
+ * @param {number} width
358
+ * @returns {string}
359
+ */
360
+ function pad(n, width) {
361
+ const s = String(n);
362
+ return s.length >= width ? s : '0'.repeat(width - s.length) + s;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Relative-date token resolver
367
+ //
368
+ // Replaces [relative:OFFSET] and [relative:OFFSET#FORMAT] tokens in step
369
+ // arguments before pattern matching. Resolution happens automatically via
370
+ // the BeforeStep hook registered below.
371
+ //
372
+ // OFFSET any expression understood by parseRelativeOffset()
373
+ // examples: "now", "-1 day", "+2 hours", "+1 week", "next monday"
374
+ // FORMAT subset of date-fns-style tokens: YYYY, MM, DD, HH, mm, ss
375
+ // examples: "YYYY-MM-DD", "DD/MM/YYYY HH:mm"
376
+ //
377
+ // Without FORMAT the resolved value is the Unix timestamp in seconds.
378
+ //
379
+ // Examples:
380
+ // [relative:-1 day] -> "1714867200"
381
+ // [relative:-1 day#YYYY-MM-DD] -> "2026-05-06"
382
+ // ---------------------------------------------------------------------------
383
+
384
+ const RELATIVE_TOKEN_RE = /\[relative:([^\]#]+)(?:#([^\]]+))?\]/g;
385
+
386
+ /**
387
+ * Format a Date with a tiny token vocabulary (`YYYY`, `MM`, `DD`, `HH`,
388
+ * `mm`, `ss`). Sufficient for typical step-arg date formats.
389
+ *
390
+ * @param {Date} date
391
+ * @param {string} fmt
392
+ * @returns {string}
393
+ */
394
+ function formatRelativeDate(date, fmt) {
395
+ return fmt
396
+ .replace(/YYYY/g, date.getFullYear())
397
+ .replace(/MM/g, pad(date.getMonth() + 1, 2))
398
+ .replace(/DD/g, pad(date.getDate(), 2))
399
+ .replace(/HH/g, pad(date.getHours(), 2))
400
+ .replace(/mm/g, pad(date.getMinutes(), 2))
401
+ .replace(/ss/g, pad(date.getSeconds(), 2));
402
+ }
403
+
404
+ /**
405
+ * Parse a relative-date offset expression and return absolute ms.
406
+ * Accepts `"now"`, `"+1 day"`, `"-2 hours"`, `"next monday"`, etc.
407
+ *
408
+ * @param {string} offset
409
+ * @param {number} baseMs - Reference epoch in ms.
410
+ * @returns {number} Resolved epoch in ms.
411
+ */
412
+ function parseRelativeOffset(offset, baseMs) {
413
+ const trimmed = offset.trim().toLowerCase();
414
+ if (trimmed === 'now') return baseMs;
415
+
416
+ const weekdayMatch = trimmed.match(/^(next|last)\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)$/);
417
+ if (weekdayMatch) {
418
+ const direction = weekdayMatch[1] === 'next' ? 1 : -1;
419
+ const target = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].indexOf(weekdayMatch[2]);
420
+ const d = new Date(baseMs);
421
+ let diff = (target - d.getDay()) * direction;
422
+ if (diff <= 0) diff += 7;
423
+ d.setDate(d.getDate() + diff * direction);
424
+ return d.getTime();
425
+ }
426
+
427
+ const m = trimmed.match(/^([+-]?\d+)\s*(second|minute|hour|day|week|month|year)s?$/);
428
+ if (m) {
429
+ const n = parseInt(m[1], 10);
430
+ const unit = m[2];
431
+ const d = new Date(baseMs);
432
+ switch (unit) {
433
+ case 'second': d.setSeconds(d.getSeconds() + n); break;
434
+ case 'minute': d.setMinutes(d.getMinutes() + n); break;
435
+ case 'hour': d.setHours(d.getHours() + n); break;
436
+ case 'day': d.setDate(d.getDate() + n); break;
437
+ case 'week': d.setDate(d.getDate() + n * 7); break;
438
+ case 'month': d.setMonth(d.getMonth() + n); break;
439
+ case 'year': d.setFullYear(d.getFullYear() + n); break;
440
+ }
441
+ return d.getTime();
442
+ }
443
+
444
+ const parsed = Date.parse(offset);
445
+ if (!Number.isNaN(parsed)) return parsed;
446
+
447
+ throw new Error(`Cannot evaluate relative date offset: "${offset}"`);
448
+ }
449
+
450
+ /**
451
+ * Resolve `[relative:OFFSET]` / `[relative:OFFSET#FORMAT]` tokens in any
452
+ * string. Without `#FORMAT` the token expands to a Unix timestamp in
453
+ * seconds; with it, to the formatted string.
454
+ *
455
+ * @param {string} value - Step argument that may contain tokens.
456
+ * @param {number} [nowMs=Date.now()] - Reference epoch for resolution.
457
+ * @returns {string} Value with tokens replaced.
458
+ */
459
+ function resolveRelativeDate(value, nowMs) {
460
+ if (typeof value !== 'string' || !value.includes('[relative:')) return value;
461
+ const base = nowMs || Date.now();
462
+ return value.replace(RELATIVE_TOKEN_RE, (_, offset, fmt) => {
463
+ const ms = parseRelativeOffset(offset, base);
464
+ if (fmt) return formatRelativeDate(new Date(ms), fmt);
465
+ return String(Math.floor(ms / 1000));
466
+ });
467
+ }
468
+
469
+ // BeforeStep hook — transform every string argument (and DataTable cells)
470
+ // in the current pickle step before the step definition runs.
471
+ BeforeStep(function (scope) {
472
+ const step = scope && scope.pickleStep;
473
+ if (!step) return;
474
+
475
+ if (typeof step.text === 'string' && step.text.includes('[relative:')) {
476
+ step.text = resolveRelativeDate(step.text);
477
+ }
478
+
479
+ if (!step.argument) return;
480
+
481
+ if (step.argument.docString && typeof step.argument.docString.content === 'string') {
482
+ step.argument.docString.content = resolveRelativeDate(step.argument.docString.content);
483
+ }
484
+
485
+ if (step.argument.dataTable && Array.isArray(step.argument.dataTable.rows)) {
486
+ for (const row of step.argument.dataTable.rows) {
487
+ if (!row || !Array.isArray(row.cells)) continue;
488
+ for (const cell of row.cells) {
489
+ if (cell && typeof cell.value === 'string') {
490
+ cell.value = resolveRelativeDate(cell.value);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ });
496
+
497
+ /**
498
+ * Translate a raw Playwright / Node / HTTP error message into one short,
499
+ * plain-English sentence aimed at non-coder testers. Falls back to a
500
+ * cleaned-up first line of the original when no pattern matches.
501
+ *
502
+ * @param {Error|string} err
503
+ * @returns {string}
504
+ */
505
+ function humanize(err) {
506
+ const msg = typeof err === 'string' ? err : (err && err.message) || '';
507
+ const first = msg.split('\n')[0].trim();
508
+ // Common Playwright / Node patterns → plain English.
509
+ const map = [
510
+ [/Timeout\s+\d+ms\s+exceeded/i, 'the page took too long to respond'],
511
+ [/locator\.(click|press|fill|hover|dblclick|tap|focus|dispatchEvent|scrollIntoViewIfNeeded)/i,
512
+ 'the element could not be reached'],
513
+ [/strict mode violation/i, 'the locator matched more than one element'],
514
+ [/element is not visible/i, 'the element is hidden or off-screen'],
515
+ [/element is not attached/i, 'the element was removed from the page'],
516
+ [/element is not enabled/i, 'the element is disabled'],
517
+ [/element is outside of the viewport/i, 'the element is outside the visible area'],
518
+ [/intercepts pointer events/i, 'another element is on top of the target'],
519
+ [/ECONNREFUSED|net::ERR_CONNECTION_REFUSED/i, 'the server is not running or refused the connection'],
520
+ [/ERR_NAME_NOT_RESOLVED|NS_ERROR_UNKNOWN_HOST/i, 'the hostname could not be resolved'],
521
+ [/ERR_HTTP_RESPONSE_CODE_FAILURE/i, 'the page returned an HTTP error status'],
522
+ [/ENOENT/i, 'the file could not be found'],
523
+ [/EACCES|EPERM/i, 'the file is not readable or writable'],
524
+ [/Invalid (regular expression|regex)/i, 'the regular expression is invalid'],
525
+ [/Unexpected token|Unexpected end of JSON input/i, 'the JSON could not be parsed'],
526
+ [/Target page, context or browser has been closed/i, 'the browser closed before the step finished'],
527
+ [/page\.goto:/i, 'the page could not be opened'],
528
+ [/frameLocator|content frame/i, 'the iframe content was not ready'],
529
+ [/Cookies? not found|Cookie .* not (set|found)/i, 'the cookie does not exist on this page'],
530
+ [/setViewportSize/i, 'the viewport cannot be resized for this browser context'],
531
+ ];
532
+ for (const [re, plain] of map) {
533
+ if (re.test(msg)) return plain;
534
+ }
535
+ // Strip noisy "Call log:" suffix common in Playwright errors.
536
+ return first.replace(/^\w+\.\w+:\s*/, '').replace(/\s+Call log:.*$/i, '');
537
+ }
538
+
539
+ /**
540
+ * Build a tester-friendly error.
541
+ *
542
+ * Three forms:
543
+ * 1. `friendly(message)` — message becomes the stack.
544
+ * 2. `friendly(message, err)` — appends "Why: <humanized>" + cleaned cause.
545
+ * 3. `friendly({ action, target, hint, cause })` — builds a structured message.
546
+ *
547
+ * The Error's `.stack` is set to the rendered message, so cucumber-js does
548
+ * not print a JS stack trace — only the lines we authored.
549
+ *
550
+ * @param {string|Object} input
551
+ * @param {Error} [cause]
552
+ * @returns {Error}
553
+ */
554
+ function friendly(input, cause) {
555
+ let body;
556
+ if (typeof input === 'string') {
557
+ body = input;
558
+ if (cause) {
559
+ body += `\n Why: ${humanize(cause)}`;
560
+ }
561
+ } else if (input && typeof input === 'object') {
562
+ const lines = [];
563
+ if (input.action && input.target) lines.push(`Could not ${input.action} "${input.target}".`);
564
+ else if (input.action) lines.push(`Could not ${input.action}.`);
565
+ else if (input.message) lines.push(input.message);
566
+ if (input.cause) lines.push(` Why: ${humanize(input.cause)}`);
567
+ if (input.hint) lines.push(` Hint: ${input.hint}`);
568
+ body = lines.join('\n');
569
+ } else {
570
+ body = String(input);
571
+ }
572
+ const e = new Error(body);
573
+ e.stack = body;
574
+ return e;
575
+ }
576
+
577
+ module.exports = {
578
+ smartSettle,
579
+ waitForPageLoad,
580
+ getModalSelector,
581
+ getModalLocator,
582
+ waitForModalState,
583
+ findVisibleModal,
584
+ isAnyModalVisible,
585
+ buildSelector,
586
+ gotoUrl,
587
+ fillField,
588
+ getLocatorText,
589
+ pad,
590
+ resolveRelativeDate,
591
+ parseRelativeOffset,
592
+ formatRelativeDate,
593
+ friendly,
594
+ humanize,
595
+ };
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Auto HTML report on cucumber-js process exit.
599
+ // Disable: VARBASE_E2E_REPORT_DISABLE=1. Extra flags: VARBASE_E2E_REPORT_ARGS="--theme hierarchy --layout 2".
600
+ // Registered once per process.
601
+ // ---------------------------------------------------------------------------
602
+ if (!global.__VARBASE_E2E_AUTO_REPORT__) {
603
+ global.__VARBASE_E2E_AUTO_REPORT__ = true;
604
+ process.on('exit', () => {
605
+ if (process.env.VARBASE_E2E_REPORT_DISABLE) return;
606
+ try {
607
+ const { run } = require(path.join(__dirname, '..', '..', 'bin', 'generate-reports'));
608
+ const extra = (process.env.VARBASE_E2E_REPORT_ARGS || '').split(/\s+/).filter(Boolean);
609
+ run(extra);
610
+ } catch (err) {
611
+ console.error('[varbase-e2e] Report generation failed:', err.message);
612
+ }
613
+ });
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // World
618
+ // ---------------------------------------------------------------------------
619
+ // Step timeout must exceed Playwright's 30s default so that locator timeouts
620
+ // (e.g. click() with no match) reach our try/catch wrappers BEFORE cucumber
621
+ // trips its own timeout — otherwise testers see "function timed out" instead
622
+ // of the friendly Why/Hint message.
623
+ setDefaultTimeout(45 * 1000);
624
+
625
+ class PlaywrightWorld extends World {
626
+ constructor(options) {
627
+ super(options);
628
+ this.launchUrl = this.parameters.launchUrl;
629
+ this.minWaitTime = this.parameters.minWaitTime;
630
+ this.playwrightBrowser = null;
631
+ this.context = null;
632
+ this.page = null;
633
+ this.assetsFolder = path.join(__dirname, '../assets/');
634
+ }
635
+
636
+ async openBrowser(extraContextOptions) {
637
+ const { browser: browserName, launchOptions, contextOptions } = playwrightConfig;
638
+ this.playwrightBrowser = await playwright[browserName].launch(launchOptions);
639
+ const merged = Object.assign({}, contextOptions, extraContextOptions || {});
640
+ this.context = await this.playwrightBrowser.newContext(merged);
641
+ // BBR: install in-flight fetch/XHR counter on every page (init script runs
642
+ // before any document script). The counter lets smartSettle() detect the
643
+ // edge of background activity rather than guessing a fixed delay.
644
+ await this.context.addInitScript(() => {
645
+ if (window.__varbaseE2eAjaxInstalled) return;
646
+ window.__varbaseE2eAjaxInstalled = true;
647
+ window.__varbaseE2eAjaxCount = 0;
648
+ window.__varbaseE2ePendingTimers = 0;
649
+ window.__varbaseE2eLastMutation = Date.now();
650
+
651
+ // Track in-flight fetch requests.
652
+ const origFetch = window.fetch;
653
+ if (typeof origFetch === 'function') {
654
+ window.fetch = function (...args) {
655
+ window.__varbaseE2eAjaxCount++;
656
+ const p = origFetch.apply(this, args);
657
+ const settle = () => { window.__varbaseE2eAjaxCount--; };
658
+ p.then(settle, settle);
659
+ return p;
660
+ };
661
+ }
662
+
663
+ // Track in-flight XMLHttpRequests.
664
+ const XHR = window.XMLHttpRequest;
665
+ if (XHR && XHR.prototype) {
666
+ const origSend = XHR.prototype.send;
667
+ XHR.prototype.send = function (...args) {
668
+ window.__varbaseE2eAjaxCount++;
669
+ const settle = () => { window.__varbaseE2eAjaxCount--; };
670
+ this.addEventListener('loadend', settle, { once: true });
671
+ return origSend.apply(this, args);
672
+ };
673
+ }
674
+
675
+ // Track pending setTimeout callbacks. Many client-side frameworks use
676
+ // setTimeout to schedule UI updates (animation frames, throttled
677
+ // re-renders, fade-out → display:none). BBR demands we wait for those
678
+ // timers to drain rather than guess a fixed delay.
679
+ const origSetTimeout = window.setTimeout;
680
+ const origClearTimeout = window.clearTimeout;
681
+ const liveTimers = new Set();
682
+ if (typeof origSetTimeout === 'function') {
683
+ window.setTimeout = function (cb, delay, ...args) {
684
+ window.__varbaseE2ePendingTimers++;
685
+ let id;
686
+ const wrapped = function () {
687
+ try {
688
+ if (typeof cb === 'function') return cb.apply(this, args);
689
+ } finally {
690
+ if (liveTimers.delete(id)) window.__varbaseE2ePendingTimers--;
691
+ }
692
+ };
693
+ id = origSetTimeout(wrapped, delay);
694
+ liveTimers.add(id);
695
+ return id;
696
+ };
697
+ if (typeof origClearTimeout === 'function') {
698
+ window.clearTimeout = function (id) {
699
+ if (liveTimers.delete(id)) window.__varbaseE2ePendingTimers--;
700
+ return origClearTimeout(id);
701
+ };
702
+ }
703
+ }
704
+
705
+ // Track last DOM mutation so smartSettle can wait for the *edge* of
706
+ // client-side rendering (animations, async hydration, transitions).
707
+ const startObserver = () => {
708
+ if (!document || !document.documentElement) return false;
709
+ try {
710
+ new MutationObserver(() => { window.__varbaseE2eLastMutation = Date.now(); })
711
+ .observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
712
+ return true;
713
+ } catch (e) { return false; }
714
+ };
715
+ if (!startObserver()) {
716
+ document.addEventListener('DOMContentLoaded', startObserver, { once: true });
717
+ }
718
+ });
719
+ this.page = await this.context.newPage();
720
+ }
721
+
722
+ async closeBrowser() {
723
+ if (this.playwrightBrowser) {
724
+ await this.playwrightBrowser.close();
725
+ this.playwrightBrowser = null;
726
+ this.context = null;
727
+ this.page = null;
728
+ }
729
+ }
730
+ }
731
+
732
+ setWorldConstructor(PlaywrightWorld);
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // Video recording — Playwright records at browser-context creation. The
736
+ // helpers below resolve config + tags into either an empty {} (no
737
+ // recording) or { recordVideo: { dir, size } } that gets merged into the
738
+ // context options.
739
+ // ---------------------------------------------------------------------------
740
+
741
+ const VIDEO_MODES = new Set(['off', 'on', 'on-failure', 'tag']);
742
+
743
+ function videoSettings(world, scope) {
744
+ const cfg = (world.parameters && world.parameters.video) || {};
745
+ let mode = process.env.VARBASE_E2E_VIDEO || cfg.mode || 'off';
746
+ if (!VIDEO_MODES.has(mode)) mode = 'off';
747
+ return {
748
+ mode,
749
+ dir: process.env.VARBASE_E2E_VIDEO_DIR || cfg.dir || './videos',
750
+ size: cfg.size || { width: 1280, height: 720 },
751
+ filenamePattern: cfg.filenamePattern || '{datetime}.{feature_file}.{scenario}.{status}.{ext}',
752
+ };
753
+ }
754
+
755
+ function videoHasTag(scope, name) {
756
+ return !!(scope && scope.pickle && Array.isArray(scope.pickle.tags) &&
757
+ scope.pickle.tags.some((t) => t.name === name));
758
+ }
759
+
760
+ function shouldRecordAtStart(s, scope) {
761
+ if (videoHasTag(scope, '@no-video')) return false;
762
+ if (videoHasTag(scope, '@video')) return true;
763
+ return s.mode === 'on' || s.mode === 'on-failure';
764
+ }
765
+
766
+ function sanitisePart(s) {
767
+ return String(s || '').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80);
768
+ }
769
+
770
+ function buildVideoFilename(template, scope, status) {
771
+ const featurePath = (scope && scope.gherkinDocument && scope.gherkinDocument.uri) || 'unknown';
772
+ const feature = (scope && scope.gherkinDocument && scope.gherkinDocument.feature && scope.gherkinDocument.feature.name) || 'feature';
773
+ const scenario = (scope && scope.pickle && scope.pickle.name) || 'scenario';
774
+ const featureFile = featurePath.split('/').pop().replace(/\.feature$/, '');
775
+ const dt = new Date().toISOString().replace(/[:.]/g, '-');
776
+ return template
777
+ .replace('{datetime}', dt)
778
+ .replace('{feature_file}', sanitisePart(featureFile))
779
+ .replace('{feature}', sanitisePart(feature))
780
+ .replace('{scenario}', sanitisePart(scenario))
781
+ .replace('{status}', sanitisePart(status))
782
+ .replace('{ext}', 'webm');
783
+ }
784
+
785
+ Before({ order: 5 }, async function (scope) {
786
+ this._videoSettings = videoSettings(this, scope);
787
+ this._videoRequested = shouldRecordAtStart(this._videoSettings, scope);
788
+ this._videoForcedByTag = videoHasTag(scope, '@video');
789
+
790
+ const extra = {};
791
+ if (this._videoRequested) {
792
+ require('fs').mkdirSync(this._videoSettings.dir, { recursive: true });
793
+ extra.recordVideo = {
794
+ dir: this._videoSettings.dir,
795
+ size: this._videoSettings.size,
796
+ };
797
+ }
798
+
799
+ await this.openBrowser(extra);
800
+ if (this.minWaitTime.before_scenario > 0) {
801
+ await this.page.waitForTimeout(this.minWaitTime.before_scenario);
802
+ }
803
+ });
804
+
805
+ After({ order: 5 }, async function (scope) {
806
+ if (this.minWaitTime.after_scenario > 0) {
807
+ try { await this.page.waitForTimeout(this.minWaitTime.after_scenario); } catch { /* page already closed */ }
808
+ }
809
+
810
+ const status = (scope && scope.result && scope.result.status) || 'unknown';
811
+ const videoRef = (this._videoRequested && this.page) ? this.page.video() : null;
812
+ // Capture the raw Playwright path BEFORE closing the context — after
813
+ // close, the remote handle is gone and video.path() throws.
814
+ let rawPath = null;
815
+ if (videoRef) {
816
+ try { rawPath = await videoRef.path(); } catch { /* may throw on remote */ }
817
+ }
818
+
819
+ // Close the *context* (flushes the webm to disk). Browser stays up so
820
+ // saveAs() / delete() can still talk to it.
821
+ if (this.context) {
822
+ try { await this.context.close(); } catch { /* ignore */ }
823
+ }
824
+
825
+ if (videoRef) {
826
+ const settings = this._videoSettings;
827
+ const filename = this._videoSaveAsName || buildVideoFilename(settings.filenamePattern, scope, status);
828
+ const dest = require('path').join(settings.dir, filename);
829
+ try {
830
+ const passed = status === 'PASSED' || status === 'passed';
831
+ if (settings.mode === 'on-failure' && passed && !this._videoForcedByTag) {
832
+ await videoRef.delete().catch(() => {});
833
+ } else {
834
+ await videoRef.saveAs(dest);
835
+ await videoRef.delete().catch(() => {});
836
+ process.stderr.write(`\n[varbase-e2e] video saved → ${dest}\n`);
837
+ }
838
+ } catch (e) {
839
+ process.stderr.write(`\n[varbase-e2e] video save failed: ${e.message}\n`);
840
+ }
841
+ // Fallback: remove the Playwright scratch file via fs if it still exists.
842
+ if (rawPath) {
843
+ try { require('fs').unlinkSync(rawPath); } catch { /* already gone */ }
844
+ }
845
+ }
846
+
847
+ // Finally close the browser.
848
+ if (this.playwrightBrowser) {
849
+ try { await this.playwrightBrowser.close(); } catch { /* ignore */ }
850
+ this.playwrightBrowser = null;
851
+ }
852
+ this.context = null;
853
+ this.page = null;
854
+ });
855
+
856
+ BeforeStep(async function () {
857
+ if (this.page && this.minWaitTime.before_step > 0) {
858
+ await this.page.waitForTimeout(this.minWaitTime.before_step);
859
+ }
860
+ });
861
+
862
+ // BBR: regex matches steps that mutate page state (click/press/fill/check/etc.).
863
+ // After such a step, run a short smartSettle so assertions in the next step
864
+ // observe the post-action edge instead of mid-transition state.
865
+ const STATE_MUTATING_STEP = /\b(click|press|fill|submit|select|check|uncheck|choose|attach|reload|move (?:back|forward)|go to|navigate|on (?:the )?(?:home|front)page)\b/i;
866
+
867
+ AfterStep(async function (scope) {
868
+ if (!this.page) return;
869
+ if (this.minWaitTime.after_step > 0) {
870
+ await this.page.waitForTimeout(this.minWaitTime.after_step);
871
+ }
872
+ // Auto-settle only after state-changing steps. Skip if disabled via env var.
873
+ if (process.env.VARBASE_E2E_AUTO_SETTLE === 'off') return;
874
+ const text = scope && scope.pickleStep && scope.pickleStep.text;
875
+ if (typeof text !== 'string') return;
876
+ if (!STATE_MUTATING_STEP.test(text)) return;
877
+ try { await smartSettle(this.page, 1500); } catch { /* best effort */ }
878
+ });
879
+
880
+
881
+ // ===========================================================================
882
+ // All step definitions previously defined in this file have been split out
883
+ // by topic. Cucumber-js auto-loads every *.steps.js in tests/step-definitions/,
884
+ // so feature files keep working unchanged.
885
+ //
886
+ // Topical homes:
887
+ // navigation.steps.js Given/When/Then for sessions, homepage, paths, history
888
+ // action.steps.js press, click (text/attr/row), follow, attach file
889
+ // form.steps.js fill, select, additionally select, check, uncheck, radio
890
+ // assertion.steps.js should see / not see, in row, in element, response,
891
+ // text matching, link href, count, status code
892
+ // field.steps.js field/checkbox/radio state assertions
893
+ // modal.steps.js modal visibility, content, button click, close
894
+ // wait.steps.js every BBR wait phrasing
895
+ // scroll.steps.js page + scoped element scrolling
896
+ // debug.steps.js print URL / response (debug only)
897
+ // ===========================================================================