@webship-js/odoo-steps 2.0.0-alpha1 → 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
@@ -15,10 +15,13 @@ It is a **plugin for webship-js** — it does not run standalone. It ships one f
15
15
  - Jobs listing
16
16
  - `/contactus` form
17
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
18
19
  - 404 page
19
20
 
20
21
  See [tests/step-definitions/odoo.steps.js](tests/step-definitions/odoo.steps.js) for the full list of steps with Gherkin examples.
21
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
+
22
25
  ## Requirements
23
26
 
24
27
  - Node.js >= 20
@@ -43,6 +46,14 @@ module.exports = {
43
46
  paths: ['tests/features/**/*.feature'],
44
47
  worldParameters: {
45
48
  launchUrl: process.env.LAUNCH_URL || 'https://your-odoo-site.example',
49
+ // Named accounts for `Given I am a logged in user with the "<key>" user`.
50
+ // Keeps passwords out of feature files; override per environment.
51
+ users: {
52
+ admin: {
53
+ username: process.env.ODOO_ADMIN_USER || 'admin',
54
+ password: process.env.ODOO_ADMIN_PASSWORD || 'admin',
55
+ },
56
+ },
46
57
  // ...
47
58
  },
48
59
  },
@@ -91,6 +102,7 @@ Other scripts:
91
102
  | --- | --- |
92
103
  | `npm test` | Full suite, pretty output |
93
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) |
94
106
  | `npm run test:headed` | Headed browser |
95
107
  | `npm run test:chromium` / `test:firefox` / `test:webkit` | Pick a browser |
96
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webship-js/odoo-steps",
3
- "version": "2.0.0-alpha1",
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,90 +3,189 @@
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
+ }
96
+
97
+ // Fill and submit Odoo's login form. The submit is dispatched from inside the
98
+ // page rather than with a pointer click: a fixed overlay (a cookie bar, a chat
99
+ // widget) can cover the button and make a real click wait out its
100
+ // actionability timeout, while the form's own submit cannot be covered.
101
+ // A successful login redirects to the backend webclient (/web), which keeps a
102
+ // permanent longpoll connection open (Discuss/bus), so networkidle never
103
+ // fires there and the shared settle helpers would not settle: wait for the
104
+ // navigation and DOM content only. An invalid login stays on /web/login
105
+ // without a full navigation, so this resolves quickly either way.
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
+ }
118
+ await fillField(page, 'login', username);
119
+ await fillField(page, 'password', password);
120
+ await Promise.all([
121
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}),
122
+ page.evaluate(() => {
123
+ const form = document.querySelector('form[action="/web/login"]');
124
+ const submit = form && form.querySelector('button[type="submit"]');
125
+ if (submit) { submit.click(); return; }
126
+ if (form) form.submit();
127
+ }),
128
+ ]);
129
+ await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
130
+ }
27
131
 
28
132
  // ---------------------------------------------------------------------------
29
133
  // Core layout: #wrapwrap, top menu, footer
30
134
  // ---------------------------------------------------------------------------
31
135
 
32
136
  /**
33
- * Assert the current page was rendered by the Odoo Website builder, i.e. it
34
- * has the `#wrapwrap` element every Odoo Website page wraps its header/
35
- * main/footer in, regardless of which front-end app (Website, eCommerce,
36
- * 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.
37
141
  *
38
142
  * Example #1: Then I should be on an Odoo website page
39
- * Example #2: And I should be on an Odoo website page
143
+ * Example #2: And I should be on a website page
40
144
  * Example #3: Then we should be on an Odoo website page
41
- * Example #4: And should be on an Odoo website page
42
- * 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
43
147
  *
44
148
  */
45
- Then(/^(I |we )*should be on an? Odoo website page$/, async function (pronounCase) {
46
- const wrapwrap = this.page.locator('#wrapwrap');
47
- await assert.doesNotReject(
48
- () => wrapwrap.waitFor({ state: 'attached', timeout: 45000 }),
49
- 'Expected the Odoo Website wrapper ("#wrapwrap") to be present'
50
- );
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
+ });
51
154
  });
52
155
 
53
156
  /**
54
- * Assert the Odoo Website top menu (main navigation) is visible. Matches
55
- * `#top_menu`, the `<ul>` every Odoo Website header renders its menu items
56
- * 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.
57
160
  *
58
161
  * Example #1: Then I should see the Odoo top menu
59
- * Example #2: And I should see the Odoo main menu
162
+ * Example #2: And I should see the main menu
60
163
  * Example #3: Then we should see the Odoo menu
61
- * Example #4: And should see the Odoo top menu
62
- * 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
63
166
  *
64
167
  */
