@webship-js/odoo-steps 2.0.0-beta1 → 2.0.0-rc1

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/README.md CHANGED
@@ -20,6 +20,8 @@ It is a **plugin for webship-js** — it does not run standalone. It ships one f
20
20
 
21
21
  See [tests/step-definitions/odoo.steps.js](tests/step-definitions/odoo.steps.js) for the full list of steps with Gherkin examples.
22
22
 
23
+ The word "Odoo" is optional in every sentence: `Then I should see the top menu` and `Then I should see the Odoo top menu` are the same step, so a feature written for another webship-js step pack reads the same here. Every assertion takes `not` (`Then I should not see the login form`), waits for the page rather than reading it once, and fails with a message that says what was expected, why it failed and what to check.
24
+
23
25
  ## Requirements
24
26
 
25
27
  - Node.js >= 20
@@ -100,6 +102,7 @@ Other scripts:
100
102
  | --- | --- |
101
103
  | `npm test` | Full suite, pretty output |
102
104
  | `npm run test:dry` | `--dry-run` — checks for undefined/ambiguous steps only |
105
+ | `npm run test:examples` | No browser: every step's JSDoc carries five `Example #n:` lines that match its own definition and no other, and every feature step resolves to exactly one definition (also runs in CI) |
103
106
  | `npm run test:headed` | Headed browser |
104
107
  | `npm run test:chromium` / `test:firefox` / `test:webkit` | Pick a browser |
105
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webship-js/odoo-steps",
3
- "version": "2.0.0-beta1",
3
+ "version": "2.0.0-rc1",
4
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
5
  "main": "tests/step-definitions/odoo.steps.js",
