Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
odoo.define('web.test_env', async function (require) {
"use strict";
const Bus = require('web.Bus');
const session = require('web.session');
const { makeTestEnvServices } = require('@web/../tests/legacy/helpers/test_services');
const { templates, setLoadXmlDefaultApp } = require("@web/core/assets");
const { renderToString } = require('@web/core/utils/render');
const { App, Component } = owl;
let app;
/**
* 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
* will result in an error.
*
* @param {Object} [env={}]
* @param {Function} [providedRPC=null]
* @returns {Proxy}
*/
function makeTestEnvironment(env = {}, providedRPC = null) {
if (!app) {
app = new App(null, { templates, test: true });
renderToString.app = app;
setLoadXmlDefaultApp(app);
}
const defaultTranslationParamters = {
code: "en_US",
date_format: '%m/%d/%Y',
decimal_point: ".",
direction: 'ltr',
grouping: [],
thousands_sep: ",",
time_format: '%H:%M:%S',
};
let _t;
if ('_t' in env) {
_t = Object.assign(env._t, {database: env._t.database || {}})
} else {
_t = Object.assign(((s) => s), { database: {} });
}
_t.database.parameters = Object.assign(defaultTranslationParamters, _t.database.parameters);
const defaultEnv = {
_t,
browser: Object.assign({
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
Date: window.Date,
fetch: (window.fetch || (() => { })).bind(window),
}, env.browser),
bus: env.bus || new Bus(),
device: Object.assign({
isMobile: false,
SIZES: { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 },
}, env.device),
isDebug: env.isDebug || (() => false),
services: makeTestEnvServices(env),
session: Object.assign({
rpc(route, params, options) {
if (providedRPC) {
return providedRPC(route, params, options);
}
throw new Error(`No method to perform RPC`);
},
url: session.url,
getTZOffset: (() => 0),
}, env.session),
};
return Object.assign(env, defaultEnv);
}
/**
* Before each test, we want Component.env to be a fresh test environment.
*/
QUnit.on('OdooBeforeTestHook', function () {
Component.env = makeTestEnvironment();
});
return makeTestEnvironment;
});

View file

@ -0,0 +1,38 @@
/** @odoo-module */
import { buildQuery } from 'web.rpc';
const testEnvServices = {
getCookie() {},
httpRequest(/* route, params = {}, readMethod = 'json' */) {
return Promise.resolve('');
},
hotkey: { add: () => () => {} }, // fake service
notification: { notify() {} },
};
/**
* Creates services for the test environment. object
*
* @param {Object} [env]
* @returns {Object}
*/
function makeTestEnvServices(env) {
return Object.assign({}, testEnvServices, {
ajax: {
rpc() {
return env.session.rpc(...arguments); // Compatibility Legacy Widgets
},
},
rpc(params, options) {
const query = buildQuery(params);
return env.session.rpc(query.route, query.params, options);
},
ui: { activeElement: document }, // fake service
}, env.services);
}
export {
makeTestEnvServices,
testEnvServices,
};

View file

@ -0,0 +1,277 @@
odoo.define('web.test_utils', async function (require) {
"use strict";
/**
* Test Utils
*
* In this module, we define various utility functions to help simulate a mock
* environment as close as possible as a real environment. The main function is
* certainly createView, which takes a bunch of parameters and give you back an
* instance of a view, appended in the dom, ready to be tested.
*/
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');
const testUtilsForm = require('web.test_utils_form');
const testUtilsGraph = require('web.test_utils_graph');
const testUtilsKanban = require('web.test_utils_kanban');
const testUtilsMock = require('web.test_utils_mock');
const testUtilsModal = require('web.test_utils_modal');
const testUtilsPivot = require('web.test_utils_pivot');
const tools = require('web.tools');
function deprecated(fn, type) {
const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
`Please use 'testUtils.${type}.${fn.name}' instead.`;
return tools.deprecated(fn, msg);
}
/**
* Helper function, make a promise with a public resolve function. Note that
* this is not standard and should not be used outside of tests...
*
* @returns {Promise + resolve and reject function}
*/
function makeTestPromise() {
let resolve;
let reject;
const promise = new Promise(function (_resolve, _reject) {
resolve = _resolve;
reject = _reject;
});
promise.resolve = function () {
resolve.apply(null, arguments);
return promise;
};
promise.reject = function () {
reject.apply(null, arguments);
return promise;
};
return promise;
}
/**
* Make a promise with public resolve and reject functions (see
* @makeTestPromise). Perform an assert.step when the promise is
* resolved/rejected.
*
* @param {Object} assert instance object with the assertion methods
* @param {function} assert.step
* @param {string} str message to pass to assert.step
* @returns {Promise + resolve and reject function}
*/
function makeTestPromiseWithAssert(assert, str) {
const prom = makeTestPromise();
prom.then(() => assert.step('ok ' + str)).catch(function () { });
prom.catch(() => assert.step('ko ' + str));
return prom;
}
/**
* Create a new promise that can be waited by the caller in order to execute
* code after the next microtask tick and before the next jobqueue tick.
*
* @return {Promise} an already fulfilled promise
*/
async function nextMicrotaskTick() {
return Promise.resolve();
}
/**
* Returns a promise that will be resolved after the tick after the
* nextAnimationFrame
*
* This is usefull to guarantee that OWL has had the time to render
*
* @returns {Promise}
*/
async function nextTick() {
return testUtilsDom.returnAfterNextAnimationFrame();
}
/**
* Awaits for an additionnal rendering frame initiated by the Owl
* compatibility layer processing.
*
* By default a simple "nextTick" will handle the rendering of any widget/
* component stuctures having at most 1 switch between the type of
* entities (Component > Widget or Widget > Component). However more time
* must be spent rendering in case we have additionnal switches. In such
* cases this function must be used (1 call for each additionnal switch)
* since it will be removed along with the compatiblity layer once the
* framework has been entirely converted, and using this helper will make
* it easier to wipe it from the code base.
*
* @returns {Promise}
*/
async function owlCompatibilityExtraNextTick() {
return testUtilsDom.returnAfterNextAnimationFrame();
}
// Loading static files cannot be properly simulated when their real content is
// really needed. This is the case for static XML files so we load them here,
// before starting the qunit test suite.
// (session.js is in charge of loading the static xml bundle and we also have
// to load xml files that are normally lazy loaded by specific widgets).
// Assets can also contain static xml files. They are loaded when the session
// is launched.
await session.is_bound;
setTimeout(function () {
// jquery autocomplete refines the search in a setTimeout() parameterized
// with a delay, so we force this delay to 0 s.t. the dropdown is filtered
// directly on the next tick
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
}, 0);
return {
mock: {
addMockEnvironment: testUtilsMock.addMockEnvironment,
addMockEnvironmentOwl: testUtilsMock.addMockEnvironmentOwl,
intercept: testUtilsMock.intercept,
patch: testUtilsMock.patch,
patchDate: testUtilsMock.patchDate,
unpatch: testUtilsMock.unpatch,
getView: testUtilsMock.getView,
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,
addCondition: testUtilsControlPanel.addCondition,
// 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,
// ComparisonMenu interactions
toggleComparisonMenu: testUtilsControlPanel.toggleComparisonMenu,
// 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,
triggerPositionalTapEvents: testUtilsDom.triggerPositionalTapEvents,
dragAndDrop: testUtilsDom.dragAndDrop,
find: testUtilsDom.findItem,
getNode: testUtilsDom.getNode,
openDatepicker: testUtilsDom.openDatepicker,
click: testUtilsDom.click,
clickFirst: testUtilsDom.clickFirst,
clickLast: testUtilsDom.clickLast,
triggerEvents: testUtilsDom.triggerEvents,
triggerEvent: testUtilsDom.triggerEvent,
},
form: {
clickEdit: testUtilsForm.clickEdit,
clickSave: testUtilsForm.clickSave,
clickCreate: testUtilsForm.clickCreate,
clickDiscard: testUtilsForm.clickDiscard,
reload: testUtilsForm.reload,
},
graph: {
reload: testUtilsGraph.reload,
},
kanban: {
reload: testUtilsKanban.reload,
clickCreate: testUtilsKanban.clickCreate,
quickCreate: testUtilsKanban.quickCreate,
toggleGroupSettings: testUtilsKanban.toggleGroupSettings,
toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown,
},
modal: {
clickButton: testUtilsModal.clickButton,
},
pivot: {
clickMeasure: testUtilsPivot.clickMeasure,
toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown,
reload: testUtilsPivot.reload,
},
fields: {
many2one: {
createAndEdit: testUtilsFields.clickM2OCreateAndEdit,
clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown,
clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem,
clickItem: testUtilsFields.clickM2OItem,
searchAndClickItem: testUtilsFields.searchAndClickM2OItem,
},
editInput: testUtilsFields.editInput,
editSelect: testUtilsFields.editSelect,
editAndTrigger: testUtilsFields.editAndTrigger,
triggerKey: testUtilsFields.triggerKey,
triggerKeydown: testUtilsFields.triggerKeydown,
triggerKeyup: testUtilsFields.triggerKeyup,
},
file: {
createFile: testUtilsFile.createFile,
dragoverFile: testUtilsFile.dragoverFile,
dropFile: testUtilsFile.dropFile,
dropFiles: testUtilsFile.dropFiles,
inputFiles: testUtilsFile.inputFiles,
},
createComponent: testUtilsCreate.createComponent,
createControlPanel: testUtilsCreate.createControlPanel,
createAsyncView: testUtilsCreate.createView,
createCalendarView: testUtilsCreate.createCalendarView,
createView: testUtilsCreate.createView,
createModel: testUtilsCreate.createModel,
createParent: testUtilsCreate.createParent,
makeTestPromise: makeTestPromise,
makeTestPromiseWithAssert: makeTestPromiseWithAssert,
nextMicrotaskTick: nextMicrotaskTick,
nextTick: nextTick,
owlCompatibilityExtraNextTick,
prepareTarget: testUtilsCreate.prepareTarget,
returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame,
// backward-compatibility
addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'),
dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'),
getView: deprecated(testUtilsMock.getView, 'mock'),
intercept: deprecated(testUtilsMock.intercept, 'mock'),
openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'),
patch: deprecated(testUtilsMock.patch, 'mock'),
patchDate: deprecated(testUtilsMock.patchDate, 'mock'),
triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'),
triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'),
triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'),
unpatch: deprecated(testUtilsMock.unpatch, 'mock'),
};
});