65
- Then(/^(I |we )*should see the Odoo (?:top |main )?(?:menu|navigation menu)$/, async function (pronounCase) {
66
- const menu = this.page.locator('#top_menu');
67
- await assert.doesNotReject(
68
- () => menu.waitFor({ state: 'visible', timeout: 45000 }),
69
- 'Expected the Odoo Website top menu ("#top_menu") to be visible'
70
- );
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
+ });
71
172
  });
72
173
 
73
174
  /**
74
- * Assert the Odoo Website footer is visible. Matches `footer#bottom`, the
75
- * 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.
76
177
  *
77
178
  * Example #1: Then I should see the Odoo footer
78
- * Example #2: And I should see the Odoo site footer
179
+ * Example #2: And I should see the site footer
79
180
  * Example #3: Then we should see the Odoo page footer
80
- * Example #4: And should see the Odoo footer
81
- * 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
82
183
  *
83
184
  */
84
- Then(/^(I |we )*should see the Odoo (?:site |page )?footer$/, async function (pronounCase) {
85
- const footer = this.page.locator('footer#bottom, #footer').first();
86
- await assert.doesNotReject(
87
- () => footer.waitFor({ state: 'visible', timeout: 45000 }),
88
- 'Expected the Odoo Website footer ("footer#bottom") to be visible'
89
- );
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
+ });
90
189
  });
91
190
 
92
191
  // ---------------------------------------------------------------------------
@@ -94,64 +193,65 @@ Then(/^(I |we )*should see the Odoo (?:site |page )?footer$/, async function (pr
94
193
  // ---------------------------------------------------------------------------
95
194
 
96
195
  /**
97
- * Assert the current page is the Odoo Blog index/listing template. Matches
98
- * `#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.
99
198
  *
100
199
  * Example #1: Then I should be on the Odoo blog page
101
- * Example #2: And I should be on the Odoo blog index
200
+ * Example #2: And I should be on the blog index
102
201
  * Example #3: Then we should be on the Odoo blog page
103
- * Example #4: And should be on the Odoo blog index
104
- * 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
105
204
  *
106
205
  */
107
- Then(/^(I |we )*should be on the Odoo blog (?:index|page|listing page)$/, async function (pronounCase) {
108
- const index = this.page.locator('#o_wblog_index_content');
109
- await assert.doesNotReject(
110
- () => index.waitFor({ state: 'attached', timeout: 45000 }),
111
- 'Expected the Odoo Blog index template ("#o_wblog_index_content") to be present'
112
- );
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
+ });
113
211
  });
114
212
 
115
213
  /**
116
- * Assert the current page is a single Odoo Blog post template. Matches
117
- * `.o_wblog_post_title`, an element only the single-post view renders (the
118
- * 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).
119
217
  *
120
218
  * Example #1: Then I should be on an Odoo blog post page
121
- * Example #2: And I should be on a Odoo blog post page
122
- * Example #3: Then we should be on an Odoo blog post page
123
- * Example #4: And should be on an Odoo blog post page
124
- * 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
125
223
  *
126
224
  */
127
- 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) {
128
226
  // .first(): the single-post template can also render a "related posts"
129
227
  // teaser section further down the page reusing the same title class.
130
- const title = this.page.locator('.o_wblog_post_title').first();
131
- await assert.doesNotReject(
132
- () => title.waitFor({ state: 'attached', timeout: 45000 }),
133
- 'Expected the Odoo single blog post template (".o_wblog_post_title") to be present'
134
- );
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
+ });
135
232
  });
136
233
 
137
234
  /**
138
- * Assert the current Odoo blog post's title matches the given text. Reads
139
- * `.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.
140
238
  *
141
239
  * Example #1: Then I should see the Odoo blog post titled "Customer Reviews"
142
- * 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"
143
241
  * Example #3: Then we should see the Odoo blog post titled "Release Notes"
144
- * Example #4: And should see the Odoo blog post titled "Product Update"
145
- * 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"
146
244
  *
147
245
  */
148
- 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) {
149
247
  const title = this.page.locator('.o_wblog_post_title').first();
150
- const text = (await title.textContent()) || '';
151
- assert.ok(
152
- text.includes(expectedTitle),
153
- `Expected the Odoo blog post title to contain "${expectedTitle}" but found "${text.trim()}"`
154
- );
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
+ });
155
255
  });
