@webship-js/odoo-steps 2.0.0 → 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,541 @@
1
+ 'use strict';
2
+
3
+ // Shared helpers for the Odoo step files.
4
+ //
5
+ // Not a step file: it registers no step definitions and no hooks. It holds
6
+ // what the step files share: the wait on Odoo's own activity signals, the
7
+ // users registry, the login form, presence and text assertions, and the
8
+ // lookups for the web client (the active view, a form field by its label, a
9
+ // dialog, the search view).
10
+ //
11
+ // Everything here builds on webship-js's own helpers (`gotoUrl`,
12
+ // `waitForPageLoad`, `friendly`) so the steps behave like the core webship-js
13
+ // steps.
14
+
15
+ const { gotoUrl, waitForPageLoad, friendly } = require('webship-js/tests/step-definitions/webship');
16
+
17
+ // Kept below webship-js's 45 s step timeout so a locator that never settles
18
+ // fails through the authored message (with its hint), not as "function timed
19
+ // out".
20
+ const WAIT_TIMEOUT = 30000;
21
+
22
+ // A shorter wait for a second look at something that is expected to be there
23
+ // already (a menu that just opened, a field of a form that is on screen).
24
+ const SHORT_TIMEOUT = 10000;
25
+
26
+ /**
27
+ * Whether an optional step argument was actually supplied. A regex group that
28
+ * did not take part in the match reaches the step as `undefined` or `null`.
29
+ *
30
+ * @param {*} value
31
+ * @returns {boolean}
32
+ */
33
+ function supplied(value) {
34
+ return value !== undefined && value !== null;
35
+ }
36
+
37
+ /**
38
+ * The per-page wait budget from `worldParameters.minWaitTime.page`.
39
+ *
40
+ * @param {object} world
41
+ * @returns {number}
42
+ */
43
+ function budget(world) {
44
+ return (world && world.minWaitTime && world.minWaitTime.page) || 3000;
45
+ }
46
+
47
+ /**
48
+ * Join the launch URL and a path with exactly one slash. Absolute URLs pass
49
+ * through.
50
+ *
51
+ * @param {object} world
52
+ * @param {string} path
53
+ * @returns {string}
54
+ */
55
+ function siteUrl(world, path) {
56
+ if (/^https?:\/\//i.test(String(path))) return String(path);
57
+ return String(world.launchUrl).replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '');
58
+ }
59
+
60
+ /**
61
+ * Escape a string for use inside a regular expression.
62
+ *
63
+ * @param {string} text
64
+ * @returns {string}
65
+ */
66
+ function escapeRegExp(text) {
67
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ }
69
+
70
+ /**
71
+ * A whitespace-tolerant, case-sensitive pattern for a text. With `exact` the
72
+ * whole text must match; otherwise the text may appear anywhere.
73
+ *
74
+ * @param {string} text
75
+ * @param {{exact?: boolean}} [options]
76
+ * @returns {RegExp}
77
+ */
78
+ function textPattern(text, { exact = false } = {}) {
79
+ const body = String(text).trim().split(/\s+/).map(escapeRegExp).join('\\s+');
80
+ return new RegExp(exact ? `^\\s*${body}\\s*$` : body);
81
+ }
82
+
83
+ /**
84
+ * The pattern for the text of a form label. Odoo appends a "?" help marker
85
+ * (`<sup>?</sup>`) to a label whose field has a tooltip, so the marker is
86
+ * accepted but not required.
87
+ *
88
+ * @param {string} label
89
+ * @returns {RegExp}
90
+ */
91
+ function labelPattern(label) {
92
+ const body = String(label).trim().replace(/\s*:$/, '').split(/\s+/).map(escapeRegExp).join('\\s+');
93
+ return new RegExp(`^\\s*${body}\\s*:?\\s*\\??\\s*$`);
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Waiting on Odoo's own signals
98
+ // ---------------------------------------------------------------------------
99
+
100
+ // The requests in flight, per page. The web client talks to the server
101
+ // through XMLHttpRequest and fetch calls (`/web/dataset/call_kw`,
102
+ // `/web/action/load`, ...); the bus keeps its own long-lived connection,
103
+ // which is left out so that the count can reach zero.
104
+ const pendingRequests = new WeakMap();
105
+
106
+ const LONG_LIVED_REQUEST = /\/longpolling\/|\/websocket|\/bus\/|im_status/;
107
+
108
+ /**
109
+ * Start counting the page's data requests. Safe to call more than once.
110
+ *
111
+ * @param {import('playwright').Page} page
112
+ * @returns {Set<object>} The requests in flight.
113
+ */
114
+ function trackRequests(page) {
115
+ let pending = pendingRequests.get(page);
116
+ if (pending) return pending;
117
+ pending = new Set();
118
+ pendingRequests.set(page, pending);
119
+ page.on('request', (request) => {
120
+ const type = request.resourceType();
121
+ if (type !== 'xhr' && type !== 'fetch') return;
122
+ if (LONG_LIVED_REQUEST.test(request.url())) return;
123
+ pending.add(request);
124
+ });
125
+ const done = (request) => pending.delete(request);
126
+ page.on('requestfinished', done);
127
+ page.on('requestfailed', done);
128
+ return pending;
129
+ }
130
+
131
+ /**
132
+ * Wait until Odoo is idle: the document is loaded, no data request is in
133
+ * flight, and none of the web client's own busy signals is on screen (the
134
+ * "Loading" indicator of the navigation bar, `.o_loading_indicator`, and the
135
+ * screen blocker shown during a long action, `.o_blockUI`). The page must
136
+ * stay idle for a short moment, so that a request chained to the previous
137
+ * one (a save followed by a reload) is waited for as well.
138
+ *
139
+ * Returns within `timeout` milliseconds even when the page never becomes
140
+ * idle; the assertion that follows then reports what is missing.
141
+ *
142
+ * @param {object} world - The cucumber World (`this` inside a step).
143
+ * @param {number} [timeout]
144
+ * @returns {Promise<boolean>} Whether the page became idle in time.
145
+ */
146
+ async function settle(world, timeout = WAIT_TIMEOUT) {
147
+ const page = world.page;
148
+ const pending = trackRequests(page);
149
+ const deadline = Date.now() + timeout;
150
+ const QUIET_MS = 200;
151
+ let quietSince = 0;
152
+ while (Date.now() < deadline) {
153
+ const busy = pending.size > 0 || await page.evaluate(() => {
154
+ if (document.readyState !== 'complete') return true;
155
+ return Boolean(document.querySelector('.o_loading_indicator, .o_blockUI'));
156
+ }).catch(() => true);
157
+ if (busy) {
158
+ quietSince = 0;
159
+ } else if (!quietSince) {
160
+ quietSince = Date.now();
161
+ } else if (Date.now() - quietSince >= QUIET_MS) {
162
+ return true;
163
+ }
164
+ await new Promise((resolve) => setTimeout(resolve, 40));
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Open a path on the site under test and wait for the page to settle.
171
+ *
172
+ * @param {object} world
173
+ * @param {string} path
174
+ * @returns {Promise<void>}
175
+ */
176
+ async function navigate(world, path) {
177
+ trackRequests(world.page);
178
+ await gotoUrl(world.page, siteUrl(world, path));
179
+ await world.page.waitForSelector('body', { state: 'attached', timeout: WAIT_TIMEOUT });
180
+ await waitForPageLoad(world.page, budget(world));
181
+ }
182
+
183
+ /**
184
+ * Wait for the web client to be ready: the navigation bar is drawn, an action
185
+ * is on screen and Odoo is idle.
186
+ *
187
+ * @param {object} world
188
+ * @returns {Promise<void>}
189
+ */
190
+ async function waitForWebClient(world) {
191
+ const page = world.page;
192
+ try {
193
+ await page.locator('.o_web_client .o_main_navbar').first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
194
+ // An action on screen, or the dialog Odoo opens when the action could
195
+ // not be loaded (an access error, for example).
196
+ await page.locator('.o_action_manager > *, .modal[role="dialog"]').first().waitFor({ state: 'attached', timeout: WAIT_TIMEOUT });
197
+ } catch (error) {
198
+ throw friendly({
199
+ message: 'Expected the Odoo web client to be on screen.',
200
+ cause: `the page is ${page.url()}`,
201
+ hint: 'Log in with an internal user first ("Given I am a logged in user with the "admin" user"); a portal user is sent to /my and never reaches the web client.',
202
+ });
203
+ }
204
+ await settle(world);
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Assertions
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Assert an element is present or, when the step said "not", absent. The
213
+ * assertion is the wait: Playwright polls the locator for the wanted state
214
+ * and a miss fails through a friendly message with a hint. `attached` picks
215
+ * presence in the document over visibility.
216
+ *
217
+ * @param {import('playwright').Locator} locator
218
+ * @param {string|null|undefined} notCase - The captured " not" group, if any.
219
+ * @param {string} label - What the element is, for the message.
220
+ * @param {{attached?: boolean, hint?: string, timeout?: number}} [options]
221
+ * @returns {Promise<void>}
222
+ */
223
+ async function assertPresence(locator, notCase, label, { attached = false, hint, timeout = WAIT_TIMEOUT } = {}) {
224
+ const state = attached ? (notCase ? 'detached' : 'attached') : (notCase ? 'hidden' : 'visible');
225
+ try {
226
+ await locator.waitFor({ state, timeout });
227
+ } catch (error) {
228
+ throw friendly({
229
+ message: notCase ? `Expected ${label} to be absent.` : `Expected ${label} to be present.`,
230
+ cause: error,
231
+ hint,
232
+ });
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Assert a locator's text contains (or, with `notCase`, does not contain) the
238
+ * expected text. The locator is narrowed to "has this text" and Playwright
239
+ * polls for that narrowed set to exist or to be gone.
240
+ *
241
+ * @param {import('playwright').Locator} locator
242
+ * @param {string} expectedText
243
+ * @param {string|null|undefined} notCase
244
+ * @param {string} label
245
+ * @param {{exact?: boolean, hint?: string, timeout?: number}} [options]
246
+ * @returns {Promise<void>}
247
+ */
248
+ async function assertText(locator, expectedText, notCase, label, { exact = false, hint, timeout = WAIT_TIMEOUT } = {}) {
249
+ const withText = locator.filter({ hasText: textPattern(expectedText, { exact }) });
250
+ try {
251
+ await withText.first().waitFor({ state: notCase ? 'detached' : 'attached', timeout });
252
+ } catch (error) {
253
+ const text = ((await locator.first().textContent({ timeout: 1000 }).catch(() => '')) || '').replace(/\s+/g, ' ').trim();
254
+ throw friendly({
255
+ message: notCase
256
+ ? `Expected ${label} not to ${exact ? 'read' : 'contain'} "${expectedText}".`
257
+ : `Expected ${label} to ${exact ? 'read' : 'contain'} "${expectedText}".`,
258
+ cause: `it reads "${text}"`,
259
+ hint,
260
+ });
261
+ }
262
+ }
263
+
264
+ /**
265
+ * The visible texts of a set of elements, trimmed and without duplicates, for
266
+ * a hint that lists what the page offers.
267
+ *
268
+ * @param {import('playwright').Locator} locator
269
+ * @param {number} [limit]
270
+ * @returns {Promise<string>}
271
+ */
272
+ async function listTexts(locator, limit = 25) {
273
+ const texts = await locator.evaluateAll((elements) => elements
274
+ .filter((element) => element.offsetParent !== null || element.getClientRects().length > 0)
275
+ .map((element) => (element.textContent || '').replace(/\s+/g, ' ').trim())
276
+ .filter(Boolean)).catch(() => []);
277
+ const unique = [...new Set(texts)];
278
+ if (!unique.length) return '(none)';
279
+ return unique.slice(0, limit).map((text) => `"${text}"`).join(', ') + (unique.length > limit ? ', ...' : '');
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Users registry and the login form
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Look up a user by its handle in `worldParameters.users`. The registry maps
288
+ * a handle a feature file can read ("admin", "salesperson") to the real
289
+ * account: `{ username, password }`. A handle with no `username` uses the
290
+ * handle itself as the login.
291
+ *
292
+ * @param {object} world
293
+ * @param {string} handle
294
+ * @returns {{username: string, password: string}}
295
+ */
296
+ function resolveUser(world, handle) {
297
+ const users = (world.parameters && world.parameters.users) || {};
298
+ const entry = users[handle];
299
+ if (!entry) {
300
+ throw friendly({
301
+ message: `User "${handle}" is not configured.`,
302
+ hint: `Add it to worldParameters.users in cucumber.js. Known users: ${Object.keys(users).join(', ') || '(none)'}.`,
303
+ });
304
+ }
305
+ const username = typeof entry === 'string' ? handle : (entry.username || handle);
306
+ const password = typeof entry === 'string' ? entry : entry.password;
307
+ if (!password) {
308
+ throw friendly({
309
+ message: `User "${handle}" has no password configured.`,
310
+ hint: `Set worldParameters.users.${handle}.password in cucumber.js, ideally from an environment variable.`,
311
+ });
312
+ }
313
+ return { username, password };
314
+ }
315
+
316
+ /**
317
+ * The path of the login screen. On a server that holds several databases the
318
+ * `database` setting (`worldParameters.odoo.database`) selects one, so the
319
+ * database selector is skipped.
320
+ *
321
+ * @param {object} world
322
+ * @returns {string}
323
+ */
324
+ function loginPath(world) {
325
+ const settings = (world.parameters && world.parameters.odoo) || {};
326
+ return settings.database ? `/web/login?db=${encodeURIComponent(settings.database)}` : '/web/login';
327
+ }
328
+
329
+ /**
330
+ * Fill and submit Odoo's login form (`form.oe_login_form`, `#login`,
331
+ * `#password`). The submit is dispatched from inside the page: a fixed
332
+ * overlay (a cookie bar, a chat widget) can cover the button and make a real
333
+ * click wait out its timeout, while the form's own submit cannot be covered.
334
+ *
335
+ * @param {object} world
336
+ * @param {string} username
337
+ * @param {string} password
338
+ * @returns {Promise<void>}
339
+ */
340
+ async function submitLogin(world, username, password) {
341
+ const page = world.page;
342
+ trackRequests(page);
343
+ try {
344
+ await page.locator('#login').waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
345
+ } catch (error) {
346
+ const onSelector = /\/web\/database\/selector/.test(page.url());
347
+ throw friendly({
348
+ action: 'find the login form',
349
+ target: 'form[action="/web/login"]',
350
+ cause: onSelector ? 'the server shows the database selector' : error,
351
+ hint: onSelector
352
+ ? 'The server holds more than one database: set worldParameters.odoo.database in cucumber.js, or start Odoo with --db-filter.'
353
+ : 'Open the login page first ("Given I am on the login page"); a site that replaces /web/login needs its own step.',
354
+ });
355
+ }
356
+ await page.locator('#login').fill(username);
357
+ await page.locator('#password').fill(password);
358
+ await Promise.all([
359
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: WAIT_TIMEOUT }).catch(() => {}),
360
+ page.evaluate(() => {
361
+ const form = document.querySelector('form[action^="/web/login"]');
362
+ const submit = form && form.querySelector('button[type="submit"]');
363
+ if (submit) { submit.click(); return; }
364
+ if (form) form.submit();
365
+ }),
366
+ ]);
367
+ await page.waitForLoadState('domcontentloaded', { timeout: WAIT_TIMEOUT }).catch(() => {});
368
+ }
369
+
370
+ /**
371
+ * The session the browser holds, as the server reports it
372
+ * (`/web/session/get_session_info`): `uid` is empty for a visitor. The
373
+ * request runs in the page, with its cookies, and does not change the page.
374
+ *
375
+ * @param {object} world
376
+ * @returns {Promise<{uid: (number|null), name: string, username: string, isInternal: boolean}>}
377
+ */
378
+ async function sessionInfo(world) {
379
+ const info = await world.page.evaluate(async () => {
380
+ try {
381
+ const response = await fetch('/web/session/get_session_info', {
382
+ method: 'POST',
383
+ headers: { 'Content-Type': 'application/json' },
384
+ credentials: 'same-origin',
385
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: {} }),
386
+ });
387
+ const data = await response.json();
388
+ return data.result || null;
389
+ } catch (error) {
390
+ return null;
391
+ }
392
+ }).catch(() => null);
393
+ if (!info || !info.uid) return { uid: null, name: '', username: '', isInternal: false };
394
+ return {
395
+ uid: info.uid,
396
+ name: info.name || info.partner_display_name || '',
397
+ username: info.username || '',
398
+ isInternal: Boolean(info.is_internal_user),
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Log in as a user of the registry and wait for the page the user lands on:
404
+ * the web client for an internal user, the portal or the website for a
405
+ * portal user. A refused login fails here, with the message the login form
406
+ * shows, instead of in the step that follows.
407
+ *
408
+ * @param {object} world
409
+ * @param {string} handle
410
+ * @returns {Promise<void>}
411
+ */
412
+ async function loginAs(world, handle) {
413
+ const page = world.page;
414
+ const { username, password } = resolveUser(world, handle);
415
+ // Start from a new session: a logged-in visitor is redirected away from
416
+ // the login screen and would never see the form.
417
+ await world.context.clearCookies();
418
+ await navigate(world, loginPath(world));
419
+ await submitLogin(world, username, password);
420
+ const outcome = page.locator('.o_main_navbar, #wrapwrap, form[action^="/web/login"] .alert-danger').first();
421
+ await outcome.waitFor({ state: 'attached', timeout: WAIT_TIMEOUT }).catch(() => {});
422
+ const refusal = page.locator('form[action^="/web/login"] .alert-danger').first();
423
+ if (await refusal.isVisible().catch(() => false)) {
424
+ const text = ((await refusal.textContent()) || '').replace(/\s+/g, ' ').trim();
425
+ throw friendly({
426
+ message: `Logging in as "${handle}" (login "${username}") was refused.`,
427
+ cause: `the login form reads "${text}"`,
428
+ hint: 'Check that the account exists on the site under test (scripts/build-odoo.sh creates the accounts of this pack) and that the password in worldParameters.users is current.',
429
+ });
430
+ }
431
+ if (await page.locator('.o_main_navbar').first().isVisible().catch(() => false)) {
432
+ await waitForWebClient(world);
433
+ return;
434
+ }
435
+ await waitForPageLoad(page, budget(world));
436
+ // Neither the web client nor a refusal: the step must not pass on a login
437
+ // that never happened, so the server is asked.
438
+ const session = await sessionInfo(world);
439
+ if (!session.uid) {
440
+ throw friendly({
441
+ message: `Logging in as "${handle}" (login "${username}") did not start a session.`,
442
+ cause: `the page is ${page.url()} and the server reports no session for this browser`,
443
+ hint: 'The site did not answer the login form in time, or the session cookie was not kept (a proxy that drops cookies, a site served over both http and https).',
444
+ });
445
+ }
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // The web client: scope, views, dialogs
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /**
453
+ * The dialog on top, if one is open. Odoo stacks dialogs; the last one in the
454
+ * document is the one the user sees.
455
+ *
456
+ * @param {import('playwright').Page} page
457
+ * @returns {import('playwright').Locator}
458
+ */
459
+ function topDialog(page) {
460
+ return page.locator('.modal[role="dialog"]:visible, .modal.show:visible').last();
461
+ }
462
+
463
+ /**
464
+ * Whether a dialog is open.
465
+ *
466
+ * @param {import('playwright').Page} page
467
+ * @returns {Promise<boolean>}
468
+ */
469
+ async function dialogIsOpen(page) {
470
+ return (await page.locator('.modal[role="dialog"]:visible, .modal.show:visible').count()) > 0;
471
+ }
472
+
473
+ /**
474
+ * The part of the page the user is working in: the dialog on top when one is
475
+ * open, the main action otherwise. A step that names a field or a button
476
+ * looks there, as a user would.
477
+ *
478
+ * @param {import('playwright').Page} page
479
+ * @returns {Promise<import('playwright').Locator>}
480
+ */
481
+ async function activeScope(page) {
482
+ if (await dialogIsOpen(page)) return topDialog(page);
483
+ return page.locator('.o_action_manager').first();
484
+ }
485
+
486
+ /**
487
+ * Require a view of the given kind in the active scope and return it.
488
+ *
489
+ * @param {object} world
490
+ * @param {'list'|'kanban'|'form'} kind
491
+ * @returns {Promise<import('playwright').Locator>}
492
+ */
493
+ async function requireView(world, kind) {
494
+ const page = world.page;
495
+ const selectors = {
496
+ list: '.o_list_view, .o_list_renderer',
497
+ kanban: '.o_kanban_view, .o_kanban_renderer',
498
+ form: '.o_form_view',
499
+ };
500
+ const scope = await activeScope(page);
501
+ const view = scope.locator(selectors[kind]).first();
502
+ try {
503
+ await view.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
504
+ } catch (error) {
505
+ throw friendly({
506
+ message: `Expected a ${kind} view on screen.`,
507
+ cause: `the page is ${page.url()}`,
508
+ hint: kind === 'form'
509
+ ? 'Open or create a record first ("When I open the "..." record", "When I create a new record").'
510
+ : `Open a menu that lists records, or switch the view ("When I switch to the ${kind} view").`,
511
+ });
512
+ }
513
+ return view;
514
+ }
515
+
516
+ module.exports = {
517
+ WAIT_TIMEOUT,
518
+ SHORT_TIMEOUT,
519
+ supplied,
520
+ budget,
521
+ siteUrl,
522
+ escapeRegExp,
523
+ textPattern,
524
+ labelPattern,
525
+ trackRequests,
526
+ settle,
527
+ navigate,
528
+ waitForWebClient,
529
+ assertPresence,
530
+ assertText,
531
+ listTexts,
532
+ resolveUser,
533
+ loginPath,
534
+ submitLogin,
535
+ sessionInfo,
536
+ loginAs,
537
+ topDialog,
538
+ dialogIsOpen,
539
+ activeScope,
540
+ requireView,
541
+ };