odoo.define('mail/static/src/utils/test_utils.js', function (require) {
'use strict';
const BusService = require('bus.BusService');
const {
addMessagingToEnv,
addTimeControlToEnv,
} = require('mail/static/src/env/test_env.js');
const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js');
const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js');
const { nextTick } = require('mail/static/src/utils/utils.js');
const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js');
const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js');
const AbstractStorageService = require('web.AbstractStorageService');
const NotificationService = require('web.NotificationService');
const RamStorage = require('web.RamStorage');
const {
createActionManager,
createView,
makeTestPromise,
mock: {
addMockEnvironment,
patch: legacyPatch,
unpatch: legacyUnpatch,
},
} = require('web.test_utils');
const Widget = require('web.Widget');
const { Component } = owl;
//------------------------------------------------------------------------------
// 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'],
};
}
/**
* @private
* @param {Object} callbacks
* @param {function[]} callbacks.init
* @param {function[]} callbacks.mount
* @param {function[]} callbacks.destroy
* @param {function[]} callbacks.return
* @returns {Object} update callbacks
*/
function _useChatWindow(callbacks) {
const {
mount: prevMount,
destroy: prevDestroy,
} = callbacks;
return Object.assign({}, callbacks, {
mount: prevMount.concat(() => {
// trigger mounting of chat window manager
Component.env.services['chat_window']._onWebClientReady();
}),
destroy: prevDestroy.concat(() => {
Component.env.services['chat_window'].destroy();
}),
});
}
/**
* @private
* @param {Object} callbacks
* @param {function[]} callbacks.init
* @param {function[]} callbacks.mount
* @param {function[]} callbacks.destroy
* @param {function[]} callbacks.return
* @returns {Object} update callbacks
*/
function _useDialog(callbacks) {
const {
mount: prevMount,
destroy: prevDestroy,
} = callbacks;
return Object.assign({}, callbacks, {
mount: prevMount.concat(() => {
// trigger mounting of dialog manager
Component.env.services['dialog']._onWebClientReady();
}),
destroy: prevDestroy.concat(() => {
Component.env.services['dialog'].destroy();
}),
});
}
/**
* @private
* @param {Object} callbacks
* @param {function[]} callbacks.init
* @param {function[]} callbacks.mount
* @param {function[]} callbacks.destroy
* @param {function[]} callbacks.return
* @return {Object} update callbacks
*/
function _useDiscuss(callbacks) {
const {
init: prevInit,
mount: prevMount,
return: prevReturn,
} = callbacks;
let discussWidget;
const state = {
autoOpenDiscuss: false,
discussData: {},
};
return Object.assign({}, callbacks, {
init: prevInit.concat(params => {
const {
autoOpenDiscuss = state.autoOpenDiscuss,
discuss: discussData = state.discussData
} = params;
Object.assign(state, { autoOpenDiscuss, discussData });
delete params.autoOpenDiscuss;
delete params.discuss;
}),
mount: prevMount.concat(async params => {
const { selector, widget } = params;
DiscussWidget.prototype._pushStateActionManager = () => {};
discussWidget = new DiscussWidget(widget, state.discussData);
await discussWidget.appendTo($(selector));
if (state.autoOpenDiscuss) {
discussWidget.on_attach_callback();
}
}),
return: prevReturn.concat(result => {
Object.assign(result, { discussWidget });
}),
});
}
/**
* @private
* @param {Object} callbacks
* @param {function[]} callbacks.init
* @param {function[]} callbacks.mount
* @param {function[]} callbacks.destroy
* @param {function[]} callbacks.return
* @returns {Object} update callbacks
*/
function _useMessagingMenu(callbacks) {
const {
mount: prevMount,
return: prevReturn,
} = callbacks;
let messagingMenuWidget;
return Object.assign({}, callbacks, {
mount: prevMount.concat(async ({ selector, widget }) => {
messagingMenuWidget = new MessagingMenuWidget(widget, {});
await messagingMenuWidget.appendTo($(selector));
messagingMenuWidget.on_attach_callback();
}),
return: prevReturn.concat(result => {
Object.assign(result, { messagingMenuWidget });
}),
});
}
//------------------------------------------------------------------------------
// Public: rendering timers
//------------------------------------------------------------------------------
/**
* Returns a promise resolved at the next animation frame.
*
* @returns {Promise}
*/
function nextAnimationFrame() {
const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
return new Promise(function (resolve) {
setTimeout(() => requestAnimationFrame(() => resolve()));
});
}
/**
* Returns a promise resolved the next time OWL stops rendering.
*
* @param {function} func function which, when called, is
* expected to trigger OWL render(s).
* @param {number} [timeoutDelay=5000] in ms
* @returns {Promise}
*/
const afterNextRender = (function () {
const stop = owl.Component.scheduler.stop;
const stopPromises = [];
owl.Component.scheduler.stop = function () {
const wasRunning = this.isRunning;
stop.call(this);
if (wasRunning) {
while (stopPromises.length) {
stopPromises.pop().resolve();
}
}
};
async function afterNextRender(func, timeoutDelay = 5000) {
// Define the potential errors outside of the promise to get a proper
// trace if they happen.
const startError = new Error("Timeout: the render didn't start.");
const stopError = new Error("Timeout: the render didn't stop.");
// Set up the timeout to reject if no render happens.
let timeoutNoRender;
const timeoutProm = new Promise((resolve, reject) => {
timeoutNoRender = setTimeout(() => {
let error = startError;
if (owl.Component.scheduler.isRunning) {
error = stopError;
}
console.error(error);
reject(error);
}, timeoutDelay);
});
// Set up the promise to resolve if a render happens.
const prom = makeTestPromise();
stopPromises.push(prom);
// Start the function expected to trigger a render after the promise
// has been registered to not miss any potential render.
const funcRes = func();
// Make them race (first to resolve/reject wins).
await Promise.race([prom, timeoutProm]);
clearTimeout(timeoutNoRender);
// Wait the end of the function to ensure all potential effects are
// taken into account during the following verification step.
await funcRes;
// Wait one more frame to make sure no new render has been queued.
await nextAnimationFrame();
if (owl.Component.scheduler.isRunning) {
await afterNextRender(() => {}, timeoutDelay);
}
}
return afterNextRender;
})();
//------------------------------------------------------------------------------
// Public: test lifecycle
//------------------------------------------------------------------------------
function beforeEach(self) {
const data = {
initMessaging: {
channel_slots: {},
commands: [],
is_moderator: false,
mail_failures: [],
mention_partner_suggestions: [],
menu_id: false,
moderation_counter: 0,
moderation_channel_ids: [],
needaction_inbox_counter: 0,
partner_root: {
active: false,
display_name: "OdooBot",
id: 2,
},
public_partner: {
active: false,
display_name: "Public user",
id: 4,
},
shortcodes: [],
starred_counter: 0,
},
'ir.attachment': {
fields: {
name: { type: 'char', string: "attachment name", required: true },
res_model: { type: 'char', string: "res model" },
res_id: { type: 'integer', string: "res id" },
url: { type: 'char', string: 'url' },
type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] },
mimetype: { type: 'char', string: "mimetype" },
},
},
'mail.activity': {
fields: {
can_write: {
type: 'boolean',
},
icon: {
type: 'string',
},
id: {
type: 'integer',
},
res_id: {
type: 'integer',
},
res_model: {
type: 'string',
},
},
},
'mail.channel': {
fields: {
channel_type: {
string: "Channel Type",
type: "selection",
},
id: {
string: "Id",
type: 'integer',
},
is_minimized: {
// In python this belongs to mail.channel.partner. Here for simplicity.
string: "isMinimized",
type: "boolean",
},
is_pinned: {
// In python this belongs to mail.channel.partner. Here for simplicity.
string: "isPinned",
type: "boolean",
},
message_unread_counter: {
string: "# unread messages",
type: 'integer',
},
name: {
string: "Name",
type: "char",
required: true,
},
state: {
// In python this belongs to mail.channel.partner. Here for simplicity.
string: "FoldState",
type: "char",
},
uuid: {
string: "UUID",
type: "char",
required: true,
},
},
records: [],
},
'mail.followers': {
fields: {
channel_id: {
type: 'integer',
},
email: {
type: 'string',
},
id: {
type: 'integer',
},
is_active: {
type: 'boolean',
},
is_editable: {
type: 'boolean',
},
name: {
type: 'string',
},
partner_id: {
type: 'integer',
},
},
},
'mail.message': {
fields: {
attachment_ids: {
string: "Attachments",
type: 'many2many',
relation: 'ir.attachment',
default: [],
},
author_id: {
string: "Author",
relation: 'res.partner',
},
body: {
string: "Contents",
type: 'html',
},
channel_ids: {
string: "Channels",
type: 'many2many',
relation: 'mail.channel',
},
date: {
string: "Date",
type: 'datetime',
},
history_partner_ids: {
string: "Partners with History",
type: 'many2many',
relation: 'res.partner',
},
id: {
string: "Id",
type: 'integer',
},
is_discussion: {
string: "Discussion",
type: 'boolean',
},
is_note: {
string: "Note",
type: 'boolean',
},
is_notification: {
string: "Notification",
type: 'boolean',
},
is_starred: {
string: "Starred",
type: 'boolean',
},
message_type: {
string: "Type",
type: 'selection',
},
model: {
string: "Related Document model",
type: 'char',
},
needaction: {
string: "Need Action",
type: 'boolean',
},
needaction_partner_ids: {
string: "Partners with Need Action",
type: 'many2many',
relation: 'res.partner',
},
record_name: {
string: "Name",
type: 'string',
},
res_id: {
string: "Related Document ID",
type: 'integer',
},
starred: {
string: "Starred",
type: 'boolean',
},
starred_partner_ids: {
string: "Favorited By",
type: 'many2many',
relation: 'res.partner',
},
},
},
'mail.notification': {
fields: {
is_read: {
string: "Is Read",
type: 'boolean',
},
mail_message_id: {
string: "Message",
type: 'many2one',
relation: 'mail.message',
},
res_partner_id: {
string: "Needaction Recipient",
type: 'many2one',
relation: 'res.partner',
},
},
},
'res.partner': {
fields: {
activity_ids: {
string: "Activities",
type: 'one2many',
relation: 'mail.activity',
},
description: {
string: 'description',
type: 'text',
},
display_name: { string: "Displayed name", type: "char" },
im_status: {
string: "status",
type: 'char',
},
message_follower_ids: {
relation: 'mail.followers',
string: "Followers",
type: "one2many",
},
message_attachment_count: {
string: 'Attachment count',
type: 'integer',
},
message_ids: {
string: "Messages",
type: 'one2many',
relation: 'mail.message',
},
name: {
string: "Name",
type: 'char',
},
},
records: [],
},
'res.users': {
fields: {
partner_id: {
string: "Related partners",
type: 'many2one',
relation: 'res.partner',
},
},
},
};
const originals = {
'_.debounce': _.debounce,
'_.throttle': _.throttle,
'window.fetch': window.fetch,
};
(function patch() {
// patch _.debounce and _.throttle to be fast and synchronous
_.debounce = _.identity;
_.throttle = _.identity;
let uploadedAttachmentsCount = 1;
window.fetch = async function (route, form) {
const formData = form.body;
return {
async text() {
const ufiles = formData.getAll('ufile');
const files = ufiles.map(ufile => JSON.stringify({
filename: ufile.name,
id: uploadedAttachmentsCount,
mimetype: ufile.type,
name: ufile.name,
}));
const callback = formData.get('callback');
uploadedAttachmentsCount++;
return `
`;
}
};
};
})();
function unpatch() {
_.debounce = originals['_.debounce'];
_.throttle = originals['_.throttle'];
window.fetch = originals['window.fetch'];
}
Object.assign(self, { data, unpatch });
return {
data,
unpatch,
};
}
function afterEach(self) {
self.unpatch();
}
/**
* Main function used to make a mocked environment with mocked messaging env.
*
* @param {Object} [param0={}]
* @param {string} [param0.arch] makes only sense when `param0.hasView` is set:
* the arch to use in createView.
* @param {Object} [param0.archs]
* @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when
* `param0.hasDiscuss` is set: determine whether mounted discuss should be
* open initially.
* @param {boolean} [param0.debug=false]
* @param {Object} [param0.data] makes only sense when `param0.hasView` is set:
* the data to use in createView.
* @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss`
* is set: provide data that is passed to discuss widget (= client action) as
* 2nd positional argument.
* @param {Object} [param0.env={}]
* @param {function} [param0.mockRPC]
* @param {boolean} [param0.hasActionManager=false] if set, use
* createActionManager.
* @param {boolean} [param0.hasChatWindow=false] if set, mount chat window
* service.
* @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app.
* @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging
* menu.
* @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
* with `env.browser.setTimeout` are fully controlled by test itself.
* @see addTimeControlToEnv that adds `advanceTime` function in
* `env.testUtils`.
* @param {boolean} [param0.hasView=false] if set, use createView to create a
* view instead of a generic widget.
* @param {string} [param0.model] makes only sense when `param0.hasView` is set:
* the model to use in createView.
* @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set:
* the res_id to use in createView.
* @param {Object} [param0.services]
* @param {Object} [param0.session]
* @param {Object} [param0.View] makes only sense when `param0.hasView` is set:
* the View class to use in createView.
* @param {Object} [param0.viewOptions] makes only sense when `param0.hasView`
* is set: the view options to use in createView.
* @param {boolean} [param0.waitUntilMessagingInitialized=true]
* @param {...Object} [param0.kwargs]
* @returns {Object}
*/
async function start(param0 = {}) {
let callbacks = {
init: [],
mount: [],
destroy: [],
return: [],
};
const {
hasActionManager = false,
hasChatWindow = false,
hasDialog = false,
hasDiscuss = false,
hasMessagingMenu = false,
hasTimeControl = false,
hasView = false,
waitUntilMessagingInitialized = true,
} = param0;
delete param0.hasActionManager;
delete param0.hasChatWindow;
delete param0.hasDiscuss;
delete param0.hasMessagingMenu;
delete param0.hasTimeControl;
delete param0.hasView;
if (hasChatWindow) {
callbacks = _useChatWindow(callbacks);
}
if (hasDialog) {
callbacks = _useDialog(callbacks);
}
if (hasDiscuss) {
callbacks = _useDiscuss(callbacks);
}
if (hasMessagingMenu) {
callbacks = _useMessagingMenu(callbacks);
}
const {
init: initCallbacks,
mount: mountCallbacks,
destroy: destroyCallbacks,
return: returnCallbacks,
} = callbacks;
const { debug = false } = param0;
initCallbacks.forEach(callback => callback(param0));
let env = addMessagingToEnv(param0.env, { debug });
if (hasTimeControl) {
env = addTimeControlToEnv(env);
}
const services = Object.assign({}, {
bus_service: BusService.extend({
_beep() {}, // Do nothing
_poll() {}, // Do nothing
_registerWindowUnload() {}, // Do nothing
isOdooFocused() {
return true;
},
updateOption() {},
}),
chat_window: ChatWindowService.extend({
_getParentNode() {
return document.querySelector(debug ? 'body' : '#qunit-fixture');
},
_listenHomeMenu: () => {},
}),
dialog: DialogService.extend({
_getParentNode() {
return document.querySelector(debug ? 'body' : '#qunit-fixture');
},
_listenHomeMenu: () => {},
}),
local_storage: AbstractStorageService.extend({ storage: new RamStorage() }),
notification: NotificationService.extend(),
}, param0.services);
const kwargs = Object.assign({}, param0, {
archs: Object.assign({}, {
'mail.message,false,search': ''
}, param0.archs),
debug: param0.debug || false,
services: Object.assign({}, services, param0.services),
}, { env });
let widget;
let mockServer; // only in basic mode
let testEnv;
const selector = debug ? 'body' : '#qunit-fixture';
if (hasView) {
widget = await createView(kwargs);
legacyPatch(widget, {
destroy() {
this._super(...arguments);
destroyCallbacks.forEach(callback => callback({ widget }));
legacyUnpatch(widget);
if (testEnv) {
testEnv.destroyMessaging();
}
}
});
} else if (hasActionManager) {
widget = await createActionManager(kwargs);
legacyPatch(widget, {
destroy() {
this._super(...arguments);
destroyCallbacks.forEach(callback => callback({ widget }));
legacyUnpatch(widget);
if (testEnv) {
testEnv.destroyMessaging();
}
}
});
} else {
const Parent = Widget.extend({ do_push_state() {} });
const parent = new Parent();
mockServer = await addMockEnvironment(parent, kwargs);
widget = new Widget(parent);
await widget.appendTo($(selector));
Object.assign(widget, {
destroy() {
delete widget.destroy;
destroyCallbacks.forEach(callback => callback({ widget }));
parent.destroy();
if (testEnv) {
testEnv.destroyMessaging();
}
},
});
}
testEnv = Component.env;
/**
* Components cannot use web.bus, because they cannot use
* EventDispatcherMixin, and webclient cannot easily access env.
* Communication between webclient and components by core.bus
* (usable by webclient) and messagingBus (usable by components), which
* the messaging service acts as mediator since it can easily use both
* kinds of buses.
*/
testEnv.bus.on(
'hide_home_menu',
null,
() => testEnv.messagingBus.trigger('hide_home_menu')
);
testEnv.bus.on(
'show_home_menu',
null,
() => testEnv.messagingBus.trigger('show_home_menu')
);
testEnv.bus.on(
'will_hide_home_menu',
null,
() => testEnv.messagingBus.trigger('will_hide_home_menu')
);
testEnv.bus.on(
'will_show_home_menu',
null,
() => testEnv.messagingBus.trigger('will_show_home_menu')
);
testEnv.modelManager.start(testEnv);
/**
* Create the messaging singleton record.
*/
testEnv.messaging = testEnv.models['mail.messaging'].create();
testEnv.messaging.start().then(() =>
testEnv.messagingInitializedDeferred.resolve()
);
const result = { env: testEnv, mockServer, widget };
if (waitUntilMessagingInitialized) {
// env key only accessible after MessagingService has started
await testEnv.messagingInitializedDeferred;
}
if (mountCallbacks.length > 0) {
await afterNextRender(async () => {
await Promise.all(mountCallbacks.map(callback => callback({ selector, widget })));
});
}
returnCallbacks.forEach(callback => callback(result));
return result;
}
//------------------------------------------------------------------------------
// Public: file utilities
//------------------------------------------------------------------------------
/**
* 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);
}
/**
* Set files in a file input
*
* @param {DOM.Element} el
* @param {Object[]} files must have been created beforehand
* @see testUtils.file.createFile
*/
function inputFiles(el, files) {
const dataTransfer = new window.DataTransfer();
for (const file of files) {
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'));
}
}
/**
* 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);
}
//------------------------------------------------------------------------------
// Export
//------------------------------------------------------------------------------
return {
afterEach,
afterNextRender,
beforeEach,
dragenterFiles,
dropFiles,
inputFiles,
nextAnimationFrame,
nextTick,
pasteFiles,
start,
};
});