156
256
 
157
257
  // ---------------------------------------------------------------------------
@@ -159,147 +259,198 @@ Then(/^(I |we )*should see the Odoo blog post titled "([^"]*)?"$/, async functio
159
259
  // ---------------------------------------------------------------------------
160
260
 
161
261
  /**
162
- * 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.
163
263
  * Matches `#products_grid`, the container the "website_sale" module renders
164
264
  * the product listing into.
165
265
  *
166
266
  * Example #1: Then I should be on the Odoo shop page
167
- * Example #2: And I should be on the Odoo shop index
267
+ * Example #2: And I should be on the shop index
168
268
  * Example #3: Then we should be on the Odoo shop page
169
- * Example #4: And should be on the Odoo shop index
170
- * 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
171
271
  *
172
272
  */
173
- Then(/^(I |we )*should be on the Odoo shop (?:page|index|catalog page)$/, async function (pronounCase) {
174
- const grid = this.page.locator('#products_grid');
175
- await assert.doesNotReject(
176
- () => grid.waitFor({ state: 'attached', timeout: 45000 }),
177
- 'Expected the Odoo shop product grid ("#products_grid") to be present'
178
- );
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
+ });
179
278
  });
180
279
 
181
280
  /**
182
- * 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
183
282
  * `.o_wsale_product_page`, rendered by "website_sale" for every product
184
283
  * detail page.
185
284
  *
186
285
  * Example #1: Then I should be on an Odoo product page
187
- * Example #2: And I should be on a Odoo shop product page
188
- * Example #3: Then we should be on an Odoo product page
189
- * Example #4: And should be on an Odoo shop product page
190
- * 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
290
+ *
291
+ */
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
+ });
297
+ });
298
+
299
+ /**
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
191
309
  *
192
310
  */
193
- Then(/^(I |we )*should be on an? Odoo (?:shop )?product(?: detail)? page$/, async function (pronounCase) {
194
- const product = this.page.locator('.o_wsale_product_page');
195
- await assert.doesNotReject(
196
- () => product.waitFor({ state: 'attached', timeout: 45000 }),
197
- 'Expected the Odoo product page (".o_wsale_product_page") to be present'
198
- );
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);
199
333
  });
200
334
 
201
335
  /**
202
- * Submit the Odoo shop's search form with the given term. Fills the
203
- * `input[name="search"]` scoped to `.o_wsale_products_searchbar_form` — the
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
204
338
  * shop listing's own search box, as opposed to the (hidden until toggled)
205
- * 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
206
340
  * Enter.
207
341
  *
208
342
  * Example #1: When I search the Odoo shop for "mug"
209
- * Example #2: When we search the Odoo shop for "polo shirt"
210
- * Example #3: When I search the Odoo shop for "t-shirt"
211
- * Example #4: When we search the Odoo shop for "backpack"
212
- * 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"
213
347
  *
214
348
  */
215
- When(/^(I |we )*search the Odoo shop for "([^"]*)?"$/, async function (pronounCase, term) {
216
- const defaultTime = this.minWaitTime.page || 3000;
217
- const searchField = this.page
218
- .locator('.o_wsale_products_searchbar_form input[name="search"]')
219
- .first();
220
- 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
+ }
221
361
  await searchField.fill(term);
222
362
  await searchField.press('Enter');
223
- await waitForPageLoad(this.page, defaultTime);
363
+ await waitForPageLoad(this.page, budget(this));
224
364
  });
225
365
 
226
366
  /**
227
- * Assert the Odoo shop search returned results for the given term: the
228
- * 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.
229
369
  *
230
370
  * Example #1: Then I should see Odoo shop search results for "mug"
231
- * 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"
232
372
  * Example #3: Then we should see Odoo shop search results for "t-shirt"
233
- * Example #4: And should see Odoo shop search results for "backpack"
234
- * 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"
235
375
  *
236
376
  */
