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