Odoo18-Base/addons/web/static/tests/legacy/helpers/test_utils_dom.js
2025-03-10 11:12:23 +07:00

611 lines
24 KiB
JavaScript

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