View file

@ -0,0 +1,360 @@
odoo.define('web.test_utils_control_panel', function (require) {
"use strict";
const { click, findItem, getNode, triggerEvent } = require('web.test_utils_dom');
const { editInput, editSelect, editAndTrigger } = require('web.test_utils_fields');
//-------------------------------------------------------------------------
// Exported functions
//-------------------------------------------------------------------------
/**
* @param {EventTarget} el
* @param {(number|string)} menuFinder
* @returns {Promise}
*/
async function toggleMenu(el, menuFinder) {
const menu = findItem(el, `.dropdown > button`, menuFinder);
await click(menu);
}
/**
* @param {EventTarget} el
* @param {(number|string)} itemFinder
* @returns {Promise}
*/
async function toggleMenuItem(el, itemFinder) {
const item = findItem(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(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(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(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(`.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 addCondition(el) {
await click(getNode(el).querySelector(`div.o_add_filter_menu > button.o_add_condition`));
}
/**
* @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(`span.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 .dropdown-item`));
}
/**
* @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(el, `.o_favorite_menu .o_menu_item`, favoriteFinder);
await click(favorite.querySelector('i.fa-trash'));
}
/**
* @param {EventTarget} el
* @returns {Promise}
*/
async function toggleComparisonMenu(el) {
await click(getNode(el).querySelector(`div.o_comparison_menu > 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(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(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,
addCondition,
// GroupByMenu interactions
toggleGroupByMenu,
toggleAddCustomGroup,
selectGroup,
applyGroup,
// FavoriteMenu interactions
toggleFavoriteMenu,
toggleSaveFavorite,
editFavoriteName,
saveFavorite,
deleteFavorite,
// ComparisonMenu interactions
toggleComparisonMenu,
// SearchBar interactions
getFacetTexts,
removeFacet,
editSearch,
validateSearch,
// Action menus interactions
toggleActionMenu,
// Pager interactions
pagerPrevious,
pagerNext,
getPagerValue,
getPagerSize,
setPagerValue,
// View switcher
switchView,
};
});

View file

@ -0,0 +1,403 @@
odoo.define('web.test_utils_create', function (require) {
"use strict";
/**
* Create Test Utils
*
* This module defines various utility functions to help creating mock widgets
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
const ActionMenus = require('web.ActionMenus');
const concurrency = require('web.concurrency');
const ControlPanel = require('web.ControlPanel');
const { useListener } = require("@web/core/utils/hooks");
const dom = require('web.dom');
const makeTestEnvironment = require('web.test_env');
const ActionModel = require('web.ActionModel');
const Registry = require('web.Registry');
const testUtilsMock = require('web.test_utils_mock');
const Widget = require('web.Widget');
const { destroy, getFixture, mount, useChild } = require('@web/../tests/helpers/utils');
const { registerCleanup } = require("@web/../tests/helpers/cleanup");
const { LegacyComponent } = require("@web/legacy/legacy_component");
const { Component, onMounted, onWillStart, useState, xml } = owl;
/**
* Similar as createView, but specific for calendar views. Some calendar
* tests need to trigger positional clicks on the DOM produced by fullcalendar.
* Those tests must use this helper with option positionalClicks set to true.
* This will move the rendered calendar to the body (required to do positional
* clicks), and wait for a setTimeout(0) before returning, because fullcalendar
* makes the calendar scroll to 6:00 in a setTimeout(0), which might have an
* impact according to where we want to trigger positional clicks.
*
* @param {Object} params @see createView
* @param {Object} [options]
* @param {boolean} [options.positionalClicks=false]
* @returns {Promise<CalendarController>}
*/
async function createCalendarView(params, options) {
const calendar = await createView(params);
if (!options || !options.positionalClicks) {
return calendar;
}
const viewElements = [...document.getElementById('qunit-fixture').children];
// prepend reset the scrollTop to zero so we restore it manually
let fcScroller = document.querySelector('.fc-scroller');
const scrollPosition = fcScroller.scrollTop;
viewElements.forEach(el => document.body.prepend(el));
fcScroller = document.querySelector('.fc-scroller');
fcScroller.scrollTop = scrollPosition;
const destroy = calendar.destroy;
calendar.destroy = () => {
viewElements.forEach(el => el.remove());
destroy();
};
await concurrency.delay(0);
return calendar;
}
/**
* 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<Component>} instance of `constructor`
*/
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 cleanUp = await testUtilsMock.addMockEnvironmentOwl(Component, params);
class Parent extends LegacyComponent {
setup() {
this.Component = constructor;
this.state = useState(params.props || {});
for (const eventName in params.intercepts || {}) {
useListener(eventName, params.intercepts[eventName]);
}
useChild();
}
}
Parent.template = xml`<t t-component="Component" t-props="state"/>`;
const target = getFixture();
const env = Component.env;
const parent = await mount(Parent, target, { env });
registerCleanup(cleanUp);
registerCleanup(() => {
destroy(parent);
});
return parent.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.cpModelConfig]
* @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 env = makeTestEnvironment(params.env || {});
const props = Object.assign({
action: {},
fields: {},
}, params.cpProps);
const globalConfig = Object.assign({
context: {},
domain: [],
}, params.cpModelConfig);
if (globalConfig.arch && globalConfig.fields) {
const model = "__mockmodel__";
const serverParams = {
model,
data: { [model]: { fields: globalConfig.fields, records: [] } },
};
const mockServer = await testUtilsMock.addMockEnvironment(
new Widget(),
serverParams,
);
const { arch } = testUtilsMock.getView(mockServer, {
arch: globalConfig.arch,
fields: globalConfig.fields,
model,
viewOptions: { context: globalConfig.context },
});
Object.assign(globalConfig, { arch });
}
globalConfig.env = env;
const archs = (globalConfig.arch && { search: globalConfig.arch, }) || {};
const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
const extensions = {
ControlPanel: { archNodes: controlPanelInfo.children, },
};
class Parent extends LegacyComponent {
setup() {
this.searchModel = new ActionModel(extensions, globalConfig);
this.state = useState(props);
useChild();
onWillStart(async () => {
await this.searchModel.load();
});
onMounted(() => {
if (params['get-controller-query-params']) {
this.searchModel.on('get-controller-query-params', this,
params['get-controller-query-params']);
}
if (params.search) {
this.searchModel.on('search', this, params.search);
}
});
}
}
Parent.components = { ControlPanel };
Parent.template = xml`
<ControlPanel
t-props="state"
searchModel="searchModel"
/>`;
const target = getFixture();
const parent = await mount(Parent, target, { env });
const controlPanel = parent.child;
controlPanel.getQuery = () => parent.searchModel.get("query");
registerCleanup(() => {
destroy(parent);
});
return controlPanel;
}
/**
* Create a model from given parameters.
*
* @param {Object} params This object will be given to addMockEnvironment, so
* any parameters from that method applies
* @param {Class} params.Model the model class to use
* @returns {Model}
*/
async function createModel(params) {
const widget = new Widget();
const model = new params.Model(widget, params);
await testUtilsMock.addMockEnvironment(widget, params);
// override the model's 'destroy' so that it calls 'destroy' on the widget
// instead, as the widget is the parent of the model and the mockServer.
model.destroy = function () {
// remove the override to properly destroy the model when it will be
// called the second time (by its parent)
delete model.destroy;
widget.destroy();
};
return model;
}
/**
* Create a widget parent from given parameters.
*
* @param {Object} params This object will be given to addMockEnvironment, so
* any parameters from that method applies
* @returns {Promise<Widget>}
*/
async function createParent(params) {
const widget = new Widget();
await testUtilsMock.addMockEnvironment(widget, params);
return widget;
}
/**
* Create a view from various parameters. Here, a view means a javascript
* instance of an AbstractView class, such as a form view, a list view or a
* kanban view.
*
* It returns the instance of the view, properly created, with all rpcs going
* through a mock method using the data object as source, and already loaded/
* started.
*
* @param {Object} params
* @param {string} params.arch the xml (arch) of the view to be instantiated
* @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
* @param {string} params.model a model name, will be given to the view
* @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 {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true.
* Default is false.
* @param {Boolean} [params.touchScreen=false] will add the o_touch_device to the webclient (flag used to define a
* device with a touch screen. Default value is false
* @returns {Promise<AbstractController>} the instance of the view
*/
async function createView(params) {
const target = prepareTarget(params.debug);
const widget = new Widget();
// reproduce the DOM environment of views
const webClient = Object.assign(document.createElement('div'), {
className: params.touchScreen ? 'o_web_client o_touch_device' : 'o_web_client',
});
const actionManager = Object.assign(document.createElement('div'), {
className: 'o_action_manager',
});
const dialogContainer = Object.assign(document.createElement('div'), {
className: 'o_dialog_container',
});
target.prepend(webClient);
webClient.append(actionManager);
webClient.append(dialogContainer);
// add mock environment: mock server, session, fieldviewget, ...
const mockServer = await testUtilsMock.addMockEnvironment(widget, params);
const viewInfo = testUtilsMock.getView(mockServer, params);
params.server = mockServer;
// create the view
const View = params.View;
const modelName = params.model || 'foo';
const defaultAction = {
res_model: modelName,
context: {},
type: 'ir.actions.act_window',
};
const viewOptions = Object.assign({
action: Object.assign(defaultAction, params.action),
view: { ...viewInfo, fields: mockServer.fieldsGet(params.model) },
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 || {},
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)
testUtilsMock.patch(View, {
_updateMVCParams() {
this._super(...arguments);
this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || [];
testUtilsMock.unpatch(View);
},
});
if ('hasSelectors' in params) {
viewOptions.hasSelectors = params.hasSelectors;
}
let view;
if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') {
// TODO: probably needs to create an helper just for that
view = new params.View({ viewInfo, modelName });
} else {
viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.getView(mockServer, {
arch: params.archs && params.archs[params.model + ',false,search'] || '<search/>',
fields: viewInfo.fields,
model: params.model,
}), { favoriteFilters: params.favoriteFilters });
view = new params.View(viewInfo, viewOptions);
}
if (params.interceptsPropagate) {
for (const name in params.interceptsPropagate) {
testUtilsMock.intercept(widget, name, params.interceptsPropagate[name], true);
}
}
// 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.
viewController.__destroy = viewController.destroy;
viewController.destroy = function () {
// remove the override to properly destroy the viewController and its children
// when it will be called the second time (by its parent)
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
// without being in the DOM
const fragment = document.createDocumentFragment();
await viewController.appendTo(fragment);
dom.prepend(actionManager, fragment, {
callbacks: [{ widget: viewController }],
in_DOM: true,
});
if (!params.doNotDisableAHref) {
[...viewController.el.getElementsByTagName('A')].forEach(elem => {
elem.addEventListener('click', ev => {
ev.preventDefault();
});
});
}
return viewController;
}
/**
* Get the target (fixture or body) of the document and adds event listeners
* to intercept custom or DOM events.
*
* @param {boolean} [debug=false] if true, the widget will be appended in
* the DOM. Also, RPCs and uncaught OdooEvent will be logged
* @returns {HTMLElement}
*/
function prepareTarget(debug = false) {
document.body.classList.toggle('debug', debug);
return debug ? document.body : document.getElementById('qunit-fixture');
}
return {
createCalendarView,
createComponent,
createControlPanel,
createModel,
createParent,
createView,
prepareTarget,
};
});

View file

@ -0,0 +1,610 @@
odoo.define('web.test_utils_dom', function (require) {
"use strict";
const concurrency = require('web.concurrency');
const Widget = require('web.Widget');
const { Component } = owl;
/**
* DOM Test Utils
*
* This module defines various utility functions to help simulate DOM events.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
//-------------------------------------------------------------------------
// Private functions
//-------------------------------------------------------------------------
// TriggerEvent helpers
const keyboardEventBubble = args => Object.assign({}, args, { bubbles: true, keyCode: args.which });
const mouseEventMapping = args => Object.assign({}, args, {
bubbles: true,
cancelable: true,
clientX: args ? args.clientX || args.pageX : undefined,
clientY: args ? args.clientY || args.pageY : undefined,
view: window,
});
const mouseEventNoBubble = args => Object.assign({}, args, {
bubbles: false,
cancelable: false,
clientX: args ? args.clientX || args.pageX : undefined,
clientY: args ? args.clientY || args.pageY : undefined,
view: window,
});
const touchEventMapping = args => Object.assign({}, args, {
cancelable: true,
bubbles: true,
composed: true,
view: window,
rotation: 0.0,
zoom: 1.0,
});
const touchEventCancelMapping = args => Object.assign({}, touchEventMapping(args), {
cancelable: false,
});
const noBubble = args => Object.assign({}, args, { bubbles: false });
const onlyBubble = args => Object.assign({}, args, { bubbles: true });
// TriggerEvent constructor/args processor mapping
const EVENT_TYPES = {
auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
click: { constructor: MouseEvent, processParameters: mouseEventMapping },
contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping },
dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping },
mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping },
mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping },
mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping },
mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping },
focus: { constructor: FocusEvent, processParameters: noBubble },
focusin: { constructor: FocusEvent, processParameters: onlyBubble },
blur: { constructor: FocusEvent, processParameters: noBubble },
cut: { constructor: ClipboardEvent, processParameters: onlyBubble },
copy: { constructor: ClipboardEvent, processParameters: onlyBubble },
paste: { constructor: ClipboardEvent, processParameters: onlyBubble },
keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
drag: { constructor: DragEvent, processParameters: onlyBubble },
dragend: { constructor: DragEvent, processParameters: onlyBubble },
dragenter: { constructor: DragEvent, processParameters: onlyBubble },
dragstart: { constructor: DragEvent, processParameters: onlyBubble },
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 },
};
if (typeof TouchEvent === 'function') {
Object.assign(EVENT_TYPES, {
touchstart: {constructor: TouchEvent, processParameters: touchEventMapping},
touchend: {constructor: TouchEvent, processParameters: touchEventMapping},
touchmove: {constructor: TouchEvent, processParameters: touchEventMapping},
touchcancel: {constructor: TouchEvent, processParameters: touchEventCancelMapping},
});
}
/**
* Check if an object is an instance of EventTarget.
*
* @param {Object} node
* @returns {boolean}
*/
function _isEventTarget(node) {
if (!node) {
throw new Error(`Provided node is ${node}.`);
}
if (node instanceof window.top.EventTarget) {
return true;
}
const contextWindow = node.defaultView || // document
(node.ownerDocument && node.ownerDocument.defaultView); // iframe node
return contextWindow && node instanceof contextWindow.EventTarget;
}
//-------------------------------------------------------------------------
// Public functions
//-------------------------------------------------------------------------
/**
* Click on a specified element. If the option first or last is not specified,
* this method also check the unicity and the visibility of the target.
*
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
* @param {Object} [options={}] click options
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
* element event if it is invisible
* @param {boolean} [options.first=false] if true, clicks on the first element
* @param {boolean} [options.last=false] if true, clicks on the last element
* @returns {Promise}
*/
async function click(el, options = {}) {
let matches, target;
let selectorMsg = "";
if (typeof el === 'string') {
el = $(el);
}
if (el.disabled || (el instanceof jQuery && el.get(0).disabled)) {
throw new Error("Can't click on a disabled button");
}
if (_isEventTarget(el)) {
// EventTarget
matches = [el];
} else {
// Any other iterable object containing EventTarget objects (jQuery, HTMLCollection, etc.)
matches = [...el];
}
const validMatches = options.allowInvisible ?
matches : matches.filter(t => $(t).is(':visible'));
if (options.first) {
if (validMatches.length === 1) {
throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
' you are sure that there is exactly one target, please use the ' +
'click function instead of the clickFirst function');
}
target = validMatches[0];
} else if (options.last) {
if (validMatches.length === 1) {
throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
' you are sure that there is exactly one target, please use the ' +
'click function instead of the clickLast function');
}
target = validMatches[validMatches.length - 1];
} else if (validMatches.length !== 1) {
throw new Error(`Found ${validMatches.length} elements to click on, instead of 1 ${selectorMsg}`);
} else {
target = validMatches[0];
}
if (validMatches.length === 0 && matches.length > 0) {
throw new Error(`Element to click on is not visible ${selectorMsg}`);
}
if (target.disabled) {
return;
}
return triggerEvent(target, 'click');
}
/**
* Click on the first element of a list of elements. Note that if the list has
* only one visible element, we trigger an error. In that case, it is better to
* use the click helper instead.
*
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
* @param {boolean} [options={}] click options
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
* element event if it is invisible
* @returns {Promise}
*/
async function clickFirst(el, options) {
return click(el, Object.assign({}, options, { first: true }));
}
/**
* Click on the last element of a list of elements. Note that if the list has
* only one visible element, we trigger an error. In that case, it is better to
* use the click helper instead.
*
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
* @param {boolean} [options={}] click options
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
* element event if it is invisible
* @returns {Promise}
*/
async function clickLast(el, options) {
return click(el, Object.assign({}, options, { last: true }));
}
/**
* Simulate a drag and drop operation between 2 jquery nodes: $el and $to.
* This is a crude simulation, with only the mousedown, mousemove and mouseup
* events, but it is enough to help test drag and drop operations with jqueryUI
* sortable.
*
* @todo: remove the withTrailingClick option when the jquery update branch is
* merged. This is not the default as of now, because handlers are triggered
* synchronously, which is not the same as the 'reality'.
*
* @param {jQuery|EventTarget} $el
* @param {jQuery|EventTarget} $to
* @param {Object} [options]
* @param {string|Object} [options.position='center'] target position:
* can either be one of {'top', 'bottom', 'left', 'right'} or
* an object with two attributes (top and left))
* @param {boolean} [options.disableDrop=false] whether to trigger the drop action
* @param {boolean} [options.continueMove=false] whether to trigger the
* mousedown action (will only work after another call of this function with
* without this option)
* @param {boolean} [options.withTrailingClick=false] if true, this utility
* function will also trigger a click on the target after the mouseup event
* (this is actually what happens when a drag and drop operation is done)
* @param {jQuery|EventTarget} [options.mouseenterTarget=undefined] target of the mouseenter event
* @param {jQuery|EventTarget} [options.mousedownTarget=undefined] target of the mousedown event
* @param {jQuery|EventTarget} [options.mousemoveTarget=undefined] target of the mousemove event
* @param {jQuery|EventTarget} [options.mouseupTarget=undefined] target of the mouseup event
* @param {jQuery|EventTarget} [options.ctrlKey=undefined] if the ctrl key should be considered pressed at the time of mouseup
* @returns {Promise}
*/
async function dragAndDrop($el, $to, options) {
let el = null;
if (_isEventTarget($el)) {
el = $el;
$el = $(el);
}
if (_isEventTarget($to)) {
$to = $($to);
}
options = options || {};
const position = options.position || 'center';
const elementCenter = $el.offset();
const toOffset = $to.offset();
if (typeof position === 'object') {
toOffset.top += position.top + 1;
toOffset.left += position.left + 1;
} else {
toOffset.top += $to.outerHeight() / 2;
toOffset.left += $to.outerWidth() / 2;
const vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
if (position === 'top') {
toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
} else if (position === 'bottom') {
toOffset.top += $to.outerHeight() / 2 - vertical_offset;
} else if (position === 'left') {
toOffset.left -= $to.outerWidth() / 2;
} else if (position === 'right') {
toOffset.left += $to.outerWidth() / 2;
}
}
if ($to[0].ownerDocument !== document) {
// we are in an iframe
const bound = $('iframe')[0].getBoundingClientRect();
toOffset.left += bound.left;
toOffset.top += bound.top;
}
await triggerEvent(options.mouseenterTarget || el || $el, 'mouseover', {}, true);
if (!(options.continueMove)) {
elementCenter.left += $el.outerWidth() / 2;
elementCenter.top += $el.outerHeight() / 2;
await triggerEvent(options.mousedownTarget || el || $el, 'mousedown', {
which: 1,
pageX: elementCenter.left,
pageY: elementCenter.top
}, true);
}
await triggerEvent(options.mousemoveTarget || el || $el, 'mousemove', {
which: 1,
pageX: toOffset.left,
pageY: toOffset.top
}, true);
if (!options.disableDrop) {
await triggerEvent(options.mouseupTarget || el || $el, 'mouseup', {
which: 1,
pageX: toOffset.left,
pageY: toOffset.top,
ctrlKey: options.ctrlKey,
}, true);
if (options.withTrailingClick) {
await triggerEvent(options.mouseupTarget || el || $el, 'click', {}, true);
}
} else {
// It's impossible to drag another element when one is already
// being dragged. So it's necessary to finish the drop when the test is
// over otherwise it's impossible for the next tests to drag and
// drop elements.
$el.on('remove', function () {
triggerEvent($el, 'mouseup', {}, true);
});
}
return returnAfterNextAnimationFrame();
}
/**
* 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.
* @param {Element} el
* @param {string} selector
* @param {number | string} [elFinder=0]
* @returns {Element | null}
*/
function findItem(el, selector, elFinder = 0) {
const elements = [...getNode(el).querySelectorAll(selector)];
if (!elements.length) {
throw new Error(`No element found with selector "${selector}".`);
}
switch (typeof elFinder) {
case "number": {
const match = elements[elFinder];
if (!match) {
throw new Error(
`No element with selector "${selector}" at index ${elFinder}.`
);
}
return match;
}
case "string": {
const match = elements.find(
(el) => el.innerText.trim().toLowerCase() === elFinder.toLowerCase()
);
if (!match) {
throw new Error(
`No element with selector "${selector}" containing "${elFinder}".
`);
}
return match;
}
default: throw new Error(
`Invalid provided element finder: must be a number|string|function.`
);
}
}
/**
* 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 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.
*
* @param {jQuery} $datepickerEl element to which a datepicker is attached
*/
async function openDatepicker($datepickerEl) {
return click($datepickerEl.find('.o_datepicker_input'));
}
/**
* Returns a promise that will be resolved after the nextAnimationFrame after
* the next tick
*
* This is useful to guarantee that OWL has had the time to render
*
* @returns {Promise}
*/
async function returnAfterNextAnimationFrame() {
await concurrency.delay(0);
await new Promise(resolve => {
window.requestAnimationFrame(resolve);
});
}
/**
* Trigger an event on the specified target.
* This function will dispatch a native event to an EventTarget or a
* jQuery event to a jQuery object. This behaviour can be overridden by the
* jquery option.
*
* @param {EventTarget|EventTarget[]} el
* @param {string} eventType event type
* @param {Object} [eventAttrs] event attributes
* on a jQuery element with the `$.fn.trigger` function
* @param {Boolean} [fast=false] true if the trigger event have to wait for a single tick instead of waiting for the next animation frame
* @returns {Promise}
*/
async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) {
let matches;
let selectorMsg = "";
if (_isEventTarget(el)) {
matches = [el];
} else {
matches = [...el];
}
if (matches.length !== 1) {
throw new Error(`Found ${matches.length} elements to trigger "${eventType}" on, instead of 1 ${selectorMsg}`);
}
const target = matches[0];
let event;
if (!EVENT_TYPES[eventType] && !EVENT_TYPES[eventType.type]) {
event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true }));
} else {
if (typeof eventType === "object") {
const { constructor, processParameters } = EVENT_TYPES[eventType.type];
const eventParameters = processParameters(eventType);
event = new constructor(eventType.type, eventParameters);
} else {
const { constructor, processParameters } = EVENT_TYPES[eventType];
event = new constructor(eventType, processParameters(eventAttrs));
}
}
target.dispatchEvent(event);
return fast ? undefined : returnAfterNextAnimationFrame();
}
/**
* Trigger multiple events on the specified element.
*
* @param {EventTarget|EventTarget[]} el
* @param {string[]} events the events you want to trigger
* @returns {Promise}
*/
async function triggerEvents(el, events) {
if (el instanceof jQuery) {
if (el.length !== 1) {
throw new Error(`target has length ${el.length} instead of 1`);
}
}
if (typeof events === 'string') {
events = [events];
}
for (let e = 0; e < events.length; e++) {
await triggerEvent(el, events[e]);
}
}
/**
* Simulate a keypress event for a given character
*
* @param {string} char the character, or 'ENTER'
* @returns {Promise}
*/
async function triggerKeypressEvent(char) {
let keycode;
if (char === 'Enter') {
keycode = $.ui.keyCode.ENTER;
} else if (char === "Tab") {
keycode = $.ui.keyCode.TAB;
} else {
keycode = char.charCodeAt(0);
}
return triggerEvent(document.body, 'keypress', {
key: char,
keyCode: keycode,
which: keycode,
});
}
/**
* simulate a mouse event with a custom event who add the item position. This is
* sometimes necessary because the basic way to trigger an event (such as
* $el.trigger('mousemove')); ) is too crude for some uses.
*
* @param {jQuery|EventTarget} $el
* @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
* @returns {Promise}
*/
async function triggerMouseEvent($el, type) {
const el = $el instanceof jQuery ? $el[0] : $el;
if (!el) {
throw new Error(`no target found to trigger MouseEvent`);
}
const rect = el.getBoundingClientRect();
// try to click around the center of the element, biased to the bottom
// right as chrome messes up when clicking on the top-left corner...
const left = rect.x + Math.ceil(rect.width / 2);
const top = rect.y + Math.ceil(rect.height / 2);
return triggerEvent(el, type, {which: 1, clientX: left, clientY: top});
}
/**
* simulate a mouse event with a custom event on a position x and y. This is
* sometimes necessary because the basic way to trigger an event (such as
* $el.trigger('mousemove')); ) is too crude for some uses.
*
* @param {integer} x
* @param {integer} y
* @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
* @returns {HTMLElement}
*/
async function triggerPositionalMouseEvent(x, y, type) {
const ev = document.createEvent("MouseEvent");
const el = document.elementFromPoint(x, y);
ev.initMouseEvent(
type,
true /* bubble */,
true /* cancelable */,
window, null,
x, y, x, y, /* coordinates */
false, false, false, false, /* modifier keys */
0 /*left button*/, null
);
el.dispatchEvent(ev);
return el;
}
/**
* Simulate a "TAP" (touch) event with a custom position x and y.
*
* @param {number} x
* @param {number} y
* @returns {HTMLElement}
*/
async function triggerPositionalTapEvents(x, y) {
const element = document.elementFromPoint(x, y);
const touch = new Touch({
identifier: 0,
target: element,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
await triggerEvent(element, 'touchstart', {
touches: [touch],
targetTouches: [touch],
changedTouches: [touch],
});
await triggerEvent(element, 'touchmove', {
touches: [touch],
targetTouches: [touch],
changedTouches: [touch],
});
await triggerEvent(element, 'touchend', {
changedTouches: [touch],
});
return element;
}
return {
click,
clickFirst,
clickLast,
dragAndDrop,
findItem,
getNode,
openDatepicker,
returnAfterNextAnimationFrame,
triggerEvent,
triggerEvents,
triggerKeypressEvent,
triggerMouseEvent,
triggerPositionalMouseEvent,
triggerPositionalTapEvents,
};
});

View file

@ -0,0 +1,250 @@
odoo.define('web.test_utils_fields', function (require) {
"use strict";
/**
* Field Test Utils
*
* This module defines various utility functions to help testing field widgets.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
const testUtilsDom = require('web.test_utils_dom');
const ARROW_KEYS_MAPPING = {
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
up: 'ArrowUp',
};
//-------------------------------------------------------------------------
// Public functions
//-------------------------------------------------------------------------
/**
* Autofills the input of a many2one field and clicks on the "Create and Edit" option.
*
* @param {string} fieldName
* @param {string} text Used as default value for the record name
* @see clickM2OItem
*/
async function clickM2OCreateAndEdit(fieldName, text = "ABC") {
await clickOpenM2ODropdown(fieldName);
const match = document.querySelector(`.o_field_many2one[name=${fieldName}] input`);
await editInput(match, text);
return clickM2OItem(fieldName, "Create and Edit");
}
/**
* Click on the active (highlighted) selection in a m2o dropdown.
*
* @param {string} fieldName
* @param {[string]} selector if set, this will restrict the search for the m2o
* input
* @returns {Promise}
*/
async function clickM2OHighlightedItem(fieldName, selector) {
const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
const $dropdown = $(m2oSelector).autocomplete('widget');
// clicking on an li (no matter which one), will select the focussed one
return testUtilsDom.click($dropdown[0].querySelector('li'));
}
/**
* Click on a menuitem in the m2o dropdown. This helper will target an element
* which contains some specific text. Note that it assumes that the dropdown
* is currently open.
*
* Example:
* testUtils.fields.many2one.clickM2OItem('partner_id', 'George');
*
* @param {string} fieldName
* @param {string} searchText
* @returns {Promise}
*/
async function clickM2OItem(fieldName, searchText) {
const m2oSelector = `.o_field_many2one[name=${fieldName}] input`;
const $dropdown = $(m2oSelector).autocomplete('widget');
const $target = $dropdown.find(`li:contains(${searchText})`).first();
if ($target.length !== 1 || !$target.is(':visible')) {
throw new Error('Menu item should be visible');
}
$target.mouseenter(); // This is NOT a mouseenter event. See jquery.js:5516 for more headaches.
return testUtilsDom.click($target);
}
/**
* Click to open the dropdown on a many2one
*
* @param {string} fieldName
* @param {[string]} selector if set, this will restrict the search for the m2o
* input
* @returns {Promise<HTMLInputElement>} the main many2one input
*/
async function clickOpenM2ODropdown(fieldName, selector) {
const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
const matches = document.querySelectorAll(m2oSelector);
if (matches.length !== 1) {
throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`);
}
await testUtilsDom.click(matches[0]);
return matches[0];
}
/**
* Sets the value of an element and then, trigger all specified events.
* Note that this helper also checks the unicity of the target.
*
* Example:
* testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']);
*
* @param {jQuery|EventTarget} el should target an input, textarea or select
* @param {string|number} value
* @param {string[]} events
* @returns {Promise}
*/
async function editAndTrigger(el, value, events) {
if (el instanceof jQuery) {
if (el.length !== 1) {
throw new Error(`target ${el.selector} has length ${el.length} instead of 1`);
}
el.val(value);
} else {
el.value = value;
}
return testUtilsDom.triggerEvents(el, events);
}
/**
* Sets the value of an input.
*
* Note that this helper also checks the unicity of the target.
*
* Example:
* testUtils.fields.editInput($('selector'), 'somevalue');
*
* @param {jQuery|EventTarget} el should target an input, textarea or select
* @param {string|number} value
* @returns {Promise}
*/
async function editInput(el, value) {
return editAndTrigger(el, value, ['input']);
}
/**
* Sets the value of a select.
*
* Note that this helper also checks the unicity of the target.
*
* Example:
* testUtils.fields.editSelect($('selector'), 'somevalue');
*
* @param {jQuery|EventTarget} el should target an input, textarea or select
* @param {string|number} value
* @returns {Promise}
*/
function editSelect(el, value) {
return editAndTrigger(el, value, ['change']);
}
/**
* This helper is useful to test many2one fields. Here is what it does:
* - click to open the dropdown
* - enter a search string in the input
* - wait for the selection
* - click on the requested menuitem, or the active one by default
*
* Example:
* testUtils.fields.many2one.searchAndClickM2OItem('partner_id', {search: 'George'});
*
* @param {string} fieldName
* @param {[Object]} [options = {}]
* @param {[string]} [options.selector]
* @param {[string]} [options.search]
* @param {[string]} [options.item]
* @returns {Promise}
*/
async function searchAndClickM2OItem(fieldName, options = {}) {
const input = await clickOpenM2ODropdown(fieldName, options.selector);
if (options.search) {
await editInput(input, options.search);
}
if (options.item) {
return clickM2OItem(fieldName, options.item);
} else {
return clickM2OHighlightedItem(fieldName, options.selector);
}
}
/**
* Helper to trigger a key event on an element.
*
* @param {string} type type of key event ('press', 'up' or 'down')
* @param {jQuery} $el
* @param {number|string} keyCode used as number, but if string, it'll check if
* the string corresponds to a key -otherwise it will keep only the first
* char to get a letter key- and convert it into a keyCode.
* @returns {Promise}
*/
function triggerKey(type, $el, keyCode) {
type = 'key' + type;
const params = {};
if (typeof keyCode === 'string') {
// Key (new API)
if (keyCode in ARROW_KEYS_MAPPING) {
params.key = ARROW_KEYS_MAPPING[keyCode];
} else {
params.key = keyCode[0].toUpperCase() + keyCode.slice(1).toLowerCase();
}
// KeyCode/which (jQuery)
if (keyCode.length > 1) {
keyCode = keyCode.toUpperCase();
keyCode = $.ui.keyCode[keyCode];
} else {
keyCode = keyCode.charCodeAt(0);
}
}
params.keyCode = keyCode;
params.which = keyCode;
return testUtilsDom.triggerEvent($el, type, params);
}
/**
* Helper to trigger a keydown event on an element.
*
* @param {jQuery} $el
* @param {number|string} keyCode @see triggerKey
* @returns {Promise}
*/
function triggerKeydown($el, keyCode) {
return triggerKey('down', $el, keyCode);
}
/**
* Helper to trigger a keyup event on an element.
*
* @param {jQuery} $el
* @param {number|string} keyCode @see triggerKey
* @returns {Promise}
*/
function triggerKeyup($el, keyCode) {
return triggerKey('up', $el, keyCode);
}
return {
clickM2OCreateAndEdit,
clickM2OHighlightedItem,
clickM2OItem,
clickOpenM2ODropdown,
editAndTrigger,
editInput,
editSelect,
searchAndClickM2OItem,
triggerKey,
triggerKeydown,
triggerKeyup,
};
});

View file

@ -0,0 +1,158 @@
odoo.define('web.test_utils_file', function () {
"use strict";
/**
* FILE Test Utils
*
* This module defines various utility functions to help simulate events with
* files, such as drag-and-drop.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
//------------------------------------------------------------------------------
// Private functions
//------------------------------------------------------------------------------
/**
* Create a fake object 'dataTransfer', linked to some files, which is passed to
* drag and drop events.
*
* @param {Object[]} files
* @returns {Object}
*/
function _createFakeDataTransfer(files) {
return {
dropEffect: 'all',
effectAllowed: 'all',
files,
getData: function () {
return files;
},
items: [],
types: ['Files'],
};
}
//------------------------------------------------------------------------------
// Public functions
//------------------------------------------------------------------------------
/**
* Create a file object, which can be used for drag-and-drop.
*
* @param {Object} data
* @param {string} data.name
* @param {string} data.content
* @param {string} data.contentType
* @returns {Promise<Object>} resolved with file created
*/
function createFile(data) {
// Note: this is only supported by Chrome, and does not work in Incognito mode
return new Promise(function (resolve, reject) {
var requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
if (!requestFileSystem) {
throw new Error('FileSystem API is not supported');
}
requestFileSystem(window.TEMPORARY, 1024 * 1024, function (fileSystem) {
fileSystem.root.getFile(data.name, { create: true }, function (fileEntry) {
fileEntry.createWriter(function (fileWriter) {
fileWriter.onwriteend = function (e) {
fileSystem.root.getFile(data.name, {}, function (fileEntry) {
fileEntry.file(function (file) {
resolve(file);
});
});
};
fileWriter.write(new Blob([ data.content ], { type: data.contentType }));
});
});
});
});
}
/**
* Drag a file over a DOM element
*
* @param {$.Element} $el
* @param {Object} file must have been created beforehand (@see createFile)
*/
function dragoverFile($el, file) {
var ev = new Event('dragover', { bubbles: true });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer(file),
});
$el[0].dispatchEvent(ev);
}
/**
* Drop a file on a DOM element.
*
* @param {$.Element} $el
* @param {Object} file must have been created beforehand (@see createFile)
*/
function dropFile($el, file) {
var ev = new Event('drop', { bubbles: true, });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer([file]),
});
$el[0].dispatchEvent(ev);
}
/**
* Drop some files on a DOM element.
*
* @param {$.Element} $el
* @param {Object[]} files must have been created beforehand (@see createFile)
*/
function dropFiles($el, files) {
var ev = new Event('drop', { bubbles: true, });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer(files),
});
$el[0].dispatchEvent(ev);
}
/**
* Set files in a file input
*
* @param {DOM.Element} el
* @param {Object[]} files must have been created beforehand
* @see testUtils.file.createFile
*/
function inputFiles(el, files) {
// could not use _createFakeDataTransfer as el.files assignation will only
// work with a real FileList object.
const dataTransfer = new window.DataTransfer();
for (const file of files) {
dataTransfer.items.add(file);
}
el.files = dataTransfer.files;
/**
* Changing files programatically is not supposed to trigger the event but
* it does in Chrome versions before 73 (which is on runbot), so in that
* case there is no need to make a manual dispatch, because it would lead to
* the files being added twice.
*/
const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
if (!chromeVersion || chromeVersion >= 73) {
el.dispatchEvent(new Event('change'));
}
}
//------------------------------------------------------------------------------
// Exposed API
//------------------------------------------------------------------------------
return {
createFile: createFile,
dragoverFile: dragoverFile,
dropFile: dropFile,
dropFiles,
inputFiles,
};
});

View file

@ -0,0 +1,74 @@
odoo.define('web.test_utils_form', function (require) {
"use strict";
/**
* Form Test Utils
*
* This module defines various utility functions to help test form views.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
var testUtilsDom = require('web.test_utils_dom');
/**
* Clicks on the Edit button in a form view, to set it to edit mode. Note that
* it checks that the button is visible, so calling this method in edit mode
* will fail.
*
* @param {FormController} form
*/
function clickEdit(form) {
return testUtilsDom.click(form.$buttons.find('.o_form_button_edit'));
}
/**
* Clicks on the Save button in a form view. Note that this method checks that
* the Save button is visible.
*
* @param {FormController} form
*/
function clickSave(form) {
return testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
}
/**
* Clicks on the Create button in a form view. Note that this method checks that
* the Create button is visible.
*
* @param {FormController} form
*/
function clickCreate(form) {
return testUtilsDom.click(form.$buttons.find('.o_form_button_create'));
}
/**
* Clicks on the Discard button in a form view. Note that this method checks that
* the Discard button is visible.
*
* @param {FormController} form
*/
function clickDiscard(form) {
return testUtilsDom.click(form.$buttons.find('.o_form_button_cancel'));
}
/**
* Reloads a form view.
*
* @param {FormController} form
* @param {[Object]} params given to the controller reload method
*/
function reload(form, params) {
return form.reload(params);
}
return {
clickEdit: clickEdit,
clickSave: clickSave,
clickCreate: clickCreate,
clickDiscard: clickDiscard,
reload: reload,
};
});

View file

@ -0,0 +1,28 @@
odoo.define('web.test_utils_graph', function () {
"use strict";
/**
* Graph Test Utils
*
* This module defines various utility functions to help test graph views.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
/**
* Reloads a graph view.
*
* @param {GraphController} graph
* @param {[Object]} params given to the controller reload method
*/
function reload(graph, params) {
return graph.reload(params);
}
return {
reload: reload,
};
});

View file

@ -0,0 +1,102 @@
odoo.define('web.test_utils_kanban', function (require) {
"use strict";
/**
* Kanban Test Utils
*
* This module defines various utility functions to help testing kanban views.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
var testUtilsDom = require('web.test_utils_dom');
var testUtilsFields = require('web.test_utils_fields');
/**
* Clicks on the Create button in a kanban view. Note that this method checks that
* the Create button is visible.
*
* @param {KanbanController} kanban
* @returns {Promise}
*/
function clickCreate(kanban) {
return testUtilsDom.click(kanban.$buttons.find('.o-kanban-button-new'));
}
/**
* Open the settings menu for a column (in a grouped kanban view)
*
* @param {jQuery} $column
* @returns {Promise}
*/
function toggleGroupSettings($column) {
var $dropdownToggler = $column.find('.o_kanban_config > a.dropdown-toggle');
if (!$dropdownToggler.is(':visible')) {
$dropdownToggler.css('display', 'block');
}
return testUtilsDom.click($dropdownToggler);
}
/**
* Edit a value in a quickcreate form view (this method assumes that the quick
* create feature is active, and a sub form view is open)
*
* @param {kanbanController} kanban
* @param {string|number} value
* @param {[string]} fieldName
* @returns {Promise}
*/
function quickCreate(kanban, value, fieldName) {
var additionalSelector = fieldName ? ('[name=' + fieldName + ']'): '';
var enterEvent = $.Event(
'keydown',
{
which: $.ui.keyCode.ENTER,
keyCode: $.ui.keyCode.ENTER,
}
);
return testUtilsFields.editAndTrigger(
kanban.$('.o_kanban_quick_create input' + additionalSelector),
value,
['input', enterEvent]
);
}
/**
* Reloads a kanban view.
*
* @param {KanbanController} kanban
* @param {[Object]} params given to the controller reload method
* @returns {Promise}
*/
function reload(kanban, params) {
return kanban.reload(params);
}
/**
* Open the setting dropdown of a kanban record. Note that the template of a
* kanban record is not standardized, so this method will fail if the template
* does not comply with the usual dom structure.
*
* @param {jQuery} $record
* @returns {Promise}
*/
function toggleRecordDropdown($record) {
var $dropdownToggler = $record.find('.o_dropdown_kanban > a.dropdown-toggle');
if (!$dropdownToggler.is(':visible')) {
$dropdownToggler.css('display', 'block');
}
return testUtilsDom.click($dropdownToggler);
}
return {
clickCreate: clickCreate,
quickCreate: quickCreate,
reload: reload,
toggleGroupSettings: toggleGroupSettings,
toggleRecordDropdown: toggleRecordDropdown,
};
});

View file

@ -0,0 +1,742 @@
odoo.define('web.test_utils_mock', function (require) {
"use strict";
/**
* Mock Test Utils
*
* This module defines various utility functions to help mocking data.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
const AbstractStorageService = require('web.AbstractStorageService');
const AjaxService = require('web.AjaxService');
const basic_fields = require('web.basic_fields');
const Bus = require('web.Bus');
const config = require('web.config');
const core = require('web.core');
const dom = require('web.dom');
const FormController = require('web.FormController');
const makeTestEnvironment = require('web.test_env');
const MockServer = require('web.MockServer');
const RamStorage = require('web.RamStorage');
const session = require('web.session');
const { patchWithCleanup, patchDate } = require("@web/../tests/helpers/utils");
const { browser } = require("@web/core/browser/browser");
const { assets } = require("@web/core/assets");
const { processArch } = require("@web/legacy/legacy_load_views");
const { Component } = require("@odoo/owl");
const DebouncedField = basic_fields.DebouncedField;
//------------------------------------------------------------------------------
// Private functions
//------------------------------------------------------------------------------
/**
* Returns a mocked environment to be used by OWL components in tests, with
* requested services (+ ajax, local_storage and session_storage) deployed.
*
* @private
* @param {Object} params
* @param {Bus} [params.bus]
* @param {boolean} [params.debug]
* @param {Object} [params.env]
* @param {Bus} [params.env.bus]
* @param {Object} [params.env.dataManager]
* @param {Object} [params.env.services]
* @param {Object[]} [params.favoriteFilters]
* @param {Object} [params.services]
* @param {Object} [params.session]
* @param {MockServer} [mockServer]
* @returns {Promise<Object>} env
*/
async function _getMockedOwlEnv(params, mockServer) {
params.env = params.env || {};
const database = {parameters: params.translateParameters || {}};
// build the env
const favoriteFilters = params.favoriteFilters;
const debug = params.debug;
const services = {};
const env = Object.assign({}, params.env, {
_t: params.env && params.env._t || Object.assign((s => s), { database }),
browser: Object.assign({
fetch: (resource, init) => mockServer.performFetch(resource, init),
}, params.env.browser),
bus: params.bus || params.env.bus || new Bus(),
dataManager: Object.assign({
load_action: (actionID, context) => {
return mockServer.performRpc('/web/action/load', {
action_id: actionID,
additional_context: context,
});
},
load_views: (params, options) => {
return mockServer.performRpc('/web/dataset/call_kw/' + params.model, {
args: [],
kwargs: {
context: params.context,
options: options,
views: params.views_descr,
},
method: 'get_views',
model: params.model,
}).then(function (views) {
views = _.mapObject(views, viewParams => {
return getView(mockServer, viewParams);
});
if (favoriteFilters && 'search' in views) {
views.search.favoriteFilters = favoriteFilters;
}
return views;
});
},
load_filters: params => {
if (debug) {
console.log('[mock] load_filters', params);
}
return Promise.resolve([]);
},
}, params.env.dataManager),
services: Object.assign(services, params.env.services),
session: params.env.session || params.session || {},
});
// deploy services into the env
// determine services to instantiate (classes), and already register function services
const servicesToDeploy = {};
for (const name in params.services || {}) {
const Service = params.services[name];
if (Service.constructor.name === 'Class') {
servicesToDeploy[name] = Service;
} else {
services[name] = Service;
}
}
// always deploy ajax, local storage and session storage
if (!servicesToDeploy.ajax) {
const MockedAjaxService = AjaxService.extend({
rpc: mockServer.performRpc.bind(mockServer),
});
services.ajax = new MockedAjaxService(env);
}
const RamStorageService = AbstractStorageService.extend({
storage: new RamStorage(),
});
if (!servicesToDeploy.local_storage) {
services.local_storage = new RamStorageService(env);
}
if (!servicesToDeploy.session_storage) {
services.session_storage = new RamStorageService(env);
}
// deploy other requested services
let done = false;
while (!done) {
const serviceName = Object.keys(servicesToDeploy).find(serviceName => {
const Service = servicesToDeploy[serviceName];
return Service.prototype.dependencies.every(depName => {
return env.services[depName];
});
});
if (serviceName) {
const Service = servicesToDeploy[serviceName];
services[serviceName] = new Service(env);
delete servicesToDeploy[serviceName];
services[serviceName].start();
} else {
const serviceNames = _.keys(servicesToDeploy);
if (serviceNames.length) {
console.warn("Non loaded services:", serviceNames);
}
done = true;
}
}
// wait for asynchronous services to properly start
await new Promise(setTimeout);
return env;
}
/**
* This function is used to mock global objects (session, config...) in tests.
* It is necessary for legacy widgets. It returns a cleanUp function to call at
* the end of the test.
*
* The function could be removed as soon as we do not support legacy widgets
* anymore.
*
* @private
* @param {Object} params
* @param {Object} [params.config] if given, it is used to extend the global
* config,
* @param {Object} [params.session] if given, it is used to extend the current,
* real session.
* @param {Object} [params.translateParameters] if given, it will be used to
* extend the core._t.database.parameters object.
* @returns {function} a cleanUp function to restore everything, to call at the
* end of the test
*/
function _mockGlobalObjects(params) {
// store initial session state (for restoration)
const initialSession = Object.assign({}, session);
const sessionPatch = Object.assign({
getTZOffset() { return 0; },
async user_has_group() { return false; },
}, params.session);
// patch session
Object.assign(session, sessionPatch);
// patch config
let initialConfig;
if ('config' in params) {
initialConfig = Object.assign({}, config);
initialConfig.device = Object.assign({}, config.device);
if ('device' in params.config) {
Object.assign(config.device, params.config.device);
}
if ('debug' in params.config) {
odoo.debug = params.config.debug;
}
}
// patch translate params
let initialParameters;
if ('translateParameters' in params) {
initialParameters = Object.assign({}, core._t.database.parameters);
Object.assign(core._t.database.parameters, params.translateParameters);
}
// build the cleanUp function to restore everything at the end of the test
function cleanUp() {
let key;
for (key in sessionPatch) {
delete session[key];
}
Object.assign(session, initialSession);
if ('config' in params) {
for (key in config) {
delete config[key];
}
_.extend(config, initialConfig);
}
if ('translateParameters' in params) {
for (key in core._t.database.parameters) {
delete core._t.database.parameters[key];
}
_.extend(core._t.database.parameters, initialParameters);
}
}
return cleanUp;
}
/**
* logs all event going through the target widget.
*
* @param {Widget} widget
*/
function _observe(widget) {
var _trigger_up = widget._trigger_up.bind(widget);
widget._trigger_up = function (event) {
console.log('%c[event] ' + event.name, 'color: blue; font-weight: bold;', event);
_trigger_up(event);
};
}
//------------------------------------------------------------------------------
// Public functions
//------------------------------------------------------------------------------
/**
* performs a get_view, and mocks the postprocessing done by the
* data_manager to return an equivalent structure.
*
* @param {MockServer} server
* @param {Object} params
* @param {string} params.model
* @returns {Object} an object with 3 keys: arch, fields and viewFields
*/
function getView(server, params) {
var view = server.getView(params);
const fields = server.fieldsGet(params.model);
// mock the structure produced by the DataManager
const models = { [params.model]: fields };
for (const modelName of view.models) {
models[modelName] = models[modelName] || server.fieldsGet(modelName);
}
const { arch, viewFields } = processArch(view.arch, view.type, params.model, models);
return {
arch,
fields,
model: view.model,
toolbar: view.toolbar,
type: view.type,
viewFields,
view_id: view.id,
};
}
/**
* intercepts an event bubbling up the widget hierarchy. The event intercepted
* must be a "custom event", i.e. an event generated by the method 'trigger_up'.
*
* Note that this method really intercepts the event if @propagate is not set.
* It will not be propagated further, and even the handlers on the target will
* not fire.
*
* @param {Widget} widget the target widget (any Odoo widget)
* @param {string} eventName description of the event
* @param {function} fn callback executed when the even is intercepted
* @param {boolean} [propagate=false]
*/
function intercept(widget, eventName, fn, propagate) {
var _trigger_up = widget._trigger_up.bind(widget);
widget._trigger_up = function (event) {
if (event.name === eventName) {
fn(event);
if (!propagate) { return; }
}
_trigger_up(event);
};
}
/**
* Add a mock environment to test Owl Components. This function generates a test
* env and sets it on the given Component. It also has several side effects,
* like patching the global session or config objects. It returns a cleanup
* function to call at the end of the test.
*
* @param {Component} Component
* @param {Object} [params]
* @param {Object} [params.actions]
* @param {Object} [params.archs]
* @param {string} [params.currentDate]
* @param {Object} [params.data]
* @param {boolean} [params.debug]
* @param {function} [params.mockFetch]
* @param {function} [params.mockRPC]
* @param {number} [params.fieldDebounce=0] the value of the DEBOUNCE attribute
* of fields
* @param {boolean} [params.debounce=true] if false, patch _.debounce to remove
* its behavior
* @param {boolean} [params.throttle=false] by default, _.throttle is patched to
* remove its behavior, except if this params is set to true
* @param {boolean} [params.mockSRC=false] if true, redirect src GET requests to
* the mockServer
* @param {MockServer} [mockServer]
* @returns {Promise<function>} the cleanup function
*/
async function addMockEnvironmentOwl(Component, params, mockServer) {
params = params || {};
// instantiate a mockServer if not provided
if (!mockServer) {
let Server = MockServer;
if (params.mockFetch) {
Server = Server.extend({ _performFetch: params.mockFetch });
}
if (params.mockRPC) {
Server = Server.extend({ _performRpc: params.mockRPC });
}
mockServer = new Server(params.data, {
actions: params.actions,
archs: params.archs,
currentDate: params.currentDate,
debug: params.debug,
});
}
patchWithCleanup(browser, {
fetch: async (url, args) => {
const result = await mockServer.performFetch(url, args || {});
return {
json: () => result,
text: () => result,
};
},
});
if (params.mockFetch) {
const { loadJS, loadCSS } = assets;
patchWithCleanup(assets, {
loadJS: async function (ressource) {
let res = await params.mockFetch(ressource, {});
if (res === undefined) {
res = await loadJS(ressource);
} else {
console.log("%c[assets] fetch (mock) JS ressource " + ressource, "color: #66e; font-weight: bold;");
}
return res;
},
loadCSS: async function (ressource) {
let res = await params.mockFetch(ressource, {});
if (res === undefined) {
res = await loadCSS(ressource);
} else {
console.log("%c[assets] fetch (mock) CSS ressource " + ressource, "color: #66e; font-weight: bold;");
}
return res;
},
});
}
// remove the multi-click delay for the quick edit in form view
const initialQuickEditDelay = FormController.prototype.multiClickTime;
FormController.prototype.multiClickTime = params.formMultiClickTime || 0;
// make sure the debounce value for input fields is set to 0
const initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
const initialDOMDebounceValue = dom.DEBOUNCE;
dom.DEBOUNCE = 0;
// patch underscore debounce/throttle functions
const initialDebounce = _.debounce;
if (params.debounce === false) {
_.debounce = function (func) {
return func;
};
}
// fixme: throttle is inactive by default, should we make it explicit ?
const initialThrottle = _.throttle;
if (!('throttle' in params) || !params.throttle) {
_.throttle = function (func) {
return func;
};
}
// mock global objects for legacy widgets (session, config...)
const restoreMockedGlobalObjects = _mockGlobalObjects(params);
// set the test env on owl Component
const env = await _getMockedOwlEnv(params, mockServer);
const originalEnv = Component.env;
const __env = makeTestEnvironment(env, mockServer.performRpc.bind(mockServer));
owl.Component.env = __env;
// while we have a mix between Owl and legacy stuff, some of them triggering
// events on the env.bus (a new Bus instance especially created for the current
// test), the others using core.bus, we have to ensure that events triggered
// on env.bus are also triggered on core.bus (note that outside the testing
// environment, both are the exact same instance of Bus)
const envBusTrigger = env.bus.trigger;
env.bus.trigger = function () {
core.bus.trigger(...arguments);
envBusTrigger.call(env.bus, ...arguments);
};
// build the clean up function to call at the end of the test
function cleanUp() {
env.bus.destroy();
Object.keys(env.services).forEach(function (s) {
var service = env.services[s] || {};
if (service.destroy && !service.isDestroyed()) {
service.destroy();
}
});
FormController.prototype.multiClickTime = initialQuickEditDelay;
DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
dom.DEBOUNCE = initialDOMDebounceValue;
_.debounce = initialDebounce;
_.throttle = initialThrottle;
// clear the caches (e.g. data_manager, ModelFieldSelector) at the end
// of each test to avoid collisions
core.bus.trigger('clear_cache');
$('body').off('DOMNodeInserted.removeSRC');
$('.blockUI').remove(); // fixme: move to qunit_config in OdooAfterTestHook?
restoreMockedGlobalObjects();
Component.env = originalEnv;
}
return cleanUp;
}
/**
* Add a mock environment to a widget. This helper function can simulate
* various kind of side effects, such as mocking RPCs, changing the session,
* or the translation settings.
*
* The simulated environment lasts for the lifecycle of the widget, meaning it
* disappears when the widget is destroyed. It is particularly relevant for the
* session mocks, because the previous session is restored during the destroy
* call. So, it means that you have to be careful and make sure that it is
* properly destroyed before another test is run, otherwise you risk having
* interferences between tests.
*
* @param {Widget} widget
* @param {Object} params
* @param {Object} [params.archs] a map of string [model,view_id,view_type] to
* a arch object. It is used to mock answers to 'load_views' custom events.
* This is useful when the widget instantiate a formview dialog that needs
* to load a particular arch.
* @param {string} [params.currentDate] a string representation of the current
* date. It is given to the mock server.
* @param {Object} params.data the data given to the created mock server. It is
* used to generate mock answers for every kind of routes supported by odoo
* @param {number} [params.debug] if set to true, logs RPCs and uncaught Odoo
* events.
* @param {Object} [params.bus] the instance of Bus that will be used (in the env)
* @param {function} [params.mockFetch] a function that will be used to override
* the _performFetch method from the mock server. It is really useful to add
* some custom fetch mocks, or to check some assertions.
* @param {function} [params.mockRPC] a function that will be used to override
* the _performRpc method from the mock server. It is really useful to add
* some custom rpc mocks, or to check some assertions.
* @param {Object} [params.session] if it is given, it will be used as answer
* for all calls to this.getSession() by the widget, of its children. Also,
* it will be used to extend the current, real session. This side effect is
* undone when the widget is destroyed.
* @param {Object} [params.translateParameters] if given, it will be used to
* extend the core._t.database.parameters object. After the widget
* destruction, the original parameters will be restored.
* @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. Some events ('call_service', "load_views",
* "get_session", "load_filters") have a special treatment beforehand.
* @param {Object} [params.services={}] list of services to load in
* addition to the ajax service. For instance, if a test needs the local
* storage service in order to work, it can provide a mock version of it.
* @param {boolean} [debounce=true] set to false to completely remove the
* debouncing, forcing the handler to be called directly (not on the next
* execution stack, like it does with delay=0).
* @param {boolean} [throttle=false] set to true to keep the throttling, which
* is completely removed by default.
*
* @returns {Promise<MockServer>} 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.
*/
async function addMockEnvironment(widget, params) {
// log events triggered up if debug flag is true
if (params.debug) {
_observe(widget);
var separator = window.location.href.indexOf('?') !== -1 ? "&" : "?";
var url = window.location.href + separator + 'testId=' + QUnit.config.current.testId;
console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url);
}
// instantiate mock server
var Server = MockServer;
if (params.mockFetch) {
Server = MockServer.extend({ _performFetch: params.mockFetch });
}
if (params.mockRPC) {
Server = Server.extend({ _performRpc: params.mockRPC });
}
var mockServer = new Server(params.data, {
actions: params.actions,
archs: params.archs,
currentDate: params.currentDate,
debug: params.debug,
widget: widget,
});
// build and set the Owl env on Component
if (!('mockSRC' in params)) { // redirect src rpcs to the mock server
params.mockSRC = true;
}
const cleanUp = await addMockEnvironmentOwl(Component, params, mockServer);
const env = Component.env;
// ensure to clean up everything when the widget will be destroyed
const destroy = widget.destroy;
widget.destroy = function () {
cleanUp();
destroy.call(this, ...arguments);
};
// intercept service/data manager calls and redirect them to the env
intercept(widget, 'call_service', function (ev) {
if (env.services[ev.data.service]) {
var service = env.services[ev.data.service];
const result = service[ev.data.method].apply(service, ev.data.args || []);
ev.data.callback(result);
}
});
intercept(widget, 'load_action', async ev => {
const action = await env.dataManager.load_action(ev.data.actionID, ev.data.context);
ev.data.on_success(action);
});
intercept(widget, "load_views", async ev => {
const params = {
model: ev.data.modelName,
context: ev.data.context,
views_descr: ev.data.views,
};
const views = await env.dataManager.load_views(params, ev.data.options);
if ('search' in views && params.favoriteFilters) {
views.search.favoriteFilters = params.favoriteFilters;
}
ev.data.on_success(views);
});
intercept(widget, "get_session", ev => {
ev.data.callback(session);
});
intercept(widget, "load_filters", async ev => {
const filters = await env.dataManager.load_filters(ev.data);
ev.data.on_success(filters);
});
// make sure all other Odoo events bubbling up are intercepted
Object.keys(params.intercepts || {}).forEach(function (name) {
intercept(widget, name, params.intercepts[name]);
});
return mockServer;
}
/**
* Patch window.Date so that the time starts its flow from the provided Date.
*
* Usage:
*
* ```
* testUtils.mock.patchDate(2018, 0, 10, 17, 59, 30)
* new window.Date(); // "Wed Jan 10 2018 17:59:30 GMT+0100 (Central European Standard Time)"
* ... // 5 hours delay
* new window.Date(); // "Wed Jan 10 2018 22:59:30 GMT+0100 (Central European Standard Time)"
* ```
*
* The returned function is there to preserve the former API. Before it was
* necessary to call that function to unpatch the date. Now the unpatch is
* done automatically via a call to registerCleanup.
*
* @param {integer} year
* @param {integer} month index of the month, starting from zero.
* @param {integer} day the day of the month.
* @param {integer} hours the digits for hours (24h)
* @param {integer} minutes
* @param {integer} seconds
* @returns {Function} callback function is now useless
*/
function legacyPatchDate(year, month, day, hours, minutes, seconds) {
patchDate(year, month, day, hours, minutes, seconds);
return function () {}; // all calls to that function are now useless
}
var patches = {};
/**
* Patches a given Class or Object with the given properties.
*
* @param {Class|Object} target
* @param {Object} props
*/
function patch(target, props) {
var patchID = _.uniqueId('patch_');
target.__patchID = patchID;
patches[patchID] = {
target: target,
otherPatchedProps: [],
ownPatchedProps: [],
};
if (target.prototype) {
_.each(props, function (value, key) {
if (target.prototype.hasOwnProperty(key)) {
patches[patchID].ownPatchedProps.push({
key: key,
initialValue: target.prototype[key],
});
} else {
patches[patchID].otherPatchedProps.push(key);
}
});
target.include(props);
} else {
_.each(props, function (value, key) {
if (key in target) {
var oldValue = target[key];
patches[patchID].ownPatchedProps.push({
key: key,
initialValue: oldValue,
});
if (typeof value === 'function') {
target[key] = function () {
var oldSuper = this._super;
this._super = oldValue;
var result = value.apply(this, arguments);
if (oldSuper === undefined) {
delete this._super;
} else {
this._super = oldSuper;
}
return result;
};
} else {
target[key] = value;
}
} else {
patches[patchID].otherPatchedProps.push(key);
target[key] = value;
}
});
}
}
/**
* Unpatches a given Class or Object.
*
* @param {Class|Object} target
*/
function unpatch(target) {
var patchID = target.__patchID;
var patch = patches[patchID];
if (target.prototype) {
_.each(patch.ownPatchedProps, function (p) {
target.prototype[p.key] = p.initialValue;
});
_.each(patch.otherPatchedProps, function (key) {
delete target.prototype[key];
});
} else {
_.each(patch.ownPatchedProps, function (p) {
target[p.key] = p.initialValue;
});
_.each(patch.otherPatchedProps, function (key) {
delete target[key];
});
}
delete patches[patchID];
delete target.__patchID;
}
window.originalSetTimeout = window.setTimeout;
function patchSetTimeout() {
var original = window.setTimeout;
var self = this;
window.setTimeout = function (handler, delay) {
console.log("calling setTimeout on " + (handler.name || "some function") + "with delay of " + delay);
console.trace();
var handlerArguments = Array.prototype.slice.call(arguments, 1);
return original(function () {
handler.bind(self, handlerArguments)();
console.log('after doing the action of the setTimeout');
}, delay);
};
return function () {
window.setTimeout = original;
};
}
return {
addMockEnvironment: addMockEnvironment,
getView: getView,
addMockEnvironmentOwl: addMockEnvironmentOwl,
intercept: intercept,
patchDate: legacyPatchDate,
patch: patch,
unpatch: unpatch,
patchSetTimeout: patchSetTimeout,
};
});

View file

@ -0,0 +1,26 @@
odoo.define('web.test_utils_modal', function (require) {
"use strict";
/**
* Modal Test Utils
*
* This module defines various utility functions to help test pivot views.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
const { _t } = require('web.core');
const testUtilsDom = require('web.test_utils_dom');
/**
* Click on a button in the footer of a modal (which contains a given string).
*
* @param {string} text (in english: this method will perform the translation)
*/
function clickButton(text) {
return testUtilsDom.click($(`.modal-footer button:contains(${_t(text)})`));
}
return { clickButton };
});

View file

@ -0,0 +1,57 @@
odoo.define('web.test_utils_pivot', function (require) {
"use strict";
var testUtilsDom = require('web.test_utils_dom');
/**
* Pivot Test Utils
*
* This module defines various utility functions to help test pivot views.
*
* Note that all methods defined in this module are exported in the main
* testUtils file.
*/
/**
* Select a measure by clicking on the corresponding dropdown item (in the
* control panel 'Measure' submenu).
*
* Note that this method assumes that the dropdown menu is open.
* @see toggleMeasuresDropdown
*
* @param {PivotController} pivot
* @param {string} measure
*/
function clickMeasure(pivot, measure) {
return testUtilsDom.click(pivot.$buttons.find(`.dropdown-item[data-field=${measure}]`));
}
/**
* Open the 'Measure' dropdown menu (in the control panel)
*
* @see clickMeasure
*
* @param {PivotController} pivot
*/
function toggleMeasuresDropdown(pivot) {
return testUtilsDom.click(pivot.$buttons.filter('.btn-group:first').find('> button'));
}
/**
* Reloads a graph view.
*
* @param {PivotController} pivot
* @param {[Object]} params given to the controller reload method
*/
function reload(pivot, params) {
return pivot.reload(params);
}
return {
clickMeasure: clickMeasure,
reload: reload,
toggleMeasuresDropdown: toggleMeasuresDropdown,
};
});

View file

@ -0,0 +1,36 @@
odoo.define('web.testUtilsTests', function (require) {
"use strict";
var testUtils = require('web.test_utils');
QUnit.module('web', {}, function () {
QUnit.module('testUtils', {}, function () {
QUnit.module('patch date');
QUnit.test('new date', function (assert) {
assert.expect(5);
const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
const date = new Date();
assert.strictEqual(date.getFullYear(), 2018);
assert.strictEqual(date.getMonth(), 9);
assert.strictEqual(date.getDate(), 23);
assert.strictEqual(date.getHours(), 14);
assert.strictEqual(date.getMinutes(), 50);
unpatchDate();
});
QUnit.test('new moment', function (assert) {
assert.expect(1);
const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
const m = moment();
assert.strictEqual(m.format('YYYY-MM-DD HH:mm'), '2018-10-23 14:50');
unpatchDate();
});
});
});
});