6
6
  "files": [
@@ -11,6 +11,7 @@
11
11
  "scripts": {
12
12
  "test": "cucumber-js --config cucumber.js",
13
13
  "test:dry": "cucumber-js --config cucumber.js --dry-run",
14
+ "test:examples": "node tests/check-examples.js",
14
15
  "test:chromium": "BROWSER=chromium cucumber-js --config cucumber.js",
15
16
  "test:firefox": "BROWSER=firefox cucumber-js --config cucumber.js",
16
17
  "test:webkit": "BROWSER=webkit cucumber-js --config cucumber.js",
@@ -46,11 +47,11 @@
46
47
  "node": ">= 20.0"
47
48
  },
48
49
  "peerDependencies": {
49
- "webship-js": "^2.0.4"
50
+ "webship-js": "^2.0.6"
50
51
  },
51
52
  "devDependencies": {
52
53
  "tsx": "^4.19.2",
53
- "webship-js": "^2.0.4"
54
+ "webship-js": "^2.0.6"
54
55
  },
55
56
  "overrides": {
56
57
  "@cucumber/cucumber": "^12"
@@ -3,27 +3,96 @@
3
3
  // Odoo-specific Cucumber step definitions for webship-js. These steps
4
4
  // understand the markup the Odoo Website builder (and the eCommerce, Blog,
5
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,
6
+ // Odoo site: the #wrapwrap/#top_menu layout, o_wblog_* blog templates,
7
7
  // o_wsale_* eCommerce templates, o_wevent_* events templates, o_jobs
8
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.
9
+ // /web/login screen. Scenarios assert against the main pages of an Odoo site
10
+ // without hand-rolling CSS selectors in every feature file.
11
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.
12
+ // The Gherkin phrasing stays product-agnostic: the word "Odoo" is optional in
13
+ // every sentence ("I should see the top menu" and "I should see the Odoo top
14
+ // menu" are the same step), so a feature written for another step pack reads
15
+ // the same here. Only these comments and the error messages name the product,
16
+ // to document which convention each step targets.
17
17
  //
18
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").
19
+ // assertion.steps.js, form.steps.js, response.steps.js, ...) rather than
20
+ // duplicating them: use those directly in feature files wherever they already
21
+ // cover the need ("the response status code should be 200", "the path should
22
+ // be "/web"").
23
23
 
24
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');
25
+ const { gotoUrl, waitForPageLoad, fillField, friendly } = require('webship-js/tests/step-definitions/webship');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ // Kept well under webship-js's 45s step timeout so a locator that never
32
+ // settles fails through the authored message (with its hint), not as
33
+ // "function timed out".
34
+ const WAIT_TIMEOUT = 30000;
35
+
36
+ // The per-page wait budget from `worldParameters.minWaitTime.page`.
37
+ function budget(world) {
38
+ return (world && world.minWaitTime && world.minWaitTime.page) || 3000;
39
+ }
40
+
41
+ // Join the launch URL and a path with exactly one slash.
42
+ function siteUrl(world, path) {
43
+ return String(world.launchUrl).replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '');
44
+ }
45
+
46
+ // Open a path on the site under test and wait for the page to settle.
47
+ async function navigate(world, path) {
48
+ await gotoUrl(world.page, siteUrl(world, path));
49
+ await world.page.waitForSelector('body', { state: 'attached', timeout: WAIT_TIMEOUT });
50
+ await waitForPageLoad(world.page, budget(world));
51
+ }
52
+
53
+ // Assert an element is present or, when the step said "not", absent. The
54
+ // assertion is the wait: Playwright polls the locator for the wanted state and
55
+ // a miss fails through a friendly message with a hint. `attached` picks
56
+ // presence-in-DOM over visibility for template markers a theme may render
57
+ // off-screen.
58
+ async function assertPresence(locator, notCase, label, { attached = false, hint, timeout = WAIT_TIMEOUT } = {}) {
59
+ const state = attached ? (notCase ? 'detached' : 'attached') : (notCase ? 'hidden' : 'visible');
60
+ try {
61
+ await locator.waitFor({ state, timeout });
62
+ } catch (error) {
63
+ throw friendly({
64
+ message: notCase ? `Expected ${label} to be absent.` : `Expected ${label} to be present.`,
65
+ cause: error,
66
+ hint,
67
+ });
68
+ }
69
+ }
70
+
71
+ // Whitespace-tolerant, case-sensitive regex for "contains this text", so a
72
+ // Playwright `hasText` filter can be the wait.
73
+ function textPattern(text, { exact = false } = {}) {
74
+ const body = text.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
75
+ return new RegExp(exact ? `^\\s*${body}\\s*$` : body);
76
+ }
77
+
78
+ // Assert a locator's text contains (or, with `notCase`, does not contain) the
79
+ // expected text: the locator is narrowed to "has this text" and Playwright
80
+ // polls for that narrowed set to exist or to be gone.
81
+ async function assertText(locator, expectedText, notCase, label, { exact = false, hint, timeout = WAIT_TIMEOUT } = {}) {
82
+ const withText = locator.filter({ hasText: textPattern(expectedText, { exact }) });
83
+ try {
84
+ await withText.waitFor({ state: notCase ? 'detached' : 'attached', timeout });
85
+ } catch (error) {
86
+ const text = ((await locator.textContent({ timeout: 1000 }).catch(() => '')) || '').replace(/\s+/g, ' ').trim();
87
+ throw friendly({
88
+ message: notCase
89
+ ? `Expected ${label} not to ${exact ? 'read' : 'contain'} "${expectedText}".`
90
+ : `Expected ${label} to ${exact ? 'read' : 'contain'} "${expectedText}".`,
91
+ cause: `it reads "${text}"`,
92
+ hint,
93
+ });
94
+ }
95
+ }
27
96
 
28
97
  // Fill and submit Odoo's login form. The submit is dispatched from inside the
29
98
  // page rather than with a pointer click: a fixed overlay (a cookie bar, a chat
@@ -34,7 +103,18 @@ const { gotoUrl, waitForPageLoad, fillField } = require('webship-js/tests/step-d
34
103
  // fires there and the shared settle helpers would not settle: wait for the
35
104
  // navigation and DOM content only. An invalid login stays on /web/login
36
105
  // without a full navigation, so this resolves quickly either way.
37
- async function submitLogin(page, username, password) {
106
+ async function submitLogin(world, username, password) {
107
+ const page = world.page;
108
+ try {
109
+ await page.locator('#login').waitFor({ state: 'visible', timeout: budget(world) });
110
+ } catch (error) {
111
+ throw friendly({
112
+ action: 'find the login form',
113
+ target: 'form[action="/web/login"]',
114
+ cause: error,
115
+ hint: 'Open the login page first ("Given I am on the login page"); a site that replaces /web/login needs its own step.',
116
+ });
117
+ }
38
118
  await fillField(page, 'login', username);
39
119
  await fillField(page, 'password', password);
40
120
  await Promise.all([
@@ -54,63 +134,58 @@ async function submitLogin(page, username, password) {
54
134
  // ---------------------------------------------------------------------------
55
135
 
56
136
  /**
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.
137
+ * Assert the current page was, or was not, rendered by the Odoo Website
138
+ * builder: it has the `#wrapwrap` element every Odoo Website page wraps its
139
+ * header, main and footer in, regardless of which front-end app (Website,
140
+ * eCommerce, Blog, Events, Jobs, ...) rendered the page.
61
141
  *
62
142
  * Example #1: Then I should be on an Odoo website page
63
- * Example #2: And I should be on an Odoo website page
143
+ * Example #2: And I should be on a website page
64
144
  * 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
145
+ * Example #4: And should not be on a website page
146
+ * Example #5: Then I should not be on an Odoo website page
67
147
  *
68
148
  */
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
- );
149
+ Then(/^(I |we )*should( not)* be on an? (?:Odoo )?website page$/, async function (pronounCase, notCase) {
150
+ await assertPresence(this.page.locator('#wrapwrap'), notCase, 'the Website wrapper ("#wrapwrap")', {
151
+ attached: true,
152
+ hint: 'Every page the Odoo Website builder renders carries #wrapwrap; the backend webclient (/web) and the login screen do not.',
153
+ });
75
154
  });
76
155
 
77
156
  /**
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.
157
+ * Assert the Website top menu (main navigation) is, or is not, visible.
158
+ * Matches `#top_menu`, the `<ul>` every Odoo Website header renders its menu
159
+ * items into.
81
160
  *
82
161
  * Example #1: Then I should see the Odoo top menu
83
- * Example #2: And I should see the Odoo main menu
162
+ * Example #2: And I should see the main menu
84
163
  * 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
164
+ * Example #4: And should not see the top menu
165
+ * Example #5: Then I should see the main navigation menu
87
166
  *
88
167
  */
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
- );
168
+ Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:top |main )?(?:menu|navigation menu)$/, async function (pronounCase, notCase) {
169
+ await assertPresence(this.page.locator('#top_menu').first(), notCase, 'the Website top menu ("#top_menu")', {
170
+ hint: 'The header renders #top_menu on every Website page; a header template without a menu, or a hidden header, leaves it out.',
171
+ });
95
172
  });
96
173
 
97
174
  /**
98
- * Assert the Odoo Website footer is visible. Matches `footer#bottom`, the
99
- * element every Odoo Website footer snippet renders into.
175
+ * Assert the Website footer is, or is not, visible. Matches `footer#bottom`,
176
+ * the element every Odoo Website footer snippet renders into.
100
177
  *
101
178
  * Example #1: Then I should see the Odoo footer
102
- * Example #2: And I should see the Odoo site footer
179
+ * Example #2: And I should see the site footer
103
180
  * 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
181
+ * Example #4: And should not see the footer
182
+ * Example #5: Then I should not see the Odoo site footer
106
183
  *
107
184
  */
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
- );
185
+ Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:site |page )?footer$/, async function (pronounCase, notCase) {
186
+ await assertPresence(this.page.locator('footer#bottom, #footer').first(), notCase, 'the Website footer ("footer#bottom")', {
187
+ hint: 'The footer can be hidden per page in the Website builder (page properties); check the page, not the theme.',
188
+ });
114
189
  });