237
- Then(/^(I |we )*should see Odoo shop search results for "([^"]*)?"$/, async function (pronounCase, term) {
238
- const grid = this.page.locator('#products_grid');
239
- await assert.doesNotReject(
240
- () => grid.waitFor({ state: 'attached', timeout: 45000 }),
241
- 'Expected the Odoo shop product grid ("#products_grid") to be present'
242
- );
243
- const currentUrl = this.page.url();
244
- const encoded = encodeURIComponent(term).replace(/%20/g, '+');
245
- assert.ok(
246
- currentUrl.includes(`search=${encoded}`) || currentUrl.includes(`search=${encodeURIComponent(term)}`),
247
- `Expected the shop search URL to contain the term "${term}" but URL was: ${currentUrl}`
248
- );
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
+ }
249
395
  });
250
396
 
251
397
  /**
252
- * Add the current Odoo product page's product to the cart. Clicks
253
- * `#add_to_cart`, the "website_sale" add-to-cart control every product
254
- * page renders. When the "sale_product_configurator" module is installed
255
- * (a website_sale dependency as of Odoo 17), this opens an "Added to your
256
- * cart" modal instead of updating the cart directly — in that case this
257
- * step dismisses it via its "Continue Shopping" action, which keeps the
258
- * 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.
259
405
  *
260
406
  * Example #1: When I add the product to the Odoo cart
261
- * Example #2: When we add this product to the Odoo cart
262
- * Example #3: When I add the current product to the Odoo cart
263
- * Example #4: When we add the product to the Odoo cart
264
- * 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
265
411
  *
266
412
  */
267
- When(/^(I |we )*add (?:the |this )?(?:current )?product to the Odoo cart$/, async function (pronounCase) {
268
- const defaultTime = this.minWaitTime.page || 3000;
413
+ When(/^(I |we )*add (?:the |this )?(?:current )?product to the (?:Odoo )?cart$/, async function (pronounCase) {
269
414
  const addToCart = this.page.locator('#add_to_cart').first();
270
- await addToCart.waitFor({ state: 'visible', timeout: 45000 });
271
- await addToCart.click();
272
- 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();
273
427
  if (await continueShopping.isVisible({ timeout: 5000 }).catch(() => false)) {
274
428
  await continueShopping.click();
275
429
  }
276
- await waitForPageLoad(this.page, defaultTime);
430
+ await waitForPageLoad(this.page, budget(this));
277
431
  });
278
432
 
279
433
  /**
280
- * 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
281
435
  * `.my_cart_quantity`, the header cart-icon badge "website_sale" updates
282
- * whenever the session cart changes.
436
+ * whenever the session cart changes, and waits for it to read that number.
283
437
  *
284
438
  * Example #1: Then I should see "1" item in the Odoo cart
285
- * Example #2: And I should see "2" items in the Odoo cart
439
+ * Example #2: And I should see "2" items in the cart
286
440
  * Example #3: Then we should see "1" item in the Odoo cart
287
- * Example #4: And should see "3" items in the Odoo cart
288
- * 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
289
443
  *
290
444
  */
291
- 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) {
292
446
  const badge = this.page.locator('.my_cart_quantity').first();
293
- await assert.doesNotReject(
294
- () => badge.waitFor({ state: 'visible', timeout: 45000 }),
295
- 'Expected the Odoo cart quantity badge (".my_cart_quantity") to be visible'
296
- );
297
- const text = ((await badge.textContent()) || '').trim();
298
- assert.strictEqual(
299
- text,
300
- String(expectedCount),
301
- `Expected the Odoo cart quantity badge to read "${expectedCount}" but found "${text}"`
302
- );
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
+ });
303
454
  });
304
455
 
305
456
  // ---------------------------------------------------------------------------
@@ -307,22 +458,21 @@ Then(/^(I |we )*should see "([^"]*)?" items? in the Odoo cart$/, async function
307
458
  // ---------------------------------------------------------------------------
308
459
 
309
460
  /**
310
- * 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.
311
462
  * Matches `.o_wevent_index`, rendered by the Events app's event-list view.
312
463
  *
313
464
  * Example #1: Then I should be on the Odoo events page
314
- * Example #2: And I should be on the Odoo event page
465
+ * Example #2: And I should be on the event page
315
466
  * Example #3: Then we should be on the Odoo events page
316
- * Example #4: And should be on the Odoo events index
317
- * 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
318
469
  *
319
470
  */
320
- Then(/^(I |we )*should be on the Odoo events? (?:page|index|listing page)$/, async function (pronounCase) {
321
- const index = this.page.locator('.o_wevent_index, .o_wevent_events_list').first();
322
- await assert.doesNotReject(
323
- () => index.waitFor({ state: 'attached', timeout: 45000 }),
324
- 'Expected the Odoo Events index template (".o_wevent_index") to be present'
325
- );
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
+ });
326
476
  });
