Files
odoo_source/addons/mail/static/tests/helpers/test_utils.js
T
Pulinckx Pierre (PIPU) f4f0f78318 [REF] *: Replace underscore.js function by native JS
Before this commit : The library underscore.js and
underscore.string.js were used in the ODOO solution.
After this commit : Every usages of a function from
underscore.js lib has been replaced with native javascript.
The goal is to remove all usages of underscore.js and to
not use anymore this library in ODOO.
---
TaskId : 3246238

closes odoo/odoo#120437

Signed-off-by: Géry Debongnie <ged@odoo.com>
2023-05-15 09:37:48 +02:00

704 lines
24 KiB
JavaScript

/** @odoo-module **/
import { getPyEnv, startServer } from "@bus/../tests/helpers/mock_python_environment";
import { loadEmoji } from "@mail/emoji_picker/emoji_picker";
import { getAdvanceTime } from "@mail/../tests/helpers/time_control";
import { patchBrowserNotification } from "@mail/../tests/helpers/patch_notifications";
import { getWebClientReady } from "@mail/../tests/helpers/webclient_setup";
import { registry } from "@web/core/registry";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { session as sessionInfo } from "@web/session";
import { getFixture, makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils";
import { doAction, getActionManagerServerData } from "@web/../tests/webclient/helpers";
import { App, EventBus } from "@odoo/owl";
import {
registryNamesToCloneWithCleanup,
utils,
clearRegistryWithCleanup,
} from "@web/../tests/helpers/mock_env";
import { browser } from "@web/core/browser/browser";
const { prepareRegistriesWithCleanup } = utils;
const { afterNextRender } = App;
// load emoji data once, when the test suite starts.
QUnit.begin(loadEmoji);
registryNamesToCloneWithCleanup.push("mock_server_callbacks");
//------------------------------------------------------------------------------
// Private
//------------------------------------------------------------------------------
/**
* 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"],
};
}
//------------------------------------------------------------------------------
// Public: rendering timers
//------------------------------------------------------------------------------
/**
* Returns a promise resolved at the next animation frame.
*
* @returns {Promise}
*/
function nextAnimationFrame() {
return new Promise(function (resolve) {
setTimeout(() => requestAnimationFrame(() => resolve()));
});
}
/**
* Wait a task tick, so that anything in micro-task queue that can be processed
* is processed.
*/
async function nextTick() {
await new Promise(setTimeout);
}
//------------------------------------------------------------------------------
// Public: test lifecycle
//------------------------------------------------------------------------------
function getAfterEvent({ messagingBus }) {
/**
* Returns a promise resolved after the expected event is received.
*
* @param {Object} param0
* @param {string} param0.eventName event to wait
* @param {function} param0.func function which, when called, is expected to
* trigger the event
* @param {string} [param0.message] assertion message
* @param {function} [param0.predicate] predicate called with event data.
* If not provided, only the event name has to match.
* @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms
* @returns {Promise}
*/
return async function afterEvent({ eventName, func, message, predicate, timeoutDelay = 5000 }) {
const error = new Error(message || `Timeout: the event ${eventName} was not triggered.`);
// Set up the timeout to reject if the event is not triggered.
let timeoutNoEvent;
const timeoutProm = new Promise((resolve, reject) => {
timeoutNoEvent = setTimeout(() => {
console.warn(error);
reject(error);
}, timeoutDelay);
});
// Set up the promise to resolve if the event is triggered.
const eventProm = makeDeferred();
const eventHandler = (ev) => {
if (!predicate || predicate(ev.detail)) {
eventProm.resolve();
}
};
messagingBus.addEventListener(eventName, eventHandler);
// Start the function expected to trigger the event after the
// promise has been registered to not miss any potential event.
const funcRes = func();
// Make them race (first to resolve/reject wins).
await Promise.race([eventProm, timeoutProm]).finally(() => {
// Execute clean up regardless of whether the promise is
// rejected or not.
clearTimeout(timeoutNoEvent);
messagingBus.removeEventListener(eventName, eventHandler);
});
// If the event is triggered before the end of the async function,
// ensure the function finishes its job before returning.
return await funcRes;
};
}
function getClick({ target, afterNextRender }) {
return async function click(selector) {
await afterNextRender(() => {
if (typeof selector === "string") {
$(target ?? document.body)
.find(selector)[0]
.click();
} else if (selector instanceof HTMLElement) {
selector.click();
} else {
// jquery
selector[0].click();
}
});
};
}
function getMouseenter({ afterNextRender }) {
return async function mouseenter(selector) {
await afterNextRender(() =>
document.querySelector(selector).dispatchEvent(new window.MouseEvent("mouseenter"))
);
};
}
function getOpenDiscuss(webClient, { context = {}, params = {}, ...props } = {}) {
return async function openDiscuss(pActiveId, { waitUntilMessagesLoaded = true } = {}) {
const actionOpenDiscuss = {
// hardcoded actionId, required for discuss_container props validation.
id: 104,
context,
params,
tag: "mail.action_discuss",
type: "ir.actions.client",
};
const activeId =
pActiveId ?? context.active_id ?? params.default_active_id ?? "mail.box_inbox";
let [threadModel, threadId] =
typeof activeId === "number" ? ["discuss.channel", activeId] : activeId.split("_");
if (threadModel === "discuss.channel") {
threadId = parseInt(threadId, 10);
}
// TODO-DISCUSS-REFACTORING: remove when activeId will be handled.
webClient.env.services["mail.thread"].setDiscussThread(
webClient.env.services["mail.thread"].insert({
model: threadModel,
id: threadId,
})
);
if (waitUntilMessagesLoaded) {
const messagesLoadedPromise = makeDeferred();
const store = webClient.env.services["mail.store"];
const thread = store.threads[store.discuss.threadLocalId];
if (thread.isLoaded) {
messagesLoadedPromise.resolve();
}
let loadMessageRoute = `/mail/${threadId}/messages`;
if (Number.isInteger(threadId)) {
loadMessageRoute = "/discuss/channel/messages";
}
registry.category("mock_server_callbacks").add(
loadMessageRoute,
({ channel_id: channelId = threadId }) => {
if (channelId === threadId) {
messagesLoadedPromise.resolve();
}
},
{ force: true }
);
return afterNextRender(async () => {
await doAction(webClient, actionOpenDiscuss, { props });
await messagesLoadedPromise;
});
}
return afterNextRender(() => doAction(webClient, actionOpenDiscuss, { props }));
};
}
/**
* Wait until the form view corresponding to the given resId/resModel has loaded.
*
* @param {Function} func Function expected to trigger form view load.
* @param {Object} param1
*/
export function waitFormViewLoaded(
func,
{ resId = false, resModel, waitUntilMessagesLoaded = true, waitUntilDataLoaded = true } = {}
) {
const waitData = (func) => {
const dataLoadedPromise = makeDeferred();
registry.category("mock_server_callbacks").add(
"/mail/thread/data",
({ thread_id: threadId, thread_model: threadModel }) => {
if (threadId === resId && threadModel === resModel) {
dataLoadedPromise.resolve();
}
},
{ force: true }
);
return afterNextRender(async () => {
await func();
await dataLoadedPromise;
});
};
const waitMessages = (func) => {
const messagesLoadedPromise = makeDeferred();
registry.category("mock_server_callbacks").add(
"/mail/thread/messages",
({ thread_id: threadid, thread_model: threadModel }) => {
if (threadid === resId && threadModel === resModel) {
messagesLoadedPromise.resolve();
}
},
{ force: true }
);
return afterNextRender(async () => {
await func();
await messagesLoadedPromise;
});
};
if (waitUntilDataLoaded && waitUntilMessagesLoaded) {
return waitData(() => waitMessages(func));
}
if (waitUntilDataLoaded) {
return waitData(func);
}
if (waitUntilMessagesLoaded) {
return waitMessages(func);
}
}
function getOpenFormView(openView) {
return async function openFormView(
res_model,
res_id,
{
props,
waitUntilDataLoaded = Boolean(res_id),
waitUntilMessagesLoaded = Boolean(res_id),
} = {}
) {
const action = {
res_model,
res_id,
views: [[false, "form"]],
};
const func = () => openView(action, props);
if (waitUntilDataLoaded || waitUntilMessagesLoaded) {
return waitFormViewLoaded(func, {
resId: res_id,
resModel: res_model,
waitUntilDataLoaded,
waitUntilMessagesLoaded,
});
}
return func();
};
}
//------------------------------------------------------------------------------
// Public: start function helpers
//------------------------------------------------------------------------------
/**
* Reset registries used by the messaging environment. Useful to create multiple
* web clients.
*/
function resetRegistries() {
const categories = [
"actions",
"main_components",
"services",
"systray",
"wowlToLegacyServiceMappers",
];
for (const name of categories) {
clearRegistryWithCleanup(registry.category(name));
}
prepareRegistriesWithCleanup();
}
let tabs = [];
registerCleanup(() => (tabs = []));
/**
* Add an item to the "Switch Tab" dropdown. If it doesn't exist, create the
* dropdown and add the item afterwards.
*
* @param {HTMLElement} rootTarget Where to mount the dropdown menu.
* @param {HTMLElement} tabTarget Tab to switch to when clicking on the dropdown
* item.
*/
async function addSwitchTabDropdownItem(rootTarget, tabTarget) {
tabs.push(tabTarget);
const zIndexMainTab = 100000;
let dropdownDiv = rootTarget.querySelector(".o-mail-multi-tab-dropdown");
if (!dropdownDiv) {
tabTarget.style.zIndex = zIndexMainTab;
dropdownDiv = document.createElement("div");
dropdownDiv.style.zIndex = zIndexMainTab + 1;
dropdownDiv.style.top = "10%";
dropdownDiv.style.right = "5%";
dropdownDiv.style.position = "absolute";
dropdownDiv.classList.add("dropdown");
dropdownDiv.classList.add("o-mail-multi-tab-dropdown");
dropdownDiv.innerHTML = `
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Switch Tab (${tabs.length})
</button>
<ul class="dropdown-menu"></ul>
`;
rootTarget.appendChild(dropdownDiv);
}
const tabIndex = tabs.length;
const li = document.createElement("li");
const a = document.createElement("a");
li.appendChild(a);
a.classList.add("dropdown-item");
a.innerText = `Tab ${tabIndex}`;
browser.addEventListener("click", (ev) => {
const link = ev.target.closest(".dropdown-item");
if (a.isEqualNode(link)) {
tabs.forEach((tab) => (tab.style.zIndex = 0));
tabTarget.style.zIndex = zIndexMainTab;
dropdownDiv.querySelector(".dropdown-toggle").innerText = `Switch Tab (${tabIndex})`;
}
});
dropdownDiv.querySelector(".dropdown-menu").appendChild(li);
}
/**
* Main function used to make a mocked environment with mocked messaging env.
*
* @param {Object} [param0={}]
* @param {boolean} [param0.asTab] Whether or not the resulting WebClient should
* be considered as a separate tab.
* @param {Object} [param0.serverData] The data to pass to the webClient
* @param {Object} [param0.discuss={}] provide data that is passed to the
* discuss action.
* @param {Object} [param0.legacyServices]
* @param {Object} [param0.services]
* @param {function} [param0.mockRPC]
* @param {boolean} [param0.hasTimeControl=false] if set, all flow of time with
* `messaging.browser.setTimeout` are fully controlled by test itself.
* @param {integer} [param0.loadingBaseDelayDuration=0]
* @returns {Object}
*/
async function start(param0 = {}) {
const { discuss = {}, hasTimeControl } = param0;
const advanceTime = hasTimeControl ? getAdvanceTime() : undefined;
let target = param0["target"] || getFixture();
if (param0.asTab) {
resetRegistries();
const rootTarget = target;
target = document.createElement("div");
target.style.width = "100%";
rootTarget.appendChild(target);
addSwitchTabDropdownItem(rootTarget, target);
}
// make qunit fixture in visible range,
// so that features like IntersectionObserver work as expected
target.style.position = "absolute";
target.style.top = "0";
target.style.left = "0";
target.style.height = "100%";
target.style.opacity = QUnit.config.debug ? "" : "0";
registerCleanup(async () => {
target.style.position = "";
target.style.top = "";
target.style.left = "";
target.style.height = "";
target.style.opacity = "";
});
param0["target"] = target;
const messagingBus = new EventBus();
const afterEvent = getAfterEvent({ messagingBus });
const pyEnv = await getPyEnv();
patchWithCleanup(sessionInfo, {
user_context: {
...sessionInfo.user_context,
uid: pyEnv.currentUserId,
},
uid: pyEnv.currentUserId,
partner_id: pyEnv.currentPartnerId,
});
if (browser.Notification && !browser.Notification.isPatched) {
patchBrowserNotification("denied");
}
param0.serverData = param0.serverData || getActionManagerServerData();
param0.serverData.models = { ...pyEnv.getData(), ...param0.serverData.models };
param0.serverData.views = { ...pyEnv.getViews(), ...param0.serverData.views };
let webClient;
await afterNextRender(async () => {
webClient = await getWebClientReady({ ...param0, messagingBus });
});
if (webClient.env.isSmall) {
target.style.width = "100%";
}
const openView = async (action, options) => {
action["type"] = action["type"] || "ir.actions.act_window";
await afterNextRender(() => doAction(webClient, action, { props: options }));
};
return {
advanceTime,
afterEvent,
afterNextRender,
click: getClick({ target, afterNextRender }),
env: webClient.env,
insertText,
mouseenter: getMouseenter({ afterNextRender }),
openDiscuss: getOpenDiscuss(webClient, discuss),
openView,
openFormView: getOpenFormView(openView),
pyEnv,
target,
webClient,
};
}
//------------------------------------------------------------------------------
// Public: file utilities
//------------------------------------------------------------------------------
/**
* 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) {
const 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 }));
});
});
});
});
}
/**
* Drag some files over a DOM element
*
* @param {DOM.Element} el
* @param {Object[]} file must have been create beforehand
* @see testUtils.file.createFile
*/
function dragenterFiles(el, files) {
const ev = new Event("dragenter", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: _createFakeDataTransfer(files),
});
el.dispatchEvent(ev);
}
/**
* Drop some files on a DOM element
*
* @param {DOM.Element} el
* @param {Object[]} files must have been created beforehand
* @see testUtils.file.createFile
*/
function dropFiles(el, files) {
const ev = new Event("drop", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: _createFakeDataTransfer(files),
});
el.dispatchEvent(ev);
}
/**
* Paste some files on a DOM element
*
* @param {DOM.Element} el
* @param {Object[]} files must have been created beforehand
* @see testUtils.file.createFile
*/
function pasteFiles(el, files) {
const ev = new Event("paste", { bubbles: true });
Object.defineProperty(ev, "clipboardData", {
value: _createFakeDataTransfer(files),
});
el.dispatchEvent(ev);
}
//------------------------------------------------------------------------------
// Public: input utilities
//------------------------------------------------------------------------------
/**
* @param {string} selector
* @param {string} content
* @param {Object} [param2 = {}]
* @param {boolean} [param2.replace = false]
*/
export async function insertText(selector, content, { replace = false } = {}) {
await afterNextRender(() => {
if (replace) {
document.querySelector(selector).value = "";
}
document.querySelector(selector).focus();
for (const char of content) {
document.execCommand("insertText", false, char);
document
.querySelector(selector)
.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
document
.querySelector(selector)
.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
}
});
}
//------------------------------------------------------------------------------
// Public: DOM utilities
//------------------------------------------------------------------------------
/**
* Determine if a DOM element has been totally scrolled
*
* A 1px margin of error is given to accomodate subpixel rounding issues and
* Element.scrollHeight value being either int or decimal
*
* @param {DOM.Element} el
* @returns {boolean}
*/
function isScrolledToBottom(el) {
return Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1;
}
/**
* Determine if a DOM element is scrolled to the given scroll top position.
*
* A 1px margin of error is given to accomodate subpixel rounding issues and
* Element.scrollHeight value being either int or decimal
*
* @param {DOM.Element} el
* @param {number} scrollTop expected scroll top value.
* @returns {boolean}
*/
function isScrolledTo(el, scrollTop) {
return Math.abs(el.scrollTop - scrollTop) <= 1;
}
//------------------------------------------------------------------------------
// Public: web API utilities
//------------------------------------------------------------------------------
/**
* Mocks the browser's `navigator.mediaDevices.getUserMedia` and `navigator.mediaDevices.getDisplayMedia`
*/
export function mockGetMedia() {
class MockMediaStreamTrack extends EventTarget {
enabled = true;
readyState = "live";
constructor(kind) {
super();
this.kind = kind;
}
stop() {
this.readyState = "ended";
}
}
/**
* The audio streams are mocked as there is no way to create a MediaStream
* with an audio track without really requesting it from the device.
*/
class MockAudioMediaStream extends MediaStream {
mockTracks = [new MockMediaStreamTrack("audio")];
getTracks() {
return this.mockTracks;
}
getAudioTracks() {
return this.mockTracks;
}
getVideoTracks() {
return [];
}
}
const streams = [];
/**
* The video streams are real MediaStreams created from a 1x1 canvas at 1fps.
*/
const createVideoStream = (constraints) => {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const stream = canvas.captureStream(1);
return stream;
};
patchWithCleanup(browser.navigator.mediaDevices, {
getUserMedia(constraints) {
let stream;
if (constraints.audio) {
stream = new MockAudioMediaStream();
} else {
// The video streams are real MediaStreams
stream = createVideoStream();
}
streams.push(stream);
return stream;
},
getDisplayMedia() {
const stream = createVideoStream();
streams.push(stream);
return stream;
},
});
registerCleanup(() => {
// stop all streams as some tests may not do actions that lead to the ending of tracks
streams.forEach((stream) => {
stream.getTracks().forEach((track) => track.stop());
});
});
}
//------------------------------------------------------------------------------
// Export
//------------------------------------------------------------------------------
export {
afterNextRender,
dragenterFiles,
dropFiles,
isScrolledToBottom,
isScrolledTo,
nextAnimationFrame,
nextTick,
pasteFiles,
start,
startServer,
};
export const click = getClick({ afterNextRender });
/**
* Function that wait until a selector is present in the DOM
*
* @param {string} selector
*/
export function waitUntil(selector, count = 1) {
return new Promise((resolve, reject) => {
if ($(selector).length === count) {
return resolve($(selector));
}
const timer = setTimeout(() => {
observer.disconnect();
reject(new Error(`Waited 5 second for ${selector}`));
console.error(`Waited 5 second for ${selector}`);
}, 5000);
const observer = new MutationObserver((mutations) => {
if ($(selector).length === count) {
resolve($(selector));
observer.disconnect();
clearTimeout(timer);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}