115
190
 
116
191
  // ---------------------------------------------------------------------------
@@ -118,64 +193,65 @@ Then(/^(I |we )*should see the Odoo (?:site |page )?footer$/, async function (pr
118
193
  // ---------------------------------------------------------------------------
119
194
 
120
195
  /**
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.
196
+ * Assert the current page is, or is not, the Blog index/listing template.
197
+ * Matches `#o_wblog_index_content`, rendered by the Blog app's post-list view.
123
198
  *
124
199
  * Example #1: Then I should be on the Odoo blog page
125
- * Example #2: And I should be on the Odoo blog index
200
+ * Example #2: And I should be on the blog index
126
201
  * 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
202
+ * Example #4: And should not be on the blog index
203
+ * Example #5: Then I should be on the blog listing page
129
204
  *
130
205
  */
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
- );
206
+ Then(/^(I |we )*should( not)* be on the (?:Odoo )?blog (?:index|page|listing page)$/, async function (pronounCase, notCase) {
207
+ await assertPresence(this.page.locator('#o_wblog_index_content'), notCase, 'the Blog index template ("#o_wblog_index_content")', {
208
+ attached: true,
209
+ hint: 'The Blog index lives at /blog once the website_blog app is installed and at least one blog exists.',
210
+ });
137
211
  });
138
212
 
139
213
  /**
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).
214
+ * Assert the current page is, or is not, a single blog post template.
215
+ * Matches `.o_wblog_post_title`, an element only the single-post view renders
216
+ * (the index view's post teaser cards do not include it).
143
217
  *
144
218
  * 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
219
+ * Example #2: And I should be on a blog post page
220
+ * Example #3: Then we should be on the Odoo single blog post page
221
+ * Example #4: And should not be on a blog post page
222
+ * Example #5: Then I should not be on the single blog post page
149
223
  *
150
224
  */
151
- Then(/^(I |we )*should be on an? Odoo (?:single )?blog post page$/, async function (pronounCase) {
225
+ Then(/^(I |we )*should( not)* be on (?:an?|the) (?:Odoo )?(?:single )?blog post page$/, async function (pronounCase, notCase) {
152
226
  // .first(): the single-post template can also render a "related posts"
153
227
  // 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
- );
228
+ await assertPresence(this.page.locator('.o_wblog_post_title').first(), notCase, 'the single blog post template (".o_wblog_post_title")', {
229
+ attached: true,
230
+ hint: 'Open a post from the blog index first; the index itself renders teaser cards without this title element.',
231
+ });
159
232
  });
160
233
 
161
234
  /**
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.
235
+ * Assert the blog post on screen has, or does not have, the given title.
236
+ * Reads `.o_wblog_post_title`, the single-post view's title element, and
237
+ * waits for the text to be there.
164
238
  *
165
239
  * 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"
240
+ * Example #2: And I should see the blog post titled "Employee Testimonials"
167
241
  * 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"
242
+ * Example #4: And should not see the blog post titled "Product Update"
243
+ * Example #5: Then I should see the blog post titled "Company News"
170
244
  *
171
245
  */
172
- Then(/^(I |we )*should see the Odoo blog post titled "([^"]*)?"$/, async function (pronounCase, expectedTitle) {
246
+ Then(/^(I |we )*should( not)* see the (?:Odoo )?blog post titled "([^"]*)?"$/, async function (pronounCase, notCase, expectedTitle) {
173
247
  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
