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