@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,1398 @@
1
+ 'use strict';
2
+
3
+ // Record steps for Odoo: creating a record, the fields of a form view by
4
+ // their label (text, number, date, selection, many2one, tags, checkbox,
5
+ // radio), saving and discarding, the buttons and the status bar of a form,
6
+ // tabs, the lines of a one2many list, dialogs, notifications, the chatter and
7
+ // the access error Odoo shows a user without the rights.
8
+ //
9
+ // A step that names a field or a button looks where the user is working: in
10
+ // the dialog on top when one is open, in the main view otherwise.
11
+
12
+ const { When, Then } = require('@cucumber/cucumber');
13
+ const { friendly } = require('webship-js/tests/step-definitions/webship');
14
+ const {
15
+ WAIT_TIMEOUT,
16
+ SHORT_TIMEOUT,
17
+ supplied,
18
+ settle,
19
+ assertPresence,
20
+ assertText,
21
+ listTexts,
22
+ textPattern,
23
+ labelPattern,
24
+ escapeRegExp,
25
+ topDialog,
26
+ dialogIsOpen,
27
+ activeScope,
28
+ requireView,
29
+ } = require('./odoo-helpers');
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers: the form and its fields
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * The form the user is working in: the form of the dialog on top, or the
37
+ * form of the main view (which may be the quick-create form of a kanban
38
+ * column).
39
+ *
40
+ * @param {object} world
41
+ * @returns {Promise<import('playwright').Locator>}
42
+ */
43
+ async function activeForm(world) {
44
+ await requireView(world, 'form');
45
+ const scope = await activeScope(world.page);
46
+ return scope.locator('.o_form_view:visible').last();
47
+ }
48
+
49
+ /**
50
+ * The labels a form shows, for a hint.
51
+ *
52
+ * @param {import('playwright').Locator} form
53
+ * @returns {Promise<string>}
54
+ */
55
+ async function fieldLabels(form) {
56
+ const labels = await form.locator('label.o_form_label').evaluateAll((elements) => elements
57
+ .filter((element) => element.offsetParent !== null)
58
+ .map((element) => [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent).join(' ').replace(/\s+/g, ' ').trim())
59
+ .filter(Boolean)).catch(() => []);
60
+ const unique = [...new Set(labels)];
61
+ return unique.length ? unique.slice(0, 40).map((label) => `"${label}"`).join(', ') : '(none)';
62
+ }
63
+
64
+ /**
65
+ * Find a field of the active form. The name is, in this order: the label the
66
+ * form shows for the field ("Customer"), the placeholder of its input ("e.g.
67
+ * Product Pricing"), or the technical name of the field ("partner_id").
68
+ * Returns the field widget (`.o_field_widget`).
69
+ *
70
+ * @param {object} world
71
+ * @param {string} name
72
+ * @param {{container?: import('playwright').Locator, timeout?: number}} [options]
73
+ * @returns {Promise<import('playwright').Locator>}
74
+ */
75
+ async function findField(world, name, options = {}) {
76
+ const page = world.page;
77
+ const form = options.container || await activeForm(world);
78
+ const quoted = String(name).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
79
+
80
+ const candidates = async () => {
81
+ const found = [];
82
+ const labels = form.locator('label.o_form_label').filter({ hasText: labelPattern(name) });
83
+ const labelCount = await labels.count();
84
+ for (let index = 0; index < labelCount; index += 1) {
85
+ const label = labels.nth(index);
86
+ const target = await label.getAttribute('for').catch(() => null);
87
+ if (target) {
88
+ found.push(form.locator('.o_field_widget').filter({ has: page.locator(`[id="${target}"]`) }).last());
89
+ }
90
+ // A field with no input of that id (a read-only value, a radio group):
91
+ // the first field widget after the label.
92
+ found.push(label.locator('xpath=following::div[contains(concat(" ", normalize-space(@class), " "), " o_field_widget ")][1]'));
93
+ }
94
+ found.push(form.locator('.o_field_widget').filter({ has: page.locator(`input[placeholder="${quoted}"], textarea[placeholder="${quoted}"]`) }).last());
95
+ if (/^[a-z][a-z0-9_]*$/.test(name)) {
96
+ found.push(form.locator(`.o_field_widget[name="${name}"]`).first());
97
+ }
98
+ return found;
99
+ };
100
+
101
+ const deadline = Date.now() + (options.timeout || SHORT_TIMEOUT);
102
+ for (;;) {
103
+ for (const candidate of await candidates()) {
104
+ if (await candidate.isVisible().catch(() => false)) return candidate;
105
+ }
106
+ if (Date.now() >= deadline) break;
107
+ await settle(world, 500);
108
+ }
109
+ throw friendly({
110
+ message: `Expected a "${name}" field in the form.`,
111
+ cause: `the form shows the labels ${await fieldLabels(form)}`,
112
+ hint: 'Name the field by its label, by the placeholder of its input, or by its technical name ("partner_id"). A field in another tab is reached after opening the tab ("When I open the "..." tab").',
113
+ });
114
+ }
115
+
116
+ /**
117
+ * What kind of field a widget is, read from the widget itself.
118
+ *
119
+ * @param {import('playwright').Locator} widget
120
+ * @returns {Promise<{kind: string, readonly: boolean, classes: string}>}
121
+ */
122
+ async function describeField(widget) {
123
+ return widget.evaluate((element) => {
124
+ const classes = element.className || '';
125
+ const has = (selector) => Boolean(element.querySelector(selector));
126
+ const readonly = /\bo_readonly_modifier\b/.test(classes);
127
+ let kind = 'text';
128
+ if (/\bo_field_many2many_tags\b/.test(classes) || has('.o_field_tags')) kind = 'tags';
129
+ else if (has('input[type="checkbox"]')) kind = 'boolean';
130
+ else if (has('input[type="radio"]')) kind = 'radio';
131
+ else if (/\bo_field_priority\b/.test(classes)) kind = 'priority';
132
+ else if (has('select')) kind = 'selection';
133
+ else if (has('.o_field_many2one_selection') || /many2one/.test(classes)) kind = 'many2one';
134
+ else if (/\bo_field_html\b/.test(classes) || has('.odoo-editor-editable, .note-editable')) kind = 'html';
135
+ else if (/\bo_field_(date|datetime|daterange)\b/.test(classes)) kind = 'date';
136
+ else if (has('input, textarea')) kind = 'text';
137
+ else kind = 'static';
138
+ return { kind, readonly, classes };
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Pick an entry of the autocomplete list a many2one or a tags field opens
144
+ * (`.o-autocomplete--dropdown-menu`), by its whole text; when no entry reads
145
+ * exactly that, the single entry that contains it.
146
+ *
147
+ * @param {object} world
148
+ * @param {import('playwright').Locator} widget
149
+ * @param {string} name
150
+ * @param {string} value
151
+ * @returns {Promise<void>}
152
+ */
153
+ async function pickFromAutocomplete(world, widget, name, value) {
154
+ const page = world.page;
155
+ const input = widget.locator('.o-autocomplete input').first();
156
+ await input.click();
157
+ await input.fill(value);
158
+ const menu = widget.locator('.o-autocomplete--dropdown-menu').first();
159
+ const records = menu.locator('.o-autocomplete--dropdown-item:not(.o_m2o_dropdown_option)');
160
+ const loading = menu.locator('.o_m2o_dropdown_option:has-text("Loading"), .fa-spin');
161
+ try {
162
+ await menu.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
163
+ await loading.first().waitFor({ state: 'hidden', timeout: SHORT_TIMEOUT });
164
+ } catch (error) {
165
+ throw friendly({
166
+ action: `open the list of the "${name}" field`,
167
+ target: '.o-autocomplete--dropdown-menu',
168
+ cause: error,
169
+ hint: 'The field proposes records while the user types; a read-only field proposes nothing.',
170
+ });
171
+ }
172
+ // A proposal reads as the name of the record, sometimes after a reference
173
+ // in brackets ("[FURN_7777] Office Chair") or before a detail set off by a
174
+ // dash ("Acme Corporation \u2012 US12345673", a company and its tax ID). The
175
+ // value matches the whole text, the text without those two additions, or
176
+ // the only proposal that contains it.
177
+ const body = String(value).trim().split(/\s+/).map(escapeRegExp).join('\\s+');
178
+ const exact = new RegExp(`^\\s*(\\[[^\\]]*\\]\\s*)?${body}(\\s+\u2012\\s+.*)?\\s*$`);
179
+ const deadline = Date.now() + SHORT_TIMEOUT;
180
+ let entry = null;
181
+ for (;;) {
182
+ await settle(world, 2000);
183
+ const whole = records.filter({ hasText: exact });
184
+ if (await whole.count()) { entry = whole.first(); break; }
185
+ const partial = records.filter({ hasText: textPattern(value) });
186
+ if ((await partial.count()) === 1) { entry = partial.first(); break; }
187
+ if (Date.now() >= deadline) break;
188
+ }
189
+ if (!entry) {
190
+ const offered = await listTexts(records);
191
+ await page.keyboard.press('Escape').catch(() => {});
192
+ await input.fill('').catch(() => {});
193
+ throw friendly({
194
+ message: `Expected the "${name}" field to propose "${value}".`,
195
+ cause: `it proposes ${offered}`,
196
+ hint: 'The value is matched against the whole text of a proposal (a reference in brackets before the name and a detail after a dash are ignored); when several proposals contain the text, write one of them in full. A record the user may not read is not proposed.',
197
+ });
198
+ }
199
+ await entry.click();
200
+ await settle(world);
201
+ }
202
+
203
+ /**
204
+ * Set a field to a value, the way a user would for that kind of field.
205
+ *
206
+ * @param {object} world
207
+ * @param {import('playwright').Locator} widget
208
+ * @param {string} name
209
+ * @param {string} value
210
+ * @returns {Promise<void>}
211
+ */
212
+ async function setFieldValue(world, widget, name, value) {
213
+ const page = world.page;
214
+ const { kind, readonly } = await describeField(widget);
215
+ if (readonly || kind === 'static') {
216
+ throw friendly({
217
+ message: `The "${name}" field is read-only.`,
218
+ hint: 'A field can be read-only for this user, in this state of the record, or always (a computed value). Check the state of the record and the rights of the user.',
219
+ });
220
+ }
221
+ await widget.scrollIntoViewIfNeeded().catch(() => {});
222
+ switch (kind) {
223
+ case 'many2one': {
224
+ if (value === '') {
225
+ const input = widget.locator('.o-autocomplete input').first();
226
+ await input.fill('');
227
+ await page.keyboard.press('Escape').catch(() => {});
228
+ await input.blur();
229
+ break;
230
+ }
231
+ await pickFromAutocomplete(world, widget, name, value);
232
+ break;
233
+ }
234
+ case 'tags': {
235
+ for (const tag of value.split(/\s*,\s*/).filter(Boolean)) {
236
+ await pickFromAutocomplete(world, widget, name, tag);
237
+ }
238
+ break;
239
+ }
240
+ case 'selection': {
241
+ const select = widget.locator('select').first();
242
+ const options = await select.locator('option').evaluateAll((elements) => elements.map((option) => ({ label: (option.textContent || '').trim(), value: option.value })));
243
+ const option = options.find((candidate) => candidate.label === value);
244
+ if (!option) {
245
+ throw friendly({
246
+ message: `Expected the "${name}" field to offer "${value}".`,
247
+ cause: `it offers ${options.filter((candidate) => candidate.label).map((candidate) => `"${candidate.label}"`).join(', ') || '(none)'}`,
248
+ hint: 'The value is matched by its whole text, case-sensitive.',
249
+ });
250
+ }
251
+ await select.selectOption({ value: option.value });
252
+ break;
253
+ }
254
+ case 'radio': {
255
+ const items = widget.locator('.o_radio_item, .form-check');
256
+ const item = items.filter({ hasText: textPattern(value, { exact: true }) }).first();
257
+ if (!(await item.isVisible().catch(() => false))) {
258
+ throw friendly({
259
+ message: `Expected the "${name}" field to offer "${value}".`,
260
+ cause: `it offers ${await listTexts(items)}`,
261
+ hint: 'The value is matched by its whole text, case-sensitive.',
262
+ });
263
+ }
264
+ await item.locator('input[type="radio"]').first().check();
265
+ break;
266
+ }
267
+ case 'priority': {
268
+ const stars = widget.locator('.o_priority_star');
269
+ const star = widget.locator(`.o_priority_star[aria-label="${value.replace(/"/g, '\\"')}"]`).first();
270
+ if (!(await star.isVisible().catch(() => false))) {
271
+ const offered = await stars.evaluateAll((elements) => elements.map((element) => `"${element.getAttribute('aria-label')}"`).join(', ')).catch(() => '');
272
+ throw friendly({
273
+ message: `Expected the "${name}" field to offer "${value}".`,
274
+ cause: `it offers ${offered || '(none)'}`,
275
+ hint: 'A priority is named by the label of its star, for example "High".',
276
+ });
277
+ }
278
+ if ((await star.getAttribute('aria-checked')) !== 'true') await star.click();
279
+ break;
280
+ }
281
+ case 'boolean': {
282
+ const wanted = /^(yes|true|on|checked|1)$/i.test(value.trim());
283
+ const unwanted = /^(no|false|off|unchecked|0)$/i.test(value.trim());
284
+ if (!wanted && !unwanted) {
285
+ throw friendly({
286
+ message: `"${value}" is not a value for the "${name}" checkbox.`,
287
+ hint: 'Use "yes" or "no", or the steps "When I check the "..." field" and "When I uncheck the "..." field".',
288
+ });
289
+ }
290
+ await widget.locator('input[type="checkbox"]').first().setChecked(wanted);
291
+ break;
292
+ }
293
+ case 'html': {
294
+ const editor = widget.locator('.odoo-editor-editable, .note-editable').first();
295
+ await editor.click();
296
+ await page.keyboard.press('ControlOrMeta+a');
297
+ await page.keyboard.press('Delete');
298
+ if (value) await page.keyboard.insertText(value);
299
+ await editor.blur().catch(() => {});
300
+ break;
301
+ }
302
+ case 'date': {
303
+ const input = widget.locator('input').first();
304
+ await input.click();
305
+ await input.fill(value);
306
+ // Enter applies the typed date and closes the date picker.
307
+ await input.press('Enter');
308
+ await page.locator('.o_datetime_picker').first().waitFor({ state: 'hidden', timeout: SHORT_TIMEOUT }).catch(() => {});
309
+ break;
310
+ }
311
+ default: {
312
+ const input = widget.locator('input:not([type="hidden"]), textarea').first();
313
+ await input.fill(value);
314
+ // Odoo commits the value of an input when it changes: leaving the
315
+ // field is what a user does next.
316
+ await input.dispatchEvent('change');
317
+ await input.blur().catch(() => {});
318
+ }
319
+ }
320
+ await settle(world);
321
+ }
322
+
323
+ /**
324
+ * Read the value a field shows, as text.
325
+ *
326
+ * @param {import('playwright').Locator} widget
327
+ * @returns {Promise<string>}
328
+ */
329
+ async function readFieldValue(widget) {
330
+ const { kind } = await describeField(widget);
331
+ const clean = (text) => (text || '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
332
+ switch (kind) {
333
+ case 'tags':
334
+ return clean((await widget.locator('.o_tag_badge_text, .o_tag').evaluateAll((elements) => [...new Set(elements.map((element) => (element.textContent || '').trim()).filter(Boolean))])).join(', '));
335
+ case 'boolean':
336
+ return (await widget.locator('input[type="checkbox"]').first().isChecked()) ? 'yes' : 'no';
337
+ case 'radio':
338
+ return clean(await widget.locator('.o_radio_item:has(input:checked), .form-check:has(input:checked)').first().textContent().catch(() => ''));
339
+ case 'priority':
340
+ return clean(await widget.locator('.o_priority_star[aria-checked="true"]').last().getAttribute('aria-label').catch(() => ''));
341
+ case 'selection':
342
+ return clean(await widget.locator('select').first().evaluate((select) => (select.selectedOptions[0] ? select.selectedOptions[0].textContent : '')));
343
+ case 'html':
344
+ return clean(await widget.locator('.odoo-editor-editable, .note-editable, .o_readonly').first().textContent().catch(() => ''));
345
+ case 'static':
346
+ return clean(await widget.textContent());
347
+ default:
348
+ return clean(await widget.locator('input:not([type="hidden"]), textarea').first().inputValue());
349
+ }
350
+ }
351
+
352
+ /**
353
+ * The buttons of the active scope a user can click by their text: the action
354
+ * buttons of a form (header, status bar, smart buttons, the body of the
355
+ * form) or the buttons of a dialog.
356
+ *
357
+ * @param {object} world
358
+ * @returns {Promise<{scope: import('playwright').Locator, buttons: import('playwright').Locator, inDialog: boolean}>}
359
+ */
360
+ async function scopeButtons(world) {
361
+ const page = world.page;
362
+ const inDialog = await dialogIsOpen(page);
363
+ const scope = await activeScope(page);
364
+ const buttons = inDialog
365
+ ? scope.locator('.modal-footer button:visible, .modal-body button:visible, .modal-header button:visible')
366
+ : scope.locator('.o_form_view button:visible, .o_control_panel .o_control_panel_main_buttons button:visible, .o_kanban_quick_create button:visible');
367
+ return { scope, buttons, inDialog };
368
+ }
369
+
370
+ /**
371
+ * Find one button of the active scope by its text: the whole text first,
372
+ * then (for smart buttons such as "3 Quotations") the single button that
373
+ * contains it.
374
+ *
375
+ * @param {object} world
376
+ * @param {string} text
377
+ * @returns {Promise<{button: (import('playwright').Locator|null), offered: string, inDialog: boolean}>}
378
+ */
379
+ async function findButton(world, text) {
380
+ await settle(world);
381
+ const { buttons, inDialog } = await scopeButtons(world);
382
+ const deadline = Date.now() + SHORT_TIMEOUT;
383
+ do {
384
+ const exact = buttons.filter({ hasText: textPattern(text, { exact: true }) });
385
+ if (await exact.count()) return { button: exact.first(), offered: '', inDialog };
386
+ const partial = buttons.filter({ hasText: textPattern(text) });
387
+ if ((await partial.count()) === 1) return { button: partial.first(), offered: '', inDialog };
388
+ await settle(world, 500);
389
+ } while (Date.now() < deadline);
390
+ return { button: null, offered: await listTexts(buttons, 40), inDialog };
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Creating, saving and discarding a record
395
+ // ---------------------------------------------------------------------------
396
+
397
+ /**
398
+ * Start a new record with the "New" button of the control panel
399
+ * (`.o_list_button_add`, `.o-kanban-button-new`, `.o_form_button_create`) and
400
+ * wait for the empty form. On a kanban view with quick creation the form
401
+ * opens inside the first column.
402
+ *
403
+ * Example #1: When I create a new record
404
+ * Example #2: When we create a new record
405
+ * Example #3: And start a new record
406
+ * Example #4: When I start a new record
407
+ * Example #5: And we start a new record
408
+ *
409
+ */
410
+ When(/^(I |we )*(?:create|start) a new record$/, async function (pronounCase) {
411
+ const page = this.page;
412
+ await settle(this);
413
+ const scope = await activeScope(page);
414
+ const button = scope.locator('.o_control_panel .o_list_button_add:visible, .o_control_panel .o-kanban-button-new:visible, .o_control_panel .o_form_button_create:visible').first();
415
+ try {
416
+ await button.click({ timeout: SHORT_TIMEOUT });
417
+ } catch (error) {
418
+ throw friendly({
419
+ action: 'click the "New" button of the control panel',
420
+ target: '.o_list_button_add, .o-kanban-button-new, .o_form_button_create',
421
+ cause: error,
422
+ hint: 'The button is missing when the user may not create this kind of record, or when the view does not allow creation.',
423
+ });
424
+ }
425
+ try {
426
+ await page.locator('.o_form_view.o_form_editable:visible, .o_form_view .o_form_editable:visible, .o_kanban_quick_create:visible').first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
427
+ } catch (error) {
428
+ throw friendly({
429
+ message: 'Expected an empty form after clicking "New".',
430
+ cause: error,
431
+ hint: 'An error dialog on screen means the form could not be prepared for this user.',
432
+ });
433
+ }
434
+ await settle(this);
435
+ });
436
+
437
+ /**
438
+ * Click the save button of the form view and wait for the server. With
439
+ * `strict` a save the form refuses is an error that names the fields Odoo
440
+ * marks as invalid.
441
+ *
442
+ * @param {object} world
443
+ * @param {boolean} strict
444
+ * @returns {Promise<void>}
445
+ */
446
+ async function saveForm(world, strict) {
447
+ const page = world.page;
448
+ const form = await activeForm(world);
449
+ await settle(world);
450
+ const scope = await activeScope(page);
451
+ const save = scope.locator('.o_form_button_save:visible').first();
452
+ if (await save.isVisible().catch(() => false)) {
453
+ await save.click();
454
+ }
455
+ await settle(world);
456
+ if (!strict) return;
457
+ const invalid = form.locator('.o_field_widget.o_field_invalid');
458
+ if (await invalid.count()) {
459
+ const names = await invalid.evaluateAll((elements) => [...new Set(elements.map((element) => {
460
+ const input = element.querySelector('[id]');
461
+ const label = input && element.ownerDocument.querySelector(`label[for="${input.id}"]`);
462
+ return (label && label.textContent.replace(/\?\s*$/, '').trim()) || element.getAttribute('name');
463
+ }))].join(', '));
464
+ throw friendly({
465
+ message: 'The record was not saved: the form has invalid fields.',
466
+ cause: `Odoo marks these fields as invalid: ${names}`,
467
+ hint: 'Fill the required fields first; the notification on screen names them as well. To check a refused save on purpose, use "When I try to save the record".',
468
+ });
469
+ }
470
+ const dirty = scope.locator('.o_form_status_indicator_buttons:not(.invisible) .o_form_button_save:visible').first();
471
+ try {
472
+ await dirty.waitFor({ state: 'hidden', timeout: SHORT_TIMEOUT });
473
+ } catch (error) {
474
+ const dialogTitle = (await dialogIsOpen(page)) ? ((await topDialog(page).locator('.modal-title').first().textContent().catch(() => '')) || '').trim() : '';
475
+ throw friendly({
476
+ message: 'Expected the record to be saved.',
477
+ cause: dialogTitle ? `a dialog opened: "${dialogTitle}"` : 'the form still shows unsaved changes',
478
+ hint: 'An error dialog names the rule the server refused the record for (a validation error, an access right).',
479
+ });
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Save the record on the form view with the save button of the control
485
+ * panel (`.o_form_button_save`) and wait for the server. The step fails when
486
+ * the form refuses to save, naming the fields Odoo marks as invalid.
487
+ *
488
+ * Example #1: When I save the record
489
+ * Example #2: When we save the record
490
+ * Example #3: And save the form
491
+ * Example #4: When I save the form
492
+ * Example #5: And we save the form
493
+ *
494
+ */
495
+ When(/^(I |we )*save the (?:record|form)$/, async function (pronounCase) {
496
+ await saveForm(this, true);
497
+ });
498
+
499
+ /**
500
+ * Click the save button of the form view without expecting the save to
501
+ * succeed, for a scenario whose subject is the refusal: a missing required
502
+ * field, a validation rule. Assert the outcome in the steps that follow (a
503
+ * notification, an invalid field, a dialog).
504
+ *
505
+ * Example #1: When I try to save the record
506
+ * Example #2: When we try to save the record
507
+ * Example #3: And try to save the record
508
+ * Example #4: When I try to save the form
509
+ * Example #5: And we try to save the form
510
+ *
511
+ */
512
+ When(/^(I |we )*try to save the (?:record|form)$/, async function (pronounCase) {
513
+ await saveForm(this, false);
514
+ });
515
+
516
+ /**
517
+ * Discard the changes of the form view with the discard button of the
518
+ * control panel (`.o_form_button_cancel`). A new record that was never saved
519
+ * is dropped and the previous view comes back.
520
+ *
521
+ * Example #1: When I discard the changes
522
+ * Example #2: When we discard the changes
523
+ * Example #3: And I discard the record
524
+ * Example #4: When we discard the record
525
+ * Example #5: And we discard the changes of the record
526
+ *
527
+ */
528
+ When(/^(I |we )*discard the (?:record|changes(?: of the record)?)$/, async function (pronounCase) {
529
+ const page = this.page;
530
+ await activeForm(this);
531
+ const scope = await activeScope(page);
532
+ const discard = scope.locator('.o_form_button_cancel:visible').first();
533
+ try {
534
+ await discard.click({ timeout: SHORT_TIMEOUT });
535
+ } catch (error) {
536
+ throw friendly({
537
+ action: 'click the discard button of the form',
538
+ target: '.o_form_button_cancel',
539
+ cause: error,
540
+ hint: 'The button is shown while the form has unsaved changes, and on a new record.',
541
+ });
542
+ }
543
+ await settle(this);
544
+ });
545
+
546
+ /**
547
+ * Assert the form view has, or does not have, unsaved changes: the save and
548
+ * discard buttons of the control panel are shown while the record differs
549
+ * from what the server holds (`.o_form_status_indicator_buttons`).
550
+ *
551
+ * Example #1: Then the record should have unsaved changes
552
+ * Example #2: And the record should not have unsaved changes
553
+ * Example #3: Then the form should have unsaved changes
554
+ * Example #4: But the form should have unsaved changes
555
+ * Example #5: Then the form should not have unsaved changes
556
+ *
557
+ */
558
+ Then(/^the (?:record|form) should( not)* have unsaved changes$/, async function (notCase) {
559
+ await activeForm(this);
560
+ await settle(this);
561
+ const scope = await activeScope(this.page);
562
+ const buttons = scope.locator('.o_form_status_indicator_buttons:not(.invisible) .o_form_button_save').first();
563
+ await assertPresence(buttons, notCase, 'the save button of a form with unsaved changes (".o_form_button_save")', {
564
+ timeout: SHORT_TIMEOUT,
565
+ hint: 'A new record always counts as unsaved; Odoo also saves a record on its own when the user leaves the form.',
566
+ });
567
+ });
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // Fields
571
+ // ---------------------------------------------------------------------------
572
+
573
+ /**
574
+ * Set a field of the form to a value. The field is named by its label, by
575
+ * the placeholder of its input or by its technical name, and filled the way
576
+ * a user would for its kind: typed for a text, a number or a date, chosen
577
+ * for a selection, a radio group or a priority, picked from the proposals
578
+ * for a many2one, added for a tags field (several tags separated by commas),
579
+ * "yes" or "no" for a checkbox. An empty value clears a text or a many2one.
580
+ *
581
+ * Example #1: When I set the "Customer" field to "Azure Interior"
582
+ * Example #2: When we set the "Expiration" field to "12/31/2030"
583
+ * Example #3: And I set the "Product Type" field to "Service"
584
+ * Example #4: When I set the "e.g. Product Pricing" field to "Office chairs for the new site"
585
+ * Example #5: And we set the "Tags" field to "Product, Services"
586
+ *
587
+ */
588
+ When(/^(I |we )*set the "([^"]*)" field to "([^"]*)"$/, async function (pronounCase, name, value) {
589
+ const widget = await findField(this, name);
590
+ await setFieldValue(this, widget, name, value);
591
+ });
592
+
593
+ /**
594
+ * Set several fields of the form from a table: the first column names the
595
+ * field, the second gives the value. Each row behaves like `When I set the
596
+ * "<field>" field to "<value>"`.
597
+ *
598
+ * Example #1: When I set the fields:
599
+ * Example #2: When we set the fields:
600
+ * Example #3: And set the following fields:
601
+ * Example #4: When I set the following fields:
602
+ * Example #5: And we set the following fields:
603
+ *
604
+ */
605
+ When(/^(I |we )*set the (?:following )?fields:$/, async function (pronounCase, dataTable) {
606
+ for (const [name, value] of dataTable.raw()) {
607
+ const widget = await findField(this, name);
608
+ await setFieldValue(this, widget, name, value === undefined ? '' : value);
609
+ }
610
+ });
611
+
612
+ /**
613
+ * Check or uncheck a checkbox field of the form, named like any other
614
+ * field.
615
+ *
616
+ * Example #1: When I check the "Can be Sold" field
617
+ * Example #2: When we uncheck the "Can be Purchased" field
618
+ * Example #3: And I check the "Is a Company" field
619
+ * Example #4: When I tick the "Active" field
620
+ * Example #5: And we untick the "Can be Sold" field
621
+ *
622
+ */
623
+ When(/^(I |we )*(check|uncheck|tick|untick) the "([^"]*)" field$/, async function (pronounCase, action, name) {
624
+ const widget = await findField(this, name);
625
+ const { kind } = await describeField(widget);
626
+ if (kind !== 'boolean') {
627
+ throw friendly({
628
+ message: `The "${name}" field is not a checkbox.`,
629
+ hint: 'Use "When I set the "..." field to "..."" for the other kinds of field.',
630
+ });
631
+ }
632
+ await setFieldValue(this, widget, name, /^(check|tick)$/.test(action) ? 'yes' : 'no');
633
+ });
634
+
635
+ /**
636
+ * Remove a tag from a tags field of the form, by its text.
637
+ *
638
+ * Example #1: When I remove "Product" from the "Tags" field
639
+ * Example #2: When we remove "Services" from the "Tags" field
640
+ * Example #3: And I remove "Design" from the "Tags" field
641
+ * Example #4: When I remove "Vendor" from the "category_id" field
642
+ * Example #5: And we remove "Information" from the "Tags" field
643
+ *
644
+ */
645
+ When(/^(I |we )*remove "([^"]*)" from the "([^"]*)" field$/, async function (pronounCase, tag, name) {
646
+ const widget = await findField(this, name);
647
+ const tags = widget.locator('.o_tag');
648
+ const entry = tags.filter({ hasText: textPattern(tag, { exact: true }) }).first();
649
+ if (!(await entry.isVisible().catch(() => false))) {
650
+ throw friendly({
651
+ message: `Expected the tag "${tag}" in the "${name}" field.`,
652
+ cause: `the field holds ${await listTexts(tags)}`,
653
+ hint: 'The step removes a tag of a tags field (a many2many shown as badges).',
654
+ });
655
+ }
656
+ await entry.locator('.o_delete').first().click();
657
+ await settle(this);
658
+ });
659
+
660
+ /**
661
+ * Assert the value a field of the form shows: the text of an input, the
662
+ * chosen entry of a selection, a radio group or a many2one, the tags of a
663
+ * tags field separated by commas, "yes" or "no" for a checkbox. With "be"
664
+ * the value must be exactly the text; with "show" it only has to include
665
+ * it.
666
+ *
667
+ * Example #1: Then the "Customer" field should be "Azure Interior"
668
+ * Example #2: And the "Product Type" field should be "Service"
669
+ * Example #3: Then the "Tags" field should show "Product"
670
+ * Example #4: And the "Customer" field should not be "Deco Addict"
671
+ * Example #5: Then the "Email" field should not show "example.org"
672
+ *
673
+ */
674
+ Then(/^the "([^"]*)" field should( not)* (be|show) "([^"]*)"$/, async function (name, notCase, mode, expected) {
675
+ await settle(this);
676
+ const widget = await findField(this, name);
677
+ const wanted = expected.replace(/\s+/g, ' ').trim();
678
+ const matches = (value) => (mode === 'be' ? value === wanted : value.includes(wanted));
679
+ const deadline = Date.now() + SHORT_TIMEOUT;
680
+ let value = '';
681
+ do {
682
+ value = await readFieldValue(widget).catch(() => '');
683
+ if (matches(value) !== Boolean(notCase)) return;
684
+ await settle(this, 500);
685
+ } while (Date.now() < deadline);
686
+ throw friendly({
687
+ message: `Expected the "${name}" field ${notCase ? 'not ' : ''}to ${mode} "${expected}".`,
688
+ cause: `it shows "${value}"`,
689
+ hint: 'The value is compared as text, whitespace-collapsed and case-sensitive. A number and a date are shown in the format of the user\'s language.',
690
+ });
691
+ });
692
+
693
+ /**
694
+ * Assert a checkbox field of the form is, or is not, checked.
695
+ *
696
+ * Example #1: Then the "Can be Sold" field should be checked
697
+ * Example #2: And the "Can be Purchased" field should not be checked
698
+ * Example #3: Then the "Is a Company" field should be checked
699
+ * Example #4: And the "Active" field should be unchecked
700
+ * Example #5: Then the "Can be Sold" field should be unchecked
701
+ *
702
+ */
703
+ Then(/^the "([^"]*)" field should( not)* be (checked|unchecked)$/, async function (name, notCase, state) {
704
+ await settle(this);
705
+ const widget = await findField(this, name);
706
+ const { kind } = await describeField(widget);
707
+ if (kind !== 'boolean') {
708
+ throw friendly({
709
+ message: `The "${name}" field is not a checkbox.`,
710
+ hint: 'Use "Then the "..." field should be "..."" for the other kinds of field.',
711
+ });
712
+ }
713
+ const wanted = (state === 'checked') !== Boolean(notCase);
714
+ const checkbox = widget.locator('input[type="checkbox"]').first();
715
+ const deadline = Date.now() + SHORT_TIMEOUT;
716
+ let checked = await checkbox.isChecked();
717
+ while (checked !== wanted && Date.now() < deadline) {
718
+ await settle(this, 500);
719
+ checked = await checkbox.isChecked();
720
+ }
721
+ if (checked !== wanted) {
722
+ throw friendly({
723
+ message: `Expected the "${name}" field to be ${wanted ? 'checked' : 'unchecked'}.`,
724
+ cause: `it is ${checked ? 'checked' : 'unchecked'}`,
725
+ hint: 'Another field can change a checkbox through an onchange; check the order of the steps.',
726
+ });
727
+ }
728
+ });
729
+
730
+ /**
731
+ * Assert the form shows, or does not show, a field. A field can be hidden
732
+ * for a user without the right group, or in some states of the record.
733
+ *
734
+ * Example #1: Then I should see the "Customer" field
735
+ * Example #2: And I should not see the "Margin" field
736
+ * Example #3: Then we should see the "Salesperson" field
737
+ * Example #4: And I should see the "Expected Closing" field
738
+ * Example #5: Then we should not see the "Cost" field
739
+ *
740
+ */
741
+ Then(/^(I |we )*should( not)* see the "([^"]*)" field$/, async function (pronounCase, notCase, name) {
742
+ await settle(this);
743
+ let found = true;
744
+ let failure = null;
745
+ try {
746
+ await findField(this, name, { timeout: notCase ? 2000 : SHORT_TIMEOUT });
747
+ } catch (error) {
748
+ found = false;
749
+ failure = error;
750
+ }
751
+ if (!notCase && !found) throw failure;
752
+ if (notCase && found) {
753
+ throw friendly({
754
+ message: `Expected no "${name}" field in the form.`,
755
+ hint: 'A field is hidden by its "invisible" rule or by the groups of the user; check both.',
756
+ });
757
+ }
758
+ });
759
+
760
+ /**
761
+ * Assert a field of the form is, or is not, required, read-only or marked
762
+ * as invalid, from the classes Odoo puts on the field
763
+ * (`o_required_modifier`, `o_readonly_modifier`, `o_field_invalid`).
764
+ *
765
+ * Example #1: Then the "Customer" field should be required
766
+ * Example #2: And the "Total" field should be read-only
767
+ * Example #3: Then the "Customer" field should be invalid
768
+ * Example #4: And the "Expiration" field should not be required
769
+ * Example #5: Then the "Customer" field should not be read-only
770
+ *
771
+ */
772
+ Then(/^the "([^"]*)" field should( not)* be (required|read-only|readonly|invalid)$/, async function (name, notCase, state) {
773
+ await settle(this);
774
+ const widget = await findField(this, name);
775
+ const classes = { required: 'o_required_modifier', 'read-only': 'o_readonly_modifier', readonly: 'o_readonly_modifier', invalid: 'o_field_invalid' };
776
+ const wanted = !notCase;
777
+ const has = () => widget.evaluate((element, className) => element.classList.contains(className), classes[state]);
778
+ const deadline = Date.now() + SHORT_TIMEOUT;
779
+ let actual = await has();
780
+ while (actual !== wanted && Date.now() < deadline) {
781
+ await settle(this, 500);
782
+ actual = await has();
783
+ }
784
+ if (actual !== wanted) {
785
+ throw friendly({
786
+ message: `Expected the "${name}" field ${wanted ? '' : 'not '}to be ${state}.`,
787
+ cause: `the field ${actual ? 'is' : 'is not'} ${state}`,
788
+ hint: 'A field is marked as invalid after a save that was refused; required and read-only can depend on the state of the record.',
789
+ });
790
+ }
791
+ });
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Buttons, status bar and tabs of a form
795
+ // ---------------------------------------------------------------------------
796
+
797
+ /**
798
+ * Click a button of the form by its text: an action button of the header
799
+ * ("Confirm", "Send by Email"), a smart button ("Quotations") or a button in
800
+ * the body of the form. The step waits for the action the button starts.
801
+ *
802
+ * Example #1: When I click the "Confirm" button in the form
803
+ * Example #2: When we click the "New Quotation" button in the form
804
+ * Example #3: And I click the "Create Invoice" button in the form
805
+ * Example #4: When I click the "Won" button of the form
806
+ * Example #5: And we click the "Add" button of the form
807
+ *
808
+ */
809
+ When(/^(I |we )*click the "([^"]*)" button (?:in|of) the form$/, async function (pronounCase, text) {
810
+ await activeForm(this).catch(() => {});
811
+ const { button, offered } = await findButton(this, text);
812
+ if (!button) {
813
+ throw friendly({
814
+ message: `Expected a "${text}" button in the form.`,
815
+ cause: `the form offers ${offered}`,
816
+ hint: 'The buttons of a form depend on the state of the record and on the rights of the user. The text is matched in full, case-sensitive.',
817
+ });
818
+ }
819
+ await button.click();
820
+ await settle(this);
821
+ });
822
+
823
+ /**
824
+ * Assert the form offers, or does not offer, a button with the given text.
825
+ * The buttons of a form depend on the state of the record and on the groups
826
+ * of the user.
827
+ *
828
+ * Example #1: Then I should see the "Confirm" button in the form
829
+ * Example #2: And I should not see the "Create Invoice" button in the form
830
+ * Example #3: Then we should see the "Send by Email" button in the form
831
+ * Example #4: And I should not see the "Confirm" button of the form
832
+ * Example #5: Then we should not see the "Cancel" button of the form
833
+ *
834
+ */
835
+ Then(/^(I |we )*should( not)* see the "([^"]*)" button (?:in|of) the form$/, async function (pronounCase, notCase, text) {
836
+ await activeForm(this);
837
+ await settle(this);
838
+ const { buttons } = await scopeButtons(this);
839
+ const button = buttons.filter({ hasText: textPattern(text, { exact: true }) }).first();
840
+ await assertPresence(button, notCase, `the "${text}" button of the form`, {
841
+ timeout: SHORT_TIMEOUT,
842
+ hint: `The form offers ${await listTexts(buttons, 40)}.`,
843
+ });
844
+ });
845
+
846
+ /**
847
+ * Assert the status the status bar of the form highlights
848
+ * (`.o_statusbar_status .o_arrow_button_current`): the state of a quotation
849
+ * ("Sales Order"), the stage of an opportunity ("Qualified").
850
+ *
851
+ * Example #1: Then the status should be "Sales Order"
852
+ * Example #2: And the stage should be "Qualified"
853
+ * Example #3: Then the record status should be "Quotation"
854
+ * Example #4: And the status should not be "Cancelled"
855
+ * Example #5: Then the record stage should not be "Won"
856
+ *
857
+ */
858
+ Then(/^the (?:record )?(?:status|stage) should( not)* be "([^"]*)"$/, async function (notCase, expected) {
859
+ const form = await activeForm(this);
860
+ await settle(this);
861
+ const bar = form.locator('.o_statusbar_status').first();
862
+ await assertPresence(bar, undefined, 'the status bar of the form (".o_statusbar_status")', {
863
+ timeout: SHORT_TIMEOUT,
864
+ hint: 'Not every form has a status bar; read the state from its field instead ("Then the "..." field should be "..."").',
865
+ });
866
+ const current = bar.locator('.o_arrow_button_current, button[aria-checked="true"]');
867
+ await assertText(current, expected, notCase, 'the current status of the status bar', {
868
+ exact: false,
869
+ timeout: SHORT_TIMEOUT,
870
+ hint: `The status bar offers ${await listTexts(bar.locator('button.o_arrow_button[role="radio"], button.o_arrow_button:not([aria-label])'))}.`,
871
+ });
872
+ });
873
+
874
+ /**
875
+ * Change the stage of the record by clicking a stage of the status bar, the
876
+ * way a user moves an opportunity to "Proposition". A status bar that is
877
+ * read-only (the state of a quotation, changed by its buttons) refuses the
878
+ * click.
879
+ *
880
+ * Example #1: When I set the stage to "Proposition"
881
+ * Example #2: When we set the stage to "Qualified"
882
+ * Example #3: And I set the status to "Won"
883
+ * Example #4: When I set the record stage to "New"
884
+ * Example #5: And we set the record status to "In Progress"
885
+ *
886
+ */
887
+ When(/^(I |we )*set the (?:record )?(?:status|stage) to "([^"]*)"$/, async function (pronounCase, name) {
888
+ const page = this.page;
889
+ const form = await activeForm(this);
890
+ await settle(this);
891
+ const bar = form.locator('.o_statusbar_status').first();
892
+ const stages = bar.locator('button.o_arrow_button:visible');
893
+ let stage = stages.filter({ hasText: textPattern(name, { exact: true }) }).first();
894
+ if (!(await stage.isVisible().catch(() => false))) {
895
+ // Stages that do not fit are folded under a "More" button.
896
+ const more = bar.locator('button[aria-label="More..."]:visible, .o_statusbar_status > .dropdown-toggle:visible').first();
897
+ if (await more.isVisible().catch(() => false)) {
898
+ await more.click();
899
+ stage = page.locator('.o-dropdown--menu:visible .dropdown-item').filter({ hasText: textPattern(name, { exact: true }) }).first();
900
+ }
901
+ }
902
+ if (!(await stage.isVisible().catch(() => false))) {
903
+ throw friendly({
904
+ message: `Expected a "${name}" stage in the status bar.`,
905
+ cause: `the status bar offers ${await listTexts(stages)}`,
906
+ hint: 'The name is matched by its whole text, case-sensitive.',
907
+ });
908
+ }
909
+ if (await stage.isDisabled().catch(() => false)) {
910
+ throw friendly({
911
+ message: `The "${name}" stage of the status bar cannot be clicked.`,
912
+ hint: 'This status bar is read-only: the state changes through the buttons of the form ("When I click the "Confirm" button in the form").',
913
+ });
914
+ }
915
+ await stage.click();
916
+ await settle(this);
917
+ });
918
+
919
+ /**
920
+ * Open a tab of the form's notebook by its title (`.o_notebook_headers
921
+ * .nav-link`), to reach the fields it holds.
922
+ *
923
+ * Example #1: When I open the "Order Lines" tab
924
+ * Example #2: When we open the "Other Info" tab
925
+ * Example #3: And I open the "Sales & Purchase" tab
926
+ * Example #4: When I open the "Internal Notes" tab
927
+ * Example #5: And we open the "Extra Information" tab
928
+ *
929
+ */
930
+ When(/^(I |we )*open the "([^"]*)" tab$/, async function (pronounCase, title) {
931
+ const form = await activeForm(this);
932
+ const tabs = form.locator('.o_notebook_headers .nav-link:visible');
933
+ const tab = tabs.filter({ hasText: textPattern(title, { exact: true }) }).first();
934
+ try {
935
+ await tab.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
936
+ } catch (error) {
937
+ throw friendly({
938
+ message: `Expected a "${title}" tab in the form.`,
939
+ cause: `the form offers the tabs ${await listTexts(tabs)}`,
940
+ hint: 'A tab can be hidden for a user without the right group, or in some states of the record.',
941
+ });
942
+ }
943
+ await tab.click();
944
+ await settle(this);
945
+ });
946
+
947
+ /**
948
+ * Assert the form offers, or does not offer, a tab with the given title.
949
+ *
950
+ * Example #1: Then I should see the "Order Lines" tab
951
+ * Example #2: And I should not see the "Accounting" tab
952
+ * Example #3: Then we should see the "Other Info" tab
953
+ * Example #4: And I should see the "Internal Notes" tab
954
+ * Example #5: Then we should not see the "Invoicing" tab
955
+ *
956
+ */
957
+ Then(/^(I |we )*should( not)* see the "([^"]*)" tab$/, async function (pronounCase, notCase, title) {
958
+ const form = await activeForm(this);
959
+ await settle(this);
960
+ const tabs = form.locator('.o_notebook_headers .nav-link:visible');
961
+ await assertPresence(tabs.filter({ hasText: textPattern(title, { exact: true }) }).first(), notCase, `the "${title}" tab of the form`, {
962
+ timeout: SHORT_TIMEOUT,
963
+ hint: `The form offers the tabs ${await listTexts(tabs)}.`,
964
+ });
965
+ });
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // Lines of a one2many list (order lines, invoice lines)
969
+ // ---------------------------------------------------------------------------
970
+
971
+ /**
972
+ * Add a line to the list of the open tab (order lines, invoice lines) and
973
+ * fill its cells from a table: the first column names the column of the
974
+ * list, the second gives the value. The "Add a line" link of the list is
975
+ * named by its text, for example "Add a product".
976
+ *
977
+ * Example #1: When I add a line with "Add a product":
978
+ * Example #2: When we add a line with "Add a line":
979
+ * Example #3: And I add an line with "Add a product":
980
+ * Example #4: When I add a line with "Add a section":
981
+ * Example #5: And we add an line with "Add a line":
982
+ *
983
+ */
984
+ When(/^(I |we )*add an? line with "([^"]*)":$/, async function (pronounCase, linkText, dataTable) {
985
+ const page = this.page;
986
+ const form = await activeForm(this);
987
+ await settle(this);
988
+ const links = form.locator('.o_field_x2many_list_row_add a:visible');
989
+ const link = links.filter({ hasText: textPattern(linkText, { exact: true }) }).first();
990
+ try {
991
+ await link.click({ timeout: SHORT_TIMEOUT });
992
+ } catch (error) {
993
+ throw friendly({
994
+ message: `Expected an "${linkText}" link under a list of the form.`,
995
+ cause: `the form offers ${await listTexts(links)}`,
996
+ hint: 'Open the tab that holds the list first ("When I open the "Order Lines" tab"); a read-only record has no such link.',
997
+ });
998
+ }
999
+ await settle(this);
1000
+ const list = form.locator('.o_field_x2many .o_list_renderer').filter({ has: page.locator('tr.o_selected_row') }).first();
1001
+ const row = list.locator('tr.o_selected_row').first();
1002
+ try {
1003
+ await row.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
1004
+ } catch (error) {
1005
+ throw friendly({
1006
+ message: 'Expected a new line in edition after clicking the link.',
1007
+ cause: error,
1008
+ hint: 'A list that opens a dialog for a new line is filled with the field steps instead ("When I set the "..." field to "...").',
1009
+ });
1010
+ }
1011
+ for (const [column, value] of dataTable.raw()) {
1012
+ const headers = list.locator('thead th[data-name]');
1013
+ const header = headers.filter({ hasText: textPattern(column, { exact: true }) }).first();
1014
+ const technicalName = (await header.count()) ? await header.getAttribute('data-name') : (/^[a-z][a-z0-9_]*$/.test(column) ? column : null);
1015
+ if (!technicalName) {
1016
+ throw friendly({
1017
+ message: `Expected a "${column}" column in the list.`,
1018
+ cause: `the list shows the columns ${await listTexts(headers)}`,
1019
+ hint: 'Name the column by its header, or by the technical name of its field.',
1020
+ });
1021
+ }
1022
+ const cell = list.locator(`tr.o_selected_row td[name="${technicalName}"]`).first();
1023
+ await cell.click();
1024
+ const widget = cell.locator('.o_field_widget').first();
1025
+ await setFieldValue(this, widget, column, value);
1026
+ }
1027
+ await settle(this);
1028
+ });
1029
+
1030
+ /**
1031
+ * Assert a list of the form shows, or does not show, a line with the given
1032
+ * text (the product of an order line, the label of an invoice line).
1033
+ *
1034
+ * Example #1: Then I should see the line "Office Chair"
1035
+ * Example #2: And I should see the line "Large Cabinet"
1036
+ * Example #3: Then we should see the line "Virtual Interior Design"
1037
+ * Example #4: And I should not see the line "Office Lamp"
1038
+ * Example #5: Then we should not see the line "Desk Combination"
1039
+ *
1040
+ */
1041
+ Then(/^(I |we )*should( not)* see the line "([^"]*)"$/, async function (pronounCase, notCase, text) {
1042
+ const form = await activeForm(this);
1043
+ await settle(this);
1044
+ const rows = form.locator('.o_field_x2many .o_list_renderer tr.o_data_row');
1045
+ await assertPresence(rows.filter({ hasText: textPattern(text) }).first(), notCase, `a line showing "${text}" in a list of the form`, {
1046
+ timeout: SHORT_TIMEOUT,
1047
+ hint: 'Open the tab that holds the list first ("When I open the "Order Lines" tab").',
1048
+ });
1049
+ });
1050
+
1051
+ // ---------------------------------------------------------------------------
1052
+ // Dialogs
1053
+ // ---------------------------------------------------------------------------
1054
+
1055
+ /**
1056
+ * Assert a dialog is, or is not, open, optionally with a title
1057
+ * (`.modal[role="dialog"] .modal-title`): a confirmation, a wizard, an
1058
+ * error.
1059
+ *
1060
+ * Example #1: Then I should see a dialog
1061
+ * Example #2: And I should see the "Confirmation" dialog
1062
+ * Example #3: Then we should see the "Cancel Sales Order" dialog
1063
+ * Example #4: And I should not see a dialog
1064
+ * Example #5: Then we should not see the "Access Error" dialog
1065
+ *
1066
+ */
1067
+ Then(/^(I |we )*should( not)* see (?:an? dialog|the "([^"]*)" dialog)$/, async function (pronounCase, notCase, title) {
1068
+ const page = this.page;
1069
+ await settle(this);
1070
+ const dialogs = page.locator('.modal[role="dialog"]:visible, .modal.show:visible');
1071
+ if (!supplied(title)) {
1072
+ await assertPresence(dialogs.last(), notCase, 'a dialog (".modal")', {
1073
+ timeout: SHORT_TIMEOUT,
1074
+ hint: notCase ? `A dialog titled "${((await dialogs.last().locator('.modal-title').first().textContent().catch(() => '')) || '').trim()}" is open.` : 'A dialog opens for a confirmation, a wizard or an error.',
1075
+ });
1076
+ return;
1077
+ }
1078
+ const titled = dialogs.filter({ has: page.locator('.modal-title', { hasText: textPattern(title, { exact: true }) }) }).last();
1079
+ await assertPresence(titled, notCase, `the "${title}" dialog`, {
1080
+ timeout: SHORT_TIMEOUT,
1081
+ hint: `The open dialogs are titled ${await listTexts(dialogs.locator('.modal-title'))}.`,
1082
+ });
1083
+ });
1084
+
1085
+ /**
1086
+ * Assert the dialog on top shows, or does not show, a text in its body.
1087
+ *
1088
+ * Example #1: Then I should see "Are you sure" in the dialog
1089
+ * Example #2: And I should see "You are not allowed" in the dialog
1090
+ * Example #3: Then we should see "cancel" in the dialog
1091
+ * Example #4: And I should not see "error" in the dialog
1092
+ * Example #5: Then we should not see "Traceback" in the dialog
1093
+ *
1094
+ */
1095
+ Then(/^(I |we )*should( not)* see "([^"]*)" in the dialog$/, async function (pronounCase, notCase, text) {
1096
+ const page = this.page;
1097
+ await settle(this);
1098
+ const dialog = topDialog(page);
1099
+ await assertPresence(dialog, undefined, 'a dialog (".modal")', {
1100
+ timeout: SHORT_TIMEOUT,
1101
+ hint: 'A dialog opens for a confirmation, a wizard or an error.',
1102
+ });
1103
+ await assertText(dialog.locator('.modal-content'), text, notCase, 'the dialog', { timeout: SHORT_TIMEOUT });
1104
+ });
1105
+
1106
+ /**
1107
+ * Click a button of the dialog on top by its text ("Ok", "Discard", "Save &
1108
+ * Close", the action of a wizard) and wait for what it starts.
1109
+ *
1110
+ * Example #1: When I click the "Ok" button in the dialog
1111
+ * Example #2: When we click the "Discard" button in the dialog
1112
+ * Example #3: And I click the "Save & Close" button in the dialog
1113
+ * Example #4: When I click the "Cancel" button of the dialog
1114
+ * Example #5: And we click the "Create Draft Invoice" button of the dialog
1115
+ *
1116
+ */
1117
+ When(/^(I |we )*click the "([^"]*)" button (?:in|of) the dialog$/, async function (pronounCase, text) {
1118
+ const page = this.page;
1119
+ await settle(this);
1120
+ if (!(await dialogIsOpen(page))) {
1121
+ throw friendly({
1122
+ message: 'Expected a dialog on screen.',
1123
+ hint: 'A dialog opens for a confirmation, a wizard or an error; check the step before this one.',
1124
+ });
1125
+ }
1126
+ const { button, offered } = await findButton(this, text);
1127
+ if (!button) {
1128
+ throw friendly({
1129
+ message: `Expected a "${text}" button in the dialog.`,
1130
+ cause: `the dialog offers ${offered}`,
1131
+ hint: 'The text is matched in full, case-sensitive.',
1132
+ });
1133
+ }
1134
+ await button.click();
1135
+ await settle(this);
1136
+ });
1137
+
1138
+ /**
1139
+ * Confirm the dialog on top with its main button (the primary button of the
1140
+ * footer: "Ok", "Delete", "Confirm"), or close it without confirming (its
1141
+ * secondary button or its close button).
1142
+ *
1143
+ * Example #1: When I confirm the dialog
1144
+ * Example #2: When we confirm the dialog
1145
+ * Example #3: And I close the dialog
1146
+ * Example #4: When I dismiss the dialog
1147
+ * Example #5: And we close the dialog
1148
+ *
1149
+ */
1150
+ When(/^(I |we )*(confirm|close|dismiss) the dialog$/, async function (pronounCase, action) {
1151
+ const page = this.page;
1152
+ await settle(this);
1153
+ const dialog = topDialog(page);
1154
+ try {
1155
+ await dialog.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
1156
+ } catch (error) {
1157
+ throw friendly({
1158
+ message: 'Expected a dialog on screen.',
1159
+ cause: error,
1160
+ hint: 'A dialog opens for a confirmation, a wizard or an error; check the step before this one.',
1161
+ });
1162
+ }
1163
+ const before = await page.locator('.modal[role="dialog"]:visible, .modal.show:visible').count();
1164
+ const button = action === 'confirm'
1165
+ ? dialog.locator('.modal-footer button.btn-primary:visible').first()
1166
+ : dialog.locator('.modal-footer button.btn-secondary:visible, .modal-header button[aria-label="Close"]:visible, .modal-header .btn-close:visible, .modal-footer button:visible').first();
1167
+ try {
1168
+ await button.click({ timeout: SHORT_TIMEOUT });
1169
+ } catch (error) {
1170
+ throw friendly({
1171
+ action: `${action} the dialog`,
1172
+ target: action === 'confirm' ? '.modal-footer .btn-primary' : '.modal-footer .btn-secondary',
1173
+ cause: error,
1174
+ hint: `The dialog offers ${await listTexts(dialog.locator('.modal-footer button'))}; click one by its text ("When I click the "..." button in the dialog").`,
1175
+ });
1176
+ }
1177
+ await settle(this);
1178
+ await page.waitForFunction((count) => document.querySelectorAll('.modal[role="dialog"], .modal.show').length < count, before, { timeout: SHORT_TIMEOUT }).catch(() => {});
1179
+ await settle(this);
1180
+ });
1181
+
1182
+ /**
1183
+ * Assert Odoo shows, or does not show, an access error: the "Access Error"
1184
+ * dialog (`.o_error_dialog`) the web client opens when the server refuses an
1185
+ * operation for the current user. An optional text is looked for in the
1186
+ * message.
1187
+ *
1188
+ * Example #1: Then I should see an access error
1189
+ * Example #2: And I should not see an access error
1190
+ * Example #3: Then we should see an access error
1191
+ * Example #4: And I should see an access error "You are not allowed to access"
1192
+ * Example #5: Then we should see an access error "Sales Order"
1193
+ *
1194
+ */
1195
+ Then(/^(I |we )*should( not)* see an access error(?: "([^"]*)")?$/, async function (pronounCase, notCase, text) {
1196
+ const page = this.page;
1197
+ await settle(this);
1198
+ const dialog = page.locator('.modal[role="dialog"]:visible').filter({ has: page.locator('.o_error_dialog') }).filter({ has: page.locator('.modal-title', { hasText: /Access (Error|Denied)/ }) }).last();
1199
+ await assertPresence(dialog, notCase, 'the "Access Error" dialog (".o_error_dialog")', {
1200
+ timeout: SHORT_TIMEOUT,
1201
+ hint: notCase
1202
+ ? `The dialog reads "${((await dialog.locator('.modal-body').first().textContent().catch(() => '')) || '').replace(/\s+/g, ' ').trim().slice(0, 200)}".`
1203
+ : 'The server refuses an operation when the user lacks the access right or a record rule excludes the record; a user with the rights sees no such dialog.',
1204
+ });
1205
+ if (!notCase && supplied(text)) {
1206
+ await assertText(dialog.locator('.modal-body'), text, undefined, 'the access error', { timeout: SHORT_TIMEOUT });
1207
+ }
1208
+ });
1209
+
1210
+ // ---------------------------------------------------------------------------
1211
+ // Notifications
1212
+ // ---------------------------------------------------------------------------
1213
+
1214
+ /**
1215
+ * Assert Odoo shows, or does not show, a notification with the given text:
1216
+ * the message that appears at the top right of the web client
1217
+ * (`.o_notification`). An optional kind checks its colour: success, warning,
1218
+ * danger or info.
1219
+ *
1220
+ * Example #1: Then I should see the notification "Invalid fields"
1221
+ * Example #2: And I should see the success notification "saved"
1222
+ * Example #3: Then we should see the danger notification "Invalid fields"
1223
+ * Example #4: And I should not see the notification "Invalid fields"
1224
+ * Example #5: Then we should see the warning notification "Warning"
1225
+ *
1226
+ */
1227
+ Then(/^(I |we )*should( not)* see the (?:(success|warning|danger|info) )?notification "([^"]*)"$/, async function (pronounCase, notCase, kind, text) {
1228
+ const page = this.page;
1229
+ const notifications = page.locator('.o_notification_manager .o_notification');
1230
+ const matching = notifications.filter({ hasText: textPattern(text) });
1231
+ const wanted = kind
1232
+ ? matching.filter({ has: page.locator(`xpath=self::*[contains(@class, "border-${kind}") or contains(@class, "bg-${kind}")] | .//*[contains(@class, "bg-${kind}") or contains(@class, "border-${kind}")]`) })
1233
+ : matching;
1234
+ await assertPresence(wanted.first(), notCase, `a ${kind ? `${kind} ` : ''}notification containing "${text}"`, {
1235
+ timeout: SHORT_TIMEOUT,
1236
+ hint: `The notifications on screen read ${await listTexts(notifications)}. A notification closes on its own after a few seconds unless it is sticky.`,
1237
+ });
1238
+ });
1239
+
1240
+ /**
1241
+ * Close the notifications on screen with their close button, so that they do
1242
+ * not cover a button a later step needs.
1243
+ *
1244
+ * Example #1: When I close the notifications
1245
+ * Example #2: When we close the notifications
1246
+ * Example #3: And I close the notification
1247
+ * Example #4: When close the notifications
1248
+ * Example #5: And we close the notification
1249
+ *
1250
+ */
1251
+ When(/^(I |we )*close the notifications?$/, async function (pronounCase) {
1252
+ const page = this.page;
1253
+ const closers = page.locator('.o_notification_manager .o_notification .o_notification_close');
1254
+ let guard = 10;
1255
+ while ((await closers.count()) > 0 && guard > 0) {
1256
+ guard -= 1;
1257
+ await closers.first().click().catch(() => {});
1258
+ await settle(this, 1000);
1259
+ }
1260
+ });
1261
+
1262
+ // ---------------------------------------------------------------------------
1263
+ // Chatter
1264
+ // ---------------------------------------------------------------------------
1265
+
1266
+ /**
1267
+ * The chatter of the form on screen (`.o-mail-Chatter`), required.
1268
+ *
1269
+ * @param {object} world
1270
+ * @returns {Promise<import('playwright').Locator>}
1271
+ */
1272
+ async function requireChatter(world) {
1273
+ const page = world.page;
1274
+ const chatter = page.locator('.o-mail-Chatter:visible').first();
1275
+ try {
1276
+ await chatter.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
1277
+ } catch (error) {
1278
+ throw friendly({
1279
+ message: 'Expected the chatter of the record on screen.',
1280
+ cause: `the page is ${page.url()}`,
1281
+ hint: 'The chatter is part of the form view of a model that keeps messages (an opportunity, a quotation, a contact); a new record shows it once it is saved.',
1282
+ });
1283
+ }
1284
+ return chatter;
1285
+ }
1286
+
1287
+ /**
1288
+ * Write in the chatter with one of its two buttons and send.
1289
+ *
1290
+ * @param {object} world
1291
+ * @param {'note'|'message'} kind
1292
+ * @param {string} text
1293
+ * @returns {Promise<void>}
1294
+ */
1295
+ async function writeInChatter(world, kind, text) {
1296
+ const chatter = await requireChatter(world);
1297
+ await settle(world);
1298
+ const opener = kind === 'note' ? chatter.locator('.o-mail-Chatter-logNote').first() : chatter.locator('.o-mail-Chatter-sendMessage').first();
1299
+ try {
1300
+ await opener.click({ timeout: SHORT_TIMEOUT });
1301
+ } catch (error) {
1302
+ throw friendly({
1303
+ action: kind === 'note' ? 'click "Log note" in the chatter' : 'click "Send message" in the chatter',
1304
+ target: kind === 'note' ? '.o-mail-Chatter-logNote' : '.o-mail-Chatter-sendMessage',
1305
+ cause: error,
1306
+ hint: 'The buttons are missing for a user who may not write on the record.',
1307
+ });
1308
+ }
1309
+ const input = chatter.locator('.o-mail-Composer-input').first();
1310
+ await input.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
1311
+ await input.fill(text);
1312
+ const send = chatter.locator('.o-mail-Composer-send:visible').first();
1313
+ await send.click();
1314
+ const posted = chatter.locator('.o-mail-Message').filter({ hasText: textPattern(text) }).first();
1315
+ try {
1316
+ await posted.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
1317
+ } catch (error) {
1318
+ throw friendly({
1319
+ message: `Expected the ${kind} "${text}" in the chatter after sending it.`,
1320
+ cause: error,
1321
+ hint: 'An error dialog on screen names what the server refused.',
1322
+ });
1323
+ }
1324
+ await settle(world);
1325
+ }
1326
+
1327
+ /**
1328
+ * Assert the form shows, or does not show, the chatter: the thread of
1329
+ * messages, notes and activities of the record (`.o-mail-Chatter`).
1330
+ *
1331
+ * Example #1: Then I should see the chatter
1332
+ * Example #2: And I should not see the chatter
1333
+ * Example #3: Then we should see the chatter
1334
+ * Example #4: And I should see the chatter of the record
1335
+ * Example #5: Then we should not see the chatter of the record
1336
+ *
1337
+ */
1338
+ Then(/^(I |we )*should( not)* see the chatter(?: of the record)?$/, async function (pronounCase, notCase) {
1339
+ await settle(this);
1340
+ await assertPresence(this.page.locator('.o-mail-Chatter:visible').first(), notCase, 'the chatter (".o-mail-Chatter")', {
1341
+ timeout: SHORT_TIMEOUT,
1342
+ hint: 'The chatter is part of the form view of a model that keeps messages.',
1343
+ });
1344
+ });
1345
+
1346
+ /**
1347
+ * Log an internal note on the record with the "Log note" button of the
1348
+ * chatter, and wait for the note to appear in the thread. A note stays
1349
+ * internal: followers who are not internal users do not receive it.
1350
+ *
1351
+ * Example #1: When I log the note "Called the customer, waiting for the order"
1352
+ * Example #2: When we log the note "Checked by the review"
1353
+ * Example #3: And I log the note "Price confirmed"
1354
+ * Example #4: When I log the internal note "Reminder sent"
1355
+ * Example #5: And we log the internal note "Left a voice mail"
1356
+ *
1357
+ */
1358
+ When(/^(I |we )*log the (?:internal )?note "([^"]*)"$/, async function (pronounCase, text) {
1359
+ await writeInChatter(this, 'note', text);
1360
+ });
1361
+
1362
+ /**
1363
+ * Send a message on the record with the "Send message" button of the
1364
+ * chatter, and wait for the message to appear in the thread. A message is
1365
+ * sent to the followers of the record: on a test site, make sure outgoing
1366
+ * mail is not delivered to real addresses.
1367
+ *
1368
+ * Example #1: When I send the message "Your order is confirmed"
1369
+ * Example #2: When we send the message "Thank you for your request"
1370
+ * Example #3: And I send the message "The quotation is attached"
1371
+ * Example #4: When I send the chatter message "We will call you back"
1372
+ * Example #5: And we send the chatter message "Delivery is planned"
1373
+ *
1374
+ */
1375
+ When(/^(I |we )*send the (?:chatter )?message "([^"]*)"$/, async function (pronounCase, text) {
1376
+ await writeInChatter(this, 'message', text);
1377
+ });
1378
+
1379
+ /**
1380
+ * Assert the chatter shows, or does not show, a text in one of its messages
1381
+ * (`.o-mail-Message`): a note, a message, or the line Odoo logs when a
1382
+ * tracked field changes.
1383
+ *
1384
+ * Example #1: Then I should see "Called the customer" in the chatter
1385
+ * Example #2: And I should see "Quotation confirmed" in the chatter
1386
+ * Example #3: Then we should see "Stage" in the chatter
1387
+ * Example #4: And I should not see "Internal remark" in the chatter
1388
+ * Example #5: Then we should not see "Lost" in the chatter
1389
+ *
1390
+ */
1391
+ Then(/^(I |we )*should( not)* see "([^"]*)" in the chatter$/, async function (pronounCase, notCase, text) {
1392
+ const chatter = await requireChatter(this);
1393
+ await settle(this);
1394
+ await assertText(chatter.locator('.o-mail-Message'), text, notCase, 'the messages of the chatter (".o-mail-Message")', {
1395
+ timeout: SHORT_TIMEOUT,
1396
+ hint: 'The chatter loads its messages by pages ("Load More"); the newest are on top.',
1397
+ });
1398
+ });