- );
248
+ await assertPresence(title, undefined, 'the blog post title (".o_wblog_post_title")', {
249
+ attached: true,
250
+ hint: 'Open a single blog post first.',
251
+ });
252
+ await assertText(title, expectedTitle, notCase, 'the blog post title', {
253
+ hint: 'Text is matched as a substring, whitespace-collapsed but case-sensitive.',
254
+ });
179
255
  });
180
256
 
181
257
  // ---------------------------------------------------------------------------
@@ -183,147 +259,198 @@ Then(/^(I |we )*should see the Odoo blog post titled "([^"]*)?"$/, async functio
183
259
  // ---------------------------------------------------------------------------
184
260
 
185
261
  /**
186
- * Assert the current page is the Odoo eCommerce shop/catalog template.
262
+ * Assert the current page is, or is not, the eCommerce shop/catalog template.
187
263
  * Matches `#products_grid`, the container the "website_sale" module renders
188
264
  * the product listing into.
189
265
  *
190
266
  * Example #1: Then I should be on the Odoo shop page
191
- * Example #2: And I should be on the Odoo shop index
267
+ * Example #2: And I should be on the shop index
192
268
  * 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
269
+ * Example #4: And should not be on the shop index
270
+ * Example #5: Then I should be on the shop catalog page
195
271
  *
196
272
  */
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
- );
273
+ Then(/^(I |we )*should( not)* be on the (?:Odoo )?shop (?:page|index|catalog page)$/, async function (pronounCase, notCase) {
274
+ await assertPresence(this.page.locator('#products_grid'), notCase, 'the shop product grid ("#products_grid")', {
275
+ attached: true,
276
+ hint: 'The shop lives at /shop once the website_sale app is installed and at least one product is published.',
277
+ });
203
278
  });
204
279
 
205
280
  /**
206
- * Assert the current page is an Odoo eCommerce product page. Matches
281
+ * Assert the current page is, or is not, an eCommerce product page. Matches
207
282
  * `.o_wsale_product_page`, rendered by "website_sale" for every product
208
283
  * detail page.
209
284
  *
210
285
  * 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
286
+ * Example #2: And I should be on a shop product page
287
+ * Example #3: Then we should be on the Odoo product detail page
288
+ * Example #4: And should not be on a product page
289
+ * Example #5: Then I should not be on the product detail page
215
290
  *
216
291
  */
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
- );
292
+ Then(/^(I |we )*should( not)* be on (?:an?|the) (?:Odoo )?(?:shop )?product(?: detail)? page$/, async function (pronounCase, notCase) {
293
+ await assertPresence(this.page.locator('.o_wsale_product_page'), notCase, 'the product page (".o_wsale_product_page")', {
294
+ attached: true,
295
+ hint: 'Open a product from the shop grid first ("When I open the first product in the shop").',
296
+ });
223
297
  });
224
298
 
225
299
  /**
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
300
+ * Open a product from the shop grid on screen: the first one by default, or
301
+ * the n-th. Reads the product link from `#products_grid` and navigates to it,
302
+ * so no product slug appears in the feature file.
303
+ *
304
+ * Example #1: When I open the first product in the Odoo shop
305
+ * Example #2: And I open the first product in the shop
306
+ * Example #3: When we open the 2nd product in the shop
307
+ * Example #4: And open the first item in the Odoo shop
308
+ * Example #5: When I open the 3rd product in the shop
309
+ *
310
+ */
311
+ When(/^(I |we )*open the (first|\d+(?:st|nd|rd|th)) (?:product|item) in the (?:Odoo )?shop$/, async function (pronounCase, position) {
312
+ const index = position === 'first' ? 0 : parseInt(position, 10) - 1;
313
+ const links = this.page.locator('#products_grid .oe_product a[href*="/shop/"]');
314
+ const link = links.nth(index);
315
+ try {
316
+ await link.waitFor({ state: 'attached', timeout: WAIT_TIMEOUT });
317
+ } catch (error) {
318
+ throw friendly({
319
+ action: 'find a product in the shop grid',
320
+ target: `product ${index + 1} in "#products_grid"`,
321
+ cause: error,
322
+ hint: 'Open the shop page first and make sure the catalog lists at least that many published products.',
323
+ });
324
+ }
325
+ const href = await link.getAttribute('href');
326
+ if (!href) {
327
+ throw friendly({
328
+ message: `Product ${index + 1} in the shop grid has no link.`,
329
+ hint: 'The product card should link to its /shop/<product> page; check the grid template.',
330
+ });
331
+ }
332
+ await navigate(this, href);
333
+ });
334
+
335
+ /**
336
+ * Submit the shop's search form with the given term. Fills the
337
+ * `input[name="search"]` scoped to `.o_wsale_products_searchbar_form`, the
228
338
  * 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
339
+ * search field "website_sale" also renders in the page header, and presses
230
340
  * Enter.
231
341
  *
232
342
  * 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"
343
+ * Example #2: When we search the shop for "polo shirt"
344
+ * Example #3: And search the Odoo shop for "t-shirt"
345
+ * Example #4: When we search the shop for "backpack"
346
+ * Example #5: And I search the shop for "notebook"
237
347
  *
238
348
  */
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 });
349
+ When(/^(I |we )*search the (?:Odoo )?shop for "([^"]*)?"$/, async function (pronounCase, term) {
350
+ const searchField = this.page.locator('.o_wsale_products_searchbar_form input[name="search"]').first();
351
+ try {
352
+ await searchField.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
353
+ } catch (error) {
354
+ throw friendly({
355
+ action: 'find the shop search box',
356
+ target: '.o_wsale_products_searchbar_form input[name="search"]',
357
+ cause: error,
358
+ hint: 'Open the shop page first; the search bar is a shop option a site can turn off in the Website builder.',
359
+ });
360
+ }
245
361
  await searchField.fill(term);