327
477
 
328
478
  // ---------------------------------------------------------------------------
@@ -330,42 +480,40 @@ Then(/^(I |we )*should be on the Odoo events? (?:page|index|listing page)$/, asy
330
480
  // ---------------------------------------------------------------------------
331
481
 
332
482
  /**
333
- * Assert the current page is the Odoo Jobs (recruitment) listing template.
334
- * 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.
335
486
  *
336
487
  * Example #1: Then I should be on the Odoo jobs page
337
- * Example #2: And I should be on the Odoo careers page
488
+ * Example #2: And I should be on the careers page
338
489
  * Example #3: Then we should be on the Odoo jobs page
339
- * Example #4: And should be on the Odoo jobs listing page
340
- * 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
341
492
  *
342
493
  */
343
- Then(/^(I |we )*should be on the Odoo (?:jobs|careers|recruitment) (?:page|listing page)$/, async function (pronounCase) {
344
- const jobs = this.page.locator('#jobs_grid');
345
- await assert.doesNotReject(
346
- () => jobs.waitFor({ state: 'attached', timeout: 45000 }),
347
- 'Expected the Odoo Jobs listing template ("#jobs_grid") to be present'
348
- );
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
+ });
349
499
  });
350
500
 
351
501
  /**
352
- * Assert at least one job opening link is visible on the Odoo Jobs page.
353
- * Matches links whose `href` contains `/jobs/`, the URL pattern every job
354
- * 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.
355
505
  *
356
506
  * Example #1: Then I should see Odoo job openings
357
- * Example #2: And I should see Odoo job listings
507
+ * Example #2: And I should see job listings
358
508
  * Example #3: Then we should see Odoo job openings
359
- * Example #4: And should see Odoo job listings
360
- * 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
361
511
  *
362
512
  */
363
- Then(/^(I |we )*should see Odoo job (?:openings|listings|postings)$/, async function (pronounCase) {
364
- const jobLink = this.page.locator('a[href*="/jobs/"]').first();
365
- await assert.doesNotReject(
366
- () => jobLink.waitFor({ state: 'visible', timeout: 45000 }),
367
- 'Expected at least one Odoo job opening link ("a[href*=\\"/jobs/\\"]") to be visible'
368
- );
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
+ });
369
517
  });
370
518
 
371
519
  // ---------------------------------------------------------------------------
@@ -373,136 +521,183 @@ Then(/^(I |we )*should see Odoo job (?:openings|listings|postings)$/, async func
373
521
  // ---------------------------------------------------------------------------
374
522
 
375
523
  /**
376
- * Navigate to Odoo's core "Contact us" page at `/contactus`.
524
+ * Navigate to the core "Contact us" page at `/contactus`.
377
525
  *
378
526
  * Example #1: Given I am on the Odoo contact us page
379
- * Example #2: Given we are on the Odoo contact page
527
+ * Example #2: Given we are on the contact page
380
528
  * Example #3: Given I am on the Odoo "contact us" page
381
- * Example #4: Given we are on the Odoo contact-us page
382
- * 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
383
531
  *
384
532
  */
385
- Given(/^(I am |we are )*on the Odoo (?:"contact us"|contact[ -]?us|contact) page$/, async function (pronounCase) {
386
- const defaultTime = this.minWaitTime.page || 3000;
387
- await gotoUrl(this.page, this.launchUrl + '/contactus');
388
- await this.page.waitForSelector('body', { state: 'attached', timeout: 45000 });
389
- 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');
390
535
  });
391
536
 
392
537
  /**
393
- * Assert the Odoo "Contact us" form is visible. Matches `#contactus_form`,
394
- * the `s_website_form` snippet instance the Website app renders on the
395
- * 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.
396
541
  *
397
542
  * Example #1: Then I should see the Odoo contact us form
398
- * Example #2: And I should see the Odoo contact form
543
+ * Example #2: And I should see the contact form
399
544
  * Example #3: Then we should see the Odoo contact us form
400
- * Example #4: And should see the Odoo contact form
401
- * 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
402
547
  *
403
548
  */
404
- Then(/^(I |we )*should see the Odoo (?:"contact us"|contact[ -]?us|contact) form$/, async function (pronounCase) {
405
- const form = this.page.locator('#contactus_form');
406
- await assert.doesNotReject(
407
- () => form.waitFor({ state: 'visible', timeout: 45000 }),
408
- 'Expected the Odoo contact-us form ("#contactus_form") to be visible'
409
- );
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
+ });
410
553
  });
