odoo_17.0.1/core/web/static/tests/utils.js

720 lines
28 KiB
JavaScript
Raw Normal View History

/* @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<Object>} 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;
}