246
362
  await searchField.press('Enter');
247
- await waitForPageLoad(this.page, defaultTime);
363
+ await waitForPageLoad(this.page, budget(this));
248
364
  });
249
365
 
250
366
  /**
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.
367
+ * Assert the shop search returned results for the given term: the product
368
+ * grid is present and the URL's `search` query parameter carries the term.
253
369
  *
254
370
  * 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"
371
+ * Example #2: And I should see shop search results for "polo shirt"
256
372
  * 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"
373
+ * Example #4: And should see shop search results for "backpack"
374
+ * Example #5: Then I should see the shop search results for "notebook"
259
375
  *
260
376
  */
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
- );
377
+ Then(/^(I |we )*should see (?:the )?(?:Odoo )?shop search results for "([^"]*)?"$/, async function (pronounCase, term) {
378
+ await assertPresence(this.page.locator('#products_grid'), undefined, 'the shop product grid ("#products_grid")', {
379
+ attached: true,
380
+ hint: 'A search that matches nothing still renders the grid with an empty-state message; a missing grid means the search left the shop.',
381
+ });
382
+ // A space reaches the URL as `+` or `%20` depending on whether the form or
383
+ // a direct navigation submitted it.
384
+ const encoded = encodeURIComponent(term);
385
+ const carriesTerm = (url) => url.includes(`search=${encoded.replace(/%20/g, '+')}`) || url.includes(`search=${encoded}`);
386
+ try {
387
+ await this.page.waitForURL((url) => carriesTerm(String(url)), { timeout: budget(this) });
388
+ } catch (error) {
389
+ throw friendly({
390
+ message: `Expected the shop URL to carry the search term "${term}".`,
391
+ cause: `the URL is ${this.page.url()}`,
392
+ hint: 'Run "When I search the shop for ..." first, with the same term.',
393
+ });
394
+ }
273
395
  });
274
396
 
275
397
  /**
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.
398
+ * Add the product on screen to the cart. Clicks `#add_to_cart`, the
399
+ * "website_sale" add-to-cart control every product page renders. When the
400
+ * "sale_product_configurator" module is installed (a website_sale dependency
401
+ * as of Odoo 17), this opens an "Added to your cart" dialog instead of
402
+ * updating the cart directly; the step then dismisses it through its
403
+ * "Continue Shopping" action, which keeps the item in the cart without
404
+ * leaving the product page.
283
405
  *
284
406
  * 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
407
+ * Example #2: When we add this product to the cart
408
+ * Example #3: And add the current product to the Odoo cart
409
+ * Example #4: When we add the product to the cart
410
+ * Example #5: And I add this product to the cart
289
411
  *
290
412
  */
291
- When(/^(I |we )*add (?:the |this )?(?:current )?product to the Odoo cart$/, async function (pronounCase) {
292
- const defaultTime = this.minWaitTime.page || 3000;
413
+ When(/^(I |we )*add (?:the |this )?(?:current )?product to the (?:Odoo )?cart$/, async function (pronounCase) {
293
414
  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();
415
+ try {
416
+ await addToCart.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
417
+ await addToCart.click();
418
+ } catch (error) {
419
+ throw friendly({
420
+ action: 'click the add-to-cart button',
421
+ target: '#add_to_cart',
422
+ cause: error,
423
+ hint: 'Open a product page first; a product that is out of stock or not published has no add-to-cart button.',
424
+ });
425
+ }
426
+ const continueShopping = this.page.getByRole('button', { name: /Continue Shopping/i }).or(this.page.getByText('Continue Shopping')).first();
297
427
  if (await continueShopping.isVisible({ timeout: 5000 }).catch(() => false)) {
298
428
  await continueShopping.click();
299
429
  }
300
- await waitForPageLoad(this.page, defaultTime);
430
+ await waitForPageLoad(this.page, budget(this));
301
431
  });
302
432
 
303
433
  /**
304
- * Assert the Odoo cart badge shows the given item count. Reads
434
+ * Assert the cart badge shows, or does not show, the given item count. Reads
305
435
  * `.my_cart_quantity`, the header cart-icon badge "website_sale" updates
306
- * whenever the session cart changes.
436
+ * whenever the session cart changes, and waits for it to read that number.
307
437
  *
308
438
  * 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
439
+ * Example #2: And I should see "2" items in the cart
310
440
  * 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
441
+ * Example #4: And should not see "3" items in the cart
442
+ * Example #5: Then I should see "0" items in the cart
313
443
  *
314
444
  */
315
- Then(/^(I |we )*should see "([^"]*)?" items? in the Odoo cart$/, async function (pronounCase, expectedCount) {
445
+ Then(/^(I |we )*should( not)* see "([^"]*)?" items? in the (?:Odoo )?cart$/, async function (pronounCase, notCase, expectedCount) {
316
446
  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