411
554
 
412
555
  // ---------------------------------------------------------------------------
413
- // /web/login — authentication
556
+ // /web/login: authentication
414
557
  // ---------------------------------------------------------------------------
415
558
 
416
559
  /**
417
- * Navigate to Odoo's core login screen at `/web/login`. This template is
418
- * shared by every Odoo installation (Community and Enterprise alike) —
419
- * 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.
420
563
  *
421
564
  * Example #1: Given I am on the Odoo login page
422
- * Example #2: Given we are on the Odoo login page
423
- * Example #3: Given I am on the Odoo sign in page
424
- * Example #4: Given we are on the Odoo sign-in page
425
- * 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
426
569
  *
427
570
  */
428
- Given(/^(I am |we are )*on the Odoo (?:sign-?in|"web\/login"|login) page$/, async function (pronounCase) {
429
- const defaultTime = this.minWaitTime.page || 3000;
430
- await gotoUrl(this.page, this.launchUrl + '/web/login');
431
- await this.page.waitForSelector('body', { state: 'attached', timeout: 45000 });
432
- 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');
433
573
  });
434
574
 
435
575
  /**
436
- * 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
437
577
  * `/web/login`, with its `#login` and `#password` fields (`addons/web`'s
438
- * `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.
439
580
  *
440
581
  * Example #1: Then I should see the Odoo login form
441
- * Example #2: And I should see the Odoo sign in form
442
- * Example #3: Then we should see the Odoo login form
443
- * 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
444
585
  * Example #5: Then I should see the Odoo sign-in form
445
586
  *
446
587
  */
447
- Then(/^(I |we )*should see the Odoo (?:sign-?in|login) form$/, async function (pronounCase) {
448
- const form = this.page.locator('form[action="/web/login"]');
449
- await assert.doesNotReject(
450
- () => form.waitFor({ state: 'visible', timeout: 45000 }),
451
- 'Expected the Odoo login form ("form[action=\\"/web/login\\"]") to be visible'
452
- );
453
- await assert.doesNotReject(
454
- () => this.page.locator('#login').waitFor({ state: 'visible', timeout: 5000 }),
455
- 'Expected the Odoo login/email field ("#login") to be visible'
456
- );
457
- await assert.doesNotReject(
458
- () => this.page.locator('#password').waitFor({ state: 'visible', timeout: 5000 }),
459
- 'Expected the Odoo password field ("#password") to be visible'
460
- );
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 });
461
594
  });
462
595
 
463
596
  /**
464
- * Fill and submit Odoo's core login form with the given username/email and
465
- * 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.
466
601
  *
467
602
  * Example #1: When I log in to Odoo as "admin" with password "secret"
468
- * Example #2: When we log in to Odoo as "demo@example.com" with password "p@ssw0rd"
469
- * Example #3: When I sign in to Odoo as "admin" with password "secret"
470
- * Example #4: When we sign in to Odoo as "reviewer" with password "hunter2"
471
- * 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"
472
607
  *
473
608
  */
474
- When(/^(I |we )*(?:log in|sign in) to Odoo as "([^"]*)?" with password "([^"]*)?"$/, async function (pronounCase, username, password) {
475
- await fillField(this.page, 'login', username);
476
- await fillField(this.page, 'password', password);
477
- await this.page.locator('form[action="/web/login"] button[type="submit"]').first().click();
478
- // A successful login redirects to the backend webclient (/web), which
479
- // keeps a permanent longpoll connection open (Discuss/bus) — so
480
- // networkidle never fires there and the shared waitForPageLoad()/
481
- // smartSettle() helper (built around network-idle + ajax-quiet
482
- // detection) will not settle. Wait for DOM content only instead; an
483
- // invalid login stays on /web/login without a full navigation, so this
484
- // resolves quickly either way.
485
- await this.page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
609
+ When(/^(I |we )*(?:log in|sign[- ]?in)(?: to Odoo)? as "([^"]*)?" with password "([^"]*)?"$/, async function (pronounCase, username, password) {
610
+ await submitLogin(this, username, password);
611
+ });
612
+
613
+ /**
614
+ * Log in as a user configured in cucumber.js `worldParameters.users`, so no
615
+ * password appears in a feature file. Each key names one account:
616
+ * `{ admin: { username: 'admin', password: process.env.ODOO_ADMIN_PASSWORD } }`.
617
+ * The login screen is opened first, so this step works from any page.
618
+ *
619
+ * Example #1: Given I am a logged in user with the "admin" user
620
+ * Example #2: Given I am a logged in user with the username "admin"
621
+ * Example #3: Given we are a logged in user with "portal"
622
+ * Example #4: And a logged in user with the "admin" user
623
+ * Example #5: Given I am a logged in user with the username "demo" user
624
+ *
625
+ */
626
+ Given(/^(I am |we are )*a logged in user with( the)*( username)* "([^"]*)?"( user)*$/, async function (pronounCase, theCase, usernameCase, userKey, userCase) {
627
+ const users = (this.parameters && this.parameters.users) || {};
628
+ const entry = users[userKey];
629
+ if (!entry) {
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
+ });
634
+ }
635
+ const username = entry.username || userKey;
636
+ const password = typeof entry === 'string' ? entry : entry.password;
637
+ if (!password) {
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
+ });
642
+ }
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);
486
648
  });
