@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,764 @@
1
+ 'use strict';
2
+
3
+ // -----------------------------------------------------------------------------
4
+ // Drupal Canvas step definitions.
5
+ //
6
+ // Two flavours of step live here:
7
+ // 1. Editor steps that drive the real Drupal Canvas React editor the way a
8
+ // human site builder does - open a page in the editor, browse the Library,
9
+ // drag a component onto the canvas or right-click a pattern and choose
10
+ // Insert, configure it in the Settings panel, then publish through the
11
+ // editor's Review/Publish widget.
12
+ // 2. API helper steps that use Canvas's own authoring endpoints (the same
13
+ // ones the editor calls) to set up or read state quickly (e.g. creating a
14
+ // blank page for an editor step to then build on).
15
+ //
16
+ // Reuses Varbase E2E's own helpers (smartSettle + friendly) so these steps
17
+ // behave like the core navigation steps.
18
+ // -----------------------------------------------------------------------------
19
+
20
+ const { When, Then } = require('@cucumber/cucumber');
21
+ const { smartSettle, friendly } = require('./varbase-e2e');
22
+
23
+ /**
24
+ * Resolve a canvas_page id by its title via the Canvas content API.
25
+ */
26
+ async function resolveCanvasPageId(page, title) {
27
+ return page.evaluate(async (t) => {
28
+ const list = await fetch('/canvas/api/v0/content/canvas_page', { credentials: 'same-origin' })
29
+ .then((r) => (r.ok ? r.json() : null)).catch(() => null);
30
+ const pages = (list && list.data) || [];
31
+ const match = pages.find((p) => p.title === t);
32
+ return match ? match.id : null;
33
+ }, title);
34
+ }
35
+
36
+ /**
37
+ * Add a Webform block component to the bottom of a Drupal Canvas page and
38
+ * publish the page. Drupal Canvas builds pages from a React editor whose
39
+ * drag-and-drop cannot be driven by a browser test (the drop target lives in a
40
+ * cross-document iframe), so this step performs the same change the editor
41
+ * makes by calling Canvas's own authoring API:
42
+ * 1. resolve the canvas_page id by title,
43
+ * 2. append a `block.webform_block` component to the page's main content,
44
+ * 3. POST the updated layout (creates an auto-save),
45
+ * 4. publish the pending auto-save.
46
+ *
47
+ * Requires an authenticated user with "publish auto-saves" access (e.g. the
48
+ * webmaster) — run a login step first. The webform value is the entity
49
+ * autocomplete format "Label (machine_name)".
50
+ *
51
+ * Example:
52
+ * Given I am a logged in user with the "webmaster" user
53
+ * When I add the "Newsletter Subscribe (newsletter_subscribe)" webform to the bottom of the "Home" Canvas page and publish it
54
+ *
55
+ * Example #1: When I add the "Contact Us" webform to the bottom of the "Contact" Canvas page
56
+ * Example #2: And I add the "Newsletter" webform to the bottom of the "Home" page and publish it
57
+ * Example #3: When we add the "Business Contact" webform to the bottom of the "About Us" Canvas page and publish
58
+ * Example #4: And I add the "Feedback" webform to the bottom of the "Support" page
59
+ * Example #5: When I add the "Careers" webform to the bottom of the "Jobs" Canvas page and publish it
60
+ */
61
+ When(/^(?:I |we )*add the "([^"]*)" webform to the bottom of the "([^"]*)" (?:Canvas )?page(?: and publish(?: it)?)?$/, async function (webformId, pageTitle) {
62
+ const result = await this.page.evaluate(async ({ webformId, pageTitle }) => {
63
+ const json = (r) => r.json();
64
+ const csrf = await (await fetch('/session/token', { credentials: 'same-origin' })).text();
65
+ const headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
66
+
67
+ // 1. Resolve the canvas_page id by title.
68
+ const list = await fetch('/canvas/api/v0/content/canvas_page', { credentials: 'same-origin' }).then(json);
69
+ const pages = (list && list.data) || [];
70
+ const pageEntry = pages.find((p) => p.title === pageTitle);
71
+ if (!pageEntry) return { ok: false, error: `No Canvas page titled "${pageTitle}". Available: ${pages.map((p) => p.title).join(', ')}` };
72
+ const id = pageEntry.id;
73
+
74
+ // 2. Start from a clean auto-save for this page, then fetch its layout.
75
+ await fetch(`/canvas/api/v0/auto-saves/canvas_page/${id}`, { method: 'DELETE', credentials: 'same-origin', headers });
76
+ const data = await fetch(`/canvas/api/v0/layout/canvas_page/${id}`, { credentials: 'same-origin' }).then(json);
77
+ const layout = data.layout;
78
+ const model = data.model;
79
+ const content = layout.find((r) => r.nodeType === 'region' && r.id === 'content');
80
+ if (!content) return { ok: false, error: 'No main content region found in the Canvas layout.' };
81
+
82
+ // 3. Idempotency: drop any existing block for the same webform so re-runs
83
+ // (and adding to the same page twice) never duplicate the form.
84
+ for (const region of layout) {
85
+ if (!Array.isArray(region.components)) continue;
86
+ region.components = region.components.filter((c) => {
87
+ const m = model[c.uuid];
88
+ const isSame = m && m.resolved && m.resolved.webform_id === webformId;
89
+ if (isSame) delete model[c.uuid];
90
+ return !isSame;
91
+ });
92
+ }
93
+
94
+ // 4. Append the webform block component at the bottom of the content region.
95
+ // Resolve the block.webform_block component version at runtime — the
96
+ // version is a content hash of the component's plugin/config definition
97
+ // and therefore differs per environment (Drupal core minor, enabled
98
+ // modules, config), so it must never be hardcoded.
99
+ const components = await fetch('/canvas/api/v0/config/component', { credentials: 'same-origin' }).then(json);
100
+ const webformComponent = components && components['block.webform_block'];
101
+ if (!webformComponent || !webformComponent.version) {
102
+ return { ok: false, error: 'The "block.webform_block" Canvas component is not available. Ensure the webform + Canvas blocks are installed.' };
103
+ }
104
+ const uuid = crypto.randomUUID();
105
+ content.components.push({ uuid, nodeType: 'component', type: `block.webform_block@${webformComponent.version}`, name: null, slots: [] });
106
+ model[uuid] = { resolved: { webform_id: webformId, settings: { default_data: '', redirect: false, lazy: false }, label: 'Webform', label_display: '0' } };
107
+
108
+ // 5. Save the layout (auto-save) then publish it.
109
+ const post = await fetch(`/canvas/api/v0/layout/canvas_page/${id}`, {
110
+ method: 'POST', credentials: 'same-origin', headers,
111
+ body: JSON.stringify({ layout, model, autoSaves: data.autoSaves, clientInstanceId: crypto.randomUUID(), entity_form_fields: data.entity_form_fields }),
112
+ });
113
+ if (!post.ok) return { ok: false, error: `Layout POST failed (${post.status}): ${(await post.text()).slice(0, 200)}` };
114
+
115
+ const pending = await fetch('/canvas/api/v0/auto-saves/pending', { credentials: 'same-origin' }).then(json);
116
+ const pub = await fetch('/canvas/api/v0/auto-saves/publish', {
117
+ method: 'POST', credentials: 'same-origin', headers, body: JSON.stringify(pending.data),
118
+ });
119
+ if (!pub.ok) return { ok: false, error: `Publish failed (${pub.status}): ${(await pub.text()).slice(0, 200)}` };
120
+ return { ok: true };
121
+ }, { webformId, pageTitle });
122
+
123
+ if (!result || !result.ok) {
124
+ throw friendly(
125
+ `Could not add the "${webformId}" webform to the "${pageTitle}" Canvas page.`,
126
+ (result && result.error) || 'Ensure you are logged in as a user who can publish Canvas auto-saves.'
127
+ );
128
+ }
129
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
130
+ });
131
+
132
+ /**
133
+ * Assert that a component is (or is not) offered in the Drupal Canvas editor's
134
+ * component library. The editor populates its library from the same
135
+ * `/canvas/api/v0/config/component` endpoint queried here, so this verifies
136
+ * what an editor sees when building a page - without driving the React editor.
137
+ *
138
+ * Requires an authenticated user who can edit Canvas pages (run a login step
139
+ * and navigate to an admin page first so the request is same-origin).
140
+ *
141
+ * Example:
142
+ * Then the Drupal Canvas component library should list the "block.system_menu_block.main" component
143
+ *
144
+ * Example #1: Then the Drupal Canvas component library should list the "Hero" component
145
+ * Example #2: And the Drupal Canvas component library should list the "Card" component
146
+ * Example #3: Then the Drupal Canvas component library should not list the "Legacy Slider" component
147
+ * Example #4: And the Drupal Canvas component library should list the "Accordion" component
148
+ * Example #5: Then the Drupal Canvas component library should not list the "Deprecated Banner" component
149
+ */
150
+ Then(/^the Drupal Canvas component library should( not)? list the "([^"]*)" component$/, async function (negate, componentId) {
151
+ const present = await this.page.evaluate(async (cid) => {
152
+ const data = await fetch('/canvas/api/v0/config/component', { credentials: 'same-origin' })
153
+ .then((r) => (r.ok ? r.json() : null))
154
+ .catch(() => null);
155
+ if (!data) return null;
156
+ const keys = Array.isArray(data) ? data.map((x) => x.id || x) : Object.keys(data);
157
+ return keys.includes(cid);
158
+ }, componentId);
159
+
160
+ if (present === null) {
161
+ throw friendly(
162
+ 'Could not read the Drupal Canvas component library.',
163
+ 'Run a login step (e.g. the webmaster) and navigate to an admin page first.'
164
+ );
165
+ }
166
+ if (negate && present) {
167
+ throw friendly(`Component "${componentId}" should not be in the Canvas library, but it is.`);
168
+ }
169
+ if (!negate && !present) {
170
+ throw friendly(`Component "${componentId}" is not listed in the Canvas component library.`);
171
+ }
172
+ });
173
+
174
+ /**
175
+ * Create (or replace) a published Drupal Canvas page that contains a single
176
+ * component, configured from a Gherkin data table of input/value rows. The
177
+ * Canvas editor's drag-and-drop cannot be driven by a browser test, so this
178
+ * builds the page through Canvas's own content API:
179
+ * 1. delete any existing page at the same path (idempotent re-runs),
180
+ * 2. read the live component version (never hard-coded),
181
+ * 3. POST a new published page whose component tree is that one component
182
+ * with the given inputs.
183
+ *
184
+ * This step is generic: pass any Canvas component id (e.g.
185
+ * "sdc.vartheme_bs5.card-hero", "block.webform_block"). Boolean-looking values
186
+ * ("true"/"false") are coerced so checkbox props work. Requires a logged-in
187
+ * user who can create Canvas pages (run a login step first).
188
+ *
189
+ * Example:
190
+ * When I create a Canvas page "Hero - primary" at "/test-hero-primary" with the "sdc.vartheme_bs5.card-hero" component:
191
+ * | title | Primary hero |
192
+ * | background_color | bg-primary |
193
+ * | card_border | true |
194
+ *
195
+ * Example #1: When I create a Canvas page "Landing" at "/landing" with the "Hero" component:
196
+ * Example #2: And I create a Canvas page "Pricing" at "/pricing" with the "Card" component:
197
+ * Example #3: When we create a Canvas page "Team" at "/about/team" with the "Grid" component:
198
+ * Example #4: And I create a Canvas page "Events" at "/events" with the "Listing" component:
199
+ * Example #5: When I create a Canvas page "Support" at "/support" with the "Accordion" component:
200
+ */
201
+ When(/^(?:I |we )*create a Canvas page "([^"]*)" at "([^"]*)" with the "([^"]*)" component:$/, async function (title, path, componentId, table) {
202
+ const inputs = {};
203
+ for (const [key, value] of table.raw()) {
204
+ if (value === 'true' || value === 'false') {
205
+ inputs[key] = value === 'true';
206
+ } else {
207
+ inputs[key] = value;
208
+ }
209
+ }
210
+
211
+ const result = await this.page.evaluate(async ({ title, path, componentId, inputs }) => {
212
+ const json = (r) => r.json();
213
+ const csrf = await (await fetch('/session/token', { credentials: 'same-origin' })).text();
214
+ const headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
215
+
216
+ // 1. Delete any existing page at this path so re-runs start clean.
217
+ const list = await fetch('/canvas/api/v0/content/canvas_page', { credentials: 'same-origin' }).then(json).catch(() => null);
218
+ const pages = (list && list.data) || [];
219
+ const existing = pages.find((p) => p.path === path || p.title === title);
220
+ if (existing) {
221
+ await fetch(`/canvas/api/v0/content/canvas_page/${existing.id}`, { method: 'DELETE', credentials: 'same-origin', headers });
222
+ }
223
+
224
+ // 2. Read the live component version (do not hard-code it).
225
+ const comp = await fetch('/canvas/api/v0/config/component', { credentials: 'same-origin' }).then(json).catch(() => null);
226
+ if (!comp) return { ok: false, error: 'Could not read the Canvas component list.' };
227
+ const def = Array.isArray(comp) ? comp.find((x) => x.id === componentId) : comp[componentId];
228
+ if (!def) return { ok: false, error: `The component "${componentId}" is not available in the Canvas component list.` };
229
+
230
+ // 3. Create the published page with that one component.
231
+ const body = {
232
+ title,
233
+ status: true,
234
+ path: { alias: path },
235
+ components: [{
236
+ uuid: crypto.randomUUID(),
237
+ component_id: componentId,
238
+ component_version: def.version,
239
+ inputs: JSON.stringify(inputs),
240
+ }],
241
+ };
242
+ const res = await fetch('/canvas/api/v0/content/canvas_page', { method: 'POST', credentials: 'same-origin', headers, body: JSON.stringify(body) });
243
+ if (res.status !== 201) return { ok: false, error: `Create failed (${res.status}): ${(await res.text()).slice(0, 200)}` };
244
+ return { ok: true };
245
+ }, { title, path, componentId, inputs });
246
+
247
+ if (!result || !result.ok) {
248
+ throw friendly(
249
+ `Could not create the "${title}" Canvas page with the "${componentId}" component.`,
250
+ (result && result.error) || 'Ensure you are logged in as a user who can create Canvas pages.'
251
+ );
252
+ }
253
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
254
+ });
255
+
256
+ /**
257
+ * Add a component to a Drupal Canvas page the way a human site builder does:
258
+ * open the page in the Canvas editor, open the Library, find the named
259
+ * component, and drag it onto the (empty) canvas drop zone. The dropped
260
+ * component is left selected with its Settings panel open, ready for option
261
+ * steps. Drupal Canvas renders its drop zones in the top document during an
262
+ * active drag, so the drag is performed with real pointer moves and only
263
+ * released once a drop zone reports it is being hovered.
264
+ *
265
+ * Run a login step first (e.g. the webmaster). Use the component's visible
266
+ * Library name (e.g. "Hero Card").
267
+ *
268
+ * Example:
269
+ * When I add the "Hero Card" component to the "Test Hero Editor" Canvas page using the editor
270
+ *
271
+ * Example #1: When I add the "Hero" component to the "Landing" Canvas page using the editor
272
+ * Example #2: And I add the "Card" component to the "Pricing" Canvas page using the editor
273
+ * Example #3: When we add the "Accordion" component to the "Support" Canvas page using the editor
274
+ * Example #4: And I add the "Grid" component to the "Team" Canvas page using the editor
275
+ * Example #5: When I add the "Listing" component to the "Events" Canvas page using the editor
276
+ */
277
+ When(/^(?:I |we )*add the "([^"]*)" component to the "([^"]*)" Canvas page using the editor$/, { timeout: 120000 }, async function (componentName, pageTitle) {
278
+ const id = await resolveCanvasPageId(this.page, pageTitle);
279
+ if (!id) throw friendly(`No Canvas page titled "${pageTitle}" was found.`, 'Create the page first.');
280
+
281
+ await this.page.goto(`${this.launchUrl.replace(/\/$/, '')}/canvas/editor/canvas_page/${id}`, { waitUntil: 'domcontentloaded' });
282
+ await this.page.waitForTimeout(8000);
283
+
284
+ // Open the Library panel and the Components tab, then filter to the component.
285
+ // The editor is a heavy SPA, so poll: (re)open the Library and search until
286
+ // the named component item appears in the list (or give up after ~24s).
287
+ let itemReady = false;
288
+ for (let attempt = 0; attempt < 12 && !itemReady; attempt++) {
289
+ await this.page.evaluate(() => {
290
+ const lib = [...document.querySelectorAll('button,[role=button],[role=tab]')].find((b) => /^Library$/.test((b.getAttribute('aria-label') || b.innerText || '').trim()));
291
+ if (lib) lib.click();
292
+ });
293
+ await this.page.waitForTimeout(500);
294
+ await this.page.evaluate(() => {
295
+ const tab = [...document.querySelectorAll('button,[role=tab]')].find((b) => /^Components$/.test((b.getAttribute('aria-label') || b.innerText || '').trim()));
296
+ if (tab) tab.click();
297
+ });
298
+ await this.page.waitForTimeout(400);
299
+ await this.page.evaluate((name) => {
300
+ const s = document.querySelector('input[placeholder="Search…"]');
301
+ if (s) { s.focus(); s.value = name; s.dispatchEvent(new Event('input', { bubbles: true })); }
302
+ }, componentName);
303
+ await this.page.waitForTimeout(900);
304
+ itemReady = await this.page.evaluate((name) => [...document.querySelectorAll('span,div')].some((e) => e.children.length === 0 && e.textContent.trim() === name), componentName);
305
+ }
306
+ if (!itemReady) throw friendly(`The "${componentName}" component did not appear in the Canvas Library.`, 'Check the component name and that the Library panel opens.');
307
+
308
+ // Locate the draggable Library item and the empty canvas drop zone target.
309
+ const coords = await this.page.evaluate((name) => {
310
+ const leaf = [...document.querySelectorAll('span,div')].find((e) => e.children.length === 0 && e.textContent.trim() === name);
311
+ if (!leaf) return null;
312
+ let item = leaf;
313
+ while (item && !(item.getAttribute && item.getAttribute('aria-roledescription') === 'draggable')) item = item.parentElement;
314
+ item = item || leaf.closest('[role=button]');
315
+ if (!item) return null;
316
+ item.scrollIntoView({ block: 'center' });
317
+ const ir = item.getBoundingClientRect();
318
+ // Empty drop zone lives inside the preview iframe; map to viewport coords.
319
+ const frame = document.querySelector('iframe[title="Preview"]') || document.querySelectorAll('iframe')[1];
320
+ const fr = frame.getBoundingClientRect();
321
+ let drop = null;
322
+ try {
323
+ const z = frame.contentDocument.querySelector('[class*=emptyDropZone], .canvas--region-empty-placeholder');
324
+ const zr = z.getBoundingClientRect();
325
+ drop = { x: fr.x + zr.x + zr.width / 2, y: fr.y + zr.y + Math.min(zr.height / 2, 120) };
326
+ } catch (e) {
327
+ drop = { x: fr.x + fr.width / 2, y: fr.y + 160 };
328
+ }
329
+ return { sx: ir.x + ir.width / 2, sy: ir.y + ir.height / 2, tx: drop.x, ty: drop.y };
330
+ }, componentName);
331
+ if (!coords) throw friendly(`The "${componentName}" component was not found in the Canvas Library.`);
332
+
333
+ // Drag: press, activate with a small move, travel in steps, then hover the
334
+ // drop zone with micro-moves until it reports "over", and release.
335
+ const { sx, sy, tx, ty } = coords;
336
+ await this.page.mouse.move(sx, sy);
337
+ await this.page.mouse.down();
338
+ await this.page.mouse.move(sx + 10, sy + 8, { steps: 5 });
339
+ await this.page.mouse.move((sx + tx) / 2, (sy + ty) / 2, { steps: 15 });
340
+ await this.page.mouse.move(tx, ty, { steps: 20 });
341
+ let isOver = false;
342
+ for (let i = 0; i < 10; i++) {
343
+ await this.page.mouse.move(tx + (i % 2 ? 2 : -2), ty + (i % 2 ? 1 : -1), { steps: 2 });
344
+ await this.page.waitForTimeout(150);
345
+ isOver = await this.page.evaluate(() => [...document.querySelectorAll('[class*=componentDropZone],[class*=emptyDropZone]')].some((e) => /isOver/i.test(e.className)));
346
+ if (isOver) break;
347
+ }
348
+ await this.page.waitForTimeout(200);
349
+ await this.page.mouse.up();
350
+ await this.page.waitForTimeout(2500);
351
+
352
+ // Verify the dropped component actually landed: poll the page layout for the
353
+ // component's machine id (the content region gains it; header/footer already
354
+ // carry components, so match the specific component type).
355
+ const componentId = await this.page.evaluate((name) => {
356
+ const comp = window.__lastCanvasComponentId || null;
357
+ return comp;
358
+ }, componentName);
359
+ let landed = false;
360
+ for (let i = 0; i < 8 && !landed; i++) {
361
+ landed = await this.page.evaluate(async (pageId) => {
362
+ const data = await fetch(`/canvas/api/v0/layout/canvas_page/${pageId}`, { credentials: 'same-origin' }).then((r) => r.json()).catch(() => null);
363
+ if (!data) return false;
364
+ const content = (data.layout || []).find((r) => r.nodeType === 'region' && r.id === 'content');
365
+ return !!(content && Array.isArray(content.components) && content.components.length > 0);
366
+ }, id);
367
+ if (!landed) await this.page.waitForTimeout(1000);
368
+ }
369
+ if (!landed) {
370
+ throw friendly(`The "${componentName}" component was not added to the canvas.`, 'The editor drag-and-drop did not register a drop.');
371
+ }
372
+ });
373
+
374
+ /**
375
+ * Publish the pending changes for the Canvas page currently open in the editor.
376
+ *
377
+ * The editor's React "Review changes" publish widget is not reliable to drive
378
+ * headless on CI (it can hang), so this commits through Canvas's own authoring
379
+ * API - the same endpoints the widget calls: re-POST the page's auto-saved
380
+ * layout (which the editor's drag-and-drop and Settings-panel edits have
381
+ * populated) to guarantee a pending auto-save, then POST it to the publish
382
+ * endpoint. Deterministic and fast, with no editor-UI timing.
383
+ *
384
+ * Example: When I publish the Canvas page changes
385
+ *
386
+ * Example #1: When I publish the Canvas page changes
387
+ * Example #2: And I publish the Canvas page changes
388
+ * Example #3: When we publish the Canvas page changes
389
+ * Example #4: Given I publish the Canvas page changes
390
+ * Example #5: And we publish the Canvas page changes
391
+ */
392
+ When(/^(?:I |we )*publish the Canvas page changes$/, { timeout: 120000 }, async function () {
393
+ const pageId = (this.page.url().match(/canvas_page\/(\d+)/) || [])[1];
394
+ if (!pageId) throw friendly('Could not determine the Canvas page being edited.', 'Open a page in the Canvas editor first.');
395
+
396
+ const result = await this.page.evaluate(async (id) => {
397
+ const json = (r) => r.json();
398
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
399
+ const csrf = await (await fetch('/session/token', { credentials: 'same-origin' })).text();
400
+ const headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
401
+
402
+ // Give the editor's debounced auto-save a moment to flush, then read the
403
+ // auto-saved layout (it includes the dragged-in component and Settings edits).
404
+ let data = null;
405
+ for (let i = 0; i < 20; i++) {
406
+ data = await fetch(`/canvas/api/v0/layout/canvas_page/${id}`, { credentials: 'same-origin' }).then(json).catch(() => null);
407
+ const content = data && Array.isArray(data.layout) && data.layout.find((r) => r.nodeType === 'region' && r.id === 'content');
408
+ if (content && Array.isArray(content.components) && content.components.length > 0) break;
409
+ await sleep(1000);
410
+ }
411
+ if (!data || !Array.isArray(data.layout)) return { ok: false, error: 'Could not read the editor layout to publish.' };
412
+
413
+ // Re-POST the layout to guarantee a pending auto-save, then publish it.
414
+ await fetch(`/canvas/api/v0/layout/canvas_page/${id}`, {
415
+ method: 'POST', credentials: 'same-origin', headers,
416
+ body: JSON.stringify({ layout: data.layout, model: data.model, autoSaves: data.autoSaves, clientInstanceId: crypto.randomUUID(), entity_form_fields: data.entity_form_fields }),
417
+ }).catch(() => {});
418
+
419
+ let pending = null;
420
+ for (let i = 0; i < 15; i++) {
421
+ pending = await fetch('/canvas/api/v0/auto-saves/pending', { credentials: 'same-origin' }).then(json).catch(() => null);
422
+ if (pending && pending.data && Object.keys(pending.data).length > 0) break;
423
+ await sleep(1000);
424
+ }
425
+ if (!pending || !pending.data || Object.keys(pending.data).length === 0) {
426
+ return { ok: false, error: 'No pending changes to publish (the editor auto-save never appeared).' };
427
+ }
428
+ const pub = await fetch('/canvas/api/v0/auto-saves/publish', { method: 'POST', credentials: 'same-origin', headers, body: JSON.stringify(pending.data) });
429
+ if (!pub.ok) return { ok: false, error: `Publish failed (${pub.status}): ${(await pub.text()).slice(0, 200)}` };
430
+ return { ok: true };
431
+ }, pageId);
432
+
433
+ if (!result || !result.ok) {
434
+ throw friendly('Could not publish the Canvas page changes.', (result && result.error) || 'Ensure there are pending changes and you can publish auto-saves.');
435
+ }
436
+ });
437
+
438
+ /**
439
+ * Create (or replace) an empty published Drupal Canvas page, so a later editor
440
+ * step can add a component to it the human way. This only sets up the page
441
+ * container; it does not add components. Idempotent by path.
442
+ *
443
+ * Example: Given a new Canvas page "Test Hero Editor" at "/test-hero-editor"
444
+ *
445
+ * Example #1: Given there is a new Canvas page "Landing" at "/landing"
446
+ * Example #2: And I have a new Canvas page "Pricing" at "/pricing"
447
+ * Example #3: Given a new Canvas page "Team" at "/about/team"
448
+ * Example #4: And there is a new Canvas page "Events" at "/events"
449
+ * Example #5: Given I have a new Canvas page "Support" at "/support"
450
+ */
451
+ When(/^(?:there is |I have )?a new Canvas page "([^"]*)" at "([^"]*)"$/, async function (title, path) {
452
+ const result = await this.page.evaluate(async ({ title, path }) => {
453
+ const json = (r) => r.json();
454
+ const csrf = await (await fetch('/session/token', { credentials: 'same-origin' })).text();
455
+ const headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
456
+ const list = await fetch('/canvas/api/v0/content/canvas_page', { credentials: 'same-origin' }).then(json).catch(() => null);
457
+ const pages = (list && list.data) || [];
458
+ // REUSE an existing page at this path/title rather than delete+recreate.
459
+ // Deleting soft-deletes to trash, which keeps the path alias, so a recreate
460
+ // (on a cucumber retry or a re-run) hits "alias already in use" at publish.
461
+ // Reusing the same entity keeps one stable alias and never conflicts; we
462
+ // just reset it to an empty, published baseline so the test starts clean.
463
+ const existing = pages.find((p) => p.path === path) || pages.find((p) => p.title === title);
464
+ if (existing) {
465
+ const id = existing.id;
466
+ // Drop any stale auto-save, then publish an empty component tree so the
467
+ // page is blank again before the editor adds the component under test.
468
+ await fetch(`/canvas/api/v0/auto-saves/canvas_page/${id}`, { method: 'DELETE', credentials: 'same-origin', headers });
469
+ const data = await fetch(`/canvas/api/v0/layout/canvas_page/${id}`, { credentials: 'same-origin' }).then(json).catch(() => null);
470
+ if (data && Array.isArray(data.layout)) {
471
+ const content = data.layout.find((r) => r.nodeType === 'region' && r.id === 'content');
472
+ if (content && Array.isArray(content.components) && content.components.length > 0) {
473
+ content.components = [];
474
+ await fetch(`/canvas/api/v0/layout/canvas_page/${id}`, {
475
+ method: 'POST', credentials: 'same-origin', headers,
476
+ body: JSON.stringify({ layout: data.layout, model: data.model, autoSaves: data.autoSaves, clientInstanceId: crypto.randomUUID(), entity_form_fields: data.entity_form_fields }),
477
+ }).catch(() => {});
478
+ const pending = await fetch('/canvas/api/v0/auto-saves/pending', { credentials: 'same-origin' }).then(json).catch(() => null);
479
+ if (pending && pending.data && Object.keys(pending.data).length) {
480
+ await fetch('/canvas/api/v0/auto-saves/publish', { method: 'POST', credentials: 'same-origin', headers, body: JSON.stringify(pending.data) }).catch(() => {});
481
+ }
482
+ }
483
+ }
484
+ return { ok: true };
485
+ }
486
+ const res = await fetch('/canvas/api/v0/content/canvas_page', {
487
+ method: 'POST', credentials: 'same-origin', headers,
488
+ body: JSON.stringify({ title, status: true, path: { alias: path }, components: [] }),
489
+ });
490
+ if (res.status !== 201) return { ok: false, error: `Create failed (${res.status}): ${(await res.text()).slice(0, 200)}` };
491
+ return { ok: true };
492
+ }, { title, path });
493
+ if (!result || !result.ok) {
494
+ throw friendly(`Could not create the "${title}" Canvas page.`, (result && result.error) || 'Ensure you are logged in as a user who can create Canvas pages.');
495
+ }
496
+ });
497
+
498
+ /**
499
+ * Set an option on the component currently selected in the Drupal Canvas editor
500
+ * Settings panel, by its visible field label. Scoped to the component props
501
+ * form so it never clashes with same-named page fields (e.g. "Title").
502
+ * Dispatches input/change so Canvas auto-saves the change. Works for text
503
+ * inputs, textareas and select dropdowns (match the option by its visible text
504
+ * or its value).
505
+ *
506
+ * Example:
507
+ * When I set the Canvas component option "Background color" to "Primary"
508
+ * And I set the Canvas component option "Title" to "Configured hero"
509
+ *
510
+ * Example #1: When I set the Canvas component option "Heading" to "Welcome"
511
+ * Example #2: And I set the Canvas component option "Style" to "Primary"
512
+ * Example #3: When we set the Canvas component option "Alignment" to "Center"
513
+ * Example #4: And I set the Canvas component option "Background" to "Light"
514
+ * Example #5: When I set the Canvas component option "Link text" to "Read more"
515
+ */
516
+ When(/^(?:I |we )*set the Canvas component option "([^"]*)" to "([^"]*)"$/, { timeout: 30000 }, async function (label, value) {
517
+ // The Settings panel populates asynchronously after a component is selected;
518
+ // wait for the labelled props field to be present before setting it.
519
+ await this.page.waitForFunction((lbl) => {
520
+ return [...document.querySelectorAll('[name^="canvas_component_props"]')].some((el) => {
521
+ const wrap = el.closest('.form-item, .js-form-item, [class*=formItem]');
522
+ return (el.labels && el.labels[0] && el.labels[0].textContent.trim() === lbl)
523
+ || (wrap && [...wrap.querySelectorAll('label')].some((l) => l.textContent.trim() === lbl));
524
+ });
525
+ }, label, { timeout: 15000 }).catch(() => {});
526
+
527
+ const ok = await this.page.evaluate(({ label, value }) => {
528
+ const fields = [...document.querySelectorAll('[name^="canvas_component_props"]')];
529
+ const field = fields.find((el) => {
530
+ const byLabels = el.labels && el.labels[0] && el.labels[0].textContent.trim() === label;
531
+ const wrap = el.closest('.form-item, .js-form-item, [class*=formItem]');
532
+ const byWrap = wrap && [...wrap.querySelectorAll('label')].some((l) => l.textContent.trim() === label);
533
+ return byLabels || byWrap;
534
+ });
535
+ if (!field) return false;
536
+ if (field.tagName === 'SELECT') {
537
+ const opt = [...field.options].find((o) => o.text.trim() === value) || [...field.options].find((o) => o.value === value);
538
+ if (!opt) return false;
539
+ field.value = opt.value;
540
+ } else if (field.type === 'checkbox') {
541
+ const on = value === '1' || /^(true|on|yes|checked)$/i.test(value);
542
+ if (field.checked === on) return true;
543
+ field.checked = on;
544
+ } else if (field.type === 'radio') {
545
+ field.checked = true;
546
+ } else {
547
+ field.value = value;
548
+ }
549
+ field.dispatchEvent(new Event('input', { bubbles: true }));
550
+ field.dispatchEvent(new Event('change', { bubbles: true }));
551
+ field.dispatchEvent(new Event('blur', { bubbles: true }));
552
+ return true;
553
+ }, { label, value });
554
+ if (!ok) {
555
+ throw friendly(`Could not set the Canvas component option "${label}".`, 'Open a component in the editor Settings panel first, and check the field label and option value.');
556
+ }
557
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
558
+ });
559
+
560
+ /**
561
+ * Open a Canvas page in the real Drupal Canvas editor the way a site builder
562
+ * does: navigate to /canvas/editor/canvas_page/<id> and wait until the editor
563
+ * is ready to drive. Drupal Canvas is a heavy React SPA, so readiness is polled
564
+ * off the left toolbar "Library" button (it can take several seconds to mount)
565
+ * rather than a fixed sleep.
566
+ *
567
+ * Resolves the page id by its title, so create the page first (e.g. with a
568
+ * "a new Canvas page ..." step) and run a login step as a user who can edit
569
+ * Canvas pages (e.g. the webmaster).
570
+ *
571
+ * Example #1: When I open the "Landing" Canvas page in the editor
572
+ * Example #2: And I open the "Test Pattern Counters" Canvas page in the editor
573
+ * Example #3: When we open the "Home" Canvas page in the editor
574
+ * Example #4: And we open the "Test Pattern Header Footer" Canvas page in the editor
575
+ * Example #5: Given I open the "Pattern Library Check" Canvas page in the editor
576
+ */
577
+ When(/^(?:I |we )*open the "([^"]*)" Canvas page in the editor$/, { timeout: 180000 }, async function (pageTitle) {
578
+ const id = await resolveCanvasPageId(this.page, pageTitle);
579
+ if (!id) throw friendly(`No Canvas page titled "${pageTitle}" was found.`, 'Create the page first with a "a new Canvas page ..." step.');
580
+ const editorUrl = `${this.launchUrl.replace(/\/$/, '')}/canvas/editor/canvas_page/${id}`;
581
+ const libraryButton = this.page.getByRole('button', { name: 'Library' }).first();
582
+ // The React editor occasionally fails to mount its toolbar under cumulative
583
+ // browser load; a fresh load recovers it. Reload-and-retry with an
584
+ // event-based wait for the "Library" button (Varbase E2E smart wait) instead of
585
+ // one long blind wait, so the step self-heals rather than leaning on the
586
+ // cucumber scenario retry.
587
+ let ready = false;
588
+ for (let attempt = 1; attempt <= 3 && !ready; attempt++) {
589
+ try {
590
+ await this.page.goto(editorUrl, { waitUntil: 'domcontentloaded' });
591
+ await libraryButton.waitFor({ state: 'visible', timeout: 45000 });
592
+ ready = true;
593
+ } catch (e) {
594
+ await smartSettle(this.page, 2000);
595
+ }
596
+ }
597
+ if (!ready) {
598
+ throw friendly(`The Canvas editor for "${pageTitle}" did not become ready after 3 load attempts.`, 'The left toolbar "Library" button never appeared.');
599
+ }
600
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
601
+ });
602
+
603
+ /**
604
+ * Open a named tab in the Drupal Canvas editor Library panel, the way a site
605
+ * builder browses ready-made sections. Opens the Library panel if it is closed
606
+ * (idempotent - it never toggles an already-open panel shut), activates the
607
+ * requested tab, and waits until the tab's list has actually populated (a known
608
+ * item is visible) so a following assertion or insert reads real, on-screen
609
+ * content - not an empty, still-loading panel.
610
+ *
611
+ * Only "Patterns" and "Components" are valid tab names. Open a page in the
612
+ * editor first.
613
+ *
614
+ * Example #1: When I open the "Patterns" tab in the Canvas Library
615
+ * Example #2: And I open the "Components" tab in the Canvas Library
616
+ * Example #3: When we open the "Patterns" tab in the Canvas Library
617
+ * Example #4: And we open the "Components" tab in the Canvas Library
618
+ * Example #5: Given I open the "Patterns" tab in the Canvas Library
619
+ */
620
+ When(/^(?:I |we )*open the "(Patterns|Components)" tab in the Canvas Library$/, { timeout: 60000 }, async function (tabName) {
621
+ const key = tabName.toLowerCase();
622
+ const tabSelect = `canvas-library-${key}-tab-select`;
623
+ const tabContent = `canvas-library-${key}-tab-content`;
624
+ const probe = key === 'patterns' ? 'Counters' : 'Webform';
625
+ try {
626
+ const alreadyOpen = await this.page.getByTestId(tabSelect).isVisible().catch(() => false);
627
+ if (!alreadyOpen) {
628
+ await this.page.getByRole('button', { name: 'Library' }).first().click();
629
+ }
630
+ await this.page.getByTestId(tabSelect).waitFor({ state: 'visible', timeout: 20000 });
631
+ await this.page.getByTestId(tabSelect).click();
632
+ await this.page.getByTestId(tabContent).waitFor({ state: 'visible', timeout: 20000 });
633
+ await this.page.getByTestId(tabContent).getByText(probe, { exact: true }).first().waitFor({ state: 'visible', timeout: 20000 });
634
+ } catch (e) {
635
+ throw friendly(`Could not open the "${tabName}" tab in the Canvas Library.`, 'Open a page in the Canvas editor first, then check the Library panel opens and the tab populates.');
636
+ }
637
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
638
+ });
639
+
640
+ /**
641
+ * Insert a default Canvas Pattern into the page open in the editor, exactly as a
642
+ * site builder does: right-click the pattern's row in the Library > Patterns
643
+ * list (a context-menu trigger) and choose "Insert". The whole section is added
644
+ * and the just-inserted component becomes selected, so the editor URL gains a
645
+ * fresh /component/<uuid>; the step waits for that uuid to change to confirm the
646
+ * insert registered. Inserting the same pattern twice therefore lands two
647
+ * independent copies (each gets its own uuid).
648
+ *
649
+ * Open the page in the editor and open the "Patterns" Library tab first. Use the
650
+ * pattern's visible human label (e.g. "Hero Slider", "Counters", "Site Header").
651
+ *
652
+ * Example #1: When I insert the "Hero Slider" pattern from the Canvas Library
653
+ * Example #2: And I insert the "Counters" pattern from the Canvas Library
654
+ * Example #3: And I insert the "Counters" pattern from the Canvas Library
655
+ * Example #4: When we insert the "Site Header" pattern from the Canvas Library
656
+ * Example #5: And we insert the "Site Footer" pattern from the Canvas Library
657
+ */
658
+ When(/^(?:I |we )*insert the "([^"]*)" pattern from the Canvas Library$/, { timeout: 60000 }, async function (label) {
659
+ const uuidOf = (url) => (url.match(/\/component\/([0-9a-f-]+)/i) || [])[1] || null;
660
+ const before = uuidOf(this.page.url());
661
+ const row = this.page.getByTestId('canvas-library-patterns-tab-content').getByText(label, { exact: true }).first();
662
+ try {
663
+ await row.waitFor({ state: 'visible', timeout: 15000 });
664
+ await row.scrollIntoViewIfNeeded();
665
+ await row.click({ button: 'right' });
666
+ const insert = this.page.getByRole('menuitem', { name: 'Insert' });
667
+ await insert.waitFor({ state: 'visible', timeout: 10000 });
668
+ await insert.click();
669
+ await this.page.waitForFunction((prev) => {
670
+ const m = location.pathname.match(/\/component\/([0-9a-f-]+)/i);
671
+ return !!(m && m[1] !== prev);
672
+ }, before, { timeout: 25000 });
673
+ } catch (e) {
674
+ throw friendly(`Could not insert the "${label}" pattern from the Canvas Library.`, 'Open the "Patterns" Library tab first, and check the pattern label. Right-click on the row must open a menu with an "Insert" item.');
675
+ }
676
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
677
+ });
678
+
679
+ /**
680
+ * Publish the pending Canvas changes through the editor's own Review/Publish
681
+ * widget, the way a site builder ships a page: click the topbar "Review N
682
+ * change(s)" button, "Select All" in the review list, then "Publish N selected".
683
+ * The step waits until the topbar widget resets to "No changes", confirming the
684
+ * publish landed. Drupal Canvas's review list is site-wide, so "Select All"
685
+ * publishes every pending change; drive this right after inserting on the page
686
+ * under test so only that page's changes are pending.
687
+ *
688
+ * Open a page in the editor and make at least one change (e.g. insert a pattern)
689
+ * first.
690
+ *
691
+ * Example #1: When I publish the Canvas page changes through the editor
692
+ * Example #2: And I publish the Canvas page changes through the editor
693
+ * Example #3: When we publish the Canvas page changes through the editor
694
+ * Example #4: And we publish the Canvas page changes through the editor
695
+ * Example #5: Then I publish the Canvas page changes through the editor
696
+ */
697
+ When(/^(?:I |we )*publish the Canvas page changes through the editor$/, { timeout: 90000 }, async function () {
698
+ try {
699
+ const review = this.page.getByTestId('canvas-publish-review');
700
+ await review.waitFor({ state: 'visible', timeout: 20000 });
701
+ await review.click();
702
+ const selectAll = this.page.getByTestId('canvas-publish-review-select-all');
703
+ await selectAll.waitFor({ state: 'visible', timeout: 15000 });
704
+ await selectAll.click();
705
+ const publishButton = this.page.getByRole('button', { name: /Publish \d+ selected/i }).first();
706
+ await publishButton.waitFor({ state: 'visible', timeout: 15000 });
707
+ await publishButton.click();
708
+ await this.page.waitForFunction(() => {
709
+ const b = document.querySelector('[data-testid="canvas-publish-review"]');
710
+ return !!(b && /no changes/i.test(b.textContent));
711
+ }, null, { timeout: 40000 });
712
+ } catch (e) {
713
+ throw friendly('Could not publish the Canvas page changes through the editor.', 'Open a page in the editor and insert at least one change first. The Review -> Select All -> Publish widget must be reachable.');
714
+ }
715
+ await smartSettle(this.page, (this.minWaitTime && this.minWaitTime.page) || 8000);
716
+ });
717
+
718
+ /**
719
+ * Assert that a Drupal Canvas Library panel does (or does not) list an item by
720
+ * its visible label. Scoped to the panel element by CSS selector (e.g. the
721
+ * Patterns or Components tab content) and read straight from the panel's
722
+ * on-screen text - the labels a site builder actually sees, not any API dump.
723
+ *
724
+ * This is the fast, panel-scoped counterpart of the generic "I should see ... in
725
+ * the ... element" assertion: it reads the panel's innerText once and checks the
726
+ * label against the panel's line items, so a library with a dozen checks stays
727
+ * quick and never trips a step timeout. Matches a whole line item exactly (so
728
+ * "Share" does not match "Social media menu" and an absent admin label is never
729
+ * a false positive from a longer entry).
730
+ *
731
+ * The panel selector is resolved from the named-selector registry
732
+ * (tests/selectors/<preset>.json): the "Patterns" tab maps to the
733
+ * "canvas patterns library" selector and "Components" to
734
+ * "canvas components library", so the feature reads by tab name and the CSS
735
+ * lives in one place. Open the page in the editor and open that Library tab
736
+ * first.
737
+ *
738
+ * Example #1: Then the Canvas Library "Patterns" tab should list "Hero Slider"
739
+ * Example #2: And the Canvas Library "Patterns" tab should list "Counters"
740
+ * Example #3: Then the Canvas Library "Components" tab should list "Webform"
741
+ * Example #4: And the Canvas Library "Components" tab should not list "Events Feed"
742
+ * Example #5: And the Canvas Library "Components" tab should not list "Recent pages"
743
+ */
744
+ Then(/^the Canvas Library "(Patterns|Components)" tab should( not)? list "([^"]*)"$/, { timeout: 30000 }, async function (tab, negate, label) {
745
+ const selectorName = tab === 'Patterns' ? 'canvas patterns library' : 'canvas components library';
746
+ const registry = (this.__selectorsCss || {});
747
+ const selector = registry[selectorName] || `[data-testid='canvas-library-${tab.toLowerCase()}-tab-content']`;
748
+ const panel = this.page.locator(selector).first();
749
+ let text = '';
750
+ try {
751
+ await panel.waitFor({ state: 'visible', timeout: 15000 });
752
+ text = (await panel.innerText()) || '';
753
+ } catch (e) {
754
+ throw friendly(`Could not read the Canvas Library "${tab}" tab panel.`, 'Open the page in the editor and open that Library tab first.');
755
+ }
756
+ const items = text.split('\n').map((s) => s.trim()).filter(Boolean);
757
+ const present = items.includes(label);
758
+ if (negate && present) {
759
+ throw friendly(`The Canvas Library "${tab}" tab should not list "${label}", but it does.`);
760
+ }
761
+ if (!negate && !present) {
762
+ throw friendly(`The Canvas Library "${tab}" tab should list "${label}", but it does not.`, `Panel items seen: ${items.join(', ')}`);
763
+ }
764
+ });