@webship-js/odoo-steps 0.0.0-stage → 2.0.0-beta1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Webship.co
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # odoo-steps
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Test](https://github.com/webship/odoo-steps/actions/workflows/test.yml/badge.svg)](https://github.com/webship/odoo-steps/actions/workflows/test.yml)
4
+
5
+ Odoo-specific Cucumber step definitions for [webship-js](https://www.npmjs.com/package/webship-js) — a Playwright + Cucumber BDD testing framework. `@webship-js/odoo-steps` adds an `odoo.steps.js` step-pack that understands the markup the Odoo Website builder (and the eCommerce, Blog, Events, Jobs and core login modules bundled with it) render, so you can write readable Gherkin scenarios against the main pages of any Odoo site without hand-rolling CSS selectors.
6
+
7
+ It is a **plugin for webship-js** — it does not run standalone. It ships one file of step definitions; you drop it into an existing (or new) webship-js test project.
8
+
9
+ ## What's covered
10
+
11
+ - Homepage / core Website layout (`#wrapwrap`, top menu, footer)
12
+ - Blog index and single post
13
+ - Shop index, search, product page, add-to-cart, cart badge
14
+ - Events index
15
+ - Jobs listing
16
+ - `/contactus` form
17
+ - `/web/login` login form + authentication (and the invalid-login error path)
18
+ - Sessions — log in as a named account from `worldParameters.users`, submit the login form robustly (an in-page submit that an overlay cannot block), and `Given I am not logged in` to end a session and land on the login form; for fast suites, snapshot an authenticated session once with webship-js's own `save the auth state` / `restore the auth state` steps
19
+ - 404 page
20
+
21
+ See [tests/step-definitions/odoo.steps.js](tests/step-definitions/odoo.steps.js) for the full list of steps with Gherkin examples.
22
+
23
+ ## Requirements
24
+
25
+ - Node.js >= 20
26
+ - An existing [webship-js](https://www.npmjs.com/package/webship-js) 2.x test project (or run `npx init-webship-js` to create one)
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install --save-dev webship-js @webship-js/odoo-steps
32
+ ```
33
+
34
+ Add both step-definition paths to your project's `cucumber.js`:
35
+
36
+ ```javascript
37
+ module.exports = {
38
+ default: {
39
+ require: [
40
+ 'node_modules/webship-js/tests/step-definitions/**/*.js', // webship-js core steps
41
+ 'node_modules/@webship-js/odoo-steps/tests/step-definitions/**/*.js', // this plugin
42
+ 'tests/step-definitions/**/*.js', // your own custom steps, if any
43
+ ],
44
+ paths: ['tests/features/**/*.feature'],
45
+ worldParameters: {
46
+ launchUrl: process.env.LAUNCH_URL || 'https://your-odoo-site.example',
47
+ // Named accounts for `Given I am a logged in user with the "<key>" user`.
48
+ // Keeps passwords out of feature files; override per environment.
49
+ users: {
50
+ admin: {
51
+ username: process.env.ODOO_ADMIN_USER || 'admin',
52
+ password: process.env.ODOO_ADMIN_PASSWORD || 'admin',
53
+ },
54
+ },
55
+ // ...
56
+ },
57
+ },
58
+ };
59
+ ```
60
+
61
+ This repo's own `tests/features/*.feature` files double as a working reference project.
62
+
63
+ ## Quick example
64
+
65
+ ```gherkin
66
+ Feature: Odoo homepage
67
+
68
+ Scenario: Homepage renders the Odoo Website layout
69
+ Given I am an anonymous user
70
+ And I am on the homepage
71
+ Then I should be on an Odoo website page
72
+ And the response status code should be 200
73
+ And I should see the Odoo top menu
74
+ And I should see the Odoo footer
75
+ ```
76
+
77
+ More runnable examples: [tests/features/](tests/features/).
78
+
79
+ ## Running this repo's own test suite
80
+
81
+ This repository is itself a working webship-js project — its `tests/features/*.feature` files run by default against a **self-hosted Odoo 17 instance managed by [DDEV](https://ddev.com/)** (`.ddev/`), seeded with Odoo's own demo data for Website, Blog, eCommerce, Events and Jobs. Public Odoo-built sites' bot-detection blocks and rate-limits automated CI traffic, so this repo never targets one.
82
+
83
+ ```bash
84
+ ddev start
85
+ ddev exec -s odoo -- odoo -d demo --db_host=db --db_user=odoo --db_password=odoo \
86
+ --stop-after-init -i website,website_blog,website_sale,website_event,hr_recruitment
87
+ docker restart ddev-odoo-steps-odoo # pick up the demo database
88
+
89
+ npm install
90
+ npx playwright install --with-deps chromium
91
+ npm test # runs against http://localhost:8069
92
+ LAUNCH_URL=https://your-site.example npm test # runs against your own site instead
93
+
94
+ ddev delete -Oy odoo-steps # tear down when done
95
+ ```
96
+
97
+ Other scripts:
98
+
99
+ | Script | Effect |
100
+ | --- | --- |
101
+ | `npm test` | Full suite, pretty output |
102
+ | `npm run test:dry` | `--dry-run` — checks for undefined/ambiguous steps only |
103
+ | `npm run test:headed` | Headed browser |
104
+ | `npm run test:chromium` / `test:firefox` / `test:webkit` | Pick a browser |
105
+
106
+ ## Contributing
107
+
108
+ Issues and pull requests are tracked on GitHub. `2.0.x` is the repository's single active branch.
109
+
110
+ ## License
111
+
112
+ MIT © Webship.co — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@webship-js/odoo-steps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-beta1",
4
+ "description": "Odoo-specific Cucumber step definitions plugin for webship-js — BDD testing for the main pages of any Odoo Website site (Playwright + Cucumber).",
5
+ "main": "tests/step-definitions/odoo.steps.js",
6
+ "files": [
7
+ "tests/step-definitions/odoo.steps.js",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "scripts": {
12
+ "test": "cucumber-js --config cucumber.js",
13
+ "test:dry": "cucumber-js --config cucumber.js --dry-run",
14
+ "test:chromium": "BROWSER=chromium cucumber-js --config cucumber.js",
15
+ "test:firefox": "BROWSER=firefox cucumber-js --config cucumber.js",
16
+ "test:webkit": "BROWSER=webkit cucumber-js --config cucumber.js",
17
+ "test:headed": "HEADLESS=false cucumber-js --config cucumber.js",
18
+ "generate-reports": "generate-reports"
19
+ },
20
+ "keywords": [
21
+ "webship-js",
22
+ "odoo",
23
+ "cucumber",
24
+ "cucumber-js",
25
+ "playwright",
26
+ "bdd",
27
+ "gherkin",
28
+ "step-definitions",
29
+ "odoo-testing",
30
+ "e2e-testing"
31
+ ],
32
+ "author": "Webship.co",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/webship/odoo-steps.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/webship/odoo-steps/issues"
40
+ },
41
+ "homepage": "https://github.com/webship/odoo-steps#readme",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "engines": {
46
+ "node": ">= 20.0"
47
+ },
48
+ "peerDependencies": {
49
+ "webship-js": "^2.0.4"
50
+ },
51
+ "devDependencies": {
52
+ "tsx": "^4.19.2",
53
+ "webship-js": "^2.0.4"
54
+ },
55
+ "overrides": {
56
+ "@cucumber/cucumber": "^12"
57
+ }
58
+ }
@@ -0,0 +1 @@
1
+ # Step Definitions
@@ -0,0 +1,598 @@
1
+ 'use strict';
2
+
3
+ // Odoo-specific Cucumber step definitions for webship-js. These steps
4
+ // understand the markup the Odoo Website builder (and the eCommerce, Blog,
5
+ // Events, Jobs and core web-login modules bundled with it) renders on every
6
+ // Odoo site — #wrapwrap/#top_menu layout, o_wblog_* blog templates,
7
+ // o_wsale_* eCommerce templates, o_wevent_* events templates, o_jobs
8
+ // recruitment templates, s_website_form snippet forms, and the core
9
+ // /web/login screen — so scenarios can assert against the main pages of an
10
+ // Odoo site without hand-rolling CSS selectors in every feature file.
11
+ //
12
+ // Selectors were verified against the live markup of https://www.odoo.com
13
+ // (an Odoo-built site) rather than guessed, since Odoo (unlike WordPress)
14
+ // has no single generic "body class per page type" convention — each
15
+ // front-end app (Website, eCommerce, Blog, Events, Jobs, ...) has its own
16
+ // stable class/id conventions instead.
17
+ //
18
+ // Composes with webship-js's built-in steps (navigation.steps.js,
19
+ // assertion.steps.js, form.steps.js, response.steps.js, …) rather than
20
+ // duplicating them — use those directly in feature files wherever they
21
+ // already cover the need (e.g. "the response status code should be 200",
22
+ // "I should be on the homepage").
23
+
24
+ const { Given, When, Then } = require('@cucumber/cucumber');
25
+ const assert = require('assert');
26
+ const { gotoUrl, waitForPageLoad, fillField } = require('webship-js/tests/step-definitions/webship');
27
+
28
+ // Fill and submit Odoo's login form. The submit is dispatched from inside the
29
+ // page rather than with a pointer click: a fixed overlay (a cookie bar, a chat
30
+ // widget) can cover the button and make a real click wait out its
31
+ // actionability timeout, while the form's own submit cannot be covered.
32
+ // A successful login redirects to the backend webclient (/web), which keeps a
33
+ // permanent longpoll connection open (Discuss/bus), so networkidle never
34
+ // fires there and the shared settle helpers would not settle: wait for the
35
+ // navigation and DOM content only. An invalid login stays on /web/login
36
+ // without a full navigation, so this resolves quickly either way.
37
+ async function submitLogin(page, username, password) {
38
+ await fillField(page, 'login', username);
39
+ await fillField(page, 'password', password);
40
+ await Promise.all([
41
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}),
42
+ page.evaluate(() => {
43
+ const form = document.querySelector('form[action="/web/login"]');
44
+ const submit = form && form.querySelector('button[type="submit"]');
45
+ if (submit) { submit.click(); return; }
46
+ if (form) form.submit();
47
+ }),
48
+ ]);
49
+ await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Core layout: #wrapwrap, top menu, footer
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Assert the current page was rendered by the Odoo Website builder, i.e. it
58
+ * has the `#wrapwrap` element every Odoo Website page wraps its header/
59
+ * main/footer in, regardless of which front-end app (Website, eCommerce,
60
+ * Blog, Events, Jobs, ...) rendered the page.
61
+ *
62
+ * Example #1: Then I should be on an Odoo website page
63
+ * Example #2: And I should be on an Odoo website page
64
+ * Example #3: Then we should be on an Odoo website page
65
+ * Example #4: And should be on an Odoo website page
66
+ * Example #5: Then I should be on a Odoo website page
67
+ *
68
+ */
69
+ Then(/^(I |we )*should be on an? Odoo website page$/, async function (pronounCase) {
70
+ const wrapwrap = this.page.locator('#wrapwrap');
71
+ await assert.doesNotReject(
72
+ () => wrapwrap.waitFor({ state: 'attached', timeout: 45000 }),
73
+ 'Expected the Odoo Website wrapper ("#wrapwrap") to be present'
74
+ );
75
+ });
76
+
77
+ /**
78
+ * Assert the Odoo Website top menu (main navigation) is visible. Matches
79
+ * `#top_menu`, the `<ul>` every Odoo Website header renders its menu items
80
+ * into.
81
+ *
82
+ * Example #1: Then I should see the Odoo top menu
83
+ * Example #2: And I should see the Odoo main menu
84
+ * Example #3: Then we should see the Odoo menu
85
+ * Example #4: And should see the Odoo top menu
86
+ * Example #5: Then I should see the Odoo main navigation menu
87
+ *
88
+ */
89
+ Then(/^(I |we )*should see the Odoo (?:top |main )?(?:menu|navigation menu)$/, async function (pronounCase) {
90
+ const menu = this.page.locator('#top_menu');
91
+ await assert.doesNotReject(
92
+ () => menu.waitFor({ state: 'visible', timeout: 45000 }),
93
+ 'Expected the Odoo Website top menu ("#top_menu") to be visible'
94
+ );
95
+ });
96
+
97
+ /**
98
+ * Assert the Odoo Website footer is visible. Matches `footer#bottom`, the
99
+ * element every Odoo Website footer snippet renders into.
100
+ *
101
+ * Example #1: Then I should see the Odoo footer
102
+ * Example #2: And I should see the Odoo site footer
103
+ * Example #3: Then we should see the Odoo page footer
104
+ * Example #4: And should see the Odoo footer
105
+ * Example #5: Then I should see the Odoo site footer
106
+ *
107
+ */
108
+ Then(/^(I |we )*should see the Odoo (?:site |page )?footer$/, async function (pronounCase) {
109
+ const footer = this.page.locator('footer#bottom, #footer').first();
110
+ await assert.doesNotReject(
111
+ () => footer.waitFor({ state: 'visible', timeout: 45000 }),
112
+ 'Expected the Odoo Website footer ("footer#bottom") to be visible'
113
+ );
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Blog
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Assert the current page is the Odoo Blog index/listing template. Matches
122
+ * `#o_wblog_index_content`, rendered by the Blog app's post-list view.
123
+ *
124
+ * Example #1: Then I should be on the Odoo blog page
125
+ * Example #2: And I should be on the Odoo blog index
126
+ * Example #3: Then we should be on the Odoo blog page
127
+ * Example #4: And should be on the Odoo blog index
128
+ * Example #5: Then I should be on the Odoo blog listing page
129
+ *
130
+ */
131
+ Then(/^(I |we )*should be on the Odoo blog (?:index|page|listing page)$/, async function (pronounCase) {
132
+ const index = this.page.locator('#o_wblog_index_content');
133
+ await assert.doesNotReject(
134
+ () => index.waitFor({ state: 'attached', timeout: 45000 }),
135
+ 'Expected the Odoo Blog index template ("#o_wblog_index_content") to be present'
136
+ );
137
+ });
138
+
139
+ /**
140
+ * Assert the current page is a single Odoo Blog post template. Matches
141
+ * `.o_wblog_post_title`, an element only the single-post view renders (the
142
+ * index view's post teaser cards do not include it).
143
+ *
144
+ * Example #1: Then I should be on an Odoo blog post page
145
+ * Example #2: And I should be on a Odoo blog post page
146
+ * Example #3: Then we should be on an Odoo blog post page
147
+ * Example #4: And should be on an Odoo blog post page
148
+ * Example #5: Then I should be on the Odoo single blog post page
149
+ *
150
+ */
151
+ Then(/^(I |we )*should be on an? Odoo (?:single )?blog post page$/, async function (pronounCase) {
152
+ // .first(): the single-post template can also render a "related posts"
153
+ // teaser section further down the page reusing the same title class.
154
+ const title = this.page.locator('.o_wblog_post_title').first();
155
+ await assert.doesNotReject(
156
+ () => title.waitFor({ state: 'attached', timeout: 45000 }),
157
+ 'Expected the Odoo single blog post template (".o_wblog_post_title") to be present'
158
+ );
159
+ });
160
+
161
+ /**
162
+ * Assert the current Odoo blog post's title matches the given text. Reads
163
+ * `.o_wblog_post_title`, the single-post view's title element.
164
+ *
165
+ * Example #1: Then I should see the Odoo blog post titled "Customer Reviews"
166
+ * Example #2: And I should see the Odoo blog post titled "Employee Testimonials"
167
+ * Example #3: Then we should see the Odoo blog post titled "Release Notes"
168
+ * Example #4: And should see the Odoo blog post titled "Product Update"
169
+ * Example #5: Then I should see the Odoo blog post titled "Company News"
170
+ *
171
+ */
172
+ Then(/^(I |we )*should see the Odoo blog post titled "([^"]*)?"$/, async function (pronounCase, expectedTitle) {
173
+ const title = this.page.locator('.o_wblog_post_title').first();
174
+ const text = (await title.textContent()) || '';
175
+ assert.ok(
176
+ text.includes(expectedTitle),
177
+ `Expected the Odoo blog post title to contain "${expectedTitle}" but found "${text.trim()}"`
178
+ );
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Shop (eCommerce)
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Assert the current page is the Odoo eCommerce shop/catalog template.
187
+ * Matches `#products_grid`, the container the "website_sale" module renders
188
+ * the product listing into.
189
+ *
190
+ * Example #1: Then I should be on the Odoo shop page
191
+ * Example #2: And I should be on the Odoo shop index
192
+ * Example #3: Then we should be on the Odoo shop page
193
+ * Example #4: And should be on the Odoo shop index
194
+ * Example #5: Then I should be on the Odoo shop catalog page
195
+ *
196
+ */
197
+ Then(/^(I |we )*should be on the Odoo shop (?:page|index|catalog page)$/, async function (pronounCase) {
198
+ const grid = this.page.locator('#products_grid');
199
+ await assert.doesNotReject(
200
+ () => grid.waitFor({ state: 'attached', timeout: 45000 }),
201
+ 'Expected the Odoo shop product grid ("#products_grid") to be present'
202
+ );
203
+ });
204
+
205
+ /**
206
+ * Assert the current page is an Odoo eCommerce product page. Matches
207
+ * `.o_wsale_product_page`, rendered by "website_sale" for every product
208
+ * detail page.
209
+ *
210
+ * Example #1: Then I should be on an Odoo product page
211
+ * Example #2: And I should be on a Odoo shop product page
212
+ * Example #3: Then we should be on an Odoo product page
213
+ * Example #4: And should be on an Odoo shop product page
214
+ * Example #5: Then I should be on the Odoo product detail page
215
+ *
216
+ */
217
+ Then(/^(I |we )*should be on an? Odoo (?:shop )?product(?: detail)? page$/, async function (pronounCase) {
218
+ const product = this.page.locator('.o_wsale_product_page');
219
+ await assert.doesNotReject(
220
+ () => product.waitFor({ state: 'attached', timeout: 45000 }),
221
+ 'Expected the Odoo product page (".o_wsale_product_page") to be present'
222
+ );
223
+ });
224
+
225
+ /**
226
+ * Submit the Odoo shop's search form with the given term. Fills the
227
+ * `input[name="search"]` scoped to `.o_wsale_products_searchbar_form` — the
228
+ * shop listing's own search box, as opposed to the (hidden until toggled)
229
+ * search field "website_sale" also renders in the page header — and presses
230
+ * Enter.
231
+ *
232
+ * Example #1: When I search the Odoo shop for "mug"
233
+ * Example #2: When we search the Odoo shop for "polo shirt"
234
+ * Example #3: When I search the Odoo shop for "t-shirt"
235
+ * Example #4: When we search the Odoo shop for "backpack"
236
+ * Example #5: When I search the Odoo shop for "notebook"
237
+ *
238
+ */
239
+ When(/^(I |we )*search the Odoo shop for "([^"]*)?"$/, async function (pronounCase, term) {
240
+ const defaultTime = this.minWaitTime.page || 3000;
241
+ const searchField = this.page
242
+ .locator('.o_wsale_products_searchbar_form input[name="search"]')
243
+ .first();
244
+ await searchField.waitFor({ state: 'visible', timeout: 45000 });
245
+ await searchField.fill(term);
246
+ await searchField.press('Enter');
247
+ await waitForPageLoad(this.page, defaultTime);
248
+ });
249
+
250
+ /**
251
+ * Assert the Odoo shop search returned results for the given term: the
252
+ * product grid is present and the URL's `search` query parameter matches.
253
+ *
254
+ * Example #1: Then I should see Odoo shop search results for "mug"
255
+ * Example #2: And I should see Odoo shop search results for "polo shirt"
256
+ * Example #3: Then we should see Odoo shop search results for "t-shirt"
257
+ * Example #4: And should see Odoo shop search results for "backpack"
258
+ * Example #5: Then I should see Odoo shop search results for "notebook"
259
+ *
260
+ */
261
+ Then(/^(I |we )*should see Odoo shop search results for "([^"]*)?"$/, async function (pronounCase, term) {
262
+ const grid = this.page.locator('#products_grid');
263
+ await assert.doesNotReject(
264
+ () => grid.waitFor({ state: 'attached', timeout: 45000 }),
265
+ 'Expected the Odoo shop product grid ("#products_grid") to be present'
266
+ );
267
+ const currentUrl = this.page.url();
268
+ const encoded = encodeURIComponent(term).replace(/%20/g, '+');
269
+ assert.ok(
270
+ currentUrl.includes(`search=${encoded}`) || currentUrl.includes(`search=${encodeURIComponent(term)}`),
271
+ `Expected the shop search URL to contain the term "${term}" but URL was: ${currentUrl}`
272
+ );
273
+ });
274
+
275
+ /**
276
+ * Add the current Odoo product page's product to the cart. Clicks
277
+ * `#add_to_cart`, the "website_sale" add-to-cart control every product
278
+ * page renders. When the "sale_product_configurator" module is installed
279
+ * (a website_sale dependency as of Odoo 17), this opens an "Added to your
280
+ * cart" modal instead of updating the cart directly — in that case this
281
+ * step dismisses it via its "Continue Shopping" action, which keeps the
282
+ * item in the cart without leaving the product page.
283
+ *
284
+ * Example #1: When I add the product to the Odoo cart
285
+ * Example #2: When we add this product to the Odoo cart
286
+ * Example #3: When I add the current product to the Odoo cart
287
+ * Example #4: When we add the product to the Odoo cart
288
+ * Example #5: When I add this product to the Odoo cart
289
+ *
290
+ */
291
+ When(/^(I |we )*add (?:the |this )?(?:current )?product to the Odoo cart$/, async function (pronounCase) {
292
+ const defaultTime = this.minWaitTime.page || 3000;
293
+ const addToCart = this.page.locator('#add_to_cart').first();
294
+ await addToCart.waitFor({ state: 'visible', timeout: 45000 });
295
+ await addToCart.click();
296
+ const continueShopping = this.page.getByText('Continue Shopping').first();
297
+ if (await continueShopping.isVisible({ timeout: 5000 }).catch(() => false)) {
298
+ await continueShopping.click();
299
+ }
300
+ await waitForPageLoad(this.page, defaultTime);
301
+ });
302
+
303
+ /**
304
+ * Assert the Odoo cart badge shows the given item count. Reads
305
+ * `.my_cart_quantity`, the header cart-icon badge "website_sale" updates
306
+ * whenever the session cart changes.
307
+ *
308
+ * Example #1: Then I should see "1" item in the Odoo cart
309
+ * Example #2: And I should see "2" items in the Odoo cart
310
+ * Example #3: Then we should see "1" item in the Odoo cart
311
+ * Example #4: And should see "3" items in the Odoo cart
312
+ * Example #5: Then I should see "0" items in the Odoo cart
313
+ *
314
+ */
315
+ Then(/^(I |we )*should see "([^"]*)?" items? in the Odoo cart$/, async function (pronounCase, expectedCount) {
316
+ const badge = this.page.locator('.my_cart_quantity').first();
317
+ await assert.doesNotReject(
318
+ () => badge.waitFor({ state: 'visible', timeout: 45000 }),
319
+ 'Expected the Odoo cart quantity badge (".my_cart_quantity") to be visible'
320
+ );
321
+ const text = ((await badge.textContent()) || '').trim();
322
+ assert.strictEqual(
323
+ text,
324
+ String(expectedCount),
325
+ `Expected the Odoo cart quantity badge to read "${expectedCount}" but found "${text}"`
326
+ );
327
+ });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Events
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Assert the current page is the Odoo Events index/listing template.
335
+ * Matches `.o_wevent_index`, rendered by the Events app's event-list view.
336
+ *
337
+ * Example #1: Then I should be on the Odoo events page
338
+ * Example #2: And I should be on the Odoo event page
339
+ * Example #3: Then we should be on the Odoo events page
340
+ * Example #4: And should be on the Odoo events index
341
+ * Example #5: Then I should be on the Odoo events listing page
342
+ *
343
+ */
344
+ Then(/^(I |we )*should be on the Odoo events? (?:page|index|listing page)$/, async function (pronounCase) {
345
+ const index = this.page.locator('.o_wevent_index, .o_wevent_events_list').first();
346
+ await assert.doesNotReject(
347
+ () => index.waitFor({ state: 'attached', timeout: 45000 }),
348
+ 'Expected the Odoo Events index template (".o_wevent_index") to be present'
349
+ );
350
+ });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Jobs
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /**
357
+ * Assert the current page is the Odoo Jobs (recruitment) listing template.
358
+ * Matches `#jobs_grid`, rendered by the Recruitment app's public job board.
359
+ *
360
+ * Example #1: Then I should be on the Odoo jobs page
361
+ * Example #2: And I should be on the Odoo careers page
362
+ * Example #3: Then we should be on the Odoo jobs page
363
+ * Example #4: And should be on the Odoo jobs listing page
364
+ * Example #5: Then I should be on the Odoo recruitment page
365
+ *
366
+ */
367
+ Then(/^(I |we )*should be on the Odoo (?:jobs|careers|recruitment) (?:page|listing page)$/, async function (pronounCase) {
368
+ const jobs = this.page.locator('#jobs_grid');
369
+ await assert.doesNotReject(
370
+ () => jobs.waitFor({ state: 'attached', timeout: 45000 }),
371
+ 'Expected the Odoo Jobs listing template ("#jobs_grid") to be present'
372
+ );
373
+ });
374
+
375
+ /**
376
+ * Assert at least one job opening link is visible on the Odoo Jobs page.
377
+ * Matches links whose `href` contains `/jobs/`, the URL pattern every job
378
+ * posting page uses.
379
+ *
380
+ * Example #1: Then I should see Odoo job openings
381
+ * Example #2: And I should see Odoo job listings
382
+ * Example #3: Then we should see Odoo job openings
383
+ * Example #4: And should see Odoo job listings
384
+ * Example #5: Then I should see Odoo job postings
385
+ *
386
+ */
387
+ Then(/^(I |we )*should see Odoo job (?:openings|listings|postings)$/, async function (pronounCase) {
388
+ const jobLink = this.page.locator('a[href*="/jobs/"]').first();
389
+ await assert.doesNotReject(
390
+ () => jobLink.waitFor({ state: 'visible', timeout: 45000 }),
391
+ 'Expected at least one Odoo job opening link ("a[href*=\\"/jobs/\\"]") to be visible'
392
+ );
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Contact form
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Navigate to Odoo's core "Contact us" page at `/contactus`.
401
+ *
402
+ * Example #1: Given I am on the Odoo contact us page
403
+ * Example #2: Given we are on the Odoo contact page
404
+ * Example #3: Given I am on the Odoo "contact us" page
405
+ * Example #4: Given we are on the Odoo contact-us page
406
+ * Example #5: Given I am on the Odoo contact page
407
+ *
408
+ */
409
+ Given(/^(I am |we are )*on the Odoo (?:"contact us"|contact[ -]?us|contact) page$/, async function (pronounCase) {
410
+ const defaultTime = this.minWaitTime.page || 3000;
411
+ await gotoUrl(this.page, this.launchUrl + '/contactus');
412
+ await this.page.waitForSelector('body', { state: 'attached', timeout: 45000 });
413
+ await waitForPageLoad(this.page, defaultTime);
414
+ });
415
+
416
+ /**
417
+ * Assert the Odoo "Contact us" form is visible. Matches `#contactus_form`,
418
+ * the `s_website_form` snippet instance the Website app renders on the
419
+ * default contact-us page.
420
+ *
421
+ * Example #1: Then I should see the Odoo contact us form
422
+ * Example #2: And I should see the Odoo contact form
423
+ * Example #3: Then we should see the Odoo contact us form
424
+ * Example #4: And should see the Odoo contact form
425
+ * Example #5: Then I should see the Odoo "contact us" form
426
+ *
427
+ */
428
+ Then(/^(I |we )*should see the Odoo (?:"contact us"|contact[ -]?us|contact) form$/, async function (pronounCase) {
429
+ const form = this.page.locator('#contactus_form');
430
+ await assert.doesNotReject(
431
+ () => form.waitFor({ state: 'visible', timeout: 45000 }),
432
+ 'Expected the Odoo contact-us form ("#contactus_form") to be visible'
433
+ );
434
+ });
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // /web/login — authentication
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Navigate to Odoo's core login screen at `/web/login`. This template is
442
+ * shared by every Odoo installation (Community and Enterprise alike) —
443
+ * it ships in the base "web" module, not the Website builder.
444
+ *
445
+ * Example #1: Given I am on the Odoo login page
446
+ * Example #2: Given we are on the Odoo login page
447
+ * Example #3: Given I am on the Odoo sign in page
448
+ * Example #4: Given we are on the Odoo sign-in page
449
+ * Example #5: Given I am on the Odoo "web/login" page
450
+ *
451
+ */
452
+ Given(/^(I am |we are )*on the Odoo (?:sign-?in|"web\/login"|login) page$/, async function (pronounCase) {
453
+ const defaultTime = this.minWaitTime.page || 3000;
454
+ await gotoUrl(this.page, this.launchUrl + '/web/login');
455
+ await this.page.waitForSelector('body', { state: 'attached', timeout: 45000 });
456
+ await waitForPageLoad(this.page, defaultTime);
457
+ });
458
+
459
+ /**
460
+ * Assert Odoo's core login form is visible: the form posting to
461
+ * `/web/login`, with its `#login` and `#password` fields (`addons/web`'s
462
+ * `webclient_templates.xml`, unchanged across Odoo versions).
463
+ *
464
+ * Example #1: Then I should see the Odoo login form
465
+ * Example #2: And I should see the Odoo sign in form
466
+ * Example #3: Then we should see the Odoo login form
467
+ * Example #4: And should see the Odoo login form
468
+ * Example #5: Then I should see the Odoo sign-in form
469
+ *
470
+ */
471
+ Then(/^(I |we )*should see the Odoo (?:sign-?in|login) form$/, async function (pronounCase) {
472
+ const form = this.page.locator('form[action="/web/login"]');
473
+ await assert.doesNotReject(
474
+ () => form.waitFor({ state: 'visible', timeout: 45000 }),
475
+ 'Expected the Odoo login form ("form[action=\\"/web/login\\"]") to be visible'
476
+ );
477
+ await assert.doesNotReject(
478
+ () => this.page.locator('#login').waitFor({ state: 'visible', timeout: 5000 }),
479
+ 'Expected the Odoo login/email field ("#login") to be visible'
480
+ );
481
+ await assert.doesNotReject(
482
+ () => this.page.locator('#password').waitFor({ state: 'visible', timeout: 5000 }),
483
+ 'Expected the Odoo password field ("#password") to be visible'
484
+ );
485
+ });
486
+
487
+ /**
488
+ * Fill and submit Odoo's core login form with the given username/email and
489
+ * password (`#login`, `#password`, the form's submit button).
490
+ *
491
+ * Example #1: When I log in to Odoo as "admin" with password "secret"
492
+ * Example #2: When we log in to Odoo as "demo@example.com" with password "p@ssw0rd"
493
+ * Example #3: When I sign in to Odoo as "admin" with password "secret"
494
+ * Example #4: When we sign in to Odoo as "reviewer" with password "hunter2"
495
+ * Example #5: When I log in to Odoo as "portal_user" with password "letmein"
496
+ *
497
+ */
498
+ When(/^(I |we )*(?:log in|sign in) to Odoo as "([^"]*)?" with password "([^"]*)?"$/, async function (pronounCase, username, password) {
499
+ await submitLogin(this.page, username, password);
500
+ });
501
+
502
+ /**
503
+ * Log in as a user configured in cucumber.js `worldParameters.users`, so no
504
+ * password appears in a feature file. Each key names one account:
505
+ * `{ admin: { username: 'admin', password: process.env.ODOO_ADMIN_PASSWORD } }`.
506
+ * The login screen is opened first, so this step works from any page.
507
+ *
508
+ * Example #1: Given I am a logged in user with the "admin" user
509
+ * Example #2: Given I am a logged in user with the username "admin"
510
+ * Example #3: Given we are a logged in user with "portal"
511
+ * Example #4: And a logged in user with the "admin" user
512
+ * Example #5: Given I am a logged in user with the username "demo" user
513
+ *
514
+ */
515
+ Given(/^(I am |we are )*a logged in user with( the)*( username)* "([^"]*)?"( user)*$/, async function (pronounCase, theCase, usernameCase, userKey, userCase) {
516
+ const users = (this.parameters && this.parameters.users) || {};
517
+ const entry = users[userKey];
518
+ if (!entry) {
519
+ throw new Error(`User "${userKey}" is not configured. Add it to worldParameters.users in cucumber.js. Known users: ${Object.keys(users).join(', ') || '(none)'}.`);
520
+ }
521
+ const username = entry.username || userKey;
522
+ const password = typeof entry === 'string' ? entry : entry.password;
523
+ if (!password) {
524
+ throw new Error(`User "${userKey}" has no password configured. Set worldParameters.users.${userKey}.password in cucumber.js, ideally from an environment variable.`);
525
+ }
526
+ await gotoUrl(this.page, this.launchUrl + '/web/login');
527
+ await this.page.locator('#login').waitFor({ state: 'visible', timeout: 10000 });
528
+ await submitLogin(this.page, username, password);
529
+ });
530
+
531
+ /**
532
+ * End any authenticated session and land on the login screen as a visitor:
533
+ * cookies and web storage are cleared, `/web/login` is opened and the core
534
+ * login form is confirmed. Use it at the start of a scenario that must run
535
+ * anonymously after an earlier one logged in.
536
+ *
537
+ * Example #1: Given I am not logged in
538
+ * Example #2: Given we are not logged in
539
+ * Example #3: And I am not logged in
540
+ * Example #4: Given not logged in
541
+ * Example #5: But I am not logged in
542
+ *
543
+ */
544
+ Given(/^(I am |we are )*not logged in$/, async function (pronounCase) {
545
+ await this.context.clearCookies();
546
+ try {
547
+ await this.page.evaluate(() => {
548
+ try { localStorage.clear(); } catch { /* storage may be unavailable */ }
549
+ try { sessionStorage.clear(); } catch { /* storage may be unavailable */ }
550
+ });
551
+ } catch { /* the page may not be on a real URL yet */ }
552
+ await gotoUrl(this.page, this.launchUrl + '/web/login');
553
+ await this.page.locator('form[action="/web/login"]').waitFor({ state: 'visible', timeout: 10000 });
554
+ });
555
+
556
+ /**
557
+ * Assert the Odoo login screen is showing an authentication error. Matches
558
+ * the `.alert-danger` message Odoo's login controller renders on invalid
559
+ * credentials.
560
+ *
561
+ * Example #1: Then I should see an Odoo login error
562
+ * Example #2: And I should see an Odoo sign in error
563
+ * Example #3: Then we should see an Odoo login error
564
+ * Example #4: And should see an Odoo login error
565
+ * Example #5: Then I should see an Odoo authentication error
566
+ *
567
+ */
568
+ Then(/^(I |we )*should see an Odoo (?:login|sign-?in|authentication) error$/, async function (pronounCase) {
569
+ const error = this.page.locator('.alert-danger');
570
+ await assert.doesNotReject(
571
+ () => error.waitFor({ state: 'visible', timeout: 45000 }),
572
+ 'Expected an Odoo login error message (".alert-danger") to be visible'
573
+ );
574
+ });
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // 404
578
+ // ---------------------------------------------------------------------------
579
+
580
+ /**
581
+ * Assert the current page is Odoo's core 404 template, by checking the
582
+ * page heading for "404" (rendered by `addons/http_routing`'s
583
+ * `http_routing.404` template on every Odoo site).
584
+ *
585
+ * Example #1: Then I should be on the Odoo 404 page
586
+ * Example #2: And I should be on the Odoo "not found" page
587
+ * Example #3: Then we should be on the Odoo 404 page
588
+ * Example #4: And should be on the Odoo 404 page
589
+ * Example #5: Then I should land on the Odoo 404 page
590
+ *
591
+ */
592
+ Then(/^(I |we )*should (?:be on|land on) the Odoo (?:404|"not found") page$/, async function (pronounCase) {
593
+ const heading = this.page.locator('h1', { hasText: '404' }).first();
594
+ await assert.doesNotReject(
595
+ () => heading.waitFor({ state: 'attached', timeout: 45000 }),
596
+ 'Expected the Odoo 404 template (an <h1> containing "404") to be present'
597
+ );
598
+ });