@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,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
|
+
});
|