- );
447
+ await assertPresence(badge, undefined, 'the cart quantity badge (".my_cart_quantity")', {
448
+ hint: 'The badge sits on the header cart icon; a header without the cart link (an eCommerce option) has no badge.',
449
+ });
450
+ await assertText(badge, String(expectedCount), notCase, 'the cart quantity badge', {
451
+ exact: true,
452
+ hint: 'The badge is updated by the add-to-cart request; a configurator dialog left open keeps the old count.',
453
+ });
327
454
  });
328
455
 
329
456
  // ---------------------------------------------------------------------------
@@ -331,22 +458,21 @@ Then(/^(I |we )*should see "([^"]*)?" items? in the Odoo cart$/, async function
331
458
  // ---------------------------------------------------------------------------
332
459
 
333
460
  /**
334
- * Assert the current page is the Odoo Events index/listing template.
461
+ * Assert the current page is, or is not, the Events index/listing template.
335
462
  * Matches `.o_wevent_index`, rendered by the Events app's event-list view.
336
463
  *
337
464
  * Example #1: Then I should be on the Odoo events page
338
- * Example #2: And I should be on the Odoo event page
465
+ * Example #2: And I should be on the event page
339
466
  * 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
467
+ * Example #4: And should not be on the events index
468
+ * Example #5: Then I should be on the events listing page
342
469
  *
343
470
  */
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
- );
471
+ Then(/^(I |we )*should( not)* be on the (?:Odoo )?events? (?:page|index|listing page)$/, async function (pronounCase, notCase) {
472
+ await assertPresence(this.page.locator('.o_wevent_index, .o_wevent_events_list').first(), notCase, 'the Events index template (".o_wevent_index")', {
473
+ attached: true,
474
+ hint: 'The events list lives at /event once the website_event app is installed.',
475
+ });
350
476
  });
351
477
 
352
478
  // ---------------------------------------------------------------------------
@@ -354,42 +480,40 @@ Then(/^(I |we )*should be on the Odoo events? (?:page|index|listing page)$/, asy
354
480
  // ---------------------------------------------------------------------------
355
481
 
356
482
  /**
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.
483
+ * Assert the current page is, or is not, the Jobs (recruitment) listing
484
+ * template. Matches `#jobs_grid`, rendered by the Recruitment app's public job
485
+ * board.
359
486
  *
360
487
  * Example #1: Then I should be on the Odoo jobs page
361
- * Example #2: And I should be on the Odoo careers page
488
+ * Example #2: And I should be on the careers page
362
489
  * 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
490
+ * Example #4: And should not be on the jobs listing page
491
+ * Example #5: Then I should be on the recruitment page
365
492
  *
366
493
  */
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
- );
494
+ Then(/^(I |we )*should( not)* be on the (?:Odoo )?(?:jobs|careers|recruitment) (?:page|listing page)$/, async function (pronounCase, notCase) {
495
+ await assertPresence(this.page.locator('#jobs_grid'), notCase, 'the Jobs listing template ("#jobs_grid")', {
496
+ attached: true,
497
+ hint: 'The job board lives at /jobs once the hr_recruitment app is installed with its website module.',
498
+ });
373
499
  });
374
500
 
375
501
  /**
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.
502
+ * Assert at least one job opening link is, or none is, visible on the Jobs
503
+ * page. Matches links whose `href` contains `/jobs/`, the URL pattern every
504
+ * job posting page uses.
379
505
  *
380
506
  * Example #1: Then I should see Odoo job openings
381
- * Example #2: And I should see Odoo job listings
507
+ * Example #2: And I should see job listings
382
508
  * 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
509
+ * Example #4: And should not see job listings
510
+ * Example #5: Then I should see job postings
385
511
  *
386
512
  */
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
- );
513
+ Then(/^(I |we )*should( not)* see (?:Odoo )?job (?:openings|listings|postings)$/, async function (pronounCase, notCase) {
514
+ await assertPresence(this.page.locator('a[href*="/jobs/"]').first(), notCase, 'a job opening link ("a[href*=\\"/jobs/\\"]")', {
515
+ hint: 'Only published job positions are listed; publish one from the Recruitment app or drop the scenario.',
516
+ });
393
517
  });
394
518
 
395
519
  // ---------------------------------------------------------------------------
