@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.
@@ -0,0 +1,402 @@
1
+ 'use strict';
2
+
3
+ // Session steps for Odoo: the login screen (`/web/login`), the users registry
4
+ // of cucumber.js, the user menu of the web client and of the website, logging
5
+ // out, and what the server says about the session.
6
+ //
7
+ // Accounts are configuration. They are created once on the site under test
8
+ // (scripts/build-odoo.sh does it for this pack's own suite) and named in
9
+ // `worldParameters.users`; a feature never creates or deletes an account and
10
+ // never carries a password, except where the credentials are the subject.
11
+
12
+ const { Given, When, Then } = require('@cucumber/cucumber');
13
+ const { waitForPageLoad, friendly } = require('webship-js/tests/step-definitions/webship');
14
+ const {
15
+ WAIT_TIMEOUT,
16
+ SHORT_TIMEOUT,
17
+ budget,
18
+ settle,
19
+ navigate,
20
+ assertPresence,
21
+ assertText,
22
+ listTexts,
23
+ loginPath,
24
+ submitLogin,
25
+ sessionInfo,
26
+ loginAs,
27
+ textPattern,
28
+ } = require('./odoo-helpers');
29
+
30
+ const LOGIN_FORM = 'form[action^="/web/login"]';
31
+
32
+ /**
33
+ * Open the user menu: the avatar menu of the web client's navigation bar
34
+ * (`.o_user_menu`) or, on the website and the portal, the menu under the
35
+ * user's name in the header. Returns the open menu.
36
+ *
37
+ * @param {object} world
38
+ * @returns {Promise<import('playwright').Locator>}
39
+ */
40
+ async function openUserMenu(world) {
41
+ const page = world.page;
42
+ const backend = page.locator('.o_main_navbar .o_user_menu').first();
43
+ if (await backend.isVisible().catch(() => false)) {
44
+ const menu = backend.locator('.dropdown-menu');
45
+ if (!(await menu.isVisible().catch(() => false))) {
46
+ await backend.locator('button.dropdown-toggle').click();
47
+ }
48
+ await menu.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
49
+ return menu;
50
+ }
51
+ const frontend = page.locator('header li.dropdown:has(#o_logout):visible, header .dropdown:has(#o_logout):visible').first();
52
+ if (await frontend.isVisible().catch(() => false)) {
53
+ const menu = frontend.locator('.dropdown-menu').first();
54
+ if (!(await menu.isVisible().catch(() => false))) {
55
+ await frontend.locator('a.dropdown-toggle, [data-bs-toggle="dropdown"]').first().click();
56
+ }
57
+ await menu.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
58
+ return menu;
59
+ }
60
+ throw friendly({
61
+ message: 'Expected a user menu on the page.',
62
+ cause: `the page is ${page.url()}`,
63
+ hint: 'The user menu is shown to a logged-in user: in the navigation bar of the web client, or under the user\'s name in the website header. Log in first.',
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Navigate to the login screen at `/web/login`. The screen ships with the
69
+ * base "web" module, so every Odoo site has it.
70
+ *
71
+ * Example #1: Given I am on the login page
72
+ * Example #2: Given we are on the login page
73
+ * Example #3: Given I am on the sign in page
74
+ * Example #4: And on the sign-in page
75
+ * Example #5: Given I am on the web/login page
76
+ *
77
+ */
78
+ Given(/^(I am |we are )*on the (?:sign[- ]?in|web\/login|login) page$/, async function (pronounCase) {
79
+ await navigate(this, loginPath(this));
80
+ const page = this.page;
81
+ if (/\/web\/database\/selector/.test(page.url())) {
82
+ throw friendly({
83
+ message: 'Expected the login screen, but the server shows the database selector.',
84
+ cause: `the page is ${page.url()}`,
85
+ hint: 'The server holds more than one database: set worldParameters.odoo.database in cucumber.js, or start Odoo with --db-filter.',
86
+ });
87
+ }
88
+ // A logged-in visitor is redirected away from /web/login; the step does not
89
+ // hide that, so the assertion that follows can report it.
90
+ });
91
+
92
+ /**
93
+ * Assert the login form is, or is not, visible: the form posting to
94
+ * `/web/login`, with its `#login` and `#password` fields and its submit
95
+ * button. Each part is checked separately so a failure names the missing
96
+ * one.
97
+ *
98
+ * Example #1: Then I should see the login form
99
+ * Example #2: And I should see the sign in form
100
+ * Example #3: Then we should see the login form
101
+ * Example #4: And should not see the login form
102
+ * Example #5: Then I should see the sign-in form
103
+ *
104
+ */
105
+ Then(/^(I |we )*should( not)* see the (?:sign[- ]?in|login) form$/, async function (pronounCase, notCase) {
106
+ const hint = 'The login screen is /web/login; a logged-in visitor is redirected away from it to /web.';
107
+ await assertPresence(this.page.locator(LOGIN_FORM), notCase, 'the login form ("form[action=\\"/web/login\\"]")', { hint });
108
+ if (notCase) return;
109
+ await assertPresence(this.page.locator(`${LOGIN_FORM} #login`), undefined, 'the login field ("#login")', { hint, timeout: SHORT_TIMEOUT });
110
+ await assertPresence(this.page.locator(`${LOGIN_FORM} #password`), undefined, 'the password field ("#password")', { hint, timeout: SHORT_TIMEOUT });
111
+ await assertPresence(this.page.locator(`${LOGIN_FORM} button[type="submit"]`).first(), undefined, 'the submit button of the login form', { hint, timeout: SHORT_TIMEOUT });
112
+ });
113
+
114
+ /**
115
+ * Fill and submit the login form with the given login and password, for
116
+ * scenarios where the credentials are the subject (a wrong password, a
117
+ * disabled account). The login screen must be open already. For everything
118
+ * else use the users registry: `Given I am a logged in user with the "admin"
119
+ * user`.
120
+ *
121
+ * Example #1: When I log in as "admin" with password "secret"
122
+ * Example #2: When we log in as "demo@example.com" with password "p@ssw0rd"
123
+ * Example #3: And I sign in as "admin" with password "secret"
124
+ * Example #4: When we sign-in as "reviewer" with password "hunter2"
125
+ * Example #5: And log in as "portal_user" with password "letmein"
126
+ *
127
+ */
128
+ When(/^(I |we )*(?:log in|sign[- ]?in) as "([^"]*)?" with password "([^"]*)?"$/, async function (pronounCase, username, password) {
129
+ await submitLogin(this, username, password);
130
+ await this.page.locator(`.o_main_navbar, #wrapwrap, ${LOGIN_FORM}`).first().waitFor({ state: 'attached', timeout: WAIT_TIMEOUT }).catch(() => {});
131
+ await settle(this, budget(this));
132
+ });
133
+
134
+ /**
135
+ * Log in as a user of the registry in cucumber.js (`worldParameters.users`),
136
+ * so no password appears in a feature file. Each handle names one account:
137
+ * `{ admin: { username: 'admin', password: process.env.ODOO_ADMIN_PASSWORD } }`.
138
+ * The step starts a new session, opens the login screen, submits the form and
139
+ * waits for the page the user lands on: the web client for an internal user,
140
+ * the portal for a portal user. A refused login fails here, with the message
141
+ * the login form shows.
142
+ *
143
+ * Example #1: Given I am a logged in user with the "admin" user
144
+ * Example #2: Given I am a logged in user with the username "admin"
145
+ * Example #3: Given we are a logged in user with "portal"
146
+ * Example #4: And a logged in user with the "salesperson" user
147
+ * Example #5: Given I am a logged in user with the username "accountant" user
148
+ *
149
+ */
150
+ Given(/^(I am |we are )*a logged in user with( the)*( username)* "([^"]*)?"( user)*$/, async function (pronounCase, theCase, usernameCase, handle, userCase) {
151
+ await loginAs(this, handle);
152
+ });
153
+
154
+ /**
155
+ * End any session and land on the login screen as a visitor: cookies and web
156
+ * storage are cleared, `/web/login` is opened and the login form is
157
+ * confirmed. Use it at the start of a scenario that must run as a visitor.
158
+ * To log out the way a user does, use `When I log out`.
159
+ *
160
+ * Example #1: Given I am not logged in
161
+ * Example #2: Given we are not logged in
162
+ * Example #3: And we are not logged in
163
+ * Example #4: Given not logged in
164
+ * Example #5: But not logged in
165
+ *
166
+ */
167
+ Given(/^(I am |we are )*not logged in$/, async function (pronounCase) {
168
+ await this.context.clearCookies();
169
+ try {
170
+ await this.page.evaluate(() => {
171
+ try { localStorage.clear(); } catch { /* storage may be unavailable */ }
172
+ try { sessionStorage.clear(); } catch { /* storage may be unavailable */ }
173
+ });
174
+ } catch { /* the page may not be on a real URL yet */ }
175
+ await navigate(this, loginPath(this));
176
+ await assertPresence(this.page.locator(LOGIN_FORM), undefined, 'the login form (the session should have ended)', {
177
+ timeout: SHORT_TIMEOUT,
178
+ hint: 'A logged-in visitor is redirected from /web/login to /web; clearing the cookies should have ended the session.',
179
+ });
180
+ });
181
+
182
+ /**
183
+ * Assert the login screen is, or is not, showing an error, optionally with a
184
+ * text. Matches the `.alert-danger` message the login form renders after a
185
+ * wrong login or password ("Wrong login/password").
186
+ *
187
+ * Example #1: Then I should see an login error
188
+ * Example #2: And I should see a sign in error
189
+ * Example #3: Then we should see a login error "Wrong login/password"
190
+ * Example #4: And should not see a login error
191
+ * Example #5: Then I should see an authentication error message
192
+ *
193
+ */
194
+ Then(/^(I |we )*should( not)* see an? (?:login|sign[- ]?in|authentication) error(?: message)?(?: "([^"]*)")?$/, async function (pronounCase, notCase, expectedText) {
195
+ const error = this.page.locator(`${LOGIN_FORM} .alert-danger`).first();
196
+ const hint = 'The login screen renders the message inside the form after a wrong login or password.';
197
+ if (notCase && !expectedText) {
198
+ await assertPresence(error, notCase, 'a login error message (".alert-danger")', { hint });
199
+ return;
200
+ }
201
+ await assertPresence(error, undefined, 'a login error message (".alert-danger")', { hint });
202
+ if (expectedText) {
203
+ await assertText(error, expectedText, notCase, 'the login error message', { hint });
204
+ }
205
+ });
206
+
207
+ /**
208
+ * Log out the way a user does: open the user menu and choose "Log out" (the
209
+ * web client) or "Logout" (the website and the portal). The step then waits
210
+ * until the server reports no session.
211
+ *
212
+ * Example #1: When I log out
213
+ * Example #2: When we log out
214
+ * Example #3: And log out
215
+ * Example #4: When I sign out
216
+ * Example #5: And we sign out
217
+ *
218
+ */
219
+ When(/^(I |we )*(?:log|sign) out$/, async function (pronounCase) {
220
+ const page = this.page;
221
+ const menu = await openUserMenu(this);
222
+ const entry = menu.locator('[data-menu="logout"], #o_logout, a[href*="/web/session/logout"]').first();
223
+ try {
224
+ await entry.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
225
+ } catch (error) {
226
+ throw friendly({
227
+ message: 'Expected a "Log out" entry in the user menu.',
228
+ cause: `the menu offers ${await listTexts(menu.locator('.dropdown-item'))}`,
229
+ hint: 'The entry is part of the standard user menu; a customised menu needs its own step.',
230
+ });
231
+ }
232
+ await Promise.all([
233
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: WAIT_TIMEOUT }).catch(() => {}),
234
+ entry.click(),
235
+ ]);
236
+ await waitForPageLoad(page, budget(this));
237
+ const session = await sessionInfo(this);
238
+ if (session.uid) {
239
+ throw friendly({
240
+ message: 'Expected the session to end after logging out.',
241
+ cause: `the server still reports a session for "${session.username}"`,
242
+ hint: 'Check that /web/session/logout is reachable and not blocked by a proxy rule.',
243
+ });
244
+ }
245
+ });
246
+
247
+ /**
248
+ * Assert the browser holds, or does not hold, a session, as the server
249
+ * reports it (`/web/session/get_session_info`). The check does not depend on
250
+ * the page on screen, so it works on the web client, the website and the
251
+ * portal alike.
252
+ *
253
+ * Example #1: Then I should be logged in
254
+ * Example #2: And we should be logged in
255
+ * Example #3: Then I should not be logged in
256
+ * Example #4: And should be logged in
257
+ * Example #5: Then we should not be logged in
258
+ *
259
+ */
260
+ Then(/^(I |we )*should( not)* be logged in$/, async function (pronounCase, notCase) {
261
+ const session = await sessionInfo(this);
262
+ if (notCase && session.uid) {
263
+ throw friendly({
264
+ message: 'Expected no session.',
265
+ cause: `the server reports a session for "${session.username}" (${session.name})`,
266
+ hint: 'Log out first ("When I log out"), or start the scenario with "Given I am not logged in".',
267
+ });
268
+ }
269
+ if (!notCase && !session.uid) {
270
+ throw friendly({
271
+ message: 'Expected a session.',
272
+ cause: 'the server reports no session for this browser',
273
+ hint: 'Log in first ("Given I am a logged in user with the "admin" user").',
274
+ });
275
+ }
276
+ });
277
+
278
+ /**
279
+ * Assert the session belongs, or does not belong, to the given user. The text
280
+ * is compared with the login and with the name of the user, as the server
281
+ * reports them, so both "admin" and "Mitchell Admin" identify the
282
+ * administrator of the demo data.
283
+ *
284
+ * Example #1: Then I should be logged in as "admin"
285
+ * Example #2: And I should be logged in as "Mitchell Admin"
286
+ * Example #3: Then we should be logged in as "salesperson"
287
+ * Example #4: And I should not be logged in as "admin"
288
+ * Example #5: Then I should be logged in as "portal"
289
+ *
290
+ */
291
+ Then(/^(I |we )*should( not)* be logged in as "([^"]*)"$/, async function (pronounCase, notCase, expected) {
292
+ const session = await sessionInfo(this);
293
+ const matches = Boolean(session.uid) && (session.username === expected || session.name === expected);
294
+ if (Boolean(notCase) === matches) {
295
+ throw friendly({
296
+ message: notCase ? `Expected the session not to belong to "${expected}".` : `Expected the session to belong to "${expected}".`,
297
+ cause: session.uid ? `the server reports the login "${session.username}" and the name "${session.name}"` : 'the server reports no session for this browser',
298
+ hint: 'The text is compared with the login and with the name of the user, exactly.',
299
+ });
300
+ }
301
+ });
302
+
303
+ /**
304
+ * Assert the page is, or is not, the web client: the application internal
305
+ * users work in (`body.o_web_client` with the navigation bar
306
+ * `.o_main_navbar`), as opposed to the website and the portal.
307
+ *
308
+ * Example #1: Then I should be in the web client
309
+ * Example #2: And we should be in the web client
310
+ * Example #3: Then we should be in the backend
311
+ * Example #4: And I should not be in the web client
312
+ * Example #5: Then I should not be in the backend
313
+ *
314
+ */
315
+ Then(/^(I |we )*should( not)* be in the (?:web client|backend)$/, async function (pronounCase, notCase) {
316
+ await assertPresence(this.page.locator('body.o_web_client .o_main_navbar').first(), notCase, 'the web client (".o_web_client .o_main_navbar")', {
317
+ hint: 'Internal users land on the web client at /web after logging in; portal users are sent to /my and visitors to the login screen.',
318
+ });
319
+ if (!notCase) await settle(this);
320
+ });
321
+
322
+ /**
323
+ * Assert the page is, or is not, the portal: the "My Account" area a portal
324
+ * user lands on (`/my`, rendered inside `#wrap.o_portal_wrap`).
325
+ *
326
+ * Example #1: Then I should be in the portal
327
+ * Example #2: And we should be in the portal
328
+ * Example #3: Then we should be in the customer portal
329
+ * Example #4: And I should not be in the portal
330
+ * Example #5: Then I should not be in the customer portal
331
+ *
332
+ */
333
+ Then(/^(I |we )*should( not)* be in the (?:customer )?portal$/, async function (pronounCase, notCase) {
334
+ await assertPresence(this.page.locator('.o_portal_wrap').first(), notCase, 'the portal ("#wrap.o_portal_wrap")', {
335
+ attached: true,
336
+ hint: 'A portal user lands on /my after logging in; an internal user lands on the web client.',
337
+ });
338
+ });
339
+
340
+ /**
341
+ * Open the user menu: the avatar menu at the right of the web client's
342
+ * navigation bar, or the menu under the user's name in the website header.
343
+ *
344
+ * Example #1: When I open the user menu
345
+ * Example #2: When we open the user menu
346
+ * Example #3: And open the user menu
347
+ * Example #4: When I open the account menu
348
+ * Example #5: And we open the account menu
349
+ *
350
+ */
351
+ When(/^(I |we )*open the (?:user|account) menu$/, async function (pronounCase) {
352
+ await openUserMenu(this);
353
+ });
354
+
355
+ /**
356
+ * Assert the user menu offers, or does not offer, an entry. The menu is
357
+ * opened when it is closed.
358
+ *
359
+ * Example #1: Then I should see "My Profile" in the user menu
360
+ * Example #2: And I should see "Log out" in the user menu
361
+ * Example #3: Then we should see "My Account" in the user menu
362
+ * Example #4: And I should not see "Settings" in the user menu
363
+ * Example #5: Then I should not see "Documentation" in the account menu
364
+ *
365
+ */
366
+ Then(/^(I |we )*should( not)* see "([^"]*)" in the (?:user|account) menu$/, async function (pronounCase, notCase, text) {
367
+ const menu = await openUserMenu(this);
368
+ const entry = menu.locator('.dropdown-item').filter({ hasText: textPattern(text, { exact: true }) }).first();
369
+ const offered = await listTexts(menu.locator('.dropdown-item'));
370
+ await assertPresence(entry, notCase, `the "${text}" entry of the user menu`, {
371
+ timeout: SHORT_TIMEOUT,
372
+ hint: `The menu offers ${offered}.`,
373
+ });
374
+ });
375
+
376
+ /**
377
+ * Choose an entry of the user menu, for example "My Profile". The menu is
378
+ * opened when it is closed.
379
+ *
380
+ * Example #1: When I choose "My Profile" in the user menu
381
+ * Example #2: When we choose "My Account" in the user menu
382
+ * Example #3: And I choose "Shortcuts" in the user menu
383
+ * Example #4: When I choose "My Profile" in the account menu
384
+ * Example #5: And we choose "Log out" in the account menu
385
+ *
386
+ */
387
+ When(/^(I |we )*choose "([^"]*)" in the (?:user|account) menu$/, async function (pronounCase, text) {
388
+ const menu = await openUserMenu(this);
389
+ const entry = menu.locator('.dropdown-item').filter({ hasText: textPattern(text, { exact: true }) }).first();
390
+ try {
391
+ await entry.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
392
+ } catch (error) {
393
+ throw friendly({
394
+ message: `Expected a "${text}" entry in the user menu.`,
395
+ cause: `the menu offers ${await listTexts(menu.locator('.dropdown-item'))}`,
396
+ hint: 'The entry is matched by its whole text, case-sensitive.',
397
+ });
398
+ }
399
+ await entry.click();
400
+ await waitForPageLoad(this.page, budget(this));
401
+ await settle(this);
402
+ });