@webship-js/odoo-steps 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ });