@webship-js/odoo-steps 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,133 +1,32 @@
1
1
  'use strict';
2
2
 
3
- // Odoo-specific Cucumber step definitions for webship-js. These steps
4
- // understand the markup the Odoo Website builder (and the eCommerce, Blog,
5
- // Events, Jobs and core web-login modules bundled with it) renders on every
6
- // Odoo site: the #wrapwrap/#top_menu layout, o_wblog_* blog templates,
7
- // o_wsale_* eCommerce templates, o_wevent_* events templates, o_jobs
8
- // recruitment templates, s_website_form snippet forms, and the core
9
- // /web/login screen. Scenarios assert against the main pages of an Odoo site
10
- // without hand-rolling CSS selectors in every feature file.
3
+ // Website steps for Odoo: the pages the Website app and its companions (Blog,
4
+ // eCommerce, Events, Jobs) serve to visitors. The steps know the markup these
5
+ // apps render: the `#wrapwrap` layout with its top menu and footer, the blog
6
+ // and shop templates, the events and jobs lists, the contact form and the 404
7
+ // page.
11
8
  //
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.
9
+ // The session steps are in odoo-session.steps.js, the web client steps in
10
+ // odoo-webclient.steps.js and the record steps in odoo-record.steps.js;
11
+ // odoo-helpers.js holds what they share.
17
12
  //
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 already
21
- // cover the need ("the response status code should be 200", "the path should
22
- // be "/web"").
13
+ // The steps work next to webship-js's built-in steps and do not repeat them:
14
+ // use those in a feature wherever they cover the need ("the response status
15
+ // code should be 200", "the path should be "/shop"").
23
16
 
24
17
  const { Given, When, Then } = require('@cucumber/cucumber');
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
- }
18
+ const { waitForPageLoad, friendly } = require('webship-js/tests/step-definitions/webship');
19
+ const {
20
+ WAIT_TIMEOUT,
21
+ SHORT_TIMEOUT,
22
+ budget,
23
+ settle,
24
+ navigate,
25
+ assertPresence,
26
+ assertText,
27
+ listTexts,
28
+ textPattern,
29
+ } = require('./odoo-helpers');
131
30
 
132
31
  // ---------------------------------------------------------------------------
133
32
  // Core layout: #wrapwrap, top menu, footer