@@ -397,106 +521,93 @@ Then(/^(I |we )*should see Odoo job (?:openings|listings|postings)$/, async func
397
521
  // ---------------------------------------------------------------------------
398
522
 
399
523
  /**
400
- * Navigate to Odoo's core "Contact us" page at `/contactus`.
524
+ * Navigate to the core "Contact us" page at `/contactus`.
401
525
  *
402
526
  * Example #1: Given I am on the Odoo contact us page
403
- * Example #2: Given we are on the Odoo contact page
527
+ * Example #2: Given we are on the contact page
404
528
  * 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
529
+ * Example #4: And on the contact-us page
530
+ * Example #5: Given I am on the contact page
407
531
  *
408
532
  */
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);
533
+ Given(/^(I am |we are )*on the (?:Odoo )?(?:"contact us"|contact[ -]?us|contact) page$/, async function (pronounCase) {
534
+ await navigate(this, '/contactus');
414
535
  });
415
536
 
416
537
  /**
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.
538
+ * Assert the "Contact us" form is, or is not, visible. Matches
539
+ * `#contactus_form`, the `s_website_form` snippet instance the Website app
540
+ * renders on the default contact-us page.
420
541
  *
421
542
  * Example #1: Then I should see the Odoo contact us form
422
- * Example #2: And I should see the Odoo contact form
543
+ * Example #2: And I should see the contact form
423
544
  * 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
545
+ * Example #4: And should not see the contact form
546
+ * Example #5: Then I should see the "contact us" form
426
547
  *
427
548
  */
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
- );
549
+ Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:"contact us"|contact[ -]?us|contact) form$/, async function (pronounCase, notCase) {
550
+ await assertPresence(this.page.locator('#contactus_form'), notCase, 'the contact-us form ("#contactus_form")', {
551
+ hint: 'The default /contactus page renders the form snippet with this id; a redesigned page may have dropped or renamed it.',
552
+ });
434
553
  });
435
554
 
436
555
  // ---------------------------------------------------------------------------
437
- // /web/login — authentication
556
+ // /web/login: authentication
438
557
  // ---------------------------------------------------------------------------
439
558
 
440
559
  /**
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.
560
+ * Navigate to the core login screen at `/web/login`. This template is shared
561
+ * by every Odoo installation (Community and Enterprise alike): it ships in
562
+ * the base "web" module, not the Website builder.
444
563
  *
445
564
  * 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
565
+ * Example #2: Given we are on the login page
566
+ * Example #3: Given I am on the sign in page
567
+ * Example #4: And on the Odoo sign-in page
568
+ * Example #5: Given I am on the web/login page
450
569
  *
451
570
  */
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);
571
+ Given(/^(I am |we are )*on the (?:Odoo )?(?:sign[- ]?in|web\/login|login) page$/, async function (pronounCase) {
572
+ await navigate(this, '/web/login');
457
573
  });
458
574
 
459
575
  /**
460
- * Assert Odoo's core login form is visible: the form posting to
576
+ * Assert the core login form is, or is not, visible: the form posting to
461
577
  * `/web/login`, with its `#login` and `#password` fields (`addons/web`'s
462
- * `webclient_templates.xml`, unchanged across Odoo versions).
578
+ * `webclient_templates.xml`, unchanged across Odoo versions). Each part is
579
+ * checked separately so a failure names which one is missing.
463
580
  *
464
581
  * 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
582
+ * Example #2: And I should see the sign in form
583
+ * Example #3: Then we should see the login form
584
+ * Example #4: And should not see the login form
468
585
  * Example #5: Then I should see the Odoo sign-in form
469
586
  *
470
587
  */
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
- );
588
+ Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:sign[- ]?in|login) form$/, async function (pronounCase, notCase) {
589
+ const hint = 'The core login screen is /web/login; an authenticated session is redirected away from it to /web.';
590
+ await assertPresence(this.page.locator('form[action="/web/login"]'), notCase, 'the login form ("form[action=\\"/web/login\\"]")', { hint });
591
+ if (notCase) return;
592
+ await assertPresence(this.page.locator('#login'), undefined, 'the login/email field ("#login")', { hint, timeout: 5000 });
593
+ await assertPresence(this.page.locator('#password'), undefined, 'the password field ("#password")', { hint, timeout: 5000 });
485
594
  });
486
595
 
