@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,458 @@
1
+ 'use strict';
2
+
3
+ // -----------------------------------------------------------------------------
4
+ // Varbase step definitions: the Varbase testing users registry, the welcome
5
+ // tour, the default theme settings (sticky header), the international phone
6
+ // field, the editorial accessibility checker, the responsive warm-up pass and
7
+ // the working header / footer checklists.
8
+ //
9
+ // Ported from the Varbase Project BDD suites (11.0.x, 10.1.x, 9.2.x) so every
10
+ // Varbase site gets them from @vardot/varbase-e2e instead of copying them into
11
+ // each project's own tests/step-definitions/.
12
+ // -----------------------------------------------------------------------------
13
+
14
+ const { Given, When, Then } = require('@cucumber/cucumber');
15
+ const assert = require('assert');
16
+ const { smartSettle, friendly } = require('./varbase-e2e');
17
+ const { budget, defaultThemeSettingsPath } = require('./drupal-helpers');
18
+
19
+ /**
20
+ * Authenticate a Varbase user defined in cucumber.js worldParameters.users.
21
+ *
22
+ * Example #1: Given I am a logged in user with the "webmaster" user
23
+ * Example #2: Given I am a logged in user with the "Content admin" user
24
+ * Example #3: Given I am a logged in user with the username "editor"
25
+ * Example #4: Given I am a logged in user with "admin"
26
+ * Example #5: And I am a logged in user with the "webmaster" user
27
+ */
28
+ Given(/^I am a logged in user with( the)*( username)* "([^"]*)?"( user)*$/, async function (theCase, usernameCase, username, userCase) {
29
+ const users = this.parameters.users;
30
+
31
+ if (!(username in users)) {
32
+ throw friendly(
33
+ `User "${username}" is not configured.`,
34
+ `Add it to worldParameters.users in cucumber.js. Known users: ${Object.keys(users).join(', ')}.`
35
+ );
36
+ }
37
+
38
+ const loginName = users[username].username || username;
39
+ const password = users[username].password;
40
+ if (password == null) return;
41
+
42
+ await this.page.goto(this.launchUrl + '/user/login', { waitUntil: 'domcontentloaded' });
43
+ // Wait for the username field to be actionable, fill, then submit by
44
+ // triggering the button's native click in-page. The Varbase login form is
45
+ // a standard POST; an in-page submit cannot be intercepted by the floating
46
+ // AI chatbot widget (a Playwright click would hit a 30s actionability
47
+ // timeout when the widget overlaps the button).
48
+ await this.page.waitForSelector('#edit-name', { state: 'visible', timeout: 15000 });
49
+ await this.page.fill('#edit-name', loginName);
50
+ await this.page.fill('#edit-pass', password);
51
+ await Promise.all([
52
+ this.page.waitForNavigation({ waitUntil: 'domcontentloaded' }).catch(() => {}),
53
+ this.page.evaluate(() => {
54
+ const submit = document.querySelector('#edit-submit');
55
+ if (submit) { submit.click(); return; }
56
+ const form = document.querySelector('#user-login-form') || document.forms[0];
57
+ if (form) form.submit();
58
+ }),
59
+ ]);
60
+ // Smart-settle the post-login page so the next step sees a stable page.
61
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
62
+ });
63
+
64
+ /**
65
+ * Fill an international telephone field (Webform `tel` element with
66
+ * `#international`, rendered by the intl-tel-input library). Setting the raw
67
+ * value with a plain "fill in" step is not enough: intl-tel-input validates
68
+ * the number with libphonenumber and rejects anything it cannot parse, so the
69
+ * webform's clientside/serverside validation fails. This step drives the
70
+ * intl-tel-input instance itself (`setNumber`) and dispatches input/blur so
71
+ * both the widget and the webform see a valid, formatted number.
72
+ *
73
+ * Pass the number in E.164 form (e.g. "+14155552671") for deterministic
74
+ * results regardless of the field's selected country.
75
+ *
76
+ * Example #1: When I fill in the international phone field with "+14155552671"
77
+ * Example #2: And I fill in the international phone number with "+442071838750"
78
+ * Example #3: When we fill in the international phone field with "+14155552671"
79
+ * Example #4: And I fill in the international phone field "Phone" with "+14155552671"
80
+ * Example #5: Given I fill in the international phone number with "+12025550143"
81
+ */
82
+ When(/^(?:I |we )*fill in the international phone (?:field|number)(?: "[^"]*")? with "([^"]*)"$/, async function (number) {
83
+ const valid = await this.page.evaluate((num) => {
84
+ const g = window.intlTelInputGlobals;
85
+ if (!g || !g.instances) return null;
86
+ const keys = Object.keys(g.instances);
87
+ if (!keys.length) return null;
88
+ const inst = g.instances[keys[0]];
89
+ inst.setNumber(num);
90
+ const input = inst.telInput || document.querySelector('input[type="tel"]');
91
+ if (input) {
92
+ input.dispatchEvent(new Event('input', { bubbles: true }));
93
+ input.dispatchEvent(new Event('change', { bubbles: true }));
94
+ input.dispatchEvent(new Event('blur', { bubbles: true }));
95
+ }
96
+ return inst.isValidNumber();
97
+ }, number);
98
+
99
+ if (valid === null) {
100
+ throw friendly(
101
+ 'No intl-tel-input phone field was found on the page.',
102
+ 'This step only works on a Webform "tel" element with #international enabled.'
103
+ );
104
+ }
105
+ if (!valid) {
106
+ throw friendly(
107
+ `"${number}" is not a valid phone number for this field.`,
108
+ 'Pass a libphonenumber-valid number in E.164 form, e.g. "+14155552671".'
109
+ );
110
+ }
111
+ });
112
+
113
+ /**
114
+ * Go to the default theme's settings page, resolved by machine name (not
115
+ * hard-coded), so the test works whatever the default theme is.
116
+ *
117
+ * Example #1: When I go to the default theme settings page
118
+ * Example #2: And I go to the default theme settings
119
+ * Example #3: When we go to the default theme settings page
120
+ * Example #4: And we go to the default theme settings
121
+ * Example #5: Given I go to the default theme settings page
122
+ */
123
+ When(/^(?:I |we )*go to the default theme settings(?: page)?$/, async function () {
124
+ const path = await defaultThemeSettingsPath(this.page, this.launchUrl);
125
+ if (!path) throw friendly('Could not find the default theme settings link.', 'Open /admin/appearance as a user who can administer themes.');
126
+ await this.page.goto(`${this.launchUrl.replace(/\/$/, '')}${path}`, { waitUntil: 'domcontentloaded' });
127
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
128
+ });
129
+
130
+ /**
131
+ * Enable or disable the default theme's "Sticky header" setting and save.
132
+ * The Gin-styled checkbox is visually hidden, so it is toggled in-page. Opens
133
+ * the default theme's settings form, sets the checkbox, and submits.
134
+ *
135
+ * Run a login step first (e.g. the webmaster).
136
+ *
137
+ * Example #1: When I enable the sticky header theme setting
138
+ * Example #2: And I disable the sticky header theme setting
139
+ * Example #3: When we enable the sticky header theme setting
140
+ * Example #4: And we disable the sticky header theme setting
141
+ * Example #5: Given I enable the sticky header theme setting
142
+ */
143
+ When(/^(?:I |we )*(enable|disable) the sticky header theme setting$/, async function (action) {
144
+ const want = action === 'enable';
145
+ const settingsPath = await defaultThemeSettingsPath(this.page, this.launchUrl);
146
+ if (!settingsPath) throw friendly('Could not find the default theme settings link.', 'Open /admin/appearance as a user who can administer themes.');
147
+ await this.page.goto(`${this.launchUrl.replace(/\/$/, '')}${settingsPath}`, { waitUntil: 'domcontentloaded' });
148
+ const ok = await this.page.evaluate((on) => {
149
+ const cb = document.querySelector('input[name="sticky_header"]');
150
+ if (!cb) return false;
151
+ if (cb.checked !== on) cb.click();
152
+ const submit = document.getElementById('edit-submit') || [...document.querySelectorAll('input[type="submit"]')].find((b) => /save configuration/i.test(b.value));
153
+ if (!submit) return false;
154
+ submit.click();
155
+ return true;
156
+ }, want);
157
+ if (!ok) throw friendly('Could not toggle the "Sticky header" theme setting.', 'Open the default theme settings as a user who can administer the theme.');
158
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
159
+ });
160
+
161
+ /**
162
+ * Warm up a page across every viewport breakpoint in the testing settings.
163
+ *
164
+ * The default theme renders responsive images via drimage_improved, which builds
165
+ * a different WebP derivative per rendered width. This visits the page once at
166
+ * each breakpoint from worldParameters.selectors.breakpoints (scrolling to the
167
+ * bottom to trigger lazy images) so every derivative is generated and cached to
168
+ * disk before the health checks assert on console errors. It makes no
169
+ * assertions; it only primes the cache.
170
+ *
171
+ * Example #1: When I warm up "/" at all testing breakpoints
172
+ * Example #2: And I warm up "/features" at all testing breakpoints
173
+ * Example #3: When we warm up "/blog" at all testing breakpoints
174
+ * Example #4: And I warm up "/contact-us" at all testing breakpoints
175
+ * Example #5: Given I warm up "/about-varbase" at all testing breakpoints
176
+ */
177
+ When(/^(?:I |we )*warm up "([^"]*)" at all testing breakpoints$/, async function (path) {
178
+ const base = this.launchUrl.replace(/\/$/, '');
179
+ const url = path.startsWith('http') ? path : base + (path.startsWith('/') ? path : '/' + path);
180
+ const configured = (this.parameters.selectors && this.parameters.selectors.breakpoints) || {};
181
+ const breakpoints = Object.values(configured);
182
+ if (!breakpoints.length) {
183
+ breakpoints.push({ width: 1920, height: 1080 });
184
+ }
185
+ const budget = (this.minWaitTime && this.minWaitTime.page) || 8000;
186
+ for (const breakpoint of breakpoints) {
187
+ await this.page.setViewportSize({ width: breakpoint.width, height: breakpoint.height });
188
+ await this.page.goto(url, { waitUntil: 'domcontentloaded' });
189
+ await smartSettle(this.page, budget);
190
+ await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
191
+ await smartSettle(this.page, budget);
192
+ }
193
+ });
194
+
195
+ /**
196
+ * Click the "Next" button in the Varbase / Drupal Shepherd tour.
197
+ *
198
+ * Ports VarbaseContext::iClickNextInTour. The tour button lives in a
199
+ * shadow/overlay dialog; click it via an in-page native click after scrolling
200
+ * it into view.
201
+ *
202
+ * Example #1: When I click next button in tour
203
+ * Example #2: And I click next button in tour
204
+ * Example #3: When we click next button in tour
205
+ * Example #4: Given I click next button in tour
206
+ * Example #5: And we click next button in tour
207
+ */
208
+ When(/^(?:I |we )*click next button in tour$/, async function () {
209
+ const ok = await this.page.evaluate(() => {
210
+ // Advance via the Shepherd API. The Drupal/Varbase tour renders each tip
211
+ // through Shepherd.js; its "Next" button's click handler does not reliably
212
+ // fire under a synthetic element.click() (the internal step pointer moves
213
+ // but the new tip does not render, or nothing happens at all), so drive the
214
+ // active tour directly — which shows the next step's tip exactly as the
215
+ // button is meant to. Fall back to clicking the button if the API is not
216
+ // exposed.
217
+ if (window.Shepherd && Shepherd.activeTour && typeof Shepherd.activeTour.next === 'function') {
218
+ Shepherd.activeTour.next();
219
+ return true;
220
+ }
221
+ const byText = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Next');
222
+ const btn = byText || document.querySelector('.shepherd-button:not(.shepherd-button-secondary)');
223
+ if (!btn) return false;
224
+ btn.scrollIntoView({ block: 'center' });
225
+ btn.click();
226
+ return true;
227
+ });
228
+ if (!ok) throw friendly('The "Next" button in the tour was not found.');
229
+ await smartSettle(this.page, budget(this));
230
+ });
231
+
232
+ /**
233
+ * Close the Varbase / Drupal Shepherd tour.
234
+ *
235
+ * Ports VarbaseContext::iCloseTour: tries the cancel/close control, then falls
236
+ * back to dispatching Escape.
237
+ *
238
+ * Example #1: When I close the tour
239
+ * Example #2: And I close the tour
240
+ * Example #3: When we close the tour
241
+ * Example #4: Given I close the tour
242
+ * Example #5: And we close the tour
243
+ */
244
+ When(/^(?:I |we )*close the tour$/, async function () {
245
+ await this.page.evaluate(() => {
246
+ const btn = document.querySelector('.shepherd-cancel-icon, button[aria-label="Close"], .shepherd-button:last-child');
247
+ if (btn) { btn.scrollIntoView({ block: 'center' }); btn.click(); return; }
248
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
249
+ });
250
+ await smartSettle(this.page, budget(this));
251
+ });
252
+
253
+ /**
254
+ * Assert the Editoria11y accessibility checker panel is (or is not) present.
255
+ *
256
+ * Ports VarbaseContext::iShouldSeeTheAccessibilityChecker /
257
+ * iShouldNotSeeTheAccessibilityChecker. The checker mounts an
258
+ * <ed11y-element-panel> custom element.
259
+ *
260
+ * Example #1: Then I should see the accessibility checker
261
+ * Example #2: And I should see the a11y checker
262
+ * Example #3: Then should see a11y checker
263
+ * Example #4: And I should not see the accessibility checker
264
+ * Example #5: Then should not see a11y checker
265
+ */
266
+ Then(/^(?:I |we )*should( not)? see (?:the )?(?:accessibility |a11y )?checker$/, async function (negate) {
267
+ const loc = this.page.locator('ed11y-element-panel');
268
+ const count = await loc.count().catch(() => 0);
269
+ if (negate) {
270
+ assert.strictEqual(count, 0, friendly('The accessibility checker panel was unexpectedly present.'));
271
+ } else {
272
+ assert.ok(count > 0, friendly('The accessibility checker panel was not found on the page.'));
273
+ }
274
+ });
275
+
276
+ /**
277
+ * Close the Editoria11y accessibility checker to clear space for more actions.
278
+ *
279
+ * Ports VarbaseContext::iCloseTheAccessibilityChecker: toggles the panel via
280
+ * its shadow-root toggle button.
281
+ *
282
+ * Example #1: When I close the accessibility checker
283
+ * Example #2: And I close the a11y checker
284
+ * Example #3: When we close the accessibility checker
285
+ * Example #4: Given I close the a11y checker
286
+ * Example #5: And we close the accessibility checker
287
+ */
288
+ When(/^(?:I |we )*close (?:the )?(?:accessibility |a11y )?checker$/, async function () {
289
+ const ok = await this.page.evaluate(() => {
290
+ const panel = document.querySelector('ed11y-element-panel');
291
+ if (!panel || !panel.shadowRoot) return false;
292
+ const toggle = panel.shadowRoot.querySelector('#ed11y-toggle');
293
+ if (!toggle) return false;
294
+ toggle.click();
295
+ return true;
296
+ });
297
+ if (!ok) throw friendly('The accessibility checker toggle was not found.');
298
+ await smartSettle(this.page, budget(this));
299
+ });
300
+
301
+ /**
302
+ * Verify the page header is "working".
303
+ *
304
+ * On a Varbase site the Main navigation menu is rendered through the Drupal
305
+ * Canvas global Header region, so a working header means those primary links
306
+ * are present. Alter the links below to match your own site's main menu.
307
+ *
308
+ * Example: Then the page should have a working header
309
+ *
310
+ * Example #1: Then the page should have a working header
311
+ * Example #2: And I should have a working header
312
+ * Example #3: Then I should have a working header
313
+ * Example #4: And we should have a working header
314
+ * Example #5: Then the page should have a working header
315
+ */
316
+ Then(/^(?:the page should have|(?:I |we )*should have) a working header$/, async function () {
317
+ // Smart-wait for the page to reach a quiet edge before reading the header.
318
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
319
+
320
+ // `banner` is the ARIA role of the site header (<header role="banner">).
321
+ const header = this.page.getByRole('banner').first();
322
+ const text = (await header.textContent().catch(() => '')) || '';
323
+
324
+ // Main navigation menu links expected in the header (one per line):
325
+ if (!text.includes('About Varbase')) throw friendly('Header is missing the "About Varbase" link.', 'Check the Main navigation menu in the Canvas Header region.');
326
+ if (!text.includes('Features')) throw friendly('Header is missing the "Features" link.', 'Check the Main navigation menu in the Canvas Header region.');
327
+ if (!text.includes('Blog')) throw friendly('Header is missing the "Blog" link.', 'Check the Main navigation menu in the Canvas Header region.');
328
+ if (!text.includes('Contact Us')) throw friendly('Header is missing the "Contact Us" link.', 'Check the Main navigation menu in the Canvas Header region.');
329
+ });
330
+
331
+ /**
332
+ * Verify the page footer is "working".
333
+ *
334
+ * On a Varbase site the Secondary, Footer and Social media menus are rendered
335
+ * through the Drupal Canvas global Footer region. A working footer means the
336
+ * footer link text is present, the social profiles are linked, and the credits
337
+ * and logos show. Alter the lines below to match your own site.
338
+ *
339
+ * Example: Then the page should have a working footer
340
+ *
341
+ * Example #1: Then the page should have a working footer
342
+ * Example #2: And I should have a working footer
343
+ * Example #3: Then I should have a working footer
344
+ * Example #4: And we should have a working footer
345
+ * Example #5: Then the page should have a working footer
346
+ */
347
+ Then(/^(?:the page should have|(?:I |we )*should have) a working footer$/, async function () {
348
+ // Smart-wait for the page to reach a quiet edge before reading the footer.
349
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
350
+
351
+ // `contentinfo` is the ARIA role of the site footer (<footer role="contentinfo">).
352
+ // Scope to the role so card/section <footer> elements elsewhere are ignored.
353
+ const footer = this.page.getByRole('contentinfo').first();
354
+ const text = (await footer.textContent().catch(() => '')) || '';
355
+
356
+ // Secondary menu (Footer sitemap / Quicklinks) link text (one per line):
357
+ if (!text.includes('Quicklinks')) throw friendly('Footer is missing the "Quicklinks" heading.', 'Check the Secondary menu in the Canvas Footer region.');
358
+ if (!text.includes('Features')) throw friendly('Footer is missing the "Features" link.', 'Check the Secondary menu in the Canvas Footer region.');
359
+ if (!text.includes('About Varbase')) throw friendly('Footer is missing the "About Varbase" link.', 'Check the Secondary menu in the Canvas Footer region.');
360
+
361
+ // Footer menu (Support) link text (one per line):
362
+ if (!text.includes('Support')) throw friendly('Footer is missing the "Support" heading.', 'Check the Footer menu in the Canvas Footer region.');
363
+ if (!text.includes('Documentation')) throw friendly('Footer is missing the "Documentation" link.', 'Check the Footer menu in the Canvas Footer region.');
364
+ if (!text.includes('Get Professional Support')) throw friendly('Footer is missing the "Get Professional Support" link.', 'Check the Footer menu in the Canvas Footer region.');
365
+ if (!text.includes('Community Support')) throw friendly('Footer is missing the "Community Support" link.', 'Check the Footer menu in the Canvas Footer region.');
366
+
367
+ // Footer credit and legal text (one per line):
368
+ if (!text.includes('Enjoy the free software, or')) throw friendly('Footer is missing the "Enjoy the free software, or get a quote ..." credit.');
369
+ if (!text.includes('get a quote')) throw friendly('Footer is missing the "get a quote" link text.');
370
+ if (!text.includes('Terms and Conditions')) throw friendly('Footer is missing the "Terms and Conditions" link.');
371
+ if (!text.includes('Powered by')) throw friendly('Footer is missing the "Powered by" credit.');
372
+
373
+ // Social media menu profiles - full links (one per line):
374
+ if ((await footer.locator('a[href="https://www.linkedin.com/company/vardot"]').count()) === 0) throw friendly('Footer is missing the LinkedIn link (https://www.linkedin.com/company/vardot).', 'Check the Social media menu in the Canvas Footer region.');
375
+ if ((await footer.locator('a[href="https://www.facebook.com/vardotters/"]').count()) === 0) throw friendly('Footer is missing the Facebook link (https://www.facebook.com/vardotters/).', 'Check the Social media menu in the Canvas Footer region.');
376
+ if ((await footer.locator('a[href="https://www.instagram.com/vardotters/"]').count()) === 0) throw friendly('Footer is missing the Instagram link (https://www.instagram.com/vardotters/).', 'Check the Social media menu in the Canvas Footer region.');
377
+ if ((await footer.locator('a[href="https://x.com/Vardot"]').count()) === 0) throw friendly('Footer is missing the X link (https://x.com/Vardot).', 'Check the Social media menu in the Canvas Footer region.');
378
+
379
+ // Footer logos (one per line):
380
+ if ((await footer.locator('img[alt="Varbase logo"]').count()) === 0) throw friendly('Footer is missing the Varbase logo.');
381
+ if ((await footer.locator('img[alt="Vardot"]').count()) === 0) throw friendly('Footer is missing the Vardot logo next to "Powered by".');
382
+ });
383
+
384
+ /**
385
+ * Press a responsive-preview device button (Drupal core Responsive preview).
386
+ *
387
+ * Ports VarbaseContext::iPressResponsivePreviewDeviceButton: clicks the control
388
+ * carrying data-responsive-preview-name.
389
+ *
390
+ * Example #1: When I press the "desktop" responsive preview device button
391
+ * Example #2: And I press the "mobile" responsive preview device button
392
+ * Example #3: When we press the "tablet" responsive preview device button
393
+ * Example #4: And I press the "widescreen" responsive preview device button
394
+ * Example #5: When I press the "mobile" responsive preview device button
395
+ */
396
+ When(/^(?:I |we )*press the "([^"]*)" responsive preview device button$/, async function (deviceName) {
397
+ const ok = await this.page.evaluate((deviceName) => {
398
+ const btn = document.querySelector(`[data-responsive-preview-name="${deviceName}"]`);
399
+ if (!btn) return false;
400
+ btn.click();
401
+ return true;
402
+ }, deviceName);
403
+ if (!ok) throw friendly(`The "${deviceName}" responsive preview device option was not found.`);
404
+ await smartSettle(this.page, budget(this));
405
+ });
406
+
407
+ /**
408
+ * Assert an element renders as a sticky element (CSS position: sticky).
409
+ *
410
+ * Resolves a named selector from the registry (falling back to a raw CSS
411
+ * selector) and checks its computed position. Reads better in a feature than
412
+ * asserting on a raw "position:sticky;" CSS property string.
413
+ *
414
+ * Example #1: Then the "site header" should be sticky
415
+ * Example #2: And the "site header" should be sticky
416
+ * Example #3: Then the "site header" should not be sticky
417
+ * Example #4: And the "footer" should not be sticky
418
+ * Example #5: Then the "main content" should be sticky
419
+ */
420
+ Then(/^the "([^"]*)" should( not)? be sticky$/, async function (name, negate) {
421
+ const selector = (this.__selectorsCss && this.__selectorsCss[name]) || name;
422
+ const position = await this.page.locator(selector).first().evaluate((el) => window.getComputedStyle(el).position);
423
+ if (negate) {
424
+ assert.notStrictEqual(position, 'sticky', friendly(`Expected "${name}" not to be sticky, but its computed position is "${position}".`));
425
+ } else {
426
+ assert.strictEqual(position, 'sticky', friendly(`Expected "${name}" to be sticky, but its computed position is "${position}".`));
427
+ }
428
+ });
429
+
430
+ /**
431
+ * Assert a named element gains (or loses) a CSS class, with auto-retry.
432
+ *
433
+ * Resolves a named selector from the registry (falling back to a raw CSS
434
+ * selector) and polls its class list. Use this instead of the raw-selector
435
+ * web-first step when the feature should read with a registered name.
436
+ *
437
+ * Example #1: Then the "site header" should have the "scrolled" class within 5 seconds
438
+ * Example #2: And the "site header" should have the "scrolled" class
439
+ * Example #3: Then the "site header" should not have the "scrolled" class
440
+ * Example #4: And the "main content" should have the "is-active" class within 3 seconds
441
+ * Example #5: Then the "footer" should not have the "scrolled" class
442
+ */
443
+ Then(/^the "([^"]*)" should( not)? have the "([^"]*)" class(?: within (\d+) seconds?)?$/, async function (name, negate, cls, sec) {
444
+ const selector = (this.__selectorsCss && this.__selectorsCss[name]) || name;
445
+ const loc = this.page.locator(selector).first();
446
+ const timeout = (sec ? parseInt(sec, 10) : 5) * 1000;
447
+ const want = !negate;
448
+ const start = Date.now();
449
+ let classes = '';
450
+ do {
451
+ classes = (await loc.getAttribute('class')) || '';
452
+ if (classes.split(/\s+/).includes(cls) === want) {
453
+ return;
454
+ }
455
+ await this.page.waitForTimeout(100);
456
+ } while (Date.now() - start < timeout);
457
+ throw friendly(`Expected "${name}" ${want ? 'to have' : 'not to have'} the "${cls}" class within ${timeout / 1000}s; last class was "${classes}".`);
458
+ });
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const { friendly } = require('./varbase-e2e');
4
+
5
+ // Video recording steps — start / stop / save mid-scenario.
6
+ //
7
+ // Playwright records video only at browser-context creation. Mid-scenario
8
+ // `start` / `stop` therefore work by tearing down the current page +
9
+ // context and reopening with the new recording flag. Page state is lost
10
+ // across the boundary — call `start` BEFORE any navigation and `stop` only
11
+ // when you no longer need the current page state.
12
+ //
13
+ // Default mode is 'off' (no recording). See cucumber.js → worldParameters.video.
14
+
15
+ const { When, Then } = require('@cucumber/cucumber');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ function settings(world) {
20
+ const cfg = (world.parameters && world.parameters.video) || {};
21
+ return {
22
+ dir: process.env.VARBASE_E2E_VIDEO_DIR || cfg.dir || './videos',
23
+ size: cfg.size || { width: 1280, height: 720 },
24
+ };
25
+ }
26
+
27
+ async function rebuildContext(world, recordVideo) {
28
+ await world.closeBrowser();
29
+ if (recordVideo) {
30
+ const s = settings(world);
31
+ fs.mkdirSync(s.dir, { recursive: true });
32
+ world._videoRequested = true;
33
+ await world.openBrowser({ recordVideo: { dir: s.dir, size: s.size } });
34
+ } else {
35
+ world._videoRequested = false;
36
+ await world.openBrowser();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Start recording the browser as a webm video. Closes the current page +
42
+ * context and reopens with `recordVideo` enabled. Place BEFORE any
43
+ * navigation in the scenario.
44
+ *
45
+ * Example #1: When I start video recording
46
+ * Example #2: And I start video recording
47
+ * Example #3: Given I start video recording
48
+ * Example #4: But I start video recording
49
+ * Example #5: Then I start video recording
50
+ *
51
+ */
52
+ When(/^(I |we )*start video recording$/, async function (pronoun) {
53
+ try {
54
+ await rebuildContext(this, true);
55
+ } catch (e) {
56
+ throw friendly({
57
+ action: 'start video recording',
58
+ cause: e,
59
+ hint: `the videos directory (${(this.parameters && this.parameters.video && this.parameters.video.dir) || './videos'}) must be writable.`,
60
+ });
61
+ }
62
+ });
63
+
64
+ /**
65
+ * Stop recording. Closes the current page + context (which flushes the
66
+ * webm to disk) and reopens a fresh, non-recording browser so the rest
67
+ * of the scenario can continue.
68
+ *
69
+ * Example #1: When I stop video recording
70
+ * Example #2: And I stop video recording
71
+ * Example #3: But I stop video recording
72
+ * Example #4: Then I stop video recording
73
+ * Example #5: Then I stop video recording
74
+ *
75
+ */
76
+ When(/^(I |we )*stop video recording$/, async function (pronoun) {
77
+ try {
78
+ if (!this.page || !this._videoRequested) return;
79
+ const video = this.page.video();
80
+ // Close context first — flushes the webm — then saveAs while the
81
+ // browser is still alive, finally close the browser and reopen fresh.
82
+ try { await this.context.close(); } catch { /* ignore */ }
83
+ if (video) {
84
+ try {
85
+ const s = settings(this);
86
+ const name = this._videoSaveAsName || `manual.${Date.now()}.webm`;
87
+ const dest = path.join(s.dir, name);
88
+ await video.saveAs(dest);
89
+ await video.delete().catch(() => {});
90
+ process.stderr.write(`\n[varbase-e2e] video saved → ${dest}\n`);
91
+ // Reset so the After hook does not try to re-save.
92
+ this._videoSaveAsName = null;
93
+ } catch (e) {
94
+ process.stderr.write(`\n[varbase-e2e] video save failed: ${e.message}\n`);
95
+ }
96
+ }
97
+ if (this.playwrightBrowser) {
98
+ try { await this.playwrightBrowser.close(); } catch { /* ignore */ }
99
+ this.playwrightBrowser = null;
100
+ }
101
+ this.context = null;
102
+ this.page = null;
103
+ this._videoRequested = false;
104
+ await this.openBrowser();
105
+ } catch (e) {
106
+ throw friendly({
107
+ action: 'stop video recording',
108
+ cause: e,
109
+ hint: 'the scenario will continue with a fresh, non-recording browser.',
110
+ });
111
+ }
112
+ });
113
+
114
+ /**
115
+ * Reserve a custom filename for the current scenario's video. The actual
116
+ * save happens at scenario end (Playwright's `video.saveAs()` blocks
117
+ * until recording finishes, so we cannot copy bytes mid-flight). If the
118
+ * scenario calls `stop video recording` before scenario end, the manual
119
+ * webm uses this name instead of the automatic timestamped one.
120
+ *
121
+ * Example #1: When I save the current video as "checkout-flow.webm"
122
+ * Example #2: When I save the current video as "regression-21.webm"
123
+ * Example #3: And I save the current video as "smoke.webm"
124
+ * Example #4: Then I save the current video as "demo.webm"
125
+ * Example #5: But I save the current video as "rerun.webm"
126
+ *
127
+ */
128
+ When(/^(I |we )*save the current video as "([^"]*)"$/, function (pronoun, name) {
129
+ try {
130
+ if (!this._videoRequested) {
131
+ throw new Error('recording is not active');
132
+ }
133
+ if (!name || !name.trim()) {
134
+ throw new Error('filename is empty');
135
+ }
136
+ this._videoSaveAsName = name;
137
+ } catch (e) {
138
+ throw friendly({
139
+ action: `save the current video as "${name}"`,
140
+ cause: e,
141
+ hint: 'call "When I start video recording" first, or set worldParameters.video.mode to "on".',
142
+ });
143
+ }
144
+ });
145
+
146
+ /**
147
+ * Print the path that the current video will be written to. Diagnostic
148
+ * only — never asserts, never fails.
149
+ *
150
+ * Example #1: Then print video path
151
+ * Example #2: And print video path
152
+ * Example #3: But print video path
153
+ * Example #4: Then print video path
154
+ * Example #5: Then print video path
155
+ *
156
+ */
157
+ Then(/^print video path$/, async function () {
158
+ // Diagnostic only — swallow every error and report inline.
159
+ try {
160
+ if (!this.page || !this._videoRequested) {
161
+ console.log('\n--- Video path: recording is OFF ---');
162
+ return;
163
+ }
164
+ const video = this.page.video();
165
+ if (!video) {
166
+ console.log('\n--- Video path: no video object on this page ---');
167
+ return;
168
+ }
169
+ try {
170
+ const p = await video.path();
171
+ console.log(`\n--- Video path: ${p} ---`);
172
+ } catch (e) {
173
+ console.log(`\n--- Video path unavailable: ${e.message} ---`);
174
+ }
175
+ } catch (e) {
176
+ console.log(`\n--- Video path error: ${e.message} ---`);
177
+ }
178
+ });