487
649
 
488
650
  /**
489
- * Assert the Odoo login screen is showing an authentication error. Matches
490
- * the `.alert-danger` message Odoo's login controller renders on invalid
491
- * credentials.
651
+ * End any authenticated session and land on the login screen as a visitor:
652
+ * cookies and web storage are cleared, `/web/login` is opened and the core
653
+ * login form is confirmed. Use it at the start of a scenario that must run
654
+ * anonymously after an earlier one logged in.
655
+ *
656
+ * Example #1: Given I am not logged in
657
+ * Example #2: Given we are not logged in
658
+ * Example #3: And I am not logged in
659
+ * Example #4: Given not logged in
660
+ * Example #5: But I am not logged in
661
+ *
662
+ */
663
+ Given(/^(I am |we are )*not logged in$/, async function (pronounCase) {
664
+ await this.context.clearCookies();
665
+ try {
666
+ await this.page.evaluate(() => {
667
+ try { localStorage.clear(); } catch { /* storage may be unavailable */ }
668
+ try { sessionStorage.clear(); } catch { /* storage may be unavailable */ }
669
+ });
670
+ } catch { /* the page may not be on a real URL yet */ }
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
+ });
676
+ });
677
+
678
+ /**
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.
492
682
  *
493
683
  * Example #1: Then I should see an Odoo login error
494
- * Example #2: And I should see an Odoo sign in error
495
- * Example #3: Then we should see an Odoo login error
496
- * Example #4: And should see an Odoo login error
497
- * 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
498
688
  *
499
689
  */
500
- Then(/^(I |we )*should see an Odoo (?:login|sign-?in|authentication) error$/, async function (pronounCase) {
501
- const error = this.page.locator('.alert-danger');
502
- await assert.doesNotReject(
503
- () => error.waitFor({ state: 'visible', timeout: 45000 }),
504
- 'Expected an Odoo login error message (".alert-danger") to be visible'
505
- );
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
+ }
506
701
  });
507
702
 
508
703
  // ---------------------------------------------------------------------------
@@ -510,21 +705,20 @@ Then(/^(I |we )*should see an Odoo (?:login|sign-?in|authentication) error$/, as
510
705
  // ---------------------------------------------------------------------------
511
706
 
512
707
  /**
513
- * Assert the current page is Odoo's core 404 template, by checking the
514
- * 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
515
710
  * `http_routing.404` template on every Odoo site).
516
711
  *
517
712
  * Example #1: Then I should be on the Odoo 404 page
518
- * Example #2: And I should be on the Odoo "not found" page
713
+ * Example #2: And I should be on the not found page
519
714
  * Example #3: Then we should be on the Odoo 404 page
520
- * Example #4: And should be on the Odoo 404 page
715
+ * Example #4: And should not be on the 404 page
521
716
  * Example #5: Then I should land on the Odoo 404 page
522
717
  *
523
718
  */
524
- Then(/^(I |we )*should (?:be on|land on) the Odoo (?:404|"not found") page$/, async function (pronounCase) {
525
- const heading = this.page.locator('h1', { hasText: '404' }).first();
526
- await assert.doesNotReject(
527
- () => heading.waitFor({ state: 'attached', timeout: 45000 }),
528
- 'Expected the Odoo 404 template (an <h1> containing "404") to be present'
529
- );
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
+ });
530
724
  });