487
596
  /**
488
- * Fill and submit Odoo's core login form with the given username/email and
489
- * password (`#login`, `#password`, the form's submit button).
597
+ * Fill and submit the core login form with the given username/email and
598
+ * password (`#login`, `#password`, the form's submit button), for scenarios
599
+ * where the credentials are the subject. Requires the login screen to be
600
+ * open already.
490
601
  *
491
602
  * 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"
603
+ * Example #2: When we log in as "demo@example.com" with password "p@ssw0rd"
604
+ * Example #3: And I sign in to Odoo as "admin" with password "secret"
605
+ * Example #4: When we sign-in as "reviewer" with password "hunter2"
606
+ * Example #5: And log in as "portal_user" with password "letmein"
496
607
  *
497
608
  */
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);
609
+ When(/^(I |we )*(?:log in|sign[- ]?in)(?: to Odoo)? as "([^"]*)?" with password "([^"]*)?"$/, async function (pronounCase, username, password) {
610
+ await submitLogin(this, username, password);
500
611
  });
501
612
 
502
613
  /**
@@ -516,16 +627,24 @@ Given(/^(I am |we are )*a logged in user with( the)*( username)* "([^"]*)?"( use
516
627
  const users = (this.parameters && this.parameters.users) || {};
517
628
  const entry = users[userKey];
518
629
  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)'}.`);
630
+ throw friendly({
631
+ message: `User "${userKey}" is not configured.`,
632
+ hint: `Add it to worldParameters.users in cucumber.js. Known users: ${Object.keys(users).join(', ') || '(none)'}.`,
633
+ });
520
634
  }
521
635
  const username = entry.username || userKey;
522
636
  const password = typeof entry === 'string' ? entry : entry.password;
523
637
  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.`);
638
+ throw friendly({
639
+ message: `User "${userKey}" has no password configured.`,
640
+ hint: `Set worldParameters.users.${userKey}.password in cucumber.js, ideally from an environment variable.`,
641
+ });
525
642
  }
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);
643
+ // Start from a fresh session: an authenticated visitor is redirected away
644
+ // from /web/login and would never see the form.
645
+ await this.context.clearCookies();
646
+ await navigate(this, '/web/login');
647
+ await submitLogin(this, username, password);
529
648
  });
530
649
 
531
650
  /**
@@ -549,28 +668,36 @@ Given(/^(I am |we are )*not logged in$/, async function (pronounCase) {
549
668
  try { sessionStorage.clear(); } catch { /* storage may be unavailable */ }
550
669
  });
551
670
  } 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 });
671
+ await navigate(this, '/web/login');
672
+ await assertPresence(this.page.locator('form[action="/web/login"]'), undefined, 'the login form (the session should be anonymous)', {
673
+ timeout: 10000,
674
+ hint: 'An authenticated session is redirected from /web/login to /web; clearing the cookies should have ended it.',
675
+ });
554
676
  });
555
677
 
556
678
  /**
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.
679
+ * Assert the login screen is, or is not, showing an authentication error,
680
+ * optionally containing a text. Matches the `.alert-danger` message the login
681
+ * controller renders on invalid credentials.
560
682
  *
561
683
  * 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
684
+ * Example #2: And I should see a sign in error
685
+ * Example #3: Then we should see a login error "Wrong login/password"
686
+ * Example #4: And should not see a login error
687
+ * Example #5: Then I should see an authentication error message
566
688
  *
567
689
  */
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
- );
690
+ Then(/^(I |we )*should( not)* see an? (?:Odoo )?(?:login|sign[- ]?in|authentication) error(?: message)?(?: "([^"]*)")?$/, async function (pronounCase, notCase, expectedText) {
691
+ const error = this.page.locator('form[action="/web/login"] .alert-danger, .alert-danger').first();
692
+ const hint = 'The core login screen renders the notice inside the form after a wrong login or password.';
693
+ if (notCase && !expectedText) {
694
+ await assertPresence(error, notCase, 'a login error message (".alert-danger")', { hint });
695
+ return;
696
+ }
697
+ await assertPresence(error, undefined, 'a login error message (".alert-danger")', { hint });
698
+ if (expectedText) {
699
+ await assertText(error, expectedText, notCase, 'the login error message', { hint });
700
+ }
574
701
  });
575
702
 
576
703
  // ---------------------------------------------------------------------------
@@ -578,21 +705,20 @@ Then(/^(I |we )*should see an Odoo (?:login|sign-?in|authentication) error$/, as
578
705
  // ---------------------------------------------------------------------------
579
706
 
580
707
  /**
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
708
+ * Assert the current page is, or is not, the core 404 template, by checking
709
+ * the page heading for "404" (rendered by `addons/http_routing`'s
583
710
  * `http_routing.404` template on every Odoo site).
584
711
  *
585
712
  * Example #1: Then I should be on the Odoo 404 page
586
- * Example #2: And I should be on the Odoo "not found" page
713
+ * Example #2: And I should be on the not found page
587
714
  * Example #3: Then we should be on the Odoo 404 page
588
- * Example #4: And should be on the Odoo 404 page
715
+ * Example #4: And should not be on the 404 page
589
716
  * Example #5: Then I should land on the Odoo 404 page
590
717
  *
591
718
  */
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
- );
719
+ Then(/^(I |we )*should( not)* (?:be on|land on) the (?:Odoo )?(?:404|not found|page not found) page$/, async function (pronounCase, notCase) {
720
+ await assertPresence(this.page.locator('h1', { hasText: '404' }).first(), notCase, 'the 404 template (an <h1> containing "404")', {
721
+ attached: true,
722
+ hint: 'A site with a custom 404 page keeps the status code but may drop the "404" heading; assert the status code with the response step instead.',
723
+ });
598
724
  });