diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/tests/helpers/test_utils_dom.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/tests/helpers/test_utils_dom.js')
| -rw-r--r-- | addons/web/static/tests/helpers/test_utils_dom.js | 551 |
1 files changed, 551 insertions, 0 deletions
diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js new file mode 100644 index 00000000..eedbcbe5 --- /dev/null +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -0,0 +1,551 @@ +odoo.define('web.test_utils_dom', function (require) { + "use strict"; + + const concurrency = require('web.concurrency'); + const Widget = require('web.Widget'); + + /** + * 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.pageX : undefined, + clientY: args ? args.pageY : undefined, + view: window, + }); + const mouseEventNoBubble = args => Object.assign({}, args, { + bubbles: false, + cancelable: false, + clientX: args ? args.pageX : undefined, + clientY: args ? args.pageY : undefined, + view: window, + }); + 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 }, + }; + + /** + * 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 (_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}`); + } + + 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 owl.Component || target instanceof Widget) { + nodes = [target.el]; + } else if (typeof target === 'string') { + nodes = document.querySelectorAll(target); + } else if (target === jQuery) { // jQuery (or $) + nodes = [document.body]; + } else if (target.length) { // jQuery instance, HTMLCollection or array + nodes = target; + } else { + nodes = [target]; + } + if (nodes.length !== 1) { + throw new Error(`Found ${nodes.length} nodes instead of 1.`); + } + const node = nodes[0]; + if (!node) { + throw new Error(`Expected a node and got ${node}.`); + } + if (!_isEventTarget(node)) { + throw new Error(`Expected node to be an instance of EventTarget and got ${node.constructor.name}.`); + } + return node; + } + + /** + * Open the datepicker of a given element. + * + * @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(); + // little fix since it seems on chrome, it triggers 1px too on the left + const left = rect.x + 1; + const top = rect.y; + return triggerEvent($el, type, { + which: 1, + pageX: left, layerX: left, screenX: left, + pageY: top, layerY: top, screenY: 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; + } + + return { + click, + clickFirst, + clickLast, + dragAndDrop, + findItem, + getNode, + openDatepicker, + returnAfterNextAnimationFrame, + triggerEvent, + triggerEvents, + triggerKeypressEvent, + triggerMouseEvent, + triggerPositionalMouseEvent, + }; +}); |
