/* @odoo-module */ import { isVisible } from "@web/core/utils/ui"; import { registerCleanup } from "@web/../tests/helpers/cleanup"; import { click as webClick, getFixture, makeDeferred, triggerEvents as webTriggerEvents, } from "@web/../tests/helpers/utils"; /** * 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} resolved with file created */ export 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 })); }); }); }); }); } /** * 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 }); } function log(ok, message) { if (window.QUnit) { QUnit.assert.ok(ok, message); } else { if (ok) { console.log(message); } else { console.error(message); } } } let hasUsedContainsPositively = false; if (window.QUnit) { QUnit.testStart(() => (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} [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; this.options.targetParam = this.options.target; this.options.target ??= getFixture(); let selectorMessage = `${this.options.count} of "${this.selector}"`; if (this.options.visible !== undefined) { selectorMessage = `${selectorMessage} ${ this.options.visible ? "visible" : "invisible" }`; } if (this.options.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 QUnit as well. * * @returns {Promise} */ run() { this.done = false; this.def = makeDeferred(); this.scrollListeners = new Set(); this.onScroll = () => this.runOnce("after scroll"); if (!this.runOnce("immediately")) { this.timer = setTimeout( () => this.runOnce("Timeout of 5 seconds", { crashOnFail: true }), 5000 ); 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(this.options.target, { attributes: true, childList: true, subtree: true, }); registerCleanup(() => { 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 QUnit 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 === 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 === 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`; webClick(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")); el.dispatchEvent(new window.InputEvent("change")); } 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`; webTriggerEvents(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; } const baseRes = [...target.querySelectorAll(this.selector)] .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(); } const stepState = { expectedSteps: null, deferred: null, timeout: null, currentSteps: [], clear() { clearTimeout(this.timeout); this.timeout = null; this.deferred = null; this.currentSteps = []; this.expectedSteps = null; }, check({ crashOnFail = false } = {}) { const success = this.expectedSteps.length === this.currentSteps.length && this.expectedSteps.every((s, i) => s === this.currentSteps[i]); if (!success && !crashOnFail) { return; } QUnit.config.current.assert.verifySteps(this.expectedSteps); if (success) { this.deferred.resolve(); } else { this.deferred.reject(new Error("Steps do not match.")); } this.clear(); }, }; if (window.QUnit) { QUnit.testStart(() => registerCleanup(() => { if (stepState.expectedSteps) { stepState.check({ crashOnFail: true }); } else { stepState.clear(); } }) ); } /** * Indicate the completion of a test step. This step must then be verified by * calling `assertSteps`. * * @param {string} step */ export function step(step) { stepState.currentSteps.push(step); QUnit.config.current.assert.step(step); if (stepState.expectedSteps) { stepState.check(); } } /** * Wait for the given steps to be executed or for the timeout to be reached. * * @param {string[]} steps */ export function assertSteps(steps) { if (stepState.expectedSteps) { stepState.check({ crashOnFail: true }); } stepState.expectedSteps = steps; stepState.deferred = makeDeferred(); stepState.timeout = setTimeout(() => stepState.check({ crashOnFail: true }), 2000); stepState.check(); return stepState.deferred; }