@webship-js/odoo-steps 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -62
- package/STEPS.md +141 -0
- package/package.json +6 -3
- package/tests/step-definitions/README.md +11 -1
- package/tests/step-definitions/odoo-helpers.js +541 -0
- package/tests/step-definitions/odoo-record.steps.js +1398 -0
- package/tests/step-definitions/odoo-session.steps.js +402 -0
- package/tests/step-definitions/odoo-webclient.steps.js +1236 -0
- package/tests/step-definitions/odoo.steps.js +431 -304
|
@@ -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
|
+
};
|