@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.
- package/README.md +112 -62
- package/STEPS.md +141 -0
- package/package.json +6 -3
- package/tests/step-definitions/README.md +11 -1
- package/tests/step-definitions/odoo-helpers.js +541 -0
- package/tests/step-definitions/odoo-record.steps.js +1398 -0
- package/tests/step-definitions/odoo-session.steps.js +402 -0
- package/tests/step-definitions/odoo-webclient.steps.js +1236 -0
- package/tests/step-definitions/odoo.steps.js +431 -304
|
@@ -1,133 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Odoo
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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 {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
143
|
-
* Example #2: And
|
|
144
|
-
* Example #3: Then we should be on
|
|
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
|
|
45
|
+
* Example #5: Then I should not be on a website page
|
|
147
46
|
*
|
|
148
47
|
*/
|
|
149
|
-
Then(/^(I |we )*should( not)* be on
|
|
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
|
|
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
|
|
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 (?:
|
|
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
|
|
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
|
|
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
|
|
81
|
+
* Example #5: Then I should not see the site footer
|
|
183
82
|
*
|
|
184
83
|
*/
|
|
185
|
-
Then(/^(I |we )*should( not)* see the (?:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) (?:
|
|
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
|
|
237
|
-
* waits for the text to be
|
|
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
|
|
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
|
|
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
|
|
247
|
-
const title = this.page.locator('.o_wblog_post_title').first();
|
|
248
|
-
await assertPresence(title, undefined, 'the blog 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) (?:
|
|
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
|
|
305
|
-
* Example #2: And I open the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
368
|
-
*
|
|
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
|
|
371
|
-
* Example #2: And I should see shop search results for "
|
|
372
|
-
* Example #3: Then we should see
|
|
373
|
-
* Example #4: And should see shop search results for "
|
|
374
|
-
* Example #5: Then I should see the shop search results for "
|
|
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 )?
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (?:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
459
|
+
* The contact form of the page, required and visible.
|
|
525
460
|
*
|
|
526
|
-
*
|
|
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
|
|
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 (?:
|
|
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
|
|
540
|
-
*
|
|
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
|
|
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
|
|
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 (?:
|
|
550
|
-
await assertPresence(this.page.locator(
|
|
551
|
-
hint: 'The
|
|
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
|
-
|
|
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
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
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:
|
|
565
|
-
* Example #2:
|
|
566
|
-
* Example #3:
|
|
567
|
-
* Example #4:
|
|
568
|
-
* Example #5:
|
|
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
|
-
|
|
572
|
-
|
|
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
|
|
577
|
-
*
|
|
578
|
-
*
|
|
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
|
|
582
|
-
* Example #2: And
|
|
583
|
-
* Example #3: Then we should see the
|
|
584
|
-
* Example #4: And should not see the
|
|
585
|
-
* Example #5: Then
|
|
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 (?:
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
*
|
|
598
|
-
*
|
|
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
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
await
|
|
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
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
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:
|
|
620
|
-
* Example #2:
|
|
621
|
-
* Example #3:
|
|
622
|
-
* Example #4:
|
|
623
|
-
* Example #5:
|
|
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
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
721
|
+
try {
|
|
722
|
+
await page.locator(EDITOR_PANEL).first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
|
|
723
|
+
} catch (error) {
|
|
638
724
|
throw friendly({
|
|
639
|
-
message:
|
|
640
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
652
|
-
*
|
|
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:
|
|
657
|
-
* Example #2:
|
|
658
|
-
* Example #3:
|
|
659
|
-
* Example #4:
|
|
660
|
-
* Example #5:
|
|
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
|
-
|
|
664
|
-
await this.
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
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:
|
|
684
|
-
* Example #2:
|
|
685
|
-
* Example #3:
|
|
686
|
-
* Example #4:
|
|
687
|
-
* Example #5:
|
|
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
|
-
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
await
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (?:
|
|
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.',
|