@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,1236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Web client steps for Odoo: the apps menu and the menus of an app, the
|
|
4
|
+
// breadcrumb, opening an action by its external ID, the view switcher, the
|
|
5
|
+
// search view (search, filters, grouping, facets), the list view (records,
|
|
6
|
+
// selection, groups, pager, the Actions menu) and the kanban view (cards,
|
|
7
|
+
// columns, moving a card).
|
|
8
|
+
//
|
|
9
|
+
// Every step waits on Odoo's own signals (the "Loading" indicator, the data
|
|
10
|
+
// requests of the web client) and never on a fixed delay.
|
|
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
|
+
settle,
|
|
18
|
+
navigate,
|
|
19
|
+
waitForWebClient,
|
|
20
|
+
assertPresence,
|
|
21
|
+
assertText,
|
|
22
|
+
listTexts,
|
|
23
|
+
textPattern,
|
|
24
|
+
escapeRegExp,
|
|
25
|
+
activeScope,
|
|
26
|
+
requireView,
|
|
27
|
+
} = require('./odoo-helpers');
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Open the apps menu of the navigation bar (`.o_navbar_apps_menu`) and return
|
|
35
|
+
* the list of apps. Odoo Enterprise shows the apps on a home screen
|
|
36
|
+
* (`.o_home_menu`) instead; both are handled.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} world
|
|
39
|
+
* @returns {Promise<import('playwright').Locator>} The app entries.
|
|
40
|
+
*/
|
|
41
|
+
async function openAppsMenu(world) {
|
|
42
|
+
const page = world.page;
|
|
43
|
+
await waitForWebClient(world);
|
|
44
|
+
const homeApps = page.locator('.o_home_menu .o_app');
|
|
45
|
+
if (await homeApps.first().isVisible().catch(() => false)) return homeApps;
|
|
46
|
+
const toggle = page.locator('.o_main_navbar .o_navbar_apps_menu button.dropdown-toggle, .o_main_navbar .o_menu_toggle').first();
|
|
47
|
+
const apps = page.locator('.o_navbar_apps_menu .dropdown-menu .o_app, .o_home_menu .o_app');
|
|
48
|
+
if (!(await apps.first().isVisible().catch(() => false))) {
|
|
49
|
+
try {
|
|
50
|
+
await toggle.click({ timeout: SHORT_TIMEOUT });
|
|
51
|
+
await apps.first().waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw friendly({
|
|
54
|
+
action: 'open the apps menu',
|
|
55
|
+
target: '.o_navbar_apps_menu',
|
|
56
|
+
cause: error,
|
|
57
|
+
hint: 'The apps menu is the first button of the navigation bar; a dialog on top of the page keeps it out of reach.',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return apps;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Close the apps menu when it is open as a dropdown.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} world
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
async function closeAppsMenu(world) {
|
|
71
|
+
const page = world.page;
|
|
72
|
+
const menu = page.locator('.o_navbar_apps_menu .dropdown-menu');
|
|
73
|
+
if (await menu.isVisible().catch(() => false)) {
|
|
74
|
+
await page.keyboard.press('Escape');
|
|
75
|
+
await menu.waitFor({ state: 'hidden', timeout: SHORT_TIMEOUT }).catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The entries of the app's menu bar (`.o_menu_sections`): a direct entry is a
|
|
81
|
+
* link, an entry with children is a dropdown button. Entries that do not fit
|
|
82
|
+
* the width are moved by Odoo under a "+" button (`.o_menu_sections_more`).
|
|
83
|
+
*
|
|
84
|
+
* @param {import('playwright').Page} page
|
|
85
|
+
* @returns {import('playwright').Locator}
|
|
86
|
+
*/
|
|
87
|
+
function menuSections(page) {
|
|
88
|
+
return page.locator('.o_main_navbar .o_menu_sections > .o_nav_entry, .o_main_navbar .o_menu_sections > .dropdown > button.dropdown-toggle, .o_main_navbar .o_menu_sections > .o-dropdown > button');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Walk a menu path such as "Orders > Quotations" and return the last entry,
|
|
93
|
+
* visible and ready to be clicked. Each part is matched by its whole text.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} world
|
|
96
|
+
* @param {string} path
|
|
97
|
+
* @returns {Promise<import('playwright').Locator>}
|
|
98
|
+
*/
|
|
99
|
+
async function reachMenu(world, path) {
|
|
100
|
+
const page = world.page;
|
|
101
|
+
const parts = String(path).split(/\s*>\s*/).map((part) => part.trim()).filter(Boolean);
|
|
102
|
+
if (!parts.length) {
|
|
103
|
+
throw friendly({ message: 'The menu path is empty.', hint: 'Name the menu as it reads in the menu bar, with " > " between levels: "Orders > Quotations".' });
|
|
104
|
+
}
|
|
105
|
+
await waitForWebClient(world);
|
|
106
|
+
let top = menuSections(page).filter({ hasText: textPattern(parts[0], { exact: true }) }).first();
|
|
107
|
+
if (!(await top.isVisible().catch(() => false))) {
|
|
108
|
+
const more = page.locator('.o_main_navbar .o_menu_sections_more button, .o_main_navbar .o_menu_sections .o_menu_sections_more').first();
|
|
109
|
+
if (await more.isVisible().catch(() => false)) {
|
|
110
|
+
await more.click();
|
|
111
|
+
top = page.locator('.o_menu_sections_more .dropdown-menu .dropdown-item, .o-dropdown--menu .dropdown-item, .o-dropdown--menu .dropdown-toggle').filter({ hasText: textPattern(parts[0], { exact: true }) }).first();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await top.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw friendly({
|
|
118
|
+
message: `Expected a "${parts[0]}" menu in the menu bar of the app.`,
|
|
119
|
+
cause: `the menu bar offers ${await listTexts(menuSections(page))}`,
|
|
120
|
+
hint: 'Open the app first ("When I open the "Sales" app"); a menu the user has no access to is not shown.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
let current = top;
|
|
124
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
125
|
+
await current.click();
|
|
126
|
+
const items = page.locator('.o-dropdown--menu:visible .dropdown-item, .o-dropdown--menu:visible .dropdown-toggle, .dropdown-menu.show .dropdown-item');
|
|
127
|
+
const next = items.filter({ hasText: textPattern(parts[index], { exact: true }) }).first();
|
|
128
|
+
try {
|
|
129
|
+
await next.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw friendly({
|
|
132
|
+
message: `Expected a "${parts[index]}" entry under the "${parts.slice(0, index).join(' > ')}" menu.`,
|
|
133
|
+
cause: `the menu offers ${await listTexts(items)}`,
|
|
134
|
+
hint: 'Each part of the path is matched by its whole text, case-sensitive; a menu the user has no access to is not shown.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
current = next;
|
|
138
|
+
}
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The search view of the active scope (`.o_searchview`), required.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} world
|
|
146
|
+
* @returns {Promise<{scope: import('playwright').Locator, input: import('playwright').Locator}>}
|
|
147
|
+
*/
|
|
148
|
+
async function requireSearchView(world) {
|
|
149
|
+
const scope = await activeScope(world.page);
|
|
150
|
+
const input = scope.locator('.o_searchview input.o_searchview_input').first();
|
|
151
|
+
try {
|
|
152
|
+
await input.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw friendly({
|
|
155
|
+
message: 'Expected a search view on screen.',
|
|
156
|
+
cause: `the page is ${world.page.url()}`,
|
|
157
|
+
hint: 'The search view sits in the control panel of a list, kanban or other multi-record view; a form view has none.',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return { scope, input };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Toggle an entry of the search options menu (the caret at the right of the
|
|
165
|
+
* search view): a filter, a grouping or a favourite.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} world
|
|
168
|
+
* @param {'filter'|'group'|'favorite'} kind
|
|
169
|
+
* @param {string} name
|
|
170
|
+
* @returns {Promise<void>}
|
|
171
|
+
*/
|
|
172
|
+
async function toggleSearchOption(world, kind, name) {
|
|
173
|
+
const page = world.page;
|
|
174
|
+
const { scope } = await requireSearchView(world);
|
|
175
|
+
const sections = {
|
|
176
|
+
filter: { selector: '.o_filter_menu', label: 'filter', title: 'Filters' },
|
|
177
|
+
group: { selector: '.o_group_by_menu', label: 'grouping', title: 'Group By' },
|
|
178
|
+
favorite: { selector: '.o_favorite_menu', label: 'favourite', title: 'Favorites' },
|
|
179
|
+
};
|
|
180
|
+
const section = sections[kind];
|
|
181
|
+
const menu = page.locator('.o_search_bar_menu').first();
|
|
182
|
+
if (!(await menu.isVisible().catch(() => false))) {
|
|
183
|
+
await scope.locator('.o_searchview_dropdown_toggler').first().click();
|
|
184
|
+
await menu.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
185
|
+
}
|
|
186
|
+
const entries = menu.locator(`${section.selector} .o_menu_item`);
|
|
187
|
+
const entry = entries.filter({ hasText: textPattern(name, { exact: true }) }).first();
|
|
188
|
+
try {
|
|
189
|
+
await entry.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const offered = await listTexts(entries.filter({ hasNot: page.locator('option') }));
|
|
192
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
193
|
+
throw friendly({
|
|
194
|
+
message: `Expected a "${name}" ${section.label} under "${section.title}" in the search options.`,
|
|
195
|
+
cause: `the menu offers ${offered}`,
|
|
196
|
+
hint: 'The name is matched by its whole text, case-sensitive. The entries depend on the view: check the search options of this menu in the web client.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
await entry.click();
|
|
200
|
+
await settle(world);
|
|
201
|
+
await page.keyboard.press('Escape');
|
|
202
|
+
await menu.waitFor({ state: 'hidden', timeout: SHORT_TIMEOUT }).catch(() => {});
|
|
203
|
+
await settle(world);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The records on screen in the active scope: the rows of a list view or the
|
|
208
|
+
* cards of a kanban view.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} world
|
|
211
|
+
* @returns {Promise<{records: import('playwright').Locator, kind: string}>}
|
|
212
|
+
*/
|
|
213
|
+
async function recordsOnScreen(world) {
|
|
214
|
+
const page = world.page;
|
|
215
|
+
const scope = await activeScope(page);
|
|
216
|
+
const container = scope.locator('.o_list_renderer, .o_kanban_renderer').first();
|
|
217
|
+
try {
|
|
218
|
+
await container.waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw friendly({
|
|
221
|
+
message: 'Expected a list or a kanban view on screen.',
|
|
222
|
+
cause: `the page is ${page.url()}`,
|
|
223
|
+
hint: 'Open a menu that lists records first ("When I open the "Orders > Quotations" menu").',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
await settle(world);
|
|
227
|
+
const isList = await scope.locator('.o_list_renderer').first().isVisible().catch(() => false);
|
|
228
|
+
return isList
|
|
229
|
+
? { records: scope.locator('.o_list_renderer tr.o_data_row'), kind: 'list' }
|
|
230
|
+
: { records: scope.locator('.o_kanban_renderer .o_kanban_record:not(.o_kanban_ghost)'), kind: 'kanban' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One record on screen, by a text it shows (its name, its reference).
|
|
235
|
+
*
|
|
236
|
+
* @param {object} world
|
|
237
|
+
* @param {string} text
|
|
238
|
+
* @returns {Promise<{record: import('playwright').Locator, records: import('playwright').Locator, kind: string}>}
|
|
239
|
+
*/
|
|
240
|
+
async function findRecord(world, text) {
|
|
241
|
+
const { records, kind } = await recordsOnScreen(world);
|
|
242
|
+
const record = records.filter({ hasText: textPattern(text) }).first();
|
|
243
|
+
return { record, records, kind };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A kanban column by its title.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} world
|
|
250
|
+
* @param {string} title
|
|
251
|
+
* @returns {Promise<import('playwright').Locator>}
|
|
252
|
+
*/
|
|
253
|
+
async function findColumn(world, title) {
|
|
254
|
+
const view = await requireView(world, 'kanban');
|
|
255
|
+
await settle(world);
|
|
256
|
+
const columns = view.locator('.o_kanban_group');
|
|
257
|
+
const column = columns.filter({ has: world.page.locator('.o_column_title', { hasText: textPattern(title, { exact: true }) }) }).first();
|
|
258
|
+
try {
|
|
259
|
+
await column.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw friendly({
|
|
262
|
+
message: `Expected a "${title}" column in the kanban view.`,
|
|
263
|
+
cause: `the view shows the columns ${await listTexts(view.locator('.o_kanban_group .o_column_title'))}`,
|
|
264
|
+
hint: 'A kanban view has columns when its records are grouped (by stage, for example); a folded column shows its title only.',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return column;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Apps and menus
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Open an app from the apps menu of the navigation bar, by its name, and wait
|
|
276
|
+
* for its first screen.
|
|
277
|
+
*
|
|
278
|
+
* Example #1: When I open the "CRM" app
|
|
279
|
+
* Example #2: When we open the "Sales" app
|
|
280
|
+
* Example #3: And I open the "Contacts" app
|
|
281
|
+
* Example #4: When I open the "Invoicing" application
|
|
282
|
+
* Example #5: And we open the "Website" application
|
|
283
|
+
*
|
|
284
|
+
*/
|
|
285
|
+
When(/^(I |we )*open the "([^"]*)" app(?:lication)?$/, async function (pronounCase, name) {
|
|
286
|
+
const page = this.page;
|
|
287
|
+
const apps = await openAppsMenu(this);
|
|
288
|
+
const app = apps.filter({ hasText: textPattern(name, { exact: true }) }).first();
|
|
289
|
+
if (!(await app.isVisible().catch(() => false))) {
|
|
290
|
+
const offered = await listTexts(apps, 40);
|
|
291
|
+
await closeAppsMenu(this);
|
|
292
|
+
throw friendly({
|
|
293
|
+
message: `Expected a "${name}" app in the apps menu.`,
|
|
294
|
+
cause: `the menu offers ${offered}`,
|
|
295
|
+
hint: 'An app is listed when it is installed and the user belongs to one of its groups. The name is matched by its whole text, case-sensitive.',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
await app.click();
|
|
299
|
+
try {
|
|
300
|
+
await page.locator('.o_main_navbar .o_menu_brand').filter({ hasText: textPattern(name, { exact: true }) }).first().waitFor({ state: 'attached', timeout: WAIT_TIMEOUT });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
throw friendly({
|
|
303
|
+
message: `Expected the "${name}" app to open.`,
|
|
304
|
+
cause: `the navigation bar reads "${((await page.locator('.o_main_navbar .o_menu_brand').first().textContent().catch(() => '')) || '').trim()}"`,
|
|
305
|
+
hint: 'An error dialog on screen means the first screen of the app could not be loaded for this user.',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
await settle(this);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Assert the apps menu lists, or does not list, an app. An app is listed
|
|
313
|
+
* when it is installed and the user belongs to one of its groups, so this is
|
|
314
|
+
* the first check of an access-rights scenario.
|
|
315
|
+
*
|
|
316
|
+
* Example #1: Then I should see the "CRM" app
|
|
317
|
+
* Example #2: And I should not see the "Invoicing" app
|
|
318
|
+
* Example #3: Then we should see the "Sales" app
|
|
319
|
+
* Example #4: And I should not see the "Settings" application
|
|
320
|
+
* Example #5: Then we should not see the "Website" application
|
|
321
|
+
*
|
|
322
|
+
*/
|
|
323
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" app(?:lication)?$/, async function (pronounCase, notCase, name) {
|
|
324
|
+
const apps = await openAppsMenu(this);
|
|
325
|
+
const offered = await listTexts(apps, 40);
|
|
326
|
+
const listed = (await apps.filter({ hasText: textPattern(name, { exact: true }) }).count()) > 0;
|
|
327
|
+
await closeAppsMenu(this);
|
|
328
|
+
if (listed === Boolean(notCase)) {
|
|
329
|
+
throw friendly({
|
|
330
|
+
message: notCase ? `Expected no "${name}" app in the apps menu.` : `Expected a "${name}" app in the apps menu.`,
|
|
331
|
+
cause: `the menu offers ${offered}`,
|
|
332
|
+
hint: 'An app is listed when it is installed and the user belongs to one of its groups.',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Assert the app on screen is, or is not, the given one: the name the
|
|
339
|
+
* navigation bar shows next to the apps menu (`.o_menu_brand`).
|
|
340
|
+
*
|
|
341
|
+
* Example #1: Then I should be in the "CRM" app
|
|
342
|
+
* Example #2: And I should be in the "Sales" app
|
|
343
|
+
* Example #3: Then we should be in the "Contacts" app
|
|
344
|
+
* Example #4: And I should not be in the "Discuss" app
|
|
345
|
+
* Example #5: Then we should not be in the "Invoicing" application
|
|
346
|
+
*
|
|
347
|
+
*/
|
|
348
|
+
Then(/^(I |we )*should( not)* be in the "([^"]*)" app(?:lication)?$/, async function (pronounCase, notCase, name) {
|
|
349
|
+
await waitForWebClient(this);
|
|
350
|
+
await assertText(this.page.locator('.o_main_navbar .o_menu_brand'), name, notCase, 'the app name in the navigation bar (".o_menu_brand")', {
|
|
351
|
+
exact: true,
|
|
352
|
+
hint: 'The navigation bar shows the name of the app the current menu belongs to.',
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Open a menu of the current app, by its path in the menu bar. Levels are
|
|
358
|
+
* separated by " > ": "Orders > Quotations", "Configuration > Sales Teams".
|
|
359
|
+
*
|
|
360
|
+
* Example #1: When I open the "Orders > Quotations" menu
|
|
361
|
+
* Example #2: When we open the "Sales > My Pipeline" menu
|
|
362
|
+
* Example #3: And I open the "Customers > Invoices" menu
|
|
363
|
+
* Example #4: When I open the "Configuration > Contact Tags" menu
|
|
364
|
+
* Example #5: And we open the "Reporting" menu
|
|
365
|
+
*
|
|
366
|
+
*/
|
|
367
|
+
When(/^(I |we )*open the "([^"]*)" menu$/, async function (pronounCase, path) {
|
|
368
|
+
const entry = await reachMenu(this, path);
|
|
369
|
+
await entry.click();
|
|
370
|
+
await settle(this);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Assert the menu bar of the current app offers, or does not offer, a menu.
|
|
375
|
+
* A menu is shown when the user belongs to one of its groups. A path opens
|
|
376
|
+
* the parent menus to look inside.
|
|
377
|
+
*
|
|
378
|
+
* Example #1: Then I should see the "Orders" menu
|
|
379
|
+
* Example #2: And I should not see the "Configuration" menu
|
|
380
|
+
* Example #3: Then we should see the "Orders > Quotations" menu
|
|
381
|
+
* Example #4: And I should not see the "Reporting" menu
|
|
382
|
+
* Example #5: Then we should not see the "Configuration > Settings" menu
|
|
383
|
+
*
|
|
384
|
+
*/
|
|
385
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" menu$/, async function (pronounCase, notCase, path) {
|
|
386
|
+
const page = this.page;
|
|
387
|
+
let found = true;
|
|
388
|
+
let failure = null;
|
|
389
|
+
try {
|
|
390
|
+
await reachMenu(this, path);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
found = false;
|
|
393
|
+
failure = error;
|
|
394
|
+
}
|
|
395
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
396
|
+
if (!notCase && !found) throw failure;
|
|
397
|
+
if (notCase && found) {
|
|
398
|
+
throw friendly({
|
|
399
|
+
message: `Expected no "${path}" menu in the menu bar of the app.`,
|
|
400
|
+
cause: `the menu bar offers ${await listTexts(menuSections(page))}`,
|
|
401
|
+
hint: 'A menu is shown when the user belongs to one of its groups.',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Open an action by its external ID (`module.xml_id`), for example
|
|
408
|
+
* `sale.action_quotations_with_onboarding`. This is the stable way to reach a
|
|
409
|
+
* screen without depending on menu labels, and the way to check that a user
|
|
410
|
+
* without the rights is refused.
|
|
411
|
+
*
|
|
412
|
+
* Example #1: When I open the "sale.action_quotations_with_onboarding" action
|
|
413
|
+
* Example #2: When we open the "crm.crm_lead_action_pipeline" action
|
|
414
|
+
* Example #3: And I open the "contacts.action_contacts" action
|
|
415
|
+
* Example #4: When I open the "account.action_move_out_invoice_type" action
|
|
416
|
+
* Example #5: And we open the "base.action_res_users" action
|
|
417
|
+
*
|
|
418
|
+
*/
|
|
419
|
+
When(/^(I |we )*open the "([\w.]+)" action$/, async function (pronounCase, externalId) {
|
|
420
|
+
const page = this.page;
|
|
421
|
+
if (!/^\w+\.\w+$/.test(externalId)) {
|
|
422
|
+
throw friendly({
|
|
423
|
+
message: `"${externalId}" is not an external ID.`,
|
|
424
|
+
hint: 'An external ID reads module.name, for example "sale.action_quotations_with_onboarding" (Settings > Technical > Actions shows it in developer mode).',
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const version = await page.evaluate(() => {
|
|
428
|
+
const info = window.odoo && window.odoo.info;
|
|
429
|
+
return info && Array.isArray(info.server_version_info) ? Number(info.server_version_info[0]) : 0;
|
|
430
|
+
}).catch(() => 0);
|
|
431
|
+
// Odoo 18 moved the web client from /web#action=... to /odoo/action-...
|
|
432
|
+
const path = version >= 18 ? `/odoo/action-${externalId}` : `/web#action=${externalId}`;
|
|
433
|
+
// A change of the URL fragment alone does not reload the page, so leave
|
|
434
|
+
// the web client first; the action then loads on a fresh page.
|
|
435
|
+
await page.goto('about:blank');
|
|
436
|
+
await navigate(this, path);
|
|
437
|
+
await waitForWebClient(this);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Breadcrumb
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Assert the breadcrumb of the control panel shows, or does not show, a
|
|
446
|
+
* text: the trail of screens the user came through and the title of the
|
|
447
|
+
* current one (`.o_breadcrumb`).
|
|
448
|
+
*
|
|
449
|
+
* Example #1: Then I should see "Quotations" in the breadcrumb
|
|
450
|
+
* Example #2: And I should see "Pipeline" in the breadcrumb
|
|
451
|
+
* Example #3: Then we should see "S00045" in the breadcrumb
|
|
452
|
+
* Example #4: And I should not see "Invoices" in the breadcrumb
|
|
453
|
+
* Example #5: Then we should not see "New" in the breadcrumb
|
|
454
|
+
*
|
|
455
|
+
*/
|
|
456
|
+
Then(/^(I |we )*should( not)* see "([^"]*)" in the breadcrumb$/, async function (pronounCase, notCase, text) {
|
|
457
|
+
await settle(this);
|
|
458
|
+
const scope = await activeScope(this.page);
|
|
459
|
+
const parts = scope.locator('.o_breadcrumb .breadcrumb-item, .o_breadcrumb .o_last_breadcrumb_item');
|
|
460
|
+
await assertText(parts, text, notCase, 'the breadcrumb (".o_breadcrumb")', {
|
|
461
|
+
hint: `The breadcrumb reads ${await listTexts(parts)}.`,
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Assert the title of the current screen, the last part of the breadcrumb
|
|
467
|
+
* (`.o_last_breadcrumb_item`), is or is not the given text: the name of the
|
|
468
|
+
* view, or of the record a form shows ("New" for a record not saved yet).
|
|
469
|
+
*
|
|
470
|
+
* Example #1: Then the breadcrumb title should be "Quotations"
|
|
471
|
+
* Example #2: And the breadcrumb title should be "Pipeline"
|
|
472
|
+
* Example #3: Then the breadcrumb title should be "New"
|
|
473
|
+
* Example #4: And the breadcrumb title should not be "New"
|
|
474
|
+
* Example #5: Then the breadcrumb title should not be "Invoices"
|
|
475
|
+
*
|
|
476
|
+
*/
|
|
477
|
+
Then(/^the breadcrumb title should( not)* be "([^"]*)"$/, async function (notCase, text) {
|
|
478
|
+
await settle(this);
|
|
479
|
+
const scope = await activeScope(this.page);
|
|
480
|
+
await assertText(scope.locator('.o_breadcrumb .o_last_breadcrumb_item'), text, notCase, 'the breadcrumb title (".o_last_breadcrumb_item")', {
|
|
481
|
+
exact: true,
|
|
482
|
+
hint: 'The title is the name of the view, or of the record on a form view; a new record reads "New" until it is saved.',
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Go back through the breadcrumb: click one of the earlier screens of the
|
|
488
|
+
* trail, by its text.
|
|
489
|
+
*
|
|
490
|
+
* Example #1: When I go back to "Quotations" in the breadcrumb
|
|
491
|
+
* Example #2: When we go back to "Pipeline" in the breadcrumb
|
|
492
|
+
* Example #3: And I go back to "Contacts" in the breadcrumb
|
|
493
|
+
* Example #4: When I go back to "Invoices" in the breadcrumb
|
|
494
|
+
* Example #5: And we go back to "Products" in the breadcrumb
|
|
495
|
+
*
|
|
496
|
+
*/
|
|
497
|
+
When(/^(I |we )*go back to "([^"]*)" in the breadcrumb$/, async function (pronounCase, text) {
|
|
498
|
+
const scope = await activeScope(this.page);
|
|
499
|
+
const items = scope.locator('.o_breadcrumb .breadcrumb-item');
|
|
500
|
+
const item = items.filter({ hasText: textPattern(text, { exact: true }) }).first();
|
|
501
|
+
try {
|
|
502
|
+
await item.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw friendly({
|
|
505
|
+
message: `Expected "${text}" among the earlier screens of the breadcrumb.`,
|
|
506
|
+
cause: `the breadcrumb offers ${await listTexts(items)}`,
|
|
507
|
+
hint: 'Only the screens before the current one are links; the title of the current screen is not.',
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
await item.locator('a').first().click();
|
|
511
|
+
await settle(this);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Views
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Switch the view with the view switcher of the control panel
|
|
520
|
+
* (`.o_cp_switch_buttons`): list, kanban, calendar, pivot, graph, activity.
|
|
521
|
+
*
|
|
522
|
+
* Example #1: When I switch to the list view
|
|
523
|
+
* Example #2: When we switch to the kanban view
|
|
524
|
+
* Example #3: And I switch to the "pivot" view
|
|
525
|
+
* Example #4: When I switch to the graph view
|
|
526
|
+
* Example #5: And we switch to the "calendar" view
|
|
527
|
+
*
|
|
528
|
+
*/
|
|
529
|
+
When(/^(I |we )*switch to the "?(list|kanban|form|calendar|pivot|graph|activity|map|gantt|cohort)"? view$/, async function (pronounCase, type) {
|
|
530
|
+
const page = this.page;
|
|
531
|
+
const scope = await activeScope(page);
|
|
532
|
+
await settle(this);
|
|
533
|
+
let button = scope.locator(`.o_cp_switch_buttons .o_switch_view.o_${type}:visible`).first();
|
|
534
|
+
if (!(await button.isVisible().catch(() => false))) {
|
|
535
|
+
// On a narrow screen the switcher is a dropdown.
|
|
536
|
+
const toggle = scope.locator('.o_cp_switch_buttons button.dropdown-toggle:visible').first();
|
|
537
|
+
if (await toggle.isVisible().catch(() => false)) {
|
|
538
|
+
await toggle.click();
|
|
539
|
+
button = page.locator(`.o-dropdown--menu .o_switch_view.o_${type}, .dropdown-menu.show .o_switch_view.o_${type}`).first();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
await button.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
const offered = await scope.locator('.o_cp_switch_buttons .o_switch_view').evaluateAll((buttons) => [...new Set(buttons.map((b) => (b.getAttribute('data-tooltip') || '').toLowerCase()).filter(Boolean))].join(', ')).catch(() => '');
|
|
546
|
+
throw friendly({
|
|
547
|
+
message: `Expected a "${type}" view in the view switcher.`,
|
|
548
|
+
cause: `the switcher offers: ${offered || '(none)'}`,
|
|
549
|
+
hint: 'The views on offer depend on the action; a form view has no view switcher.',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
await button.click();
|
|
553
|
+
await scope.locator(`.o_${type}_view, .o_${type}_renderer`).first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT }).catch(() => {});
|
|
554
|
+
await settle(this);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Assert the view on screen is, or is not, of the given type
|
|
559
|
+
* (`.o_list_view`, `.o_kanban_view`, `.o_form_view`, ...).
|
|
560
|
+
*
|
|
561
|
+
* Example #1: Then I should see the list view
|
|
562
|
+
* Example #2: And I should see the kanban view
|
|
563
|
+
* Example #3: Then we should see the "form" view
|
|
564
|
+
* Example #4: And I should not see the list view
|
|
565
|
+
* Example #5: Then we should not see the "kanban" view
|
|
566
|
+
*
|
|
567
|
+
*/
|
|
568
|
+
Then(/^(I |we )*should( not)* see the "?(list|kanban|form|calendar|pivot|graph|activity|map|gantt|cohort)"? view$/, async function (pronounCase, notCase, type) {
|
|
569
|
+
await settle(this);
|
|
570
|
+
const scope = await activeScope(this.page);
|
|
571
|
+
await assertPresence(scope.locator(`.o_${type}_view, .o_${type}_renderer`).first(), notCase, `the ${type} view (".o_${type}_view")`, {
|
|
572
|
+
hint: 'Switch the view first ("When I switch to the list view"), or open a record for the form view.',
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// Search view
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Search the records on screen: type the text in the search view and press
|
|
582
|
+
* Enter, which searches the first field the view proposes (usually the
|
|
583
|
+
* name). With `in "<field>"` the proposal of that field is chosen instead
|
|
584
|
+
* ("Search Customer for: ...").
|
|
585
|
+
*
|
|
586
|
+
* Example #1: When I search the records for "chair"
|
|
587
|
+
* Example #2: When we search the records for "Azure"
|
|
588
|
+
* Example #3: And I search the records for "Azure Interior" in "Customer"
|
|
589
|
+
* Example #4: When I search the records for "S00007"
|
|
590
|
+
* Example #5: And we search the records for "Mitchell" in "Salesperson"
|
|
591
|
+
*
|
|
592
|
+
*/
|
|
593
|
+
When(/^(I |we )*search the records for "([^"]*)"(?: in "([^"]*)")?$/, async function (pronounCase, text, field) {
|
|
594
|
+
const page = this.page;
|
|
595
|
+
const { input } = await requireSearchView(this);
|
|
596
|
+
await input.fill(text);
|
|
597
|
+
const proposals = page.locator('.o_searchview_autocomplete .o_menu_item');
|
|
598
|
+
await proposals.first().waitFor({ state: 'visible', timeout: SHORT_TIMEOUT }).catch(() => {});
|
|
599
|
+
if (field) {
|
|
600
|
+
const proposal = proposals.filter({ has: page.locator('b', { hasText: textPattern(field, { exact: true }) }) }).first();
|
|
601
|
+
if (!(await proposal.isVisible().catch(() => false))) {
|
|
602
|
+
const offered = await listTexts(page.locator('.o_searchview_autocomplete .o_menu_item b:first-of-type'));
|
|
603
|
+
await input.fill('');
|
|
604
|
+
throw friendly({
|
|
605
|
+
message: `Expected the search view to propose a search in "${field}".`,
|
|
606
|
+
cause: `it proposes the fields ${offered}`,
|
|
607
|
+
hint: 'The fields are the ones the search view of this menu declares; the name is matched by its whole text, case-sensitive.',
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
await proposal.locator('a:not(.o_expand)').first().click();
|
|
611
|
+
} else {
|
|
612
|
+
await input.press('Enter');
|
|
613
|
+
}
|
|
614
|
+
await settle(this);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Turn a filter of the search options on or off, by its name under
|
|
619
|
+
* "Filters". The entry is a toggle: applying a filter that is on turns it
|
|
620
|
+
* off.
|
|
621
|
+
*
|
|
622
|
+
* Example #1: When I filter the records by "Won"
|
|
623
|
+
* Example #2: When we filter the records by "My Quotations"
|
|
624
|
+
* Example #3: And I filter the records by "Archived"
|
|
625
|
+
* Example #4: When I toggle the "My Pipeline" filter
|
|
626
|
+
* Example #5: And we toggle the "Companies" filter
|
|
627
|
+
*
|
|
628
|
+
*/
|
|
629
|
+
When(/^(I |we )*(?:filter the records by "([^"]*)"|toggle the "([^"]*)" filter)$/, async function (pronounCase, nameA, nameB) {
|
|
630
|
+
await toggleSearchOption(this, 'filter', nameA || nameB);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Group the records by an entry of "Group By" in the search options. The
|
|
635
|
+
* entry is a toggle: grouping by an entry that is on removes the grouping.
|
|
636
|
+
*
|
|
637
|
+
* Example #1: When I group the records by "Salesperson"
|
|
638
|
+
* Example #2: When we group the records by "Stage"
|
|
639
|
+
* Example #3: And I group the records by "Customer"
|
|
640
|
+
* Example #4: When I group the records by "Country"
|
|
641
|
+
* Example #5: And we group the records by "Sales Team"
|
|
642
|
+
*
|
|
643
|
+
*/
|
|
644
|
+
When(/^(I |we )*group the records by "([^"]*)"$/, async function (pronounCase, name) {
|
|
645
|
+
await toggleSearchOption(this, 'group', name);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Assert the search view shows, or does not show, a facet with the given
|
|
650
|
+
* text: the label of an active filter, grouping or search term
|
|
651
|
+
* (`.o_searchview_facet`).
|
|
652
|
+
*
|
|
653
|
+
* Example #1: Then I should see the "My Pipeline" search facet
|
|
654
|
+
* Example #2: And I should see the "chair" search facet
|
|
655
|
+
* Example #3: Then we should see the "Salesperson" search facet
|
|
656
|
+
* Example #4: And I should not see the "Won" search facet
|
|
657
|
+
* Example #5: Then we should not see the "Archived" search facet
|
|
658
|
+
*
|
|
659
|
+
*/
|
|
660
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" search facet$/, async function (pronounCase, notCase, text) {
|
|
661
|
+
const { scope } = await requireSearchView(this);
|
|
662
|
+
await settle(this);
|
|
663
|
+
const facets = scope.locator('.o_searchview .o_searchview_facet');
|
|
664
|
+
await assertText(facets, text, notCase, 'the facets of the search view (".o_searchview_facet")', {
|
|
665
|
+
hint: `The search view shows the facets ${await listTexts(facets.locator('.o_facet_values'))}.`,
|
|
666
|
+
timeout: SHORT_TIMEOUT,
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Remove one facet of the search view, by its text, with its "Remove"
|
|
672
|
+
* button.
|
|
673
|
+
*
|
|
674
|
+
* Example #1: When I remove the "My Pipeline" search facet
|
|
675
|
+
* Example #2: When we remove the "chair" search facet
|
|
676
|
+
* Example #3: And I remove the "Salesperson" search facet
|
|
677
|
+
* Example #4: When I remove the "Won" search facet
|
|
678
|
+
* Example #5: And we remove the "My Quotations" search facet
|
|
679
|
+
*
|
|
680
|
+
*/
|
|
681
|
+
When(/^(I |we )*remove the "([^"]*)" search facet$/, async function (pronounCase, text) {
|
|
682
|
+
const { scope } = await requireSearchView(this);
|
|
683
|
+
const facets = scope.locator('.o_searchview .o_searchview_facet');
|
|
684
|
+
const facet = facets.filter({ hasText: textPattern(text) }).first();
|
|
685
|
+
try {
|
|
686
|
+
await facet.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
687
|
+
} catch (error) {
|
|
688
|
+
throw friendly({
|
|
689
|
+
message: `Expected a "${text}" facet in the search view.`,
|
|
690
|
+
cause: `the search view shows the facets ${await listTexts(facets.locator('.o_facet_values'))}`,
|
|
691
|
+
hint: 'A facet is the label of an active filter, grouping or search term.',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
await facet.hover();
|
|
695
|
+
await facet.locator('.o_facet_remove').first().click();
|
|
696
|
+
await settle(this);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Clear the search view: remove every facet, including the default filter
|
|
701
|
+
* the menu applies, so that every record is listed.
|
|
702
|
+
*
|
|
703
|
+
* Example #1: When I clear the search
|
|
704
|
+
* Example #2: When we clear the search
|
|
705
|
+
* Example #3: And clear the search
|
|
706
|
+
* Example #4: When I clear the search view
|
|
707
|
+
* Example #5: And we clear the search view
|
|
708
|
+
*
|
|
709
|
+
*/
|
|
710
|
+
When(/^(I |we )*clear the search(?: view)?$/, async function (pronounCase) {
|
|
711
|
+
const { scope, input } = await requireSearchView(this);
|
|
712
|
+
const facets = scope.locator('.o_searchview .o_searchview_facet');
|
|
713
|
+
await input.fill('');
|
|
714
|
+
let guard = 20;
|
|
715
|
+
while ((await facets.count()) > 0 && guard > 0) {
|
|
716
|
+
guard -= 1;
|
|
717
|
+
const facet = facets.first();
|
|
718
|
+
await facet.hover();
|
|
719
|
+
await facet.locator('.o_facet_remove').first().click();
|
|
720
|
+
await settle(this);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// Records in a list or a kanban view
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Assert a record is, or is not, on screen: a row of the list view or a card
|
|
730
|
+
* of the kanban view that shows the given text (the name or the reference of
|
|
731
|
+
* the record).
|
|
732
|
+
*
|
|
733
|
+
* Example #1: Then I should see the "Azure Interior" record
|
|
734
|
+
* Example #2: And I should see the "S00007" record
|
|
735
|
+
* Example #3: Then we should see the "Quote for 600 Chairs" record
|
|
736
|
+
* Example #4: And I should not see the "Deco Addict" record
|
|
737
|
+
* Example #5: Then we should not see the "S00001" record
|
|
738
|
+
*
|
|
739
|
+
*/
|
|
740
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" record$/, async function (pronounCase, notCase, text) {
|
|
741
|
+
const { record, kind } = await findRecord(this, text);
|
|
742
|
+
await assertPresence(record, notCase, `a record showing "${text}" in the ${kind} view`, {
|
|
743
|
+
timeout: SHORT_TIMEOUT,
|
|
744
|
+
hint: 'Only the records of the current page and of the current search are on screen: clear or change the search ("When I clear the search", "When I search the records for ...").',
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Open a record from the list or the kanban view on screen, by a text it
|
|
750
|
+
* shows, and wait for its form view.
|
|
751
|
+
*
|
|
752
|
+
* Example #1: When I open the "Azure Interior" record
|
|
753
|
+
* Example #2: When we open the "S00007" record
|
|
754
|
+
* Example #3: And I open the "Quote for 600 Chairs" record
|
|
755
|
+
* Example #4: When I open the "Deco Addict" record
|
|
756
|
+
* Example #5: And we open the "INV/2026/00001" record
|
|
757
|
+
*
|
|
758
|
+
*/
|
|
759
|
+
When(/^(I |we )*open the "([^"]*)" record$/, async function (pronounCase, text) {
|
|
760
|
+
const page = this.page;
|
|
761
|
+
const { record, kind } = await findRecord(this, text);
|
|
762
|
+
try {
|
|
763
|
+
await record.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
764
|
+
} catch (error) {
|
|
765
|
+
throw friendly({
|
|
766
|
+
message: `Expected a record showing "${text}" in the ${kind} view.`,
|
|
767
|
+
cause: error,
|
|
768
|
+
hint: 'Only the records of the current page and of the current search are on screen: search for the record first ("When I search the records for ...").',
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
if (kind === 'list') {
|
|
772
|
+
// Click the cell that shows the text: the selection checkbox and the
|
|
773
|
+
// widgets of other cells have their own click behaviour.
|
|
774
|
+
const cell = record.locator('td.o_data_cell').filter({ hasText: textPattern(text) }).first();
|
|
775
|
+
await cell.click();
|
|
776
|
+
} else {
|
|
777
|
+
const title = record.getByText(textPattern(text)).first();
|
|
778
|
+
await title.click();
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
await page.locator('.o_form_view').first().waitFor({ state: 'visible', timeout: WAIT_TIMEOUT });
|
|
782
|
+
} catch (error) {
|
|
783
|
+
throw friendly({
|
|
784
|
+
message: `Expected the form view of the "${text}" record to open.`,
|
|
785
|
+
cause: error,
|
|
786
|
+
hint: 'A list that is editable in place does not open a form on click; an error dialog on screen means the record could not be read.',
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
await settle(this);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Assert the number of records on screen: the rows of the list view or the
|
|
794
|
+
* cards of the kanban view, for the current page and search.
|
|
795
|
+
*
|
|
796
|
+
* Example #1: Then I should see 3 records
|
|
797
|
+
* Example #2: And I should see 1 record
|
|
798
|
+
* Example #3: Then we should see 15 records
|
|
799
|
+
* Example #4: And I should see 0 records
|
|
800
|
+
* Example #5: Then we should see 80 records
|
|
801
|
+
*
|
|
802
|
+
*/
|
|
803
|
+
Then(/^(I |we )*should see (\d+) records?$/, async function (pronounCase, expected) {
|
|
804
|
+
const page = this.page;
|
|
805
|
+
const { records, kind } = await recordsOnScreen(this);
|
|
806
|
+
const wanted = Number(expected);
|
|
807
|
+
const deadline = Date.now() + SHORT_TIMEOUT;
|
|
808
|
+
let count = await records.count();
|
|
809
|
+
while (count !== wanted && Date.now() < deadline) {
|
|
810
|
+
await settle(this, 1000);
|
|
811
|
+
count = await records.count();
|
|
812
|
+
}
|
|
813
|
+
if (count !== wanted) {
|
|
814
|
+
throw friendly({
|
|
815
|
+
message: `Expected ${wanted} record(s) in the ${kind} view.`,
|
|
816
|
+
cause: `the view shows ${count}`,
|
|
817
|
+
hint: `The count is the records of the current page and search (${page.url()}). A list shows 80 records per page by default.`,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Assert the list or the kanban view shows no record: the "no content"
|
|
824
|
+
* message of the view is on screen (`.o_view_nocontent`) or the view has no
|
|
825
|
+
* record.
|
|
826
|
+
*
|
|
827
|
+
* Example #1: Then I should see no records
|
|
828
|
+
* Example #2: And I should see no record
|
|
829
|
+
* Example #3: Then we should see no records
|
|
830
|
+
* Example #4: And I should see no records at all
|
|
831
|
+
* Example #5: Then we should see no record
|
|
832
|
+
*
|
|
833
|
+
*/
|
|
834
|
+
Then(/^(I |we )*should see no records?(?: at all)?$/, async function (pronounCase) {
|
|
835
|
+
const { records, kind } = await recordsOnScreen(this);
|
|
836
|
+
const deadline = Date.now() + SHORT_TIMEOUT;
|
|
837
|
+
let count = await records.count();
|
|
838
|
+
while (count !== 0 && Date.now() < deadline) {
|
|
839
|
+
await settle(this, 1000);
|
|
840
|
+
count = await records.count();
|
|
841
|
+
}
|
|
842
|
+
if (count !== 0) {
|
|
843
|
+
throw friendly({
|
|
844
|
+
message: `Expected no record in the ${kind} view.`,
|
|
845
|
+
cause: `the view shows ${count}`,
|
|
846
|
+
hint: 'Check the search: a default filter of the menu, or a facet left from an earlier step, changes what is listed.',
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
// List view: selection, groups, pager, the Actions menu
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Select or unselect a record of the list view with its checkbox, by a text
|
|
857
|
+
* the row shows.
|
|
858
|
+
*
|
|
859
|
+
* Example #1: When I select the "S00007" record
|
|
860
|
+
* Example #2: When we select the "Azure Interior" record
|
|
861
|
+
* Example #3: And I unselect the "S00007" record
|
|
862
|
+
* Example #4: When I select the "Deco Addict" record
|
|
863
|
+
* Example #5: And we unselect the "Azure Interior" record
|
|
864
|
+
*
|
|
865
|
+
*/
|
|
866
|
+
When(/^(I |we )*(select|unselect) the "([^"]*)" record$/, async function (pronounCase, action, text) {
|
|
867
|
+
const view = await requireView(this, 'list');
|
|
868
|
+
await settle(this);
|
|
869
|
+
const row = view.locator('tr.o_data_row').filter({ hasText: textPattern(text) }).first();
|
|
870
|
+
try {
|
|
871
|
+
await row.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
872
|
+
} catch (error) {
|
|
873
|
+
throw friendly({
|
|
874
|
+
message: `Expected a row showing "${text}" in the list view.`,
|
|
875
|
+
cause: error,
|
|
876
|
+
hint: 'Only the records of the current page and search are on screen; search for the record first.',
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const checkbox = row.locator('.o_list_record_selector input[type="checkbox"]').first();
|
|
880
|
+
if (action === 'select') await checkbox.check();
|
|
881
|
+
else await checkbox.uncheck();
|
|
882
|
+
await settle(this);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Select every record of the page with the checkbox of the list header.
|
|
887
|
+
*
|
|
888
|
+
* Example #1: When I select all records
|
|
889
|
+
* Example #2: When we select all records
|
|
890
|
+
* Example #3: And I select all the records
|
|
891
|
+
* Example #4: When select all records
|
|
892
|
+
* Example #5: And we select all the records
|
|
893
|
+
*
|
|
894
|
+
*/
|
|
895
|
+
When(/^(I |we )*select all (?:the )?records$/, async function (pronounCase) {
|
|
896
|
+
const view = await requireView(this, 'list');
|
|
897
|
+
await settle(this);
|
|
898
|
+
await view.locator('thead .o_list_record_selector input[type="checkbox"]').first().check();
|
|
899
|
+
await settle(this);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Assert the number of selected records, as the control panel reports it
|
|
904
|
+
* ("3 selected", `.o_list_selection_box`). With 0 the selection box must be
|
|
905
|
+
* gone.
|
|
906
|
+
*
|
|
907
|
+
* Example #1: Then I should see 1 selected record
|
|
908
|
+
* Example #2: And I should see 3 selected records
|
|
909
|
+
* Example #3: Then we should see 15 selected records
|
|
910
|
+
* Example #4: And I should see 0 selected records
|
|
911
|
+
* Example #5: Then we should see 2 selected records
|
|
912
|
+
*
|
|
913
|
+
*/
|
|
914
|
+
Then(/^(I |we )*should see (\d+) selected records?$/, async function (pronounCase, expected) {
|
|
915
|
+
const scope = await activeScope(this.page);
|
|
916
|
+
const box = scope.locator('.o_list_selection_box').first();
|
|
917
|
+
if (Number(expected) === 0) {
|
|
918
|
+
await assertPresence(box, ' not', 'the selection box of the list (".o_list_selection_box")', {
|
|
919
|
+
timeout: SHORT_TIMEOUT,
|
|
920
|
+
hint: 'The box is shown while at least one record is selected.',
|
|
921
|
+
});
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
await assertPresence(box, undefined, 'the selection box of the list (".o_list_selection_box")', {
|
|
925
|
+
timeout: SHORT_TIMEOUT,
|
|
926
|
+
hint: 'Select a record first ("When I select the "..." record").',
|
|
927
|
+
});
|
|
928
|
+
await assertText(box, `${expected} selected`, undefined, 'the selection box of the list', {
|
|
929
|
+
timeout: SHORT_TIMEOUT,
|
|
930
|
+
hint: 'The box counts the selected records of the list.',
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Choose an entry of the "Actions" menu of the control panel (the gear on a
|
|
936
|
+
* form view): "Delete", "Archive", "Duplicate", "Export". On a list view the
|
|
937
|
+
* menu is shown once at least one record is selected.
|
|
938
|
+
*
|
|
939
|
+
* Example #1: When I choose "Delete" in the Actions menu
|
|
940
|
+
* Example #2: When we choose "Archive" in the Actions menu
|
|
941
|
+
* Example #3: And I choose "Duplicate" in the Actions menu
|
|
942
|
+
* Example #4: When I choose "Unarchive" in the actions menu
|
|
943
|
+
* Example #5: And we choose "Export" in the actions menu
|
|
944
|
+
*
|
|
945
|
+
*/
|
|
946
|
+
When(/^(I |we )*choose "([^"]*)" in the [Aa]ctions menu$/, async function (pronounCase, name) {
|
|
947
|
+
const page = this.page;
|
|
948
|
+
const scope = await activeScope(page);
|
|
949
|
+
await settle(this);
|
|
950
|
+
const toggle = scope.locator('.o_cp_action_menus button.dropdown-toggle:visible').filter({ has: page.locator('.fa-cog, .o_dropdown_title:text-is("Actions"), .o_dropdown_title:text-is("Action")') }).first();
|
|
951
|
+
try {
|
|
952
|
+
await toggle.click({ timeout: SHORT_TIMEOUT });
|
|
953
|
+
} catch (error) {
|
|
954
|
+
throw friendly({
|
|
955
|
+
action: 'open the Actions menu',
|
|
956
|
+
target: '.o_cp_action_menus',
|
|
957
|
+
cause: error,
|
|
958
|
+
hint: 'On a list view, select at least one record first ("When I select the "..." record"); on a form view the menu is the gear next to the title.',
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
const items = page.locator('.o-dropdown--menu:visible .dropdown-item, .dropdown-menu.show .dropdown-item');
|
|
962
|
+
const item = items.filter({ hasText: textPattern(name, { exact: true }) }).first();
|
|
963
|
+
try {
|
|
964
|
+
await item.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
965
|
+
} catch (error) {
|
|
966
|
+
const offered = await listTexts(items);
|
|
967
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
968
|
+
throw friendly({
|
|
969
|
+
message: `Expected a "${name}" entry in the Actions menu.`,
|
|
970
|
+
cause: `the menu offers ${offered}`,
|
|
971
|
+
hint: 'The entries depend on the model and on the rights of the user: "Delete" is missing for a user who may not delete.',
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
await item.click();
|
|
975
|
+
await settle(this);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Assert the list view shows, or does not show, a group with the given name:
|
|
980
|
+
* a group header row (`tr.o_group_header`) after a "Group By".
|
|
981
|
+
*
|
|
982
|
+
* Example #1: Then I should see the "Mitchell Admin" group
|
|
983
|
+
* Example #2: And I should see the "Sales Order" group
|
|
984
|
+
* Example #3: Then we should see the "Quotation" group
|
|
985
|
+
* Example #4: And I should not see the "Cancelled" group
|
|
986
|
+
* Example #5: Then we should not see the "Marc Demo" group
|
|
987
|
+
*
|
|
988
|
+
*/
|
|
989
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" group$/, async function (pronounCase, notCase, name) {
|
|
990
|
+
const view = await requireView(this, 'list');
|
|
991
|
+
await settle(this);
|
|
992
|
+
const groups = view.locator('tr.o_group_header .o_group_name');
|
|
993
|
+
// A group header reads "Name (count)".
|
|
994
|
+
const pattern = new RegExp(`^\\s*${escapeRegExp(name.trim())}\\s*(\\(\\s*\\d+\\s*\\))?\\s*$`);
|
|
995
|
+
const group = groups.filter({ hasText: pattern }).first();
|
|
996
|
+
await assertPresence(group, notCase, `the "${name}" group of the list`, {
|
|
997
|
+
timeout: SHORT_TIMEOUT,
|
|
998
|
+
hint: `The list shows the groups ${await listTexts(groups)}. Group the records first ("When I group the records by ...").`,
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Open or close a group of a grouped list view, by its name, to show or hide
|
|
1004
|
+
* its records.
|
|
1005
|
+
*
|
|
1006
|
+
* Example #1: When I open the "Mitchell Admin" group
|
|
1007
|
+
* Example #2: When we open the "Sales Order" group
|
|
1008
|
+
* Example #3: And I close the "Quotation" group
|
|
1009
|
+
* Example #4: When I open the "Azure Interior" group
|
|
1010
|
+
* Example #5: And we close the "Mitchell Admin" group
|
|
1011
|
+
*
|
|
1012
|
+
*/
|
|
1013
|
+
When(/^(I |we )*(open|close) the "([^"]*)" group$/, async function (pronounCase, action, name) {
|
|
1014
|
+
const view = await requireView(this, 'list');
|
|
1015
|
+
await settle(this);
|
|
1016
|
+
const headers = view.locator('tr.o_group_header');
|
|
1017
|
+
const pattern = new RegExp(`^\\s*${escapeRegExp(name.trim())}\\s*(\\(\\s*\\d+\\s*\\))?\\s*$`);
|
|
1018
|
+
const header = headers.filter({ has: this.page.locator('.o_group_name', { hasText: pattern }) }).first();
|
|
1019
|
+
try {
|
|
1020
|
+
await header.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
throw friendly({
|
|
1023
|
+
message: `Expected a "${name}" group in the list view.`,
|
|
1024
|
+
cause: `the list shows the groups ${await listTexts(view.locator('tr.o_group_header .o_group_name'))}`,
|
|
1025
|
+
hint: 'Group the records first ("When I group the records by ...").',
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
const isOpen = await header.evaluate((row) => row.classList.contains('o_group_open'));
|
|
1029
|
+
if ((action === 'open') !== isOpen) {
|
|
1030
|
+
await header.locator('.o_group_name').first().click();
|
|
1031
|
+
await settle(this);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Assert what the pager of the control panel reads: the range of records on
|
|
1037
|
+
* screen and their total, as "1-80 / 132" on a list view or "1 / 15" on a
|
|
1038
|
+
* form view (`.o_pager_value`, `.o_pager_limit`).
|
|
1039
|
+
*
|
|
1040
|
+
* Example #1: Then the pager should read "1-15 / 15"
|
|
1041
|
+
* Example #2: And the pager should read "1-80 / 132"
|
|
1042
|
+
* Example #3: Then the pager should read "1 / 2"
|
|
1043
|
+
* Example #4: And the pager should not read "1-80 / 80"
|
|
1044
|
+
* Example #5: Then the pager should not read "1 / 1"
|
|
1045
|
+
*
|
|
1046
|
+
*/
|
|
1047
|
+
Then(/^the pager should( not)* read "([^"]*)"$/, async function (notCase, expected) {
|
|
1048
|
+
const scope = await activeScope(this.page);
|
|
1049
|
+
await settle(this);
|
|
1050
|
+
const pager = scope.locator('.o_pager .o_pager_counter').first();
|
|
1051
|
+
await assertPresence(pager, undefined, 'the pager of the control panel (".o_pager")', {
|
|
1052
|
+
timeout: SHORT_TIMEOUT,
|
|
1053
|
+
hint: 'A view with no record, and a new record that is not saved yet, show no pager.',
|
|
1054
|
+
});
|
|
1055
|
+
const wanted = expected.replace(/\s+/g, '');
|
|
1056
|
+
const deadline = Date.now() + SHORT_TIMEOUT;
|
|
1057
|
+
let text = '';
|
|
1058
|
+
do {
|
|
1059
|
+
text = ((await pager.textContent().catch(() => '')) || '').replace(/\s+/g, '');
|
|
1060
|
+
if ((text === wanted) !== Boolean(notCase)) return;
|
|
1061
|
+
await settle(this, 500);
|
|
1062
|
+
} while (Date.now() < deadline);
|
|
1063
|
+
throw friendly({
|
|
1064
|
+
message: notCase ? `Expected the pager not to read "${expected}".` : `Expected the pager to read "${expected}".`,
|
|
1065
|
+
cause: `it reads "${text.replace('/', ' / ')}"`,
|
|
1066
|
+
hint: 'The pager reads "first-last / total" on a list or kanban view and "position / total" on a form view; spaces are ignored.',
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Go to the next or the previous page of records (on a form view: the next
|
|
1072
|
+
* or the previous record) with the arrows of the pager.
|
|
1073
|
+
*
|
|
1074
|
+
* Example #1: When I go to the next page of records
|
|
1075
|
+
* Example #2: When we go to the previous page of records
|
|
1076
|
+
* Example #3: And I go to the next record
|
|
1077
|
+
* Example #4: When I go to the previous record
|
|
1078
|
+
* Example #5: And go to the next page of records
|
|
1079
|
+
*
|
|
1080
|
+
*/
|
|
1081
|
+
When(/^(I |we )*go to the (next|previous) (?:page of records|record)$/, async function (pronounCase, direction) {
|
|
1082
|
+
const scope = await activeScope(this.page);
|
|
1083
|
+
await settle(this);
|
|
1084
|
+
const button = scope.locator(`.o_pager .o_pager_${direction}:visible`).first();
|
|
1085
|
+
try {
|
|
1086
|
+
await button.click({ timeout: SHORT_TIMEOUT });
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
throw friendly({
|
|
1089
|
+
action: `click the "${direction}" arrow of the pager`,
|
|
1090
|
+
target: `.o_pager_${direction}`,
|
|
1091
|
+
cause: error,
|
|
1092
|
+
hint: 'The arrows are disabled when every record fits on one page; a new record that is not saved yet has no pager.',
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
await settle(this);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
// Kanban view: columns and cards
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Assert the kanban view shows, or does not show, a column with the given
|
|
1104
|
+
* title (`.o_kanban_group .o_column_title`): a stage of the pipeline, for
|
|
1105
|
+
* example.
|
|
1106
|
+
*
|
|
1107
|
+
* Example #1: Then I should see the "New" column
|
|
1108
|
+
* Example #2: And I should see the "Won" column
|
|
1109
|
+
* Example #3: Then we should see the "Qualified" kanban column
|
|
1110
|
+
* Example #4: And I should not see the "Archived" column
|
|
1111
|
+
* Example #5: Then we should not see the "Lost" kanban column
|
|
1112
|
+
*
|
|
1113
|
+
*/
|
|
1114
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" (?:kanban )?column$/, async function (pronounCase, notCase, title) {
|
|
1115
|
+
const view = await requireView(this, 'kanban');
|
|
1116
|
+
await settle(this);
|
|
1117
|
+
const titles = view.locator('.o_kanban_group .o_column_title');
|
|
1118
|
+
await assertPresence(titles.filter({ hasText: textPattern(title, { exact: true }) }).first(), notCase, `the "${title}" column of the kanban view`, {
|
|
1119
|
+
timeout: SHORT_TIMEOUT,
|
|
1120
|
+
hint: `The view shows the columns ${await listTexts(titles)}.`,
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Assert a card is, or is not, in a column of the kanban view: a card that
|
|
1126
|
+
* shows the given text, inside the column with the given title.
|
|
1127
|
+
*
|
|
1128
|
+
* Example #1: Then I should see the "Quote for 600 Chairs" card in the "Qualified" column
|
|
1129
|
+
* Example #2: And I should see the "Info about services" card in the "New" column
|
|
1130
|
+
* Example #3: Then we should see the "Modern Open Space" card in the "Proposition" kanban column
|
|
1131
|
+
* Example #4: And I should not see the "Quote for 600 Chairs" card in the "Won" column
|
|
1132
|
+
* Example #5: Then we should not see the "Info about services" card in the "Qualified" column
|
|
1133
|
+
*
|
|
1134
|
+
*/
|
|
1135
|
+
Then(/^(I |we )*should( not)* see the "([^"]*)" card in the "([^"]*)" (?:kanban )?column$/, async function (pronounCase, notCase, text, title) {
|
|
1136
|
+
const column = await findColumn(this, title);
|
|
1137
|
+
const cards = column.locator('.o_kanban_record:not(.o_kanban_ghost)');
|
|
1138
|
+
await assertPresence(cards.filter({ hasText: textPattern(text) }).first(), notCase, `a card showing "${text}" in the "${title}" column`, {
|
|
1139
|
+
timeout: SHORT_TIMEOUT,
|
|
1140
|
+
hint: 'A column loads its cards by pages ("Load more"); the search of the view also changes which cards are shown.',
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Assert the number of cards a column of the kanban view shows.
|
|
1146
|
+
*
|
|
1147
|
+
* Example #1: Then the "New" column should have 2 cards
|
|
1148
|
+
* Example #2: And the "Won" column should have 1 card
|
|
1149
|
+
* Example #3: Then the "Qualified" kanban column should have 3 cards
|
|
1150
|
+
* Example #4: And the "Proposition" column should have 0 cards
|
|
1151
|
+
* Example #5: Then the "New" column should have 4 cards
|
|
1152
|
+
*
|
|
1153
|
+
*/
|
|
1154
|
+
Then(/^the "([^"]*)" (?:kanban )?column should have (\d+) cards?$/, async function (title, expected) {
|
|
1155
|
+
const column = await findColumn(this, title);
|
|
1156
|
+
const cards = column.locator('.o_kanban_record:not(.o_kanban_ghost)');
|
|
1157
|
+
const wanted = Number(expected);
|
|
1158
|
+
const deadline = Date.now() + SHORT_TIMEOUT;
|
|
1159
|
+
let count = await cards.count();
|
|
1160
|
+
while (count !== wanted && Date.now() < deadline) {
|
|
1161
|
+
await settle(this, 1000);
|
|
1162
|
+
count = await cards.count();
|
|
1163
|
+
}
|
|
1164
|
+
if (count !== wanted) {
|
|
1165
|
+
throw friendly({
|
|
1166
|
+
message: `Expected ${wanted} card(s) in the "${title}" column.`,
|
|
1167
|
+
cause: `the column shows ${count}`,
|
|
1168
|
+
hint: 'The count is the cards on screen for the current search; a column loads its cards by pages ("Load more").',
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Move a card of the kanban view to another column by dragging it, the way a
|
|
1175
|
+
* user moves an opportunity to another stage. The step waits for the server
|
|
1176
|
+
* to save the move and for the card to be in the target column.
|
|
1177
|
+
*
|
|
1178
|
+
* Example #1: When I drag the "Quote for 600 Chairs" card to the "Proposition" column
|
|
1179
|
+
* Example #2: When we drag the "Info about services" card to the "Qualified" column
|
|
1180
|
+
* Example #3: And I move the "Modern Open Space" card to the "Won" column
|
|
1181
|
+
* Example #4: When I drag the "Quote for 12 Tables" card to the "New" kanban column
|
|
1182
|
+
* Example #5: And we move the "Global Solutions: Furnitures" card to the "Proposition" column
|
|
1183
|
+
*
|
|
1184
|
+
*/
|
|
1185
|
+
When(/^(I |we )*(?:drag|move) the "([^"]*)" card to the "([^"]*)" (?:kanban )?column$/, async function (pronounCase, text, title) {
|
|
1186
|
+
const page = this.page;
|
|
1187
|
+
const view = await requireView(this, 'kanban');
|
|
1188
|
+
await settle(this);
|
|
1189
|
+
const card = view.locator('.o_kanban_record:not(.o_kanban_ghost)').filter({ hasText: textPattern(text) }).first();
|
|
1190
|
+
try {
|
|
1191
|
+
await card.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
throw friendly({
|
|
1194
|
+
message: `Expected a card showing "${text}" in the kanban view.`,
|
|
1195
|
+
cause: error,
|
|
1196
|
+
hint: 'Check the search of the view: a default filter may hide the card.',
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
const column = await findColumn(this, title);
|
|
1200
|
+
await card.scrollIntoViewIfNeeded();
|
|
1201
|
+
const from = await card.boundingBox();
|
|
1202
|
+
const header = await column.locator('.o_kanban_header').first().boundingBox();
|
|
1203
|
+
const target = await column.boundingBox();
|
|
1204
|
+
if (!from || !target || !header) {
|
|
1205
|
+
throw friendly({
|
|
1206
|
+
message: `The "${text}" card or the "${title}" column is not on screen.`,
|
|
1207
|
+
hint: 'Widen the viewport or scroll the kanban view so that both are visible.',
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
// Odoo starts a drag after the pointer has moved a few pixels with the
|
|
1211
|
+
// button down, and reads the drop target from the pointer position: move in
|
|
1212
|
+
// steps, and drop just under the header of the target column.
|
|
1213
|
+
const startX = from.x + from.width / 2;
|
|
1214
|
+
const startY = from.y + Math.min(from.height / 2, 20);
|
|
1215
|
+
const endX = target.x + target.width / 2;
|
|
1216
|
+
const endY = header.y + header.height + 30;
|
|
1217
|
+
await page.mouse.move(startX, startY);
|
|
1218
|
+
await page.mouse.down();
|
|
1219
|
+
await page.mouse.move(startX + 12, startY + 12, { steps: 4 });
|
|
1220
|
+
await page.mouse.move(endX, endY, { steps: 12 });
|
|
1221
|
+
await page.mouse.move(endX, endY + 2, { steps: 2 });
|
|
1222
|
+
await page.mouse.up();
|
|
1223
|
+
await settle(this);
|
|
1224
|
+
const moved = column.locator('.o_kanban_record:not(.o_kanban_ghost)').filter({ hasText: textPattern(text) }).first();
|
|
1225
|
+
try {
|
|
1226
|
+
await moved.waitFor({ state: 'visible', timeout: SHORT_TIMEOUT });
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
throw friendly({
|
|
1229
|
+
message: `Expected the "${text}" card in the "${title}" column after the move.`,
|
|
1230
|
+
cause: (await page.locator('.modal[role="dialog"]:visible').count())
|
|
1231
|
+
? `a dialog opened: "${((await page.locator('.modal[role="dialog"]:visible .modal-title').last().textContent().catch(() => '')) || '').trim()}"`
|
|
1232
|
+
: 'the card did not land in the column',
|
|
1233
|
+
hint: 'A kanban view allows the move only when its records may be dragged between groups and the user may write the record.',
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
});
|