diff --git a/addons/im_support/static/tests/helpers/test_utils.js b/addons/im_support/static/tests/helpers/test_utils.js index dece9e2c361..370d66098b9 100644 --- a/addons/im_support/static/tests/helpers/test_utils.js +++ b/addons/im_support/static/tests/helpers/test_utils.js @@ -24,7 +24,7 @@ mailTestUtils.MockMailService.include({ * @param {function} [params.mockSupportRPC] * @param {boolean} [params.enableSupportPoll=false] */ -function addMockSupportEnvironment(widget, params) { +async function addMockSupportEnvironment(widget, params) { // mock CORS RPCs var originalRPC = supportSession.rpc; var defaultMockSupportRPC = function (route, args) { @@ -58,7 +58,7 @@ function addMockSupportEnvironment(widget, params) { } return result; }; - testUtils.mock.addMockEnvironment(widget, params); + await testUtils.mock.addMockEnvironment(widget, params); var widgetDestroy = widget.destroy; widget.destroy = function () { supportSession.rpc = originalRPC; diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index 90317900660..f3328c9a1a9 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -9,11 +9,12 @@ var mailUtils = require('mail.utils'); var AbstractStorageService = require('web.AbstractStorageService'); var Class = require('web.Class'); -var ControlPanelView = require('web.ControlPanelView'); var RamStorage = require('web.RamStorage'); +const makeTestEnvironment = require('web.test_env'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); +const { prepareTarget, nextTick } = testUtils; /** * Test Utils * @@ -37,8 +38,11 @@ async function createDiscuss(params) { 'mail.message,false,search': '', }; testUtils.mock.addMockEnvironment(parent, params); + + const env = params.env || {}; + owl.Component.env = makeTestEnvironment(env); + var discuss = new Discuss(parent, params); - var selector = params.debug ? 'body' : '#qunit-fixture'; // override 'destroy' of discuss so that it calls 'destroy' on the parent // instead, which is the parent of discuss and the mockServer. @@ -49,9 +53,9 @@ async function createDiscuss(params) { parent.destroy(); }; - return discuss.appendTo($(selector)).then(function () { - return discuss; - }); + await discuss.appendTo(prepareTarget(params.debug)); + await nextTick(); + return discuss; } diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 243f3f51cd0..699a912ccc3 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -628,6 +628,28 @@ var utils = { 'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString() ].join(';'); }, + /** + * Returns a string formatted using given values. + * If the value is an object, its keys will replace `%(key)s` expressions. + * If the values are a set of strings, they will replace `%s` expressions. + * If no value is given, the string will not be formatted. + * + * @param {string} string + * @param {(Object|...string)} values + */ + sprintf: function (string, ...values) { + if (values.length === 1 && typeof values[0] === 'object') { + const valuesDict = values[0]; + for (const value in valuesDict) { + string = string.replace(`%(${value})s`, valuesDict[value]); + } + } else { + for (const value of values) { + string = string.replace(/%s/, value); + } + } + return string; + }, /** * Sort an array in place, keeping the initial order for identical values. * diff --git a/addons/web/static/src/js/tools/test_menus.js b/addons/web/static/src/js/tools/test_menus.js index 9767aa29fed..0f91cab4c60 100644 --- a/addons/web/static/src/js/tools/test_menus.js +++ b/addons/web/static/src/js/tools/test_menus.js @@ -216,8 +216,8 @@ function testFilters() { if (appsMenusOnly === true) return Promise.resolve(); var filterProm = Promise.resolve(); - // var $filters = $('div.o_control_panel div.btn-group.o_dropdown > ul.o_filters_menu > li:not(.o_add_custom_filter)'); - var $filters = $('.o_filters_menu > .o_menu_item:not(.d-none)'); + // var $filters = $('div.o_control_panel div.btn-group.o_dropdown > ul.o_filter_menu > li:not(.o_add_custom_filter)'); + var $filters = $('.o_filter_menu > .o_menu_item:not(.d-none)'); console.log("Testing " + $filters.length + " filters"); var filter_ids = _.compact(_.map($filters, function(f) { return f.dataset.id})); filter_ids.forEach(function(filter_id){ diff --git a/addons/web/static/tests/helpers/qunit_asserts.js b/addons/web/static/tests/helpers/qunit_asserts.js index 0f22acbbf78..8a5948dc676 100644 --- a/addons/web/static/tests/helpers/qunit_asserts.js +++ b/addons/web/static/tests/helpers/qunit_asserts.js @@ -13,6 +13,8 @@ odoo.define('web.qunit_asserts', function (require) { const Widget = require('web.Widget'); + /** @todo use testUtilsDom.getNode to extract the element from the 'w' argument */ + //------------------------------------------------------------------------- // Private functions //------------------------------------------------------------------------- diff --git a/addons/web/static/tests/helpers/test_env.js b/addons/web/static/tests/helpers/test_env.js index d934bd3ac84..68659e366c3 100644 --- a/addons/web/static/tests/helpers/test_env.js +++ b/addons/web/static/tests/helpers/test_env.js @@ -7,35 +7,6 @@ odoo.define('web.test_env', async function (require) { const { buildQuery } = require("web.rpc"); const session = require('web.session'); - /** - * Wrap the target object in a Proxy, giving it a generic getter that will - * throw an error instead of returning `undefined` in case the property has - * not been set. - * - * @param {Object} target - * @param {string} name - * @returns {Proxy} - */ - function _proxify(target, name) { - for (const prop in target) { - if ( - target.hasOwnProperty(prop) && - typeof target[prop] === 'object' && - target[prop] !== null - ) { - target[prop] = _proxify(target[prop], `${name}.${prop}`); - } - } - return new Proxy(target, { - get(object, property) { - if (typeof property === 'string' && !(property in object)) { - throw new Error(`Property "${property}" not implemented in "${name}".`); - } - return object[property]; - }, - }); - } - /** * Creates a test environment with the given environment object. * Any access to a key that has not been explicitly defined in the given environment object @@ -46,18 +17,26 @@ odoo.define('web.test_env', async function (require) { * @returns {Proxy} */ function makeTestEnvironment(env = {}, providedRPC = null) { - const proxiedEnv = _proxify(env, 'env'); const RamStorageService = AbstractStorageService.extend({ storage: new RamStorage(), }); + const database = { + parameters: { + code: "en_US", + date_format: '%m/%d/%Y', + decimal_point: ".", + direction: 'ltr', + grouping: [], + thousands_sep: ",", + time_format: '%H:%M:%S', + }, + }; let testEnv = {}; const defaultEnv = { - _t: env._t || (s => s), - _lt: env._lt || (s => s), + _t: env._t || Object.assign((s => s), { database }), + _lt: env._lt || Object.assign((s => s), { database }), bus: new Bus(), - device: Object.assign({ - isMobile: false, - }, env.device), + device: Object.assign({ isMobile: false }, env.device), isDebug: env.isDebug || (() => false), qweb: new owl.QWeb({ templates: session.owlTemplates }), services: Object.assign({ @@ -77,6 +56,7 @@ odoo.define('web.test_env', async function (require) { }, local_storage: new RamStorageService(), session_storage: new RamStorageService(), + notification: { notify() { } }, }, env.services), session: Object.assign({ rpc(route, params, options) { @@ -87,7 +67,7 @@ odoo.define('web.test_env', async function (require) { }, }, env.session), }; - testEnv = Object.assign(proxiedEnv, defaultEnv); + testEnv = Object.assign(env, defaultEnv); return testEnv; } diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 2ff77de4e49..f462df514a1 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -15,6 +15,7 @@ odoo.define('web.test_utils', async function (require) { const relationalFields = require('web.relational_fields'); const session = require('web.session'); const testUtilsCreate = require('web.test_utils_create'); + const testUtilsControlPanel = require('web.test_utils_control_panel'); const testUtilsDom = require('web.test_utils_dom'); const testUtilsFields = require('web.test_utils_fields'); const testUtilsFile = require('web.test_utils_file'); @@ -128,11 +129,60 @@ odoo.define('web.test_utils', async function (require) { fieldsViewGet: testUtilsMock.fieldsViewGet, patchSetTimeout: testUtilsMock.patchSetTimeout, }, + controlPanel: { + // Generic interactions + toggleMenu: testUtilsControlPanel.toggleMenu, + toggleMenuItem: testUtilsControlPanel.toggleMenuItem, + toggleMenuItemOption: testUtilsControlPanel.toggleMenuItemOption, + isItemSelected: testUtilsControlPanel.isItemSelected, + isOptionSelected: testUtilsControlPanel.isOptionSelected, + getMenuItemTexts: testUtilsControlPanel.getMenuItemTexts, + // Button interactions + getButtons: testUtilsControlPanel.getButtons, + // FilterMenu interactions + toggleFilterMenu: testUtilsControlPanel.toggleFilterMenu, + toggleAddCustomFilter: testUtilsControlPanel.toggleAddCustomFilter, + applyFilter: testUtilsControlPanel.applyFilter, + // GroupByMenu interactions + toggleGroupByMenu: testUtilsControlPanel.toggleGroupByMenu, + toggleAddCustomGroup: testUtilsControlPanel.toggleAddCustomGroup, + selectGroup: testUtilsControlPanel.selectGroup, + applyGroup: testUtilsControlPanel.applyGroup, + // FavoriteMenu interactions + toggleFavoriteMenu: testUtilsControlPanel.toggleFavoriteMenu, + toggleSaveFavorite: testUtilsControlPanel.toggleSaveFavorite, + editFavoriteName: testUtilsControlPanel.editFavoriteName, + saveFavorite: testUtilsControlPanel.saveFavorite, + deleteFavorite: testUtilsControlPanel.deleteFavorite, + // TimeRangeMenu interactions + toggleTimeRangeMenu: testUtilsControlPanel.toggleTimeRangeMenu, + selectField: testUtilsControlPanel.selectField, + selectRange: testUtilsControlPanel.selectRange, + selectComparisonRange: testUtilsControlPanel.selectComparisonRange, + toggleTimeRangeMenuBox: testUtilsControlPanel.toggleTimeRangeMenuBox, + applyTimeRange: testUtilsControlPanel.applyTimeRange, + // SearchBar interactions + getFacetTexts: testUtilsControlPanel.getFacetTexts, + removeFacet: testUtilsControlPanel.removeFacet, + editSearch: testUtilsControlPanel.editSearch, + validateSearch: testUtilsControlPanel.validateSearch, + // Action menus interactions + toggleActionMenu: testUtilsControlPanel.toggleActionMenu, + // Pager interactions + pagerPrevious: testUtilsControlPanel.pagerPrevious, + pagerNext: testUtilsControlPanel.pagerNext, + getPagerValue: testUtilsControlPanel.getPagerValue, + getPagerSize: testUtilsControlPanel.getPagerSize, + setPagerValue: testUtilsControlPanel.setPagerValue, + // View switcher + switchView: testUtilsControlPanel.switchView, + }, dom: { triggerKeypressEvent: testUtilsDom.triggerKeypressEvent, triggerMouseEvent: testUtilsDom.triggerMouseEvent, triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent, dragAndDrop: testUtilsDom.dragAndDrop, + getNode: testUtilsDom.getNode, openDatepicker: testUtilsDom.openDatepicker, click: testUtilsDom.click, clickFirst: testUtilsDom.clickFirst, @@ -187,10 +237,11 @@ odoo.define('web.test_utils', async function (require) { }, createActionManager: testUtilsCreate.createActionManager, + createComponent: testUtilsCreate.createComponent, + createControlPanel: testUtilsCreate.createControlPanel, createDebugManager: testUtilsCreate.createDebugManager, createAsyncView: testUtilsCreate.createView, createCalendarView: testUtilsCreate.createCalendarView, - createControlPanel: testUtilsCreate.createControlPanel, createView: testUtilsCreate.createView, createModel: testUtilsCreate.createModel, createParent: testUtilsCreate.createParent, diff --git a/addons/web/static/tests/helpers/test_utils_control_panel.js b/addons/web/static/tests/helpers/test_utils_control_panel.js new file mode 100644 index 00000000000..36a0a6b9bbf --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_control_panel.js @@ -0,0 +1,446 @@ +odoo.define('web.test_utils_control_panel', function (require) { + "use strict"; + + const { click, triggerEvent, getNode } = require('web.test_utils_dom'); + const { editInput, editSelect, editAndTrigger } = require('web.test_utils_fields'); + + //------------------------------------------------------------------------- + // Private functions + //------------------------------------------------------------------------- + + /** + * Helper method to retrieve a distinct item from a collection of elements defined + * by the given 'selector' string. It can either be the index of the item or its + * inner text. + * @private + * @param {HTMLElement} el + * @param {string} selector + * @param {(number|string)} elFinder + */ + function findItem(el, selector, elFinder) { + const elements = [...el.querySelectorAll(selector)]; + if (!elements.length) { + throw new Error(`No element found with selector "${selector}".`); + } + switch (typeof elFinder) { + case 'number': + const element = elements[elFinder]; + if (!element) { + throw new Error(`No element with selector "${selector}" at index ${elFinder}.`); + } + return element; + case 'string': + const stringMatch = elements.find( + e => e.innerText.trim().toLowerCase() === elFinder.toLowerCase() + ); + if (!stringMatch) { + throw new Error(`No element with selector "${selector}" containing "${elFinder}".`); + } + return stringMatch; + default: + throw new Error(`Invalid provided element finder: must be a number|string|function.`); + } + } + + //------------------------------------------------------------------------- + // Exported functions + //------------------------------------------------------------------------- + + /** + * @param {EventTarget} el + * @param {(number|string)} menuFinder + * @returns {Promise} + */ + async function toggleMenu(el, menuFinder) { + const menu = findItem(getNode(el), `.o_dropdown > button`, menuFinder); + await click(menu); + } + + /** + * @param {EventTarget} el + * @param {(number|string)} itemFinder + * @returns {Promise} + */ + async function toggleMenuItem(el, itemFinder) { + const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + await click(item); + } + + /** + * @param {EventTarget} el + * @param {(number|string)} itemFinder + * @param {(number|string)} optionFinder + * @returns {Promise} + */ + async function toggleMenuItemOption(el, itemFinder, optionFinder) { + const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + const option = findItem(item.parentNode, '.o_item_option > a', optionFinder); + await click(option); + } + + /** + * @param {EventTarget} el + * @param {(number|string)} itemFinder + * @returns {boolean} + */ + function isItemSelected(el, itemFinder) { + const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + return item.classList.contains('selected'); + } + + /** + * @param {EventTarget} el + * @param {(number|string)} itemuFinder + * @param {(number|string)} optionFinder + * @returns {boolean} + */ + function isOptionSelected(el, itemFinder, optionFinder) { + const item = findItem(getNode(el), `.o_menu_item > a`, itemFinder); + const option = findItem(item.parentNode, '.o_item_option > a', optionFinder); + return option.classList.contains('selected'); + } + + /** + * @param {EventTarget} el + * @returns {string[]} + */ + function getMenuItemTexts(el) { + return [...getNode(el).querySelectorAll(`.o_dropdown ul .o_menu_item`)].map( + e => e.innerText.trim() + ); + } + + /** + * @param {EventTarget} el + * @returns {HTMLButtonElement[]} + */ + function getButtons(el) { + return [...getNode(el).querySelector((`div.o_cp_bottom div.o_cp_buttons`)).children]; + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleFilterMenu(el) { + await click(getNode(el).querySelector(`.o_filter_menu button`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleAddCustomFilter(el) { + await click(getNode(el).querySelector(`button.o_add_custom_filter`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function applyFilter(el) { + await click(getNode(el).querySelector(`div.o_add_filter_menu > button.o_apply_filter`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleGroupByMenu(el) { + await click(getNode(el).querySelector(`.o_group_by_menu button`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleAddCustomGroup(el) { + await click(getNode(el).querySelector(`button.o_add_custom_group_by`)); + } + + /** + * @param {EventTarget} el + * @param {string} fieldName + * @returns {Promise} + */ + async function selectGroup(el, fieldName) { + await editSelect( + getNode(el).querySelector(`select.o_group_by_selector`), + fieldName + ); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function applyGroup(el) { + await click(getNode(el).querySelector(`div.o_add_group_by_menu > button.o_apply_group_by`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleFavoriteMenu(el) { + await click(getNode(el).querySelector(`.o_favorite_menu button`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleSaveFavorite(el) { + await click(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite button`)); + } + + /** + * @param {EventTarget} el + * @param {string} name + * @returns {Promise} + */ + async function editFavoriteName(el, name) { + await editInput(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite input[type="text"]`), name); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function saveFavorite(el) { + await click(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite button.o_save_favorite`)); + } + + /** + * @param {EventTarget} el + * @param {(string|number)} favoriteFinder + * @returns {Promise} + */ + async function deleteFavorite(el, favoriteFinder) { + const favorite = findItem(getNode(el), `.o_favorite_menu .o_menu_item`, favoriteFinder); + await click(favorite.querySelector('i.fa-trash-o')); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleTimeRangeMenu(el) { + await click(getNode(el).querySelector(`div.o_time_range_menu > button`)); + } + + /** + * @param {EventTarget} el + * @param {string} fieldName + * @returns {Promise} + */ + async function selectField(el, fieldName) { + await editSelect( + findItem(getNode(el), `div.o_time_range_section select`, 0), + fieldName + ); + } + + /** + * @param {EventTarget} el + * @param {string} range + * @returns {Promise} + */ + async function selectRange(el, range) { + await editSelect( + findItem(getNode(el), `div.o_time_range_section select`, 1), + range + ); + } + + /** + * @param {EventTarget} el + * @param {string} comparisonRange + * @returns {Promise} + */ + async function selectComparisonRange(el, comparisonRange) { + await editSelect( + findItem(getNode(el), `div.o_time_range_section select`, 2), + comparisonRange + ); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function toggleTimeRangeMenuBox(el) { + await click(getNode(el).querySelector(`div.o_time_range_section input`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function applyTimeRange(el) { + await click(getNode(el).querySelector(`div.o_time_range_menu ul button`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + function getFacetTexts(el) { + return [...getNode(el).querySelectorAll(`.o_searchview .o_searchview_facet`)].map( + facet => facet.innerText.trim() + ); + } + + /** + * @param {EventTarget} el + * @param {(string|number)} facetFinder + * @returns {Promise} + */ + async function removeFacet(el, facetFinder = 0) { + const facet = findItem(getNode(el), `.o_searchview .o_searchview_facet`, facetFinder); + await click(facet.querySelector('.o_facet_remove')); + } + + /** + * @param {EventTarget} el + * @param {string} value + * @returns {Promise} + */ + async function editSearch(el, value) { + await editInput(getNode(el).querySelector(`.o_searchview_input`), value); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function validateSearch(el) { + await triggerEvent( + getNode(el).querySelector(`.o_searchview_input`), + 'keydown', { key: 'Enter' } + ); + } + + /** + * @param {EventTarget} el + * @param {string} [menuFinder="Action"] + * @returns {Promise} + */ + async function toggleActionMenu(el, menuFinder = "Action") { + const dropdown = findItem(getNode(el), `.o_cp_action_menus button`, menuFinder); + await click(dropdown); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function pagerPrevious(el) { + await click(getNode(el).querySelector(`.o_pager button.o_pager_previous`)); + } + + /** + * @param {EventTarget} el + * @returns {Promise} + */ + async function pagerNext(el) { + await click(getNode(el).querySelector(`.o_pager button.o_pager_next`)); + } + + /** + * @param {EventTarget} el + * @returns {string} + */ + function getPagerValue(el) { + const pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`); + switch (pagerValue.tagName) { + case 'INPUT': + return pagerValue.value; + case 'SPAN': + return pagerValue.innerText.trim(); + } + } + + /** + * @param {EventTarget} el + * @returns {string} + */ + function getPagerSize(el) { + return getNode(el).querySelector(`.o_pager_counter span.o_pager_limit`).innerText.trim(); + } + + /** + * @param {EventTarget} el + * @param {string} value + * @returns {Promise} + */ + async function setPagerValue(el, value) { + let pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`); + if (pagerValue.tagName === 'SPAN') { + await click(pagerValue); + } + pagerValue = getNode(el).querySelector(`.o_pager_counter input.o_pager_value`); + if (!pagerValue) { + throw new Error("Pager value is being edited and cannot be changed."); + } + await editAndTrigger(pagerValue, value, ['change', 'blur']); + } + + /** + * @param {EventTarget} el + * @param {string} viewType + * @returns {Promise} + */ + async function switchView(el, viewType) { + await click(getNode(el).querySelector(`button.o_switch_view.o_${viewType}`)); + } + + return { + // Generic interactions + toggleMenu, + toggleMenuItem, + toggleMenuItemOption, + isItemSelected, + isOptionSelected, + getMenuItemTexts, + // Button interactions + getButtons, + // FilterMenu interactions + toggleFilterMenu, + toggleAddCustomFilter, + applyFilter, + // GroupByMenu interactions + toggleGroupByMenu, + toggleAddCustomGroup, + selectGroup, + applyGroup, + // FavoriteMenu interactions + toggleFavoriteMenu, + toggleSaveFavorite, + editFavoriteName, + saveFavorite, + deleteFavorite, + // TimeRangeMenu interactions + toggleTimeRangeMenu, + selectField, + selectRange, + selectComparisonRange, + toggleTimeRangeMenuBox, + applyTimeRange, + // SearchBar interactions + getFacetTexts, + removeFacet, + editSearch, + validateSearch, + // Action menus interactions + toggleActionMenu, + // Pager interactions + pagerPrevious, + pagerNext, + getPagerValue, + getPagerSize, + setPagerValue, + // View switcher + switchView, + }; +}); diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index 21bc6cc7850..8130e74ba03 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -11,14 +11,23 @@ odoo.define('web.test_utils_create', function (require) { */ const ActionManager = require('web.ActionManager'); + const ActionMenus = require('web.ActionMenus'); const concurrency = require('web.concurrency'); const config = require('web.config'); - const ControlPanelView = require('web.ControlPanelView'); + const ControlPanel = require('web.ControlPanel'); + const ControlPanelModel = require('web.ControlPanelModel'); + const customHooks = require('web.custom_hooks'); const DebugManager = require('web.DebugManager.Backend'); const dom = require('web.dom'); + const makeTestEnvironment = require('web.test_env'); + const Registry = require('web.Registry'); const testUtilsMock = require('web.test_utils_mock'); const Widget = require('web.Widget'); + const { Component } = owl; + const { useRef, useState } = owl.hooks; + const { xml } = owl.tags; + /** * Create and return an instance of ActionManager with all rpcs going through a * mock method using the data, actions and archs objects as sources. @@ -50,13 +59,16 @@ odoo.define('web.test_utils_create', function (require) { return this._super(...arguments); }, }); - testUtilsMock.addMockEnvironment(widget, Object.assign({ debounce: false }, params)); + const mockServer = await testUtilsMock.addMockEnvironment(widget, Object.assign({ debounce: false }, params)); await widget.prependTo(target); widget.el.classList.add('o_web_client'); if (config.device.isMobile) { widget.el.classList.add('o_touch_device'); } + params.server = mockServer; + Component.env = testUtilsMock.getMockedOwlEnv(params); + const userContext = params.context && params.context.user_context || {}; const actionManager = new ActionManager(widget, userContext); @@ -106,86 +118,116 @@ odoo.define('web.test_utils_create', function (require) { } /** - * Create a controlPanel from various parameters. - * - * It returns an instance of ControlPanelController - * - * @param {Object} [params={}] - * @param {Object} [params.action={}] - * @param {Object} [params.context={}] - * @param {string} [params.domain=[]] - * @param {integer} [params.fieldDebounce=0] the debounce value to use for the - * duration of the test. - * @param {Object} params.intercepts an object with event names as key, and - * callback as value. Each key,value will be used to intercept the event. - * Note that this is particularly useful if you want to intercept events going - * up in the init process of the view, because there are no other way to do it - * after this method returns - * @param {string} [params.modelName] - * @param {string[]} [params.searchMenuTypes = ['filter', 'groupBy', 'favorite']] - * determines search menus displayed. - * @param {string} [params.template] the QWeb template to render - * @param {Object} [params.viewInfo={arch: '', fields: {}}] - a controlpanel (or search) fieldsview - * @param {string} [params.viewInfo.arch] - * @param {boolean} [params.context.no_breadcrumbs=false] if set to true, - * breadcrumbs won't be rendered - * @param {boolean} [params.withBreadcrumbs=true] if set to false, - * breadcrumbs won't be rendered - * @param {boolean} [params.withSearchBar=true] if set to false, no default - * search bar will be rendered - * - * @returns {Promise} resolves with an instance of the ControlPanelController + * Create a simple component environment with a basic Parent component, an + * extensible env and a mocked server. The returned value is the instance of + * the given constructor. + * @param {class} constructor Component class to instantiate + * @param {Object} [params = {}] + * @param {boolean} [params.debug] + * @param {Object} [params.env] + * @param {Object} [params.intercepts] object in which the keys represent the + * intercepted event names and the values are their callbacks. + * @param {Object} [params.props] + * @returns {Promise} instance of `constructor` */ - async function createControlPanel(params) { - params = params || {}; - const target = prepareTarget(params.debug); - // reproduce the DOM environment of a view control panel - const $webClient = $('
').addClass('o_web_client').prependTo(target); - const $actionManager = $('
').addClass('o_action_manager').appendTo($webClient); - const $action = $('
').addClass('o_action').appendTo($actionManager); - - const widget = new Widget(); - // add mock environment: mock server, session, fieldviewget, ... - const mockServer = testUtilsMock.addMockEnvironment(widget, params); - if (!params.viewInfo) { - try { - params.viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); - } catch (e) { - // if an error occurs we keep params.viewInfo undefined. - // It will be set to {arch: '', fields: {}} in - // ControlPanelView init function. + async function createComponent(constructor, params = {}) { + if (!constructor) { + throw new Error(`Missing argument "constructor".`); + } + if (!(constructor.prototype instanceof Component)) { + throw new Error(`Argument "constructor" must be an Owl Component.`); + } + const env = Object.assign(testUtilsMock.getMockedOwlEnv(params), params.env); + class Parent extends Component { + constructor() { + super(...arguments); + this.Component = constructor; + this.state = useState(params.props || {}); + this.component = useRef('component'); + for (const eventName in params.intercepts || {}) { + customHooks.useListener(eventName, params.intercepts[eventName]); + } } } - - const viewOptions = Object.assign({ - context: params.context, - modelName: params.model, - searchMenuTypes: params.searchMenuTypes, - viewInfo: params.viewInfo, - }, params.viewOptions); - const controlPanelView = new ControlPanelView(viewOptions); - const controlPanel = await controlPanelView.getController(widget); - // override the controlPanel's 'destroy' so that it calls 'destroy' on - // the widget instead, as the widget is the parent of the controlPanel - // and the mockServer. - controlPanel.__destroy = controlPanel.destroy; - controlPanel.destroy = function () { - // remove the override to properly destroy the controlPanel and its - // children when it will be called the second time (by its parent) - delete controlPanel.destroy; - widget.destroy(); - $webClient.remove(); + Parent.env = env; + Parent.template = xml``; + const parent = new Parent(); + await parent.mount(prepareTarget(params.debug), { position: 'first-child' }); + const child = parent.component.comp; + const originalDestroy = child.destroy; + child.destroy = function () { + child.destroy = originalDestroy; + parent.destroy(); }; + return child; + } + + /** + * Create a Control Panel instance, with an extensible environment and + * its related Control Panel Model. Event interception is done through + * params['get-controller-query-params'] and params.search, for the two + * available event handlers respectively. + * @param {Object} [params={}] + * @param {Object} [params.cpProps] + * @param {Object} [params.cpStoreConfig] + * @param {boolean} [params.debug] + * @param {Object} [params.env] + * @returns {Object} useful control panel testing elements: + * - controlPanel: the control panel instance + * - el: the control panel HTML element + * - helpers: a suite of bound helpers (see above functions for all + * available helpers) + */ + async function createControlPanel(params = {}) { + const config = params.cpStoreConfig || {}; + const debug = params.debug || false; + const env = params.env || {}; + const props = Object.assign({ + action: {}, + fields: {}, + }, params.cpProps); + + class Parent extends Component { + constructor() { + super(); + config.env = this.env; + this._controlPanelModel = new ControlPanelModel(config); + this.state = useState(props); + this.controlPanel = useRef("controlPanel"); + } + async willStart() { + await this._controlPanelModel.isReady; + } + mounted() { + if (params['get-controller-query-params']) { + this._controlPanelModel.on('get-controller-query-params', this, + params['get-controller-query-params']); + } + if (params.search) { + this._controlPanelModel.on('search', this, params.search); + } + } + } + Parent.components = { ControlPanel }; + Parent.env = makeTestEnvironment(env); + Parent.template = xml` + `; + + const parent = new Parent(); + await parent.mount(prepareTarget(debug), { position: 'first-child' }); + + const controlPanel = parent.controlPanel.comp; + const destroy = controlPanel.destroy; + controlPanel.destroy = function () { + controlPanel.destroy = destroy; + parent.destroy(); + }; + controlPanel.getQuery = () => parent._controlPanelModel.getQuery(); - // render the controlPanel in a fragment as it must be able to render - // correctly without being in the DOM - const fragment = document.createDocumentFragment(); - await controlPanel.appendTo(fragment); - dom.prepend($action, fragment, { - callbacks: [{ widget: controlPanel }], - in_DOM: true, - }); return controlPanel; } @@ -279,6 +321,7 @@ odoo.define('web.test_utils_create', function (require) { * @param {any[]} [params.domain] the initial domain for the view * @param {Object} [params.context] the initial context for the view * @param {string[]} [params.groupBy] the initial groupBy for the view + * @param {Object[]} [params.favoriteFilters] the favorite filters one would like to have at initialization * @param {integer} [params.fieldDebounce=0] the debounce value to use for the * duration of the test. * @param {AbstractView} params.View the class that will be instantiated @@ -306,18 +349,29 @@ odoo.define('web.test_utils_create', function (require) { webClient.append(actionManager); // add mock environment: mock server, session, fieldviewget, ... - const mockServer = testUtilsMock.addMockEnvironment(widget, params); + const mockServer = await testUtilsMock.addMockEnvironment(widget, params); const viewInfo = testUtilsMock.fieldsViewGet(mockServer, params); + params.server = mockServer; + const env = Object.assign(testUtilsMock.getMockedOwlEnv(params)); + Component.env = env; + // create the view const View = params.View; + const modelName = params.model || 'foo'; + const defaultAction = { + res_model: modelName, + context: {}, + }; const viewOptions = Object.assign({ - modelName: params.model || 'foo', + action: Object.assign(defaultAction, params.action), + view: viewInfo, + modelName: modelName, ids: 'res_id' in params ? [params.res_id] : undefined, currentId: 'res_id' in params ? params.res_id : undefined, domain: params.domain || [], context: params.context || {}, - hasSidebar: false, + hasActionMenus: false, }, params.viewOptions); // patch the View to handle the groupBy given in params, as we can't give it // in init (unlike the domain and context which can be set in the action) @@ -335,16 +389,14 @@ odoo.define('web.test_utils_create', function (require) { let view; if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') { // TODO: probably needs to create an helper just for that - view = new params.View({ - viewInfo: viewInfo, - modelName: params.model || 'foo', - }); + view = new params.View({ viewInfo, modelName }); } else { - viewOptions.controlPanelFieldsView = testUtilsMock.fieldsViewGet(mockServer, { + viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.fieldsViewGet(mockServer, { arch: params.archs && params.archs[params.model + ',false,search'] || '', fields: viewInfo.fields, model: params.model, - }); + }), { favoriteFilters: params.favoriteFilters }); + view = new params.View(viewInfo, viewOptions); } @@ -354,6 +406,12 @@ odoo.define('web.test_utils_create', function (require) { } } + // Override the ActionMenus registry unless told otherwise. + let actionMenusRegistry = ActionMenus.registry; + if (params.actionMenusRegistry !== true) { + ActionMenus.registry = new Registry(); + } + const viewController = await view.getController(widget); // override the view's 'destroy' so that it calls 'destroy' on the widget // instead, as the widget is the parent of the view and the mockServer. @@ -364,6 +422,9 @@ odoo.define('web.test_utils_create', function (require) { delete viewController.destroy; widget.destroy(); webClient.remove(); + if (params.actionMenusRegistry !== true) { + ActionMenus.registry = actionMenusRegistry; + } }; // render the viewController in a fragment as they must be able to render correctly @@ -401,6 +462,7 @@ odoo.define('web.test_utils_create', function (require) { return { createActionManager, createCalendarView, + createComponent, createControlPanel, createDebugManager, createModel, diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js index ceca0d21fc9..8a7d3553d9e 100644 --- a/addons/web/static/tests/helpers/test_utils_dom.js +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -2,6 +2,7 @@ odoo.define('web.test_utils_dom', function (require) { "use strict"; const concurrency = require('web.concurrency'); + const Widget = require('web.Widget'); /** * DOM Test Utils @@ -68,6 +69,11 @@ odoo.define('web.test_utils_dom', function (require) { dragleave: { constructor: DragEvent, processParameters: onlyBubble }, dragover: { constructor: DragEvent, processParameters: onlyBubble }, drop: { constructor: DragEvent, processParameters: onlyBubble }, + + input: { constructor: InputEvent, processParameters: onlyBubble }, + + compositionstart: { constructor: CompositionEvent, processParameters: onlyBubble }, + compositionend: { constructor: CompositionEvent, processParameters: onlyBubble }, }; /** @@ -284,6 +290,44 @@ odoo.define('web.test_utils_dom', function (require) { return returnAfterNextAnimationFrame(); } + /** + * Helper function used to extract an HTML EventTarget element from a given + * target. The extracted element will depend on the target type: + * - Component|Widget -> el + * - jQuery -> associated element (must have 1) + * - HTMLCollection (or similar) -> first element (must have 1) + * - string -> result of document.querySelector with string + * - else -> as is + * @private + * @param {(Component|Widget|jQuery|HTMLCollection|HTMLElement|string)} target + * @returns {EventTarget} + */ + function getNode(target) { + let nodes; + if (target instanceof owl.Component || target instanceof Widget) { + nodes = [target.el]; + } else if (typeof target === 'string') { + nodes = document.querySelectorAll(target); + } else if (target === jQuery) { // jQuery (or $) + nodes = [document.body]; + } else if (target.length) { // jQuery instance, HTMLCollection or array + nodes = target; + } else { + nodes = [target]; + } + if (nodes.length !== 1) { + throw new Error(`Found ${nodes.length} nodes instead of 1.`); + } + const node = nodes[0]; + if (!node) { + throw new Error(`Expected a node and got ${node}.`); + } + if (!_isEventTarget(node)) { + throw new Error(`Expected node to be an instance of EventTarget and got ${node.constructor.name}.`); + } + return node; + } + /** * Open the datepicker of a given element. * @@ -453,6 +497,7 @@ odoo.define('web.test_utils_dom', function (require) { clickFirst, clickLast, dragAndDrop, + getNode, openDatepicker, returnAfterNextAnimationFrame, triggerEvent, diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js index bb2d1ef24a5..45b5dd6abcf 100644 --- a/addons/web/static/tests/helpers/test_utils_mock.js +++ b/addons/web/static/tests/helpers/test_utils_mock.js @@ -125,11 +125,11 @@ function removeSrcAttribute(el, widget) { * @param {boolean} [throttle=false] set to true to keep the throttling, which * is completely removed by default. * - * @returns {MockServer} the instance of the mock server, created by this + * @returns {Promise} the instance of the mock server, created by this * function. It is necessary for createView so that method can call some * other methods on it. */ -function addMockEnvironment(widget, params) { +async function addMockEnvironment(widget, params) { var Server = MockServer; params.services = params.services || {}; if (params.mockRPC) { @@ -287,6 +287,9 @@ function addMockEnvironment(widget, params) { views = _.mapObject(views, function (viewParams) { return fieldsViewGet(mockServer, viewParams); }); + if ('search' in views && params.favoriteFilters) { + views.search.favoriteFilters = params.favoriteFilters; + } event.data.on_success(views); }); }); @@ -312,6 +315,7 @@ function addMockEnvironment(widget, params) { // Deploy services var done = false; var servicesToDeploy = _.clone(params.services); + const servicePromises = []; if (!servicesToDeploy.ajax) { services.ajax = null; // use mocked ajax from mocked server } @@ -329,8 +333,7 @@ function addMockEnvironment(widget, params) { intercept(service, "get_session", function (event) { event.data.callback(session); }); - - service.start(); + servicePromises.push(service.start()); } else { var serviceNames = _.keys(servicesToDeploy); if (serviceNames.length) { @@ -340,6 +343,8 @@ function addMockEnvironment(widget, params) { } } + await Promise.all(servicePromises); + return mockServer; } @@ -368,21 +373,28 @@ function fieldsViewGet(server, params) { * @param {Object} [params.archs] this archs given to the mock server * @param {Object} [params.data] the business data given to the mock server * @param {boolean} [params.debug] + * @param {Object} [params.env] * @param {function} [params.mockRPC] + * @param {function} [params.server] an already instantiated server to avoid + * creating another one. * @returns {Object} */ -function getMockedOwlEnv(params) { - params = params || {}; - let Server = MockServer; - if (params.mockRPC) { - Server = MockServer.extend({ _performRpc: params.mockRPC }); +function getMockedOwlEnv(params = {}) { + let server; + if (params.server) { + server = params.server; + } else { + let Server = MockServer; + if (params.mockRPC) { + Server = MockServer.extend({ _performRpc: params.mockRPC }); + } + server = new Server(params.data, { + actions: params.actions, + archs: params.archs, + debug: params.debug, + }); } - const server = new Server(params.data, { - actions: params.actions, - archs: params.archs, - debug: params.debug, - }); - const env = { + const env = Object.assign({ dataManager: { load_action: (actionID, context) => { return server.performRpc('/web/action/load', { @@ -416,7 +428,7 @@ function getMockedOwlEnv(params) { }, }, session: params.session || {}, - }; + }, params.env); return makeTestEnvironment(env, server.performRpc.bind(server)); } diff --git a/addons/web/static/tests/main_tests.js b/addons/web/static/tests/main_tests.js index 079a14573d2..97d9b051bbb 100644 --- a/addons/web/static/tests/main_tests.js +++ b/addons/web/static/tests/main_tests.js @@ -5,6 +5,8 @@ odoo.define('web.web_client', async function (require) { const session = require("web.session"); const WebClient = require('web.WebClient'); + owl.config.mode = "dev"; + const webClient = new WebClient(); await session.is_bound; diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 369b7cbe836..6111f0e5732 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -638,6 +638,7 @@ + diff --git a/addons/web_tour/static/src/js/tour_step_utils.js b/addons/web_tour/static/src/js/tour_step_utils.js index cd92f6fe019..2c3b7726dac 100644 --- a/addons/web_tour/static/src/js/tour_step_utils.js +++ b/addons/web_tour/static/src/js/tour_step_utils.js @@ -121,15 +121,15 @@ return core.Class.extend({ extra_trigger: '.modal:not(.o_inactive_modal) .dropdown-menu.o_searchview_autocomplete', position: 'bottom', run: action => { - const keyboardEventUp = new KeyboardEvent('keyup', { + const keyEventEnter = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', which: 13, - keyCode: 13 + keyCode: 13, }); - action.tip_widget.$anchor[0].dispatchEvent(keyboardEventUp); + action.tip_widget.$anchor[0].dispatchEvent(keyEventEnter); }, debugHelp: this._getHelpMessage('simulateEnterKeyboardInSearchModal'), };