943 lines
34 KiB
JavaScript
943 lines
34 KiB
JavaScript
/** @odoo-module alias=@web/../tests/utils default=false */
|
|
|
|
import { __debug__, after, afterEach, expect, getFixture } from "@odoo/hoot";
|
|
import { queryAll, queryFirst } from "@odoo/hoot-dom";
|
|
import { Deferred, tick } from "@odoo/hoot-mock";
|
|
import { asyncStep, waitForSteps } from "@web/../tests/web_test_helpers";
|
|
import { isMacOS } from "@web/core/browser/feature_detection";
|
|
import { isVisible } from "@web/core/utils/ui";
|
|
export const step = asyncStep;
|
|
export const assertSteps = waitForSteps;
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapBubblingEvent = (args) => ({ ...args, bubbles: true });
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapNonBubblingEvent = (args) => ({ ...args, bubbles: false });
|
|
|
|
/** @param {EventInit} [args={}] */
|
|
const mapBubblingPointerEvent = (args = {}) => ({
|
|
clientX: args.pageX,
|
|
clientY: args.pageY,
|
|
...args,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
view: window,
|
|
});
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapNonBubblingPointerEvent = (args) => ({
|
|
...mapBubblingPointerEvent(args),
|
|
bubbles: false,
|
|
cancelable: false,
|
|
});
|
|
|
|
/** @param {EventInit} [args={}] */
|
|
const mapCancelableTouchEvent = (args = {}) => ({
|
|
...args,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
rotation: 0.0,
|
|
touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined,
|
|
view: window,
|
|
zoom: 1.0,
|
|
});
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapNonCancelableTouchEvent = (args) => ({
|
|
...mapCancelableTouchEvent(args),
|
|
cancelable: false,
|
|
});
|
|
|
|
/** @param {EventInit} [args] */
|
|
const mapKeyboardEvent = (args) => ({
|
|
...args,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
|
|
/**
|
|
* @template {typeof Event} T
|
|
* @param {EventType} eventType
|
|
* @returns {[T, (attrs: EventInit) => EventInit]}
|
|
*/
|
|
const getEventConstructor = (eventType) => {
|
|
switch (eventType) {
|
|
// Mouse events
|
|
case "auxclick":
|
|
case "click":
|
|
case "contextmenu":
|
|
case "dblclick":
|
|
case "mousedown":
|
|
case "mouseup":
|
|
case "mousemove":
|
|
case "mouseover":
|
|
case "mouseout": {
|
|
return [MouseEvent, mapBubblingPointerEvent];
|
|
}
|
|
case "mouseenter":
|
|
case "mouseleave": {
|
|
return [MouseEvent, mapNonBubblingPointerEvent];
|
|
}
|
|
// Pointer events
|
|
case "pointerdown":
|
|
case "pointerup":
|
|
case "pointermove":
|
|
case "pointerover":
|
|
case "pointerout": {
|
|
return [PointerEvent, mapBubblingPointerEvent];
|
|
}
|
|
case "pointerenter":
|
|
case "pointerleave": {
|
|
return [PointerEvent, mapNonBubblingPointerEvent];
|
|
}
|
|
// Focus events
|
|
case "focusin": {
|
|
return [FocusEvent, mapBubblingEvent];
|
|
}
|
|
case "focus":
|
|
case "blur": {
|
|
return [FocusEvent, mapNonBubblingEvent];
|
|
}
|
|
// Clipboard events
|
|
case "cut":
|
|
case "copy":
|
|
case "paste": {
|
|
return [ClipboardEvent, mapBubblingEvent];
|
|
}
|
|
// Keyboard events
|
|
case "keydown":
|
|
case "keypress":
|
|
case "keyup": {
|
|
return [KeyboardEvent, mapKeyboardEvent];
|
|
}
|
|
// Drag events
|
|
case "drag":
|
|
case "dragend":
|
|
case "dragenter":
|
|
case "dragstart":
|
|
case "dragleave":
|
|
case "dragover":
|
|
case "drop": {
|
|
return [DragEvent, mapBubblingEvent];
|
|
}
|
|
// Input events
|
|
case "input": {
|
|
return [InputEvent, mapBubblingEvent];
|
|
}
|
|
// Composition events
|
|
case "compositionstart":
|
|
case "compositionend": {
|
|
return [CompositionEvent, mapBubblingEvent];
|
|
}
|
|
// UI events
|
|
case "scroll": {
|
|
return [UIEvent, mapNonBubblingEvent];
|
|
}
|
|
// Touch events
|
|
case "touchstart":
|
|
case "touchend":
|
|
case "touchmove": {
|
|
return [TouchEvent, mapCancelableTouchEvent];
|
|
}
|
|
case "touchcancel": {
|
|
return [TouchEvent, mapNonCancelableTouchEvent];
|
|
}
|
|
// Default: base Event constructor
|
|
default: {
|
|
return [Event, mapBubblingEvent];
|
|
}
|
|
}
|
|
};
|
|
|
|
function findElement(el, selector) {
|
|
let target = el;
|
|
if (selector) {
|
|
const els = el.querySelectorAll(selector);
|
|
if (els.length === 0) {
|
|
throw new Error(`No element found (selector: ${selector})`);
|
|
}
|
|
if (els.length > 1) {
|
|
throw new Error(`Found ${els.length} elements, instead of 1 (selector: ${selector})`);
|
|
}
|
|
target = els[0];
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* @template {EventType} T
|
|
* @param {Element} el
|
|
* @param {string | null | undefined | false} selector
|
|
* @param {T} eventType
|
|
* @param {EventInit} [eventInit]
|
|
* @param {TriggerEventOptions} [options={}]
|
|
* @returns {GlobalEventHandlersEventMap[T] | Promise<GlobalEventHandlersEventMap[T]>}
|
|
*/
|
|
function triggerEvent(el, selector, eventType, eventInit, options = {}) {
|
|
const errors = [];
|
|
const target = findElement(el, selector);
|
|
|
|
// Error handling
|
|
if (typeof eventType !== "string") {
|
|
errors.push("event type must be a string");
|
|
}
|
|
if (!target) {
|
|
errors.push("cannot find target");
|
|
} else if (!options.skipVisibilityCheck && !isVisible(target)) {
|
|
errors.push("target is not visible");
|
|
}
|
|
if (errors.length) {
|
|
throw new Error(
|
|
`Cannot trigger event${eventType ? ` "${eventType}"` : ""}${
|
|
selector ? ` (with selector "${selector}")` : ""
|
|
}: ${errors.join(" and ")}`
|
|
);
|
|
}
|
|
|
|
// Actual dispatch
|
|
const [Constructor, processParams] = getEventConstructor(eventType);
|
|
const event = new Constructor(eventType, processParams(eventInit));
|
|
target.dispatchEvent(event);
|
|
|
|
if (__debug__.debug) {
|
|
const group = `%c[${event.type.toUpperCase()}]`;
|
|
console.groupCollapsed(group, "color: #b52c9b");
|
|
console.log(target, event);
|
|
console.groupEnd(group, "color: #b52c9b");
|
|
}
|
|
|
|
if (options.sync) {
|
|
return event;
|
|
} else {
|
|
return tick().then(() => event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Element} el
|
|
* @param {string | null | undefined | false} selector
|
|
* @param {(EventType | [EventType, EventInit])[]} [eventDefs]
|
|
* @param {TriggerEventOptions} [options={}]
|
|
*/
|
|
function _triggerEvents(el, selector, eventDefs, options = {}) {
|
|
const events = [...eventDefs].map((eventDef) => {
|
|
const [eventType, eventInit] = Array.isArray(eventDef) ? eventDef : [eventDef, {}];
|
|
return triggerEvent(el, selector, eventType, eventInit, options);
|
|
});
|
|
if (options.sync) {
|
|
return events;
|
|
} else {
|
|
return tick().then(() => events);
|
|
}
|
|
}
|
|
|
|
function _click(
|
|
el,
|
|
selector,
|
|
{ mouseEventInit = {}, skipDisabledCheck = false, skipVisibilityCheck = false } = {}
|
|
) {
|
|
if (!skipDisabledCheck && el.disabled) {
|
|
throw new Error("Can't click on a disabled button");
|
|
}
|
|
return _triggerEvents(
|
|
el,
|
|
selector,
|
|
[
|
|
"pointerdown",
|
|
"mousedown",
|
|
"focus",
|
|
"focusin",
|
|
"pointerup",
|
|
"mouseup",
|
|
["click", mouseEventInit],
|
|
],
|
|
{ skipVisibilityCheck }
|
|
);
|
|
}
|
|
|
|
export async function editInput(el, selector, value) {
|
|
const input = findElement(el, selector);
|
|
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) {
|
|
throw new Error("Only 'input' and 'textarea' elements can be edited with 'editInput'.");
|
|
}
|
|
if (
|
|
!["text", "textarea", "email", "search", "color", "number", "file", "tel"].includes(
|
|
input.type
|
|
)
|
|
) {
|
|
throw new Error(`Type "${input.type}" not supported by 'editInput'.`);
|
|
}
|
|
|
|
const eventOpts = {};
|
|
if (input.type === "file") {
|
|
const files = Array.isArray(value) ? value : [value];
|
|
const dataTransfer = new DataTransfer();
|
|
for (const file of files) {
|
|
if (!(file instanceof File)) {
|
|
throw new Error(`File input value should be one or several File objects.`);
|
|
}
|
|
dataTransfer.items.add(file);
|
|
}
|
|
input.files = dataTransfer.files;
|
|
eventOpts.skipVisibilityCheck = true;
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
|
|
await _triggerEvents(input, null, ["input", "change"], eventOpts);
|
|
|
|
if (input.type === "file") {
|
|
// Need to wait for the file to be loaded by the input
|
|
await tick();
|
|
await tick();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
items: [],
|
|
types: ["Files"],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then clicks on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
* @param {boolean} [options.shiftKey]
|
|
*/
|
|
export async function click(selector, options = {}) {
|
|
const { shiftKey } = options;
|
|
delete options.shiftKey;
|
|
await contains(selector, { click: { shiftKey }, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then dragenters `files` on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {Object[]} files
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function dragenterFiles(selector, files, options) {
|
|
await contains(selector, { dragenterFiles: files, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then dragovers `files` on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {Object[]} files
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function dragoverFiles(selector, files, options) {
|
|
await contains(selector, { dragoverFiles: files, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then drops `files` on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {Object[]} files
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function dropFiles(selector, files, options) {
|
|
await contains(selector, { dropFiles: files, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then inputs `files` on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {Object[]} files
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function inputFiles(selector, files, options) {
|
|
await contains(selector, { inputFiles: files, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then pastes `files` on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {Object[]} files
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function pasteFiles(selector, files, options) {
|
|
await contains(selector, { pasteFiles: files, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then focuses on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function focus(selector, options) {
|
|
await contains(selector, { setFocus: true, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then inserts the given `content`.
|
|
*
|
|
* @param {string} selector
|
|
* @param {string} content
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
* @param {boolean} [options.replace=false]
|
|
*/
|
|
export async function insertText(selector, content, options = {}) {
|
|
const { replace = false } = options;
|
|
delete options.replace;
|
|
await contains(selector, { ...options, insertText: { content, replace } });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then sets its `scrollTop` to the given value.
|
|
*
|
|
* @param {string} selector
|
|
* @param {number|"bottom"} scrollTop
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function scroll(selector, scrollTop, options) {
|
|
await contains(selector, { setScroll: scrollTop, ...options });
|
|
}
|
|
|
|
/**
|
|
* Waits until exactly one element matching the given `selector` is present in
|
|
* `options.target` and then triggers `event` on it.
|
|
*
|
|
* @param {string} selector
|
|
* @param {(import("@web/../tests/helpers/utils").EventType|[import("@web/../tests/helpers/utils").EventType, EventInit])[]} events
|
|
* @param {ContainsOptions} [options] forwarded to `contains`
|
|
*/
|
|
export async function triggerEvents(selector, events, options) {
|
|
await contains(selector, { triggerEvents: events, ...options });
|
|
}
|
|
|
|
/**
|
|
* Triggers an hotkey properly disregarding the operating system.
|
|
*
|
|
* @param {string} hotkey
|
|
* @param {boolean} addOverlayModParts
|
|
* @param {KeyboardEventInit} eventAttrs
|
|
*/
|
|
export async function triggerHotkey(hotkey, addOverlayModParts = false, eventAttrs = {}) {
|
|
eventAttrs.key = hotkey.split("+").pop();
|
|
if (/shift/i.test(hotkey)) {
|
|
eventAttrs.shiftKey = true;
|
|
}
|
|
if (/control/i.test(hotkey)) {
|
|
if (isMacOS()) {
|
|
eventAttrs.metaKey = true;
|
|
} else {
|
|
eventAttrs.ctrlKey = true;
|
|
}
|
|
}
|
|
if (/alt/i.test(hotkey) || addOverlayModParts) {
|
|
if (isMacOS()) {
|
|
eventAttrs.ctrlKey = true;
|
|
} else {
|
|
eventAttrs.altKey = true;
|
|
}
|
|
}
|
|
if (!("bubbles" in eventAttrs)) {
|
|
eventAttrs.bubbles = true;
|
|
}
|
|
const [keydownEvent, keyupEvent] = await _triggerEvents(
|
|
document.activeElement,
|
|
null,
|
|
[
|
|
["keydown", eventAttrs],
|
|
["keyup", eventAttrs],
|
|
],
|
|
{ skipVisibilityCheck: true }
|
|
);
|
|
return { keydownEvent, keyupEvent };
|
|
}
|
|
|
|
function log(ok, message) {
|
|
expect(Boolean(ok)).toBe(true, { message });
|
|
}
|
|
|
|
let hasUsedContainsPositively = false;
|
|
afterEach(() => (hasUsedContainsPositively = false));
|
|
/**
|
|
* @typedef {[string, ContainsOptions]} ContainsTuple tuple representing params of the contains
|
|
* function, where the first element is the selector, and the second element is the options param.
|
|
* @typedef {Object} ContainsOptions
|
|
* @property {ContainsTuple} [after] if provided, the found element(s) must be after the element
|
|
* matched by this param.
|
|
* @property {ContainsTuple} [before] if provided, the found element(s) must be before the element
|
|
* matched by this param.
|
|
* @property {Object} [click] if provided, clicks on the first found element
|
|
* @property {ContainsTuple|ContainsTuple[]} [contains] if provided, the found element(s) must
|
|
* contain the provided sub-elements.
|
|
* @property {number} [count=1] numbers of elements to be found to declare the contains check
|
|
* as successful. Elements are counted after applying all other filters.
|
|
* @property {Object[]} [dragenterFiles] if provided, dragenters the given files on the found element
|
|
* @property {Object[]} [dragoverFiles] if provided, dragovers the given files on the found element
|
|
* @property {Object[]} [dropFiles] if provided, drops the given files on the found element
|
|
* @property {Object[]} [inputFiles] if provided, inputs the given files on the found element
|
|
* @property {{content:string, replace:boolean}} [insertText] if provided, adds to (or replace) the
|
|
* value of the first found element by the given content.
|
|
* @property {ContainsTuple} [parent] if provided, the found element(s) must have as
|
|
* parent the node matching the parent parameter.
|
|
* @property {Object[]} [pasteFiles] if provided, pastes the given files on the found element
|
|
* @property {number|"bottom"} [scroll] if provided, the scrollTop of the found element(s)
|
|
* must match.
|
|
* Note: when using one of the scrollTop options, it is advised to ensure the height is not going
|
|
* to change soon, by checking with a preceding contains that all the expected elements are in DOM.
|
|
* @property {boolean} [setFocus] if provided, focuses the first found element.
|
|
* @property {boolean} [shadowRoot] if provided, targets the shadowRoot of the found elements.
|
|
* @property {number|"bottom"} [setScroll] if provided, sets the scrollTop on the first found
|
|
* element.
|
|
* @property {HTMLElement|OdooEnv} [target=getFixture()]
|
|
* @property {string[]} [triggerEvents] if provided, triggers the given events on the found element
|
|
* @property {string} [text] if provided, the textContent of the found element(s) or one of their
|
|
* descendants must match. Use `textContent` option for a match on the found element(s) only.
|
|
* @property {string} [textContent] if provided, the textContent of the found element(s) must match.
|
|
* Prefer `text` option for a match on the found element(s) or any of their descendants, usually
|
|
* allowing for a simpler and less specific selector.
|
|
* @property {string} [value] if provided, the input value of the found element(s) must match.
|
|
* Note: value changes are not observed directly, another mutation must happen to catch them.
|
|
* @property {boolean} [visible] if provided, the found element(s) must be (in)visible
|
|
*/
|
|
class Contains {
|
|
/**
|
|
* @param {string} selector
|
|
* @param {ContainsOptions} [options={}]
|
|
*/
|
|
constructor(selector, options = {}) {
|
|
this.selector = selector;
|
|
this.options = options;
|
|
this.options.count ??= 1;
|
|
let targetParam;
|
|
if (this.options.target?.testEnv) {
|
|
// when OdooEnv, special key `target`. See @start
|
|
targetParam = this.options.target?.target;
|
|
}
|
|
if (!targetParam) {
|
|
targetParam = this.options.target;
|
|
}
|
|
this.options.target = targetParam || getFixture();
|
|
let selectorMessage = `${this.options.count} of "${this.selector}"`;
|
|
if (this.options.visible !== undefined) {
|
|
selectorMessage = `${selectorMessage} ${
|
|
this.options.visible ? "visible" : "invisible"
|
|
}`;
|
|
}
|
|
if (targetParam) {
|
|
selectorMessage = `${selectorMessage} inside a specific target`;
|
|
}
|
|
if (this.options.parent) {
|
|
selectorMessage = `${selectorMessage} inside a specific parent`;
|
|
}
|
|
if (this.options.contains) {
|
|
selectorMessage = `${selectorMessage} with a specified sub-contains`;
|
|
}
|
|
if (this.options.text !== undefined) {
|
|
selectorMessage = `${selectorMessage} with text "${this.options.text}"`;
|
|
}
|
|
if (this.options.textContent !== undefined) {
|
|
selectorMessage = `${selectorMessage} with textContent "${this.options.textContent}"`;
|
|
}
|
|
if (this.options.value !== undefined) {
|
|
selectorMessage = `${selectorMessage} with value "${this.options.value}"`;
|
|
}
|
|
if (this.options.scroll !== undefined) {
|
|
selectorMessage = `${selectorMessage} with scroll "${this.options.scroll}"`;
|
|
}
|
|
if (this.options.after !== undefined) {
|
|
selectorMessage = `${selectorMessage} after a specified element`;
|
|
}
|
|
if (this.options.before !== undefined) {
|
|
selectorMessage = `${selectorMessage} before a specified element`;
|
|
}
|
|
this.selectorMessage = selectorMessage;
|
|
if (this.options.contains && !Array.isArray(this.options.contains[0])) {
|
|
this.options.contains = [this.options.contains];
|
|
}
|
|
if (this.options.count) {
|
|
hasUsedContainsPositively = true;
|
|
} else if (!hasUsedContainsPositively) {
|
|
throw new Error(
|
|
`Starting a test with "contains" of count 0 for selector "${this.selector}" is useless because it might immediately resolve. Start the test by checking that an expected element actually exists.`
|
|
);
|
|
}
|
|
/** @type {string} */
|
|
this.successMessage = undefined;
|
|
/** @type {function} */
|
|
this.executeError = undefined;
|
|
}
|
|
|
|
/**
|
|
* Starts this contains check, either immediately resolving if there is a
|
|
* match, or registering appropriate listeners and waiting until there is a
|
|
* match or a timeout (resolving or rejecting respectively).
|
|
*
|
|
* Success or failure messages will be logged with HOOT as well.
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
run() {
|
|
this.done = false;
|
|
this.def = new Deferred();
|
|
this.scrollListeners = new Set();
|
|
this.onScroll = () => this.runOnce("after scroll");
|
|
if (!this.runOnce("immediately")) {
|
|
this.timer = setTimeout(
|
|
() => this.runOnce("Timeout of 3 seconds", { crashOnFail: true }),
|
|
3000
|
|
);
|
|
this.observer = new MutationObserver((mutations) => {
|
|
try {
|
|
this.runOnce("after mutations");
|
|
} catch (e) {
|
|
this.def.reject(e); // prevents infinite loop in case of programming error
|
|
}
|
|
});
|
|
this.observer.observe(document.body, {
|
|
attributes: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
after(() => {
|
|
if (!this.done) {
|
|
this.runOnce("Test ended", { crashOnFail: true });
|
|
}
|
|
});
|
|
}
|
|
return this.def;
|
|
}
|
|
|
|
/**
|
|
* Runs this contains check once, immediately returning the result (or
|
|
* undefined), and possibly resolving or rejecting the main promise
|
|
* (and printing HOOT log) depending on options.
|
|
* If undefined is returned it means the check was not successful.
|
|
*
|
|
* @param {string} whenMessage
|
|
* @param {Object} [options={}]
|
|
* @param {boolean} [options.crashOnFail=false]
|
|
* @param {boolean} [options.executeOnSuccess=true]
|
|
* @returns {HTMLElement[]|undefined}
|
|
*/
|
|
runOnce(whenMessage, { crashOnFail = false, executeOnSuccess = true } = {}) {
|
|
const res = this.select();
|
|
if ((res?.length ?? 0) === this.options.count || crashOnFail) {
|
|
// clean before doing anything else to avoid infinite loop due to side effects
|
|
this.observer?.disconnect();
|
|
clearTimeout(this.timer);
|
|
for (const el of this.scrollListeners ?? []) {
|
|
el.removeEventListener("scroll", this.onScroll);
|
|
}
|
|
this.done = true;
|
|
}
|
|
if ((res?.length ?? 0) === this.options.count) {
|
|
this.successMessage = `Found ${this.selectorMessage} (${whenMessage})`;
|
|
if (executeOnSuccess) {
|
|
this.executeAction(res[0]);
|
|
}
|
|
return res;
|
|
} else {
|
|
this.executeError = () => {
|
|
let message = `Failed to find ${this.selectorMessage} (${whenMessage}).`;
|
|
message = res
|
|
? `${message} Found ${res.length} instead.`
|
|
: `${message} Parent not found.`;
|
|
if (this.parentContains) {
|
|
if (this.parentContains.successMessage) {
|
|
log(true, this.parentContains.successMessage);
|
|
} else {
|
|
this.parentContains.executeError();
|
|
}
|
|
}
|
|
log(false, message);
|
|
this.def?.reject(new Error(message));
|
|
for (const childContains of this.childrenContains || []) {
|
|
if (childContains.successMessage) {
|
|
log(true, childContains.successMessage);
|
|
} else {
|
|
childContains.executeError();
|
|
}
|
|
}
|
|
};
|
|
if (crashOnFail) {
|
|
this.executeError();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes the action(s) given to this constructor on the found element,
|
|
* prints the success messages, and resolves the main deferred.
|
|
|
|
* @param {HTMLElement} el
|
|
*/
|
|
executeAction(el) {
|
|
let message = this.successMessage;
|
|
if (this.options.click) {
|
|
message = `${message} and clicked it`;
|
|
_click(el, undefined, {
|
|
mouseEventInit: this.options.click,
|
|
skipDisabledCheck: true,
|
|
skipVisibilityCheck: true,
|
|
});
|
|
}
|
|
if (this.options.dragenterFiles) {
|
|
message = `${message} and dragentered ${this.options.dragenterFiles.length} file(s)`;
|
|
const ev = new Event("dragenter", { bubbles: true });
|
|
Object.defineProperty(ev, "dataTransfer", {
|
|
value: createFakeDataTransfer(this.options.dragenterFiles),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
if (this.options.dragoverFiles) {
|
|
message = `${message} and dragovered ${this.options.dragoverFiles.length} file(s)`;
|
|
const ev = new Event("dragover", { bubbles: true });
|
|
Object.defineProperty(ev, "dataTransfer", {
|
|
value: createFakeDataTransfer(this.options.dragoverFiles),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
if (this.options.dropFiles) {
|
|
message = `${message} and dropped ${this.options.dropFiles.length} file(s)`;
|
|
const ev = new Event("drop", { bubbles: true });
|
|
Object.defineProperty(ev, "dataTransfer", {
|
|
value: createFakeDataTransfer(this.options.dropFiles),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
if (this.options.inputFiles) {
|
|
message = `${message} and inputted ${this.options.inputFiles.length} file(s)`;
|
|
// 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 this.options.inputFiles) {
|
|
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"));
|
|
}
|
|
}
|
|
if (this.options.insertText !== undefined) {
|
|
message = `${message} and inserted text "${this.options.insertText.content}" (replace: ${this.options.insertText.replace})`;
|
|
el.focus();
|
|
if (this.options.insertText.replace) {
|
|
el.value = "";
|
|
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace" }));
|
|
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Backspace" }));
|
|
el.dispatchEvent(new window.InputEvent("input"));
|
|
}
|
|
for (const char of this.options.insertText.content) {
|
|
el.value += char;
|
|
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
|
|
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
|
|
el.dispatchEvent(new window.InputEvent("input"));
|
|
}
|
|
el.dispatchEvent(new window.InputEvent("change"));
|
|
}
|
|
if (this.options.pasteFiles) {
|
|
message = `${message} and pasted ${this.options.pasteFiles.length} file(s)`;
|
|
const ev = new Event("paste", { bubbles: true });
|
|
Object.defineProperty(ev, "clipboardData", {
|
|
value: createFakeDataTransfer(this.options.pasteFiles),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
if (this.options.setFocus) {
|
|
message = `${message} and focused it`;
|
|
el.focus();
|
|
}
|
|
if (this.options.setScroll !== undefined) {
|
|
message = `${message} and set scroll to "${this.options.setScroll}"`;
|
|
el.scrollTop =
|
|
this.options.setScroll === "bottom" ? el.scrollHeight : this.options.setScroll;
|
|
}
|
|
if (this.options.triggerEvents) {
|
|
message = `${message} and triggered "${this.options.triggerEvents.join(", ")}" events`;
|
|
_triggerEvents(el, null, this.options.triggerEvents, {
|
|
skipVisibilityCheck: true,
|
|
});
|
|
}
|
|
if (this.parentContains) {
|
|
log(true, this.parentContains.successMessage);
|
|
}
|
|
log(true, message);
|
|
for (const childContains of this.childrenContains) {
|
|
log(true, childContains.successMessage);
|
|
}
|
|
this.def?.resolve();
|
|
}
|
|
|
|
/**
|
|
* Returns the found element(s) according to this constructor setup.
|
|
* If undefined is returned it means the parent cannot be found
|
|
*
|
|
* @returns {HTMLElement[]|undefined}
|
|
*/
|
|
select() {
|
|
const target = this.selectParent();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
let elems;
|
|
if (target === getFixture() && queryFirst(this.selector) === target) {
|
|
elems = [target];
|
|
} else {
|
|
elems = queryAll(this.selector, { root: target });
|
|
}
|
|
const baseRes = elems
|
|
.map((el) => (this.options.shadowRoot ? el.shadowRoot : el))
|
|
.filter((el) => el);
|
|
/** @type {Contains[]} */
|
|
this.childrenContains = [];
|
|
const res = baseRes.filter((el, currentIndex) => {
|
|
let condition =
|
|
(this.options.textContent === undefined ||
|
|
el.textContent.trim() === this.options.textContent) &&
|
|
(this.options.value === undefined || el.value === this.options.value) &&
|
|
(this.options.scroll === undefined ||
|
|
(this.options.scroll === "bottom"
|
|
? Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1
|
|
: Math.abs(el.scrollTop - this.options.scroll) <= 1));
|
|
if (condition && this.options.text !== undefined) {
|
|
if (
|
|
el.textContent.trim() !== this.options.text &&
|
|
[...el.querySelectorAll("*")].every(
|
|
(el) => el.textContent.trim() !== this.options.text
|
|
)
|
|
) {
|
|
condition = false;
|
|
}
|
|
}
|
|
if (condition && this.options.contains) {
|
|
for (const param of this.options.contains) {
|
|
const childContains = new Contains(param[0], { ...param[1], target: el });
|
|
if (
|
|
!childContains.runOnce(`as child of el ${currentIndex + 1})`, {
|
|
executeOnSuccess: false,
|
|
})
|
|
) {
|
|
condition = false;
|
|
}
|
|
this.childrenContains.push(childContains);
|
|
}
|
|
}
|
|
if (condition && this.options.visible !== undefined) {
|
|
if (isVisible(el) !== this.options.visible) {
|
|
condition = false;
|
|
}
|
|
}
|
|
if (condition && this.options.after) {
|
|
const afterContains = new Contains(this.options.after[0], {
|
|
...this.options.after[1],
|
|
target,
|
|
});
|
|
const afterEl = afterContains.runOnce(`as "after"`, {
|
|
executeOnSuccess: false,
|
|
})?.[0];
|
|
if (
|
|
!afterEl ||
|
|
!(el.compareDocumentPosition(afterEl) & Node.DOCUMENT_POSITION_PRECEDING)
|
|
) {
|
|
condition = false;
|
|
}
|
|
this.childrenContains.push(afterContains);
|
|
}
|
|
if (condition && this.options.before) {
|
|
const beforeContains = new Contains(this.options.before[0], {
|
|
...this.options.before[1],
|
|
target,
|
|
});
|
|
const beforeEl = beforeContains.runOnce(`as "before"`, {
|
|
executeOnSuccess: false,
|
|
})?.[0];
|
|
if (
|
|
!beforeEl ||
|
|
!(el.compareDocumentPosition(beforeEl) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
) {
|
|
condition = false;
|
|
}
|
|
this.childrenContains.push(beforeContains);
|
|
}
|
|
return condition;
|
|
});
|
|
if (
|
|
this.options.scroll !== undefined &&
|
|
this.scrollListeners &&
|
|
baseRes.length === this.options.count &&
|
|
res.length !== this.options.count
|
|
) {
|
|
for (const el of baseRes) {
|
|
if (!this.scrollListeners.has(el)) {
|
|
this.scrollListeners.add(el);
|
|
el.addEventListener("scroll", this.onScroll);
|
|
}
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* Returns the found element that should act as the target (parent) for the
|
|
* main selector.
|
|
* If undefined is returned it means the parent cannot be found.
|
|
*
|
|
* @returns {HTMLElement|undefined}
|
|
*/
|
|
selectParent() {
|
|
if (this.options.parent) {
|
|
this.parentContains = new Contains(this.options.parent[0], {
|
|
...this.options.parent[1],
|
|
target: this.options.target,
|
|
});
|
|
return this.parentContains.runOnce(`as parent`, { executeOnSuccess: false })?.[0];
|
|
}
|
|
return this.options.target;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits until `count` elements matching the given `selector` are present in
|
|
* `options.target`.
|
|
*
|
|
* @param {string} selector
|
|
* @param {ContainsOptions} [options]
|
|
* @returns {Promise}
|
|
*/
|
|
export async function contains(selector, options) {
|
|
await new Contains(selector, options).run();
|
|
}
|