@@ -139,14 +38,14 @@ async function submitLogin(world, username, password) {
139
38
  * header, main and footer in, regardless of which front-end app (Website,
140
39
  * eCommerce, Blog, Events, Jobs, ...) rendered the page.
141
40
  *
142
- * Example #1: Then I should be on an Odoo website page
143
- * Example #2: And I should be on a website page
144
- * Example #3: Then we should be on an Odoo website page
41
+ * Example #1: Then I should be on a website page
42
+ * Example #2: And we should not be on a website page
43
+ * Example #3: Then we should be on a website page
145
44
  * Example #4: And should not be on a website page
146
- * Example #5: Then I should not be on an Odoo website page
45
+ * Example #5: Then I should not be on a website page
147
46
  *
148
47
  */
149
- Then(/^(I |we )*should( not)* be on an? (?:Odoo )?website page$/, async function (pronounCase, notCase) {
48
+ Then(/^(I |we )*should( not)* be on a website page$/, async function (pronounCase, notCase) {
150
49
  await assertPresence(this.page.locator('#wrapwrap'), notCase, 'the Website wrapper ("#wrapwrap")', {
151
50
  attached: true,
152
51
  hint: 'Every page the Odoo Website builder renders carries #wrapwrap; the backend webclient (/web) and the login screen do not.',
@@ -158,14 +57,14 @@ Then(/^(I |we )*should( not)* be on an? (?:Odoo )?website page$/, async function
158
57
  * Matches `#top_menu`, the `<ul>` every Odoo Website header renders its menu
159
58
  * items into.
160
59
  *
161
- * Example #1: Then I should see the Odoo top menu
60
+ * Example #1: Then I should see the top menu
162
61
  * Example #2: And I should see the main menu
163
- * Example #3: Then we should see the Odoo menu
62
+ * Example #3: Then we should see the menu
164
63
  * Example #4: And should not see the top menu
165
64
  * Example #5: Then I should see the main navigation menu
166
65
  *
167
66
  */
168
- Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:top |main )?(?:menu|navigation menu)$/, async function (pronounCase, notCase) {
67
+ Then(/^(I |we )*should( not)* see the (?:top |main )?(?:menu|navigation menu)$/, async function (pronounCase, notCase) {
169
68
  await assertPresence(this.page.locator('#top_menu').first(), notCase, 'the Website top menu ("#top_menu")', {
170
69
  hint: 'The header renders #top_menu on every Website page; a header template without a menu, or a hidden header, leaves it out.',
171
70
  });
@@ -175,14 +74,14 @@ Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:top |main )?(?:menu|navigatio
175
74
  * Assert the Website footer is, or is not, visible. Matches `footer#bottom`,
176
75
  * the element every Odoo Website footer snippet renders into.
177
76
  *
178
- * Example #1: Then I should see the Odoo footer
77
+ * Example #1: Then I should see the footer
179
78
  * Example #2: And I should see the site footer
180
- * Example #3: Then we should see the Odoo page footer
79
+ * Example #3: Then we should see the page footer
181
80
  * Example #4: And should not see the footer
182
- * Example #5: Then I should not see the Odoo site footer
81
+ * Example #5: Then I should not see the site footer
183
82
  *
184
83
  */
185
- Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:site |page )?footer$/, async function (pronounCase, notCase) {
84
+ Then(/^(I |we )*should( not)* see the (?:site |page )?footer$/, async function (pronounCase, notCase) {
186
85
  await assertPresence(this.page.locator('footer#bottom, #footer').first(), notCase, 'the Website footer ("footer#bottom")', {
187
86
  hint: 'The footer can be hidden per page in the Website builder (page properties); check the page, not the theme.',
188
87
  });
@@ -196,14 +95,14 @@ Then(/^(I |we )*should( not)* see the (?:Odoo )?(?:site |page )?footer$/, async
196
95
  * Assert the current page is, or is not, the Blog index/listing template.
197
96
  * Matches `#o_wblog_index_content`, rendered by the Blog app's post-list view.
198
97
  *
199
- * Example #1: Then I should be on the Odoo blog page
98
+ * Example #1: Then I should be on the blog page
200
99
  * Example #2: And I should be on the blog index
201
- * Example #3: Then we should be on the Odoo blog page
100
+ * Example #3: Then we should be on the blog page
202
101
  * Example #4: And should not be on the blog index
203
102
  * Example #5: Then I should be on the blog listing page
204
103
  *
205
104
  */
206
- Then(/^(I |we )*should( not)* be on the (?:Odoo )?blog (?:index|page|listing page)$/, async function (pronounCase, notCase) {
105
+ Then(/^(I |we )*should( not)* be on the blog (?:index|page|listing page)$/, async function (pronounCase, notCase) {
207
106
  await assertPresence(this.page.locator('#o_wblog_index_content'), notCase, 'the Blog index template ("#o_wblog_index_content")', {
208
107
  attached: true,
209
108
  hint: 'The Blog index lives at /blog once the website_blog app is installed and at least one blog exists.',
@@ -215,14 +114,14 @@ Then(/^(I |we )*should( not)* be on the (?:Odoo )?blog (?:index|page|listing pag
215
114
  * Matches `.o_wblog_post_title`, an element only the single-post view renders
216
115
  * (the index view's post teaser cards do not include it).
217
116
  *
218
- * Example #1: Then I should be on an Odoo blog post page
117
+ * Example #1: Then I should be on an blog post page
219
118
  * 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
119
+ * Example #3: Then we should be on the single blog post page
221
120
  * Example #4: And should not be on a blog post page
222
121
  * Example #5: Then I should not be on the single blog post page
223
122
  *
224
123
  */
225
- Then(/^(I |we )*should( not)* be on (?:an?|the) (?:Odoo )?(?:single )?blog post page$/, async function (pronounCase, notCase) {
124
+ Then(/^(I |we )*should( not)* be on (?:an?|the) (?:single )?blog post page$/, async function (pronounCase, notCase) {
226
125
  // .first(): the single-post template can also render a "related posts"
227
126
  // teaser section further down the page reusing the same title class.
228
127
  await assertPresence(this.page.locator('.o_wblog_post_title').first(), notCase, 'the single blog post template (".o_wblog_post_title")', {
@@ -233,19 +132,20 @@ Then(/^(I |we )*should( not)* be on (?:an?|the) (?:Odoo )?(?:single )?blog post
233
132
 
234
133
  /**
235
134
  * 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.
135
+ * Reads `#o_wblog_post_name`, the heading of the single-post view (the
136
+ * subtitle under it is not part of the title), and waits for the text to be
137
+ * there.
238
138
  *
239
- * Example #1: Then I should see the Odoo blog post titled "Customer Reviews"
139
+ * Example #1: Then I should see the blog post titled "Customer Reviews"
240
140
  * Example #2: And I should see the blog post titled "Employee Testimonials"
241
- * Example #3: Then we should see the Odoo blog post titled "Release Notes"
141
+ * Example #3: Then we should see the blog post titled "Release Notes"
242
142
  * Example #4: And should not see the blog post titled "Product Update"
243
143
  * Example #5: Then I should see the blog post titled "Company News"
244
144
  *
245
145
  */
246
- Then(/^(I |we )*should( not)* see the (?:Odoo )?blog post titled "([^"]*)?"$/, async function (pronounCase, notCase, expectedTitle) {
247
- const title = this.page.locator('.o_wblog_post_title').first();
248
- await assertPresence(title, undefined, 'the blog post title (".o_wblog_post_title")', {
146
+ Then(/^(I |we )*should( not)* see the blog post titled "([^"]*)?"$/, async function (pronounCase, notCase, expectedTitle) {
147
+ const title = this.page.locator('#o_wblog_post_name, .o_wblog_post_title h1').first();
148
+ await assertPresence(title, undefined, 'the blog post title ("#o_wblog_post_name")', {
249
149
  attached: true,
250
150
  hint: 'Open a single blog post first.',
251
151
  });
@@ -263,14 +163,14 @@ Then(/^(I |we )*should( not)* see the (?:Odoo )?blog post titled "([^"]*)?"$/, a
263
163
  * Matches `#products_grid`, the container the "website_sale" module renders
264
164
  * the product listing into.
265
165
  *
266
- * Example #1: Then I should be on the Odoo shop page
166
+ * Example #1: Then I should be on the shop page
267
167
  * Example #2: And I should be on the shop index
268
- * Example #3: Then we should be on the Odoo shop page
168
+ * Example #3: Then we should be on the shop page
269
169
  * Example #4: And should not be on the shop index
270
170
  * Example #5: Then I should be on the shop catalog page
271
171
  *
272
172
  */
273
- Then(/^(I |we )*should( not)* be on the (?:Odoo )?shop (?:page|index|catalog page)$/, async function (pronounCase, notCase) {
173
+ Then(/^(I |we )*should( not)* be on the shop (?:page|index|catalog page)$/, async function (pronounCase, notCase) {
274
174
  await assertPresence(this.page.locator('#products_grid'), notCase, 'the shop product grid ("#products_grid")', {
275
175
  attached: true,
276
176
  hint: 'The shop lives at /shop once the website_sale app is installed and at least one product is published.',
@@ -282,14 +182,14 @@ Then(/^(I |we )*should( not)* be on the (?:Odoo )?shop (?:page|index|catalog pag
282
182
  * `.o_wsale_product_page`, rendered by "website_sale" for every product
283
183
  * detail page.
284
184
  *
285
- * Example #1: Then I should be on an Odoo product page
185
+ * Example #1: Then I should be on an product page
286
186
  * Example #2: And I should be on a shop product page
287
- * Example #3: Then we should be on the Odoo product detail page
187
+ * Example #3: Then we should be on the product detail page
288
188
  * Example #4: And should not be on a product page
289
189
  * Example #5: Then I should not be on the product detail page
290
190
  *
291
191
  */
292
- Then(/^(I |we )*should( not)* be on (?:an?|the) (?:Odoo )?(?:shop )?product(?: detail)? page$/, async function (pronounCase, notCase) {
192
+ Then(/^(I |we )*should( not)* be on (?:an?|the) (?:shop )?product(?: detail)? page$/, async function (pronounCase, notCase) {
293
193
  await assertPresence(this.page.locator('.o_wsale_product_page'), notCase, 'the product page (".o_wsale_product_page")', {
294
194
  attached: true,
295
195
  hint: 'Open a product from the shop grid first ("When I open the first product in the shop").',
@@ -301,14 +201,14 @@ Then(/^(I |we )*should( not)* be on (?:an?|the) (?:Odoo )?(?:shop )?product(?: d
301
201
  * the n-th. Reads the product link from `#products_grid` and navigates to it,
302
202
  * so no product slug appears in the feature file.
303
203
  *
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
204
+ * Example #1: When I open the first product in the shop
205
+ * Example #2: And I open the 4th item in the shop
306
206
  * Example #3: When we open the 2nd product in the shop
307
- * Example #4: And open the first item in the Odoo shop
207
+ * Example #4: And open the first item in the shop
308
208
  * Example #5: When I open the 3rd product in the shop
309
209
  *
310
210
  */
311
- When(/^(I |we )*open the (first|\d+(?:st|nd|rd|th)) (?:product|item) in the (?:Odoo )?shop$/, async function (pronounCase, position) {
211
+ When(/^(I |we )*open the (first|\d+(?:st|nd|rd|th)) (?:product|item) in the shop$/, async function (pronounCase, position) {
312
212
  const index = position === 'first' ? 0 : parseInt(position, 10) - 1;
313
213
  const links = this.page.locator('#products_grid .oe_product a[href*="/shop/"]');
314
214
  const link = links.nth(index);
@@ -339,14 +239,14 @@ When(/^(I |we )*open the (first|\d+(?:st|nd|rd|th)) (?:product|item) in the (?:O
339
239
  * search field "website_sale" also renders in the page header, and presses
340
240
  * Enter.
341
241
  *
342
- * Example #1: When I search the Odoo shop for "mug"
242
+ * Example #1: When I search the shop for "mug"
343
243
  * Example #2: When we search the shop for "polo shirt"
344
- * Example #3: And search the Odoo shop for "t-shirt"
244
+ * Example #3: And search the shop for "t-shirt"
345
245
  * Example #4: When we search the shop for "backpack"
346
246
  * Example #5: And I search the shop for "notebook"
347
247
  *
348
248
  */
349
- When(/^(I |we )*search the (?:Odoo )?shop for "([^"]*)?"$/, async function (pronounCase, term) {
249
+ When(/^(I |we )*search the shop for "([^"]*)?"$/, async function (pronounCase, term) {
350
250
  const searchField = this.page.locator('.o_wsale_products_searchbar_form input[name="search"]').first();
351
251
  try {
352
252
  await searchField.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
@@ -364,27 +264,29 @@ When(/^(I |we )*search the (?:Odoo )?shop for "([^"]*)?"$/, async function (pron
364
264
  });
365
265
 
366
266
  /**
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.
267
+ * Assert the shop search returned, or did not return, products for the given
268
+ * term: the URL's `search` parameter carries the term and the product grid
269
+ * lists at least one product (`#products_grid .oe_product`). With "not" the
270
+ * grid must list none.
369
271
  *
370
- * Example #1: Then I should see Odoo shop search results for "mug"
371
- * Example #2: And I should see shop search results for "polo shirt"
372
- * Example #3: Then we should see Odoo shop search results for "t-shirt"
373
- * Example #4: And should see shop search results for "backpack"
374
- * Example #5: Then I should see the shop search results for "notebook"
272
+ * Example #1: Then I should see shop search results for "desk"
273
+ * Example #2: And I should see shop search results for "office chair"
274
+ * Example #3: Then we should see shop search results for "cabinet"
275
+ * Example #4: And I should not see shop search results for "no-such-product"
276
+ * Example #5: Then I should see the shop search results for "lamp"
375
277
  *
376
278
  */
377
- Then(/^(I |we )*should see (?:the )?(?:Odoo )?shop search results for "([^"]*)?"$/, async function (pronounCase, term) {
279
+ Then(/^(I |we )*should( not)* see (?:the )?shop search results for "([^"]*)?"$/, async function (pronounCase, notCase, term) {
378
280
  await assertPresence(this.page.locator('#products_grid'), undefined, 'the shop product grid ("#products_grid")', {
379
281
  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.',
282
+ hint: 'A search that matches nothing still renders the grid, with a message in place of the products; a missing grid means the search left the shop.',
381
283
  });
382
284
  // A space reaches the URL as `+` or `%20` depending on whether the form or
383
285
  // a direct navigation submitted it.
384
286
  const encoded = encodeURIComponent(term);
385
287
  const carriesTerm = (url) => url.includes(`search=${encoded.replace(/%20/g, '+')}`) || url.includes(`search=${encoded}`);
386
288
  try {
387
- await this.page.waitForURL((url) => carriesTerm(String(url)), { timeout: budget(this) });
289
+ await this.page.waitForURL((url) => carriesTerm(String(url)), { timeout: SHORT_TIMEOUT });
388
290
  } catch (error) {
389
291
  throw friendly({
390
292
  message: `Expected the shop URL to carry the search term "${term}".`,
@@ -392,6 +294,37 @@ Then(/^(I |we )*should see (?:the )?(?:Odoo )?shop search results for "([^"]*)?"
392
294
  hint: 'Run "When I search the shop for ..." first, with the same term.',
393
295
  });
394
296
  }
297
+ const products = this.page.locator('#products_grid .oe_product');
298
+ await assertPresence(products.first(), notCase, `a product in the shop grid for the search "${term}"`, {
299
+ attached: true,
300
+ timeout: SHORT_TIMEOUT,
301
+ hint: notCase
302
+ ? `The grid lists ${await listTexts(this.page.locator('#products_grid .o_wsale_products_item_title'))}.`
303
+ : 'The search looks in the name and the description of the published products; a product that is not published is not listed.',
304
+ });
305
+ });
306
+
307
+ /**
308
+ * Assert the shop grid lists, or does not list, a product by its name
309
+ * (`#products_grid .o_wsale_products_item_title`).
310
+ *
311
+ * Example #1: Then I should see the "Customizable Desk" product in the shop
312
+ * Example #2: And I should see the "Office Chair" product in the shop
313
+ * Example #3: Then we should see the "Large Cabinet" product in the shop
314
+ * Example #4: And I should not see the "Office Lamp" product in the shop
315
+ * Example #5: Then we should not see the "Warranty" product in the shop
316
+ *
317
+ */
318
+ Then(/^(I |we )*should( not)* see the "([^"]*)" product in the shop$/, async function (pronounCase, notCase, name) {
319
+ await assertPresence(this.page.locator('#products_grid'), undefined, 'the shop product grid ("#products_grid")', {
320
+ attached: true,
321
+ hint: 'Open the shop page first.',
322
+ });
323
+ const titles = this.page.locator('#products_grid .o_wsale_products_item_title');
324
+ await assertPresence(titles.filter({ hasText: textPattern(name, { exact: true }) }).first(), notCase, `the "${name}" product in the shop grid`, {
325
+ timeout: SHORT_TIMEOUT,
326
+ hint: `The grid lists ${await listTexts(titles)}. The shop shows its products by pages and only the published ones.`,
327
+ });
395
328
  });
396
329
 
397
330
  /**
@@ -403,14 +336,14 @@ Then(/^(I |we )*should see (?:the )?(?:Odoo )?shop search results for "([^"]*)?"
403
336
  * "Continue Shopping" action, which keeps the item in the cart without
404
337
  * leaving the product page.
405
338
  *
406
- * Example #1: When I add the product to the Odoo cart
339
+ * Example #1: When I add the product to the cart
407
340
  * Example #2: When we add this product to the cart
408
- * Example #3: And add the current product to the Odoo cart
341
+ * Example #3: And add the current product to the cart
409
342
  * Example #4: When we add the product to the cart
410
343
  * Example #5: And I add this product to the cart
411
344
  *
412
345
  */
413
- When(/^(I |we )*add (?:the |this )?(?:current )?product to the (?:Odoo )?cart$/, async function (pronounCase) {
346
+ When(/^(I |we )*add (?:the |this )?(?:current )?product to the cart$/, async function (pronounCase) {
414
347
  const addToCart = this.page.locator('#add_to_cart').first();
415
348
  try {
416
349
  await addToCart.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
@@ -435,14 +368,14 @@ When(/^(I |we )*add (?:the |this )?(?:current )?product to the (?:Odoo )?cart$/,
435
368
  * `.my_cart_quantity`, the header cart-icon badge "website_sale" updates
436
369
  * whenever the session cart changes, and waits for it to read that number.
437
370
  *
438
- * Example #1: Then I should see "1" item in the Odoo cart
371
+ * Example #1: Then I should see "1" item in the cart
439
372
  * Example #2: And I should see "2" items in the cart
440
- * Example #3: Then we should see "1" item in the Odoo cart
373
+ * Example #3: Then we should see "1" item in the cart
441
374
  * Example #4: And should not see "3" items in the cart
442
375
  * Example #5: Then I should see "0" items in the cart
443
376
  *
444
377
  */
445
- Then(/^(I |we )*should( not)* see "([^"]*)?" items? in the (?:Odoo )?cart$/, async function (pronounCase, notCase, expectedCount) {
378
+ Then(/^(I |we )*should( not)* see "([^"]*)?" items? in the cart$/, async function (pronounCase, notCase, expectedCount) {
446
379
  const badge = this.page.locator('.my_cart_quantity').first();
447
380
  await assertPresence(badge, undefined, 'the cart quantity badge (".my_cart_quantity")', {
448
381
  hint: 'The badge sits on the header cart icon; a header without the cart link (an eCommerce option) has no badge.',
@@ -461,14 +394,14 @@ Then(/^(I |we )*should( not)* see "([^"]*)?" items? in the (?:Odoo )?cart$/, asy
461
394
  * Assert the current page is, or is not, the Events index/listing template.
462
395
  * Matches `.o_wevent_index`, rendered by the Events app's event-list view.
463
396
  *
464
- * Example #1: Then I should be on the Odoo events page
397
+ * Example #1: Then I should be on the events page
465
398
  * Example #2: And I should be on the event page
466
- * Example #3: Then we should be on the Odoo events page
399
+ * Example #3: Then we should be on the events page
467
400
  * Example #4: And should not be on the events index
468
401
  * Example #5: Then I should be on the events listing page
469
402
  *
470
403
  */
471
- Then(/^(I |we )*should( not)* be on the (?:Odoo )?events? (?:page|index|listing page)$/, async function (pronounCase, notCase) {
404
+ Then(/^(I |we )*should( not)* be on the events? (?:page|index|listing page)$/, async function (pronounCase, notCase) {
472
405
  await assertPresence(this.page.locator('.o_wevent_index, .o_wevent_events_list').first(), notCase, 'the Events index template (".o_wevent_index")', {
473
406
  attached: true,
474
407
  hint: 'The events list lives at /event once the website_event app is installed.',
@@ -484,14 +417,14 @@ Then(/^(I |we )*should( not)* be on the (?:Odoo )?events? (?:page|index|listing
484
417
  * template. Matches `#jobs_grid`, rendered by the Recruitment app's public job
485
418
  * board.
486
419
  *
487
- * Example #1: Then I should be on the Odoo jobs page
420
+ * Example #1: Then I should be on the jobs page
488
421
  * Example #2: And I should be on the careers page
489
- * Example #3: Then we should be on the Odoo jobs page
422
+ * Example #3: Then we should be on the jobs page
490
423
  * Example #4: And should not be on the jobs listing page
491
424
  * Example #5: Then I should be on the recruitment page
492
425
  *
493
426
  */
494
- Then(/^(I |we )*should( not)* be on the (?:Odoo )?(?:jobs|careers|recruitment) (?:page|listing page)$/, async function (pronounCase, notCase) {
427
+ Then(/^(I |we )*should( not)* be on the (?:jobs|careers|recruitment) (?:page|listing page)$/, async function (pronounCase, notCase) {
495
428
  await assertPresence(this.page.locator('#jobs_grid'), notCase, 'the Jobs listing template ("#jobs_grid")', {
496
429
  attached: true,
497
430
  hint: 'The job board lives at /jobs once the hr_recruitment app is installed with its website module.',
@@ -503,14 +436,14 @@ Then(/^(I |we )*should( not)* be on the (?:Odoo )?(?:jobs|careers|recruitment) (
503
436
  * page. Matches links whose `href` contains `/jobs/`, the URL pattern every
504
437
  * job posting page uses.
505
438
  *
506
- * Example #1: Then I should see Odoo job openings
439
+ * Example #1: Then I should see job openings
507
440
  * Example #2: And I should see job listings
508
- * Example #3: Then we should see Odoo job openings
441
+ * Example #3: Then we should see job openings
509
442
  * Example #4: And should not see job listings
510
443
  * Example #5: Then I should see job postings
511
444
  *
512
445
  */
513
- Then(/^(I |we )*should( not)* see (?:Odoo )?job (?:openings|listings|postings)$/, async function (pronounCase, notCase) {
446
+ Then(/^(I |we )*should( not)* see job (?:openings|listings|postings)$/, async function (pronounCase, notCase) {
514
447
  await assertPresence(this.page.locator('a[href*="/jobs/"]').first(), notCase, 'a job opening link ("a[href*=\\"/jobs/\\"]")', {
515
448
  hint: 'Only published job positions are listed; publish one from the Recruitment app or drop the scenario.',
516
449
  });
@@ -520,184 +453,378 @@ Then(/^(I |we )*should( not)* see (?:Odoo )?job (?:openings|listings|postings)$/
520
453
  // Contact form
521
454
  // ---------------------------------------------------------------------------
522
455
 
456
+ const CONTACT_FORM = '#contactus_form, #wrap form.s_website_form, #wrap .s_website_form form';
457
+
523
458
  /**
524
- * Navigate to the core "Contact us" page at `/contactus`.
459
+ * The contact form of the page, required and visible.
525
460
  *
526
- * Example #1: Given I am on the Odoo contact us page
461
+ * @param {object} world
462
+ * @returns {Promise<import('playwright').Locator>}
463
+ */
464
+ async function requireContactForm(world) {
465
+ const form = world.page.locator(CONTACT_FORM).first();
466
+ try {
467
+ await form.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
468
+ } catch (error) {
469
+ throw friendly({
470
+ message: 'Expected the contact form on the page.',
471
+ cause: `the page is ${world.page.url()}`,
472
+ hint: 'Open the contact page first ("Given I am on the contact us page"). The form is the "Form" building block of the Website builder.',
473
+ });
474
+ }
475
+ return form;
476
+ }
477
+
478
+ /**
479
+ * Navigate to the "Contact us" page at `/contactus` and check that the site
480
+ * serves it: the Website layout is there and the page is not the 404 page.
481
+ *
482
+ * Example #1: Given I am on the contact us page
527
483
  * Example #2: Given we are on the contact page
528
- * Example #3: Given I am on the Odoo "contact us" page
484
+ * Example #3: Given we are on the contact us page
529
485
  * Example #4: And on the contact-us page
530
486
  * Example #5: Given I am on the contact page
531
487
  *
532
488
  */
533
- Given(/^(I am |we are )*on the (?:Odoo )?(?:"contact us"|contact[ -]?us|contact) page$/, async function (pronounCase) {
489
+ Given(/^(I am |we are )*on the (?:contact[ -]?us|contact) page$/, async function (pronounCase) {
534
490
  await navigate(this, '/contactus');
491
+ const page = this.page;
492
+ const notFound = await page.locator('main h1', { hasText: '404' }).count();
493
+ if (notFound || !(await page.locator('#wrapwrap').count())) {
494
+ throw friendly({
495
+ message: 'Expected the contact page at /contactus.',
496
+ cause: notFound ? 'the site answers with its 404 page' : `the page at ${page.url()} is not a Website page`,
497
+ hint: 'The page ships with the Website app; a site that unpublished or moved it needs "Given I am on "<its path>"" instead.',
498
+ });
499
+ }
535
500
  });
536
501
 
537
502
  /**
538
503
  * 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.
504
+ * `#contactus_form`, the form the Website app renders on the contact page,
505
+ * or another "Form" building block in the content of the page.
541
506
  *
542
- * Example #1: Then I should see the Odoo contact us form
507
+ * Example #1: Then I should see the contact us form
543
508
  * Example #2: And I should see the contact form
544
- * Example #3: Then we should see the Odoo contact us form
509
+ * Example #3: Then we should see the contact us form
545
510
  * Example #4: And should not see the contact form
546
511
  * Example #5: Then I should see the "contact us" form
547
512
  *
548
513
  */
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.',
514
+ Then(/^(I |we )*should( not)* see the (?:"contact us"|contact[ -]?us|contact) form$/, async function (pronounCase, notCase) {
515
+ await assertPresence(this.page.locator(CONTACT_FORM).first(), notCase, 'the contact form ("#contactus_form")', {
516
+ hint: 'The contact page renders the form with this id; a redesigned page may have dropped or renamed it.',
517
+ });
518
+ if (notCase) return;
519
+ await assertPresence(this.page.locator(CONTACT_FORM).first().locator('.s_website_form_send').first(), undefined, 'the submit button of the contact form (".s_website_form_send")', {
520
+ timeout: SHORT_TIMEOUT,
521
+ hint: 'The button is part of the "Form" building block.',
552
522
  });
553
523
  });
554
524
 
555
- // ---------------------------------------------------------------------------
556
- // /web/login: authentication
557
- // ---------------------------------------------------------------------------
525
+ /**
526
+ * Fill the contact form from a table: the first column names a field by its
527
+ * label ("Name", "Email", "Question"), the second gives the value.
528
+ *
529
+ * Example #1: When I fill in the contact form with:
530
+ * Example #2: When we fill in the contact form with:
531
+ * Example #3: And fill in the contact form with:
532
+ * Example #4: When I fill in the contact us form with:
533
+ * Example #5: And we fill in the contact us form with:
534
+ *
535
+ */
536
+ When(/^(I |we )*fill in the contact(?: us)? form with:$/, async function (pronounCase, dataTable) {
537
+ const form = await requireContactForm(this);
538
+ const labels = form.locator('label.s_website_form_label:visible');
539
+ for (const [name, value] of dataTable.raw()) {
540
+ // The label reads "Name *": the mark of a required field is not part of
541
+ // the name.
542
+ const label = labels.filter({ has: this.page.locator('.s_website_form_label_content', { hasText: textPattern(name, { exact: true }) }) }).first();
543
+ if (!(await label.isVisible().catch(() => false))) {
544
+ throw friendly({
545
+ message: `Expected a "${name}" field in the contact form.`,
546
+ cause: `the form shows the fields ${await listTexts(labels.locator('.s_website_form_label_content'))}`,
547
+ hint: 'The name is matched by the whole text of the label, case-sensitive.',
548
+ });
549
+ }
550
+ const target = await label.getAttribute('for');
551
+ const input = form.locator(`[id="${target}"]`).first();
552
+ const tag = await input.evaluate((element) => element.tagName.toLowerCase());
553
+ if (tag === 'select') await input.selectOption({ label: value });
554
+ else await input.fill(value);
555
+ }
556
+ });
558
557
 
559
558
  /**
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.
559
+ * Submit the contact form with its button (`.s_website_form_send`) and wait
560
+ * for the answer of the site: the confirmation, or the errors the form
561
+ * shows. Assert the outcome in the step that follows.
563
562
  *
564
- * Example #1: Given I am on the Odoo 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
563
+ * Example #1: When I submit the contact form
564
+ * Example #2: When we submit the contact form
565
+ * Example #3: And submit the contact us form
566
+ * Example #4: When I send the contact form
567
+ * Example #5: And we send the contact us form
569
568
  *
570
569
  */
571
- Given(/^(I am |we are )*on the (?:Odoo )?(?:sign[- ]?in|web\/login|login) page$/, async function (pronounCase) {
572
- await navigate(this, '/web/login');
570
+ When(/^(I |we )*(?:submit|send) the contact(?: us)? form$/, async function (pronounCase) {
571
+ const page = this.page;
572
+ const form = await requireContactForm(this);
573
+ const startUrl = page.url();
574
+ await form.locator('.s_website_form_send').first().click();
575
+ // The form answers in one of three ways: it redirects to its confirmation
576
+ // page, it shows its confirmation message in place, or it marks the fields
577
+ // it refuses.
578
+ await page.waitForFunction((url) => window.location.href !== url
579
+ || document.querySelector('.s_website_form_end_message:not(.d-none)')
580
+ || document.querySelector('.s_website_form_field.o_has_error, #s_website_form_result .text-danger, #s_website_form_result.text-danger'),
581
+ startUrl, { timeout: WAIT_TIMEOUT }).catch(() => {});
582
+ await waitForPageLoad(page, budget(this));
573
583
  });
574
584
 
575
585
  /**
576
- * Assert the core login form is, or is not, visible: the form posting to
577
- * `/web/login`, with its `#login` and `#password` fields (`addons/web`'s
578
- * `webclient_templates.xml`, unchanged across Odoo versions). Each part is
579
- * checked separately so a failure names which one is missing.
586
+ * Assert the site confirmed, or did not confirm, the contact form: the
587
+ * visitor is on the confirmation page (`/contactus-thank-you`) or the form
588
+ * shows its confirmation message in place (`.s_website_form_end_message`).
580
589
  *
581
- * Example #1: Then I 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
585
- * Example #5: Then I should see the Odoo sign-in form
590
+ * Example #1: Then I should see the contact form confirmation
591
+ * Example #2: And we should see the contact form confirmation
592
+ * Example #3: Then we should see the contact us form confirmation
593
+ * Example #4: And I should not see the contact form confirmation
594
+ * Example #5: Then we should not see the contact us form confirmation
586
595
  *
587
596
  */
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 });
597
+ Then(/^(I |we )*should( not)* see the contact(?: us)? form confirmation$/, async function (pronounCase, notCase) {
598
+ const page = this.page;
599
+ const confirmed = async () => /\/contactus-thank-you(\b|$)/.test(page.url())
600
+ || (await page.locator('.s_website_form_end_message:visible').count()) > 0;
601
+ const deadline = Date.now() + SHORT_TIMEOUT;
602
+ let state = await confirmed();
603
+ while (state === Boolean(notCase) && !notCase && Date.now() < deadline) {
604
+ await waitForPageLoad(page, 500);
605
+ state = await confirmed();
606
+ }
607
+ if (state === Boolean(notCase)) {
608
+ const errors = await listTexts(page.locator('.s_website_form_field.o_has_error .s_website_form_label_content'));
609
+ throw friendly({
610
+ message: notCase ? 'Expected no confirmation of the contact form.' : 'Expected the confirmation of the contact form.',
611
+ cause: notCase ? `the page is ${page.url()}` : `the page is ${page.url()} and the form marks these fields as invalid: ${errors}`,
612
+ hint: 'The form confirms by redirecting to its "thank you" page or by showing a message in place; required fields are marked with "*".',
613
+ });
614
+ }
615
+ });
616
+
617
+ /**
618
+ * Assert the contact form shows, or does not show, an error after it was
619
+ * submitted: the fields it refuses are marked (`.o_has_error`) and a message
620
+ * appears next to the button. An optional field name checks that this field
621
+ * is one of the refused ones.
622
+ *
623
+ * Example #1: Then I should see a contact form error
624
+ * Example #2: And I should not see a contact form error
625
+ * Example #3: Then we should see an contact form error
626
+ * Example #4: And I should see a contact form error on "Email"
627
+ * Example #5: Then we should see a contact us form error on "Question"
628
+ *
629
+ */
630
+ Then(/^(I |we )*should( not)* see an? contact(?: us)? form error(?: on "([^"]*)")?$/, async function (pronounCase, notCase, field) {
631
+ const form = await requireContactForm(this);
632
+ const refused = form.locator('.s_website_form_field.o_has_error');
633
+ const target = field
634
+ ? refused.filter({ has: this.page.locator('.s_website_form_label_content', { hasText: textPattern(field, { exact: true }) }) }).first()
635
+ : refused.first();
636
+ await assertPresence(target, notCase, field ? `an error on the "${field}" field of the contact form` : 'an error on the contact form (".o_has_error")', {
637
+ timeout: SHORT_TIMEOUT,
638
+ hint: `The form marks these fields as invalid: ${await listTexts(refused.locator('.s_website_form_label_content'))}. Submit the form first ("When I submit the contact form").`,
639
+ });
594
640
  });
595
641
 
642
+ // ---------------------------------------------------------------------------
643
+ // Website editor
644
+ // ---------------------------------------------------------------------------
645
+
646
+ const EDITOR_PANEL = '#oe_snippets.o_loaded';
647
+
596
648
  /**
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.
649
+ * Open a page in the website preview of the web client (`/@/<path>`), where
650
+ * a website editor finds the "Edit" button.
601
651
  *
602
- * Example #1: When I log in to Odoo as "admin" with password "secret"
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"
652
+ * @param {object} world
653
+ * @param {string} path
654
+ * @returns {Promise<void>}
655
+ */
656
+ async function openWebsitePreview(world, path) {
657
+ const page = world.page;
658
+ const target = `/@/${String(path || '/').replace(/^\/+/, '')}`;
659
+ await navigate(world, target);
660
+ try {
661
+ await page.locator('.o_website_preview iframe.o_iframe').first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
662
+ } catch (error) {
663
+ throw friendly({
664
+ message: 'Expected the website preview of the web client.',
665
+ cause: `the page is ${page.url()}`,
666
+ hint: 'The preview is shown to an internal user; log in first ("Given I am a logged in user with the "admin" user").',
667
+ });
668
+ }
669
+ await settle(world);
670
+ }
671
+
672
+ /**
673
+ * Assert the current user can, or cannot, edit the website: the website
674
+ * preview of the web client shows the "Edit" button
675
+ * (`.o_edit_website_container`) to the users of the "Restricted Editor" and
676
+ * "Editor and Designer" groups. With `on "<path>"` the check runs on that
677
+ * page; the front page otherwise.
678
+ *
679
+ * Example #1: Then I should be able to edit the website
680
+ * Example #2: And I should not be able to edit the website
681
+ * Example #3: Then we should be able to edit the website
682
+ * Example #4: And I should be able to edit the website on "/contactus"
683
+ * Example #5: Then we should not be able to edit the website on "/shop"
607
684
  *
608
685
  */
609
- When(/^(I |we )*(?:log in|sign[- ]?in)(?: to Odoo)? as "([^"]*)?" with password "([^"]*)?"$/, async function (pronounCase, username, password) {
610
- await submitLogin(this, username, password);
686
+ Then(/^(I |we )*should( not)* be able to edit the website(?: on "([^"]*)")?$/, async function (pronounCase, notCase, path) {
687
+ await openWebsitePreview(this, path || '/');
688
+ await assertPresence(this.page.locator('.o_menu_systray .o_edit_website_container a').first(), notCase, 'the "Edit" button of the website preview (".o_edit_website_container")', {
689
+ timeout: SHORT_TIMEOUT,
690
+ hint: 'The button is shown to the users of the website editor groups (Website: Restricted Editor, Editor and Designer).',
691
+ });
611
692
  });
612
693
 
613
694
  /**
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.
695
+ * Open the website editor on a page: the page is shown in the website
696
+ * preview of the web client, the "Edit" button is clicked and the step waits
697
+ * for the editor panel with its building blocks (`#oe_snippets`). With `on
698
+ * "<path>"` the editor opens on that page; on the front page otherwise.
618
699
  *
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
700
+ * Example #1: When I open the website editor
701
+ * Example #2: When we open the website editor
702
+ * Example #3: And open the website editor
703
+ * Example #4: When I open the website editor on "/contactus"
704
+ * Example #5: And we open the website editor on "/shop"
624
705
  *
625
706
  */
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) {
707
+ When(/^(I |we )*open the website editor(?: on "([^"]*)")?$/, async function (pronounCase, path) {
708
+ const page = this.page;
709
+ await openWebsitePreview(this, path || '/');
710
+ const edit = page.locator('.o_menu_systray .o_edit_website_container a').first();
711
+ try {
712
+ await edit.click({ timeout: SHORT_TIMEOUT });
713
+ } catch (error) {
630
714
  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)'}.`,
715
+ action: 'click the "Edit" button of the website preview',
716
+ target: '.o_edit_website_container',
717
+ cause: error,
718
+ hint: 'The button is shown to the users of the website editor groups (Website: Restricted Editor, Editor and Designer).',
633
719
  });
634
720
  }
635
- const username = entry.username || userKey;
636
- const password = typeof entry === 'string' ? entry : entry.password;
637
- if (!password) {
721
+ try {
722
+ await page.locator(EDITOR_PANEL).first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
723
+ } catch (error) {
638
724
  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.`,
725
+ message: 'Expected the website editor to open.',
726
+ cause: error,
727
+ hint: 'The editor loads its building blocks before it is ready; an error dialog on screen names what went wrong.',
641
728
  });
642
729
  }
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);
730
+ await settle(this);
648
731
  });
649
732
 
650
733
  /**
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.
734
+ * Assert the website editor is, or is not, open: its panel with the
735
+ * building blocks and the "Save" and "Discard" buttons (`#oe_snippets`).
655
736
  *
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
737
+ * Example #1: Then I should see the website editor
738
+ * Example #2: And I should not see the website editor
739
+ * Example #3: Then we should see the website editor
740
+ * Example #4: And I should see the editor of the website
741
+ * Example #5: Then we should not see the editor of the website
661
742
  *
662
743
  */
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.',
744
+ Then(/^(I |we )*should( not)* see the (?:website editor|editor of the website)$/, async function (pronounCase, notCase) {
745
+ await assertPresence(this.page.locator(EDITOR_PANEL).first(), notCase, 'the website editor ("#oe_snippets")', {
746
+ timeout: SHORT_TIMEOUT,
747
+ hint: 'Open the editor first ("When I open the website editor"); saving or discarding closes it.',
748
+ });
749
+ if (notCase) return;
750
+ await assertPresence(this.page.locator('.o_we_website_top_actions button[data-action="save"]').first(), undefined, 'the "Save" button of the website editor', {
751
+ timeout: SHORT_TIMEOUT,
752
+ hint: 'The button sits at the top of the editor panel.',
753
+ });
754
+ });
755
+
756
+ /**
757
+ * Assert the website editor offers, or does not offer, a building block, by
758
+ * its name in the "Blocks" tab of the editor panel (`#oe_snippets
759
+ * .oe_snippet`).
760
+ *
761
+ * Example #1: Then I should see the "Text" building block
762
+ * Example #2: And I should see the "Banner" building block
763
+ * Example #3: Then we should see the "Text - Image" building block
764
+ * Example #4: And I should not see the "No Such Block" building block
765
+ * Example #5: Then we should not see the "Countdown" website building block
766
+ *
767
+ */
768
+ Then(/^(I |we )*should( not)* see the "([^"]*)" (?:website )?building block$/, async function (pronounCase, notCase, name) {
769
+ const page = this.page;
770
+ await assertPresence(page.locator(EDITOR_PANEL).first(), undefined, 'the website editor ("#oe_snippets")', {
771
+ timeout: SHORT_TIMEOUT,
772
+ hint: 'Open the editor first ("When I open the website editor").',
675
773
  });
774
+ const blocks = page.locator('#oe_snippets .oe_snippet[name]');
775
+ const names = await blocks.evaluateAll((elements) => [...new Set(elements.map((element) => element.getAttribute('name')).filter(Boolean))]);
776
+ const offered = names.includes(name);
777
+ if (offered === Boolean(notCase)) {
778
+ throw friendly({
779
+ message: notCase ? `Expected no "${name}" building block in the website editor.` : `Expected a "${name}" building block in the website editor.`,
780
+ cause: `the editor offers ${names.slice(0, 40).map((block) => `"${block}"`).join(', ') || '(none)'}`,
781
+ hint: 'A building block is named as in the "Blocks" tab of the editor; the installed apps and the theme add their own.',
782
+ });
783
+ }
676
784
  });
677
785
 
678
786
  /**
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.
787
+ * Leave the website editor with its "Save" or its "Discard" button and wait
788
+ * for the editor to close. Discarding changes asks for a confirmation, which
789
+ * the step gives.
682
790
  *
683
- * Example #1: Then I should see an Odoo login 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
791
+ * Example #1: When I save the website editor
792
+ * Example #2: When we discard the website editor
793
+ * Example #3: And save the website editor
794
+ * Example #4: When I discard the website editor
795
+ * Example #5: And discard the website editor
688
796
  *
689
797
  */
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;
798
+ When(/^(I |we )*(save|discard) the website editor$/, async function (pronounCase, action) {
799
+ const page = this.page;
800
+ const button = page.locator(`.o_we_website_top_actions button[data-action="${action === 'save' ? 'save' : 'cancel'}"]`).first();
801
+ try {
802
+ await button.click({ timeout: SHORT_TIMEOUT });
803
+ } catch (error) {
804
+ throw friendly({
805
+ action: `click the "${action === 'save' ? 'Save' : 'Discard'}" button of the website editor`,
806
+ target: '.o_we_website_top_actions',
807
+ cause: error,
808
+ hint: 'Open the editor first ("When I open the website editor").',
809
+ });
696
810
  }
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 });
811
+ // Discarding real changes opens a confirmation.
812
+ const confirm = page.locator('.modal[role="dialog"]:visible .modal-footer .btn-primary').first();
813
+ await Promise.race([
814
+ page.locator(EDITOR_PANEL).first().waitFor({ state: 'hidden', timeout: WAIT_TIMEOUT }),
815
+ confirm.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT }),
816
+ ]).catch(() => {});
817
+ if (await confirm.isVisible().catch(() => false)) await confirm.click();
818
+ try {
819
+ await page.locator(EDITOR_PANEL).first().waitFor({ state: 'hidden', timeout: WAIT_TIMEOUT });
820
+ } catch (error) {
821
+ throw friendly({
822
+ message: 'Expected the website editor to close.',
823
+ cause: error,
824
+ hint: 'A dialog on screen names what kept the editor open.',
825
+ });
700
826
  }
827
+ await settle(this);
701
828
  });
702
829
 
703
830
  // ---------------------------------------------------------------------------
@@ -709,14 +836,14 @@ Then(/^(I |we )*should( not)* see an? (?:Odoo )?(?:login|sign[- ]?in|authenticat
709
836
  * the page heading for "404" (rendered by `addons/http_routing`'s
710
837
  * `http_routing.404` template on every Odoo site).
711
838
  *
712
- * Example #1: Then I should be on the Odoo 404 page
839
+ * Example #1: Then I should be on the 404 page
713
840
  * Example #2: And I should be on the not found page
714
- * Example #3: Then we should be on the Odoo 404 page
841
+ * Example #3: Then we should be on the 404 page
715
842
  * Example #4: And should not be on the 404 page
716
- * Example #5: Then I should land on the Odoo 404 page
843
+ * Example #5: Then I should land on the 404 page
717
844
  *
718
845
  */
719
- Then(/^(I |we )*should( not)* (?:be on|land on) the (?:Odoo )?(?:404|not found|page not found) page$/, async function (pronounCase, notCase) {
846
+ Then(/^(I |we )*should( not)* (?:be on|land on) the (?:404|not found|page not found) page$/, async function (pronounCase, notCase) {
720
847
  await assertPresence(this.page.locator('h1', { hasText: '404' }).first(), notCase, 'the 404 template (an <h1> containing "404")', {
721
848
  attached: true,
722
849
  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.',