Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/controllers/discuss', {
/**
* @override
*/
async _performRPC(route, args) {
if (route === '/mail/channel/notify_typing') {
const id = args.channel_id;
const is_typing = args.is_typing;
const context = args.context;
return this._mockRouteMailChannelNotifyTyping(id, is_typing, context);
}
if (route === '/mail/channel/ping') {
return;
}
if (route === '/mail/rtc/channel/join_call') {
return this._mockRouteMailRtcChannelJoinCall(args.channel_id, args.check_rtc_session_ids);
}
if (route === '/mail/rtc/channel/leave_call') {
return this._mockRouteMailRtcChannelLeaveCall(args.channel_id);
}
if (route === '/mail/rtc/session/update_and_broadcast') {
return;
}
return this._super(route, args);
},
/**
* Simulates the `/mail/channel/notify_typing` route.
*
* @private
* @param {integer} channel_id
* @param {integer} limit
* @param {Object} [context={}]
*/
async _mockRouteMailChannelNotifyTyping(channel_id, is_typing, context = {}) {
const partnerId = context.mockedPartnerId || this.currentPartnerId;
const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id], ['partner_id', '=', partnerId]]);
if (!memberOfCurrentUser) {
return;
}
this._mockMailChannelMember_NotifyTyping([memberOfCurrentUser.id], is_typing);
},
/**
* Simulates the `/mail/rtc/channel/join_call` route.
*
* @private
* @param {integer} channel_id
* @returns {integer[]} [check_rtc_session_ids]
*/
async _mockRouteMailRtcChannelJoinCall(channel_id, check_rtc_session_ids = []) {
const [currentChannelMember] = this.getRecords('mail.channel.member', [
['channel_id', '=', channel_id],
['partner_id', '=', this.currentPartnerId],
]);
const sessionId = this.pyEnv['mail.channel.rtc.session'].create({
channel_member_id: currentChannelMember.id,
channel_id, // on the server, this is a related field from channel_member_id and not explicitly set
});
const channelMembers = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id]]);
const rtcSessions = this.getRecords('mail.channel.rtc.session', [
['channel_member_id', 'in', channelMembers.map(channelMember => channelMember.id)],
]);
return {
'iceServers': false,
'rtcSessions': [
['insert', rtcSessions.map(rtcSession => this._mockMailChannelRtcSession_MailChannelRtcSessionFormat(rtcSession.id))],
],
'sessionId': sessionId,
};
},
/**
* Simulates the `/mail/rtc/channel/leave_call` route.
*
* @private
* @param {integer} channelId
*/
async _mockRouteMailRtcChannelLeaveCall(channel_id) {
const channelMembers = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id]]);
const rtcSessions = this.getRecords('mail.channel.rtc.session', [
['channel_member_id', 'in', channelMembers.map(channelMember => channelMember.id)],
]);
const notifications = [];
const channelInfo = this._mockMailChannelRtcSession_MailChannelRtcSessionFormatByChannel(rtcSessions.map(rtcSession => rtcSession.id));
for (const [channelId, sessionsData] of Object.entries(channelInfo)) {
const notificationRtcSessions = sessionsData.map((sessionsDataPoint) => {
return { 'id': sessionsDataPoint.id };
});
notifications.push([
channelId,
'mail.channel/rtc_sessions_update',
{
'id': Number(channelId), // JS object keys are strings, but the type from the server is number
'rtcSessions': [['insert-and-unlink', notificationRtcSessions]],
}
]);
}
for (const rtcSession of rtcSessions) {
const target = rtcSession.guest_id || rtcSession.partner_id;
notifications.push([
target,
'mail.channel.rtc.session/ended',
{ 'sessionId': rtcSession.id },
]);
}
this.pyEnv['bus.bus']._sendmany(notifications);
},
});

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
// ensure bus override is applied first.
import "@bus/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/models/ir_websocket', {
/**
* Simulates `_get_im_status` on `ir.websocket`.
*
* @param {Object} imStatusIdsByModel
* @param {Number[]|undefined} mail.guest ids of mail.guest whose im_status
* should be monitored.
*/
_mockIrWebsocket__getImStatus(imStatusIdsByModel) {
const imStatus = this._super(imStatusIdsByModel);
const { 'mail.guest': guestIds } = imStatusIdsByModel;
if (guestIds) {
imStatus['guests'] = this.pyEnv['mail.guest'].searchRead([['id', 'in', guestIds]], { context: { 'active_test': false }, fields: ['im_status'] });
}
return imStatus;
},
});

View file

@ -0,0 +1,73 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/models/mail_channel_member', {
/**
* Simulates `notify_typing` on `mail.channel.member`.
*
* @private
* @param {integer[]} ids
* @param {boolean} is_typing
*/
_mockMailChannelMember_NotifyTyping(ids, is_typing) {
const members = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
const notifications = [];
for (const member of members) {
const [channel] = this.getRecords('mail.channel', [['id', '=', member.channel_id]]);
const [data] = this._mockMailChannelMember_MailChannelMemberFormat([member.id]);
Object.assign(data, {
'isTyping': is_typing,
});
notifications.push([channel, 'mail.channel.member/typing_status', data]);
notifications.push([channel.uuid, 'mail.channel.member/typing_status', data]);
}
this.pyEnv['bus.bus']._sendmany(notifications);
},
/**
* Simulates `_mail_channel_member_format` on `mail.channel.member`.
*
* @private
* @param {integer[]} ids
* @returns {Object[]}
*/
_mockMailChannelMember_MailChannelMemberFormat(ids) {
const members = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
const dataList = [];
for (const member of members) {
let persona;
if (member.partner_id) {
persona = { 'partner': this._mockMailChannelMember_GetPartnerData([member.id]) };
}
if (member.guest_id) {
const [guest] = this.getRecords('mail.guest', [['id', '=', member.guest_id]]);
persona = {
'guest': {
'id': guest.id,
'im_status': guest.im_status,
'name': guest.name,
},
};
}
const data = {
'channel': { 'id': member.channel_id },
'id': member.id,
'persona': persona,
};
dataList.push(data);
}
return dataList;
},
/**
* Simulates `_get_partner_data` on `mail.channel.member`.
*
* @private
* @param {integer[]} ids
* @returns {Object}
*/
_mockMailChannelMember_GetPartnerData(ids) {
const [member] = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
return this._mockResPartnerMailPartnerFormat([member.partner_id]).get(member.partner_id);
},
});

View file

@ -0,0 +1,43 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/models/mail_channel_rtc_session', {
/**
* Simulates `_mail_rtc_session_format` on `mail.channel.rtc.session`.
*
* @private
* @param {integer} id
* @returns {Object}
*/
_mockMailChannelRtcSession_MailChannelRtcSessionFormat(id) {
const [rtcSession] = this.getRecords('mail.channel.rtc.session', [['id', '=', id]]);
return {
'id': rtcSession.id,
'channelMember': this._mockMailChannelMember_MailChannelMemberFormat([rtcSession.channel_member_id])[0],
'isCameraOn': rtcSession.is_camera_on,
'isDeaf': rtcSession.is_deaf,
'isSelfMuted': rtcSession.is_self_muted,
'isScreenSharingOn': rtcSession.is_screen_sharing_on,
};
},
/**
* Simulates `_mail_rtc_session_format` on `mail.channel.rtc.session`.
*
* @private
* @param {integer[]} ids
* @returns {Object}
*/
_mockMailChannelRtcSession_MailChannelRtcSessionFormatByChannel(ids) {
const rtcSessions = this.getRecords('mail.channel.rtc.session', [['id', 'in', ids]]);
const data = {};
for (const rtcSession of rtcSessions) {
if (!data[rtcSession.channel_id]) {
data[rtcSession.channel_id] = [];
}
data[rtcSession.channel_id].push(this._mockMailChannelRtcSession_MailChannelRtcSessionFormat(rtcSession.id));
}
return data;
}
});

View file

@ -0,0 +1,104 @@
/** @odoo-module **/
import { TEST_GROUP_IDS, TEST_USER_IDS } from '@bus/../tests/helpers/test_constants';
import {
addFakeModel,
addModelNamesToFetch,
insertModelFields,
insertRecords
} from '@bus/../tests/helpers/model_definitions_helpers';
//--------------------------------------------------------------------------
// Models
//--------------------------------------------------------------------------
addModelNamesToFetch([
'mail.activity', 'mail.activity.type', 'mail.channel', 'mail.channel.member',
'mail.channel.rtc.session', 'mail.followers', 'mail.guest', 'mail.link.preview', 'mail.message',
'mail.message.subtype', 'mail.notification', 'mail.shortcode', 'mail.template',
'mail.tracking.value', 'res.users.settings', 'res.users.settings.volumes'
]);
addFakeModel('res.fake', {
message_ids: { string: 'Messages', type: 'one2many', relation: 'mail.message' },
activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' },
email_cc: { type: 'char' },
partner_ids: { relation: 'res.partner', string: "Related partners", type: 'one2many' },
});
addFakeModel('m2x.avatar.user', {
user_id: { type: 'many2one', relation: 'res.users' },
user_ids: { type: 'many2many', relation: 'res.users' },
});
//--------------------------------------------------------------------------
// Insertion of fields
//--------------------------------------------------------------------------
insertModelFields('mail.activity', {
chaining_type: { default: 'suggest' },
});
insertModelFields('mail.channel', {
author_id: {
default() {
return this.currentPartnerId;
},
},
avatarCacheKey: { string: "Avatar Cache Key", type: "datetime" },
channel_member_ids: {
default() {
return [[0, 0, { partner_id: this.currentPartnerId }]];
},
},
channel_type: { default: 'channel' },
group_based_subscription: { string: "Group based subscription", type: "boolean" },
group_public_id: {
default() {
return TEST_GROUP_IDS.groupUserId;
},
},
uuid: { default: () => _.uniqueId('mail.channel_uuid-') },
});
insertModelFields('mail.channel.member', {
fold_state: { default: 'open' },
is_pinned: { default: true },
message_unread_counter: { default: 0 },
});
insertModelFields('mail.message', {
author_id: { default: TEST_USER_IDS.currentPartnerId },
history_partner_ids: { relation: 'res.partner', string: "Partners with History", type: 'many2many' },
is_discussion: { string: 'Discussion', type: 'boolean' },
is_note: { string: "Discussion", type: 'boolean' },
is_notification: { string: "Note", type: 'boolean' },
needaction_partner_ids: { relation: 'res.partner', string: "Partners with Need Action", type: 'many2many' },
res_model_name: { string: "Res Model Name", type: 'char' },
});
insertModelFields('mail.message.subtype', {
subtype_xmlid: { type: 'char' },
});
insertModelFields('mail.tracking.value', {
changed_field: { string: 'Changed field', type: 'char' },
new_value: { string: 'New value', type: 'char' },
old_value: { string: 'Old value', type: 'char' },
});
insertModelFields('res.users.settings', {
is_discuss_sidebar_category_channel_open: { default: true },
is_discuss_sidebar_category_chat_open: { default: true },
});
//--------------------------------------------------------------------------
// Insertion of records
//--------------------------------------------------------------------------
insertRecords('mail.activity.type', [
{ icon: 'fa-envelope', id: 1, name: "Email" },
{ icon: 'fa-upload', id: 28, name: "Upload Document" },
]);
insertRecords('mail.message.subtype', [
{ default: false, internal: true, name: "Activities", sequence: 90, subtype_xmlid: 'mail.mt_activities' },
{
default: false, internal: true, name: "Note", sequence: 100, subtype_xmlid: 'mail.mt_note',
track_recipients: true
},
{ name: "Discussions", sequence: 0, subtype_xmlid: 'mail.mt_comment', track_recipients: true },
]);

View file

@ -0,0 +1,106 @@
/** @odoo-module **/
import { browser } from '@web/core/browser/browser';
import { MEDIAS_BREAKPOINTS, SIZES, uiService } from '@web/core/ui/ui_service';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import config from 'web.config';
/**
* Return the width corresponding to the given size. If an upper and lower bound
* are defined, returns the lower bound: this is an arbitrary choice that should
* not impact anything. A test should pass the `width` parameter instead of `size`
* if it needs a specific width to be set.
*
* @param {number} size
* @returns {number} The width corresponding to the given size.
*/
function getWidthFromSize(size) {
const { minWidth, maxWidth } = MEDIAS_BREAKPOINTS[size];
return minWidth ? minWidth : maxWidth;
}
/**
* Return the size corresponding to the given width.
*
* @param {number} width
* @returns {number} The size corresponding to the given width.
*/
function getSizeFromWidth(width) {
return MEDIAS_BREAKPOINTS.findIndex(({ minWidth, maxWidth }) => {
if (!maxWidth) {
return width >= minWidth;
}
if (!minWidth) {
return width <= maxWidth;
}
return width >= minWidth && width <= maxWidth;
});
}
/**
* Patch legacy objects referring to the ui size. This function must be removed
* when the wowl env will be available in the form_renderer (currently the form
* renderer relies on config). This will impact env.browser.innerWidth,
* env.device.isMobile and config.device.{size_class/isMobile}.
*
* @param {number} size
* @param {number} width
*/
function legacyPatchUiSize(height, size, width) {
const legacyEnv = owl.Component.env;
patchWithCleanup(legacyEnv, {
browser: {
...legacyEnv.browser,
innerWidth: width,
innerHeight: height || browser.innerHeight,
},
device: {
...legacyEnv.device,
isMobile: size <= SIZES.SM,
}
});
patchWithCleanup(config, {
device: {
...config.device,
size_class: size,
isMobile: size <= SIZES.SM,
},
});
}
/**
* Adjust ui size either from given size (mapped to window breakpoints) or
* width. This will impact not only config.device.{size_class/isMobile} but
* uiService.{isSmall/size}, (wowl/legacy) browser.innerWidth, (wowl)
* env.isSmall and (legacy) env.device.isMobile. When a size is given, the browser
* width is set according to the breakpoints that are used by the webClient.
*
* @param {Object} params parameters to configure the ui size.
* @param {number|undefined} [params.size]
* @param {number|undefined} [params.width]
* @param {number|undefined} [params.height]
*/
function patchUiSize({ height, size, width }) {
if (!size && !width || size && width) {
throw new Error('Either size or width must be given to the patchUiSize function');
}
size = size === undefined ? getSizeFromWidth(width) : size;
width = width || getWidthFromSize(size);
patchWithCleanup(browser, {
innerWidth: width,
innerHeight: height || browser.innerHeight,
});
patchWithCleanup(uiService, {
getSize() {
return size;
},
});
legacyPatchUiSize(height, size, width);
}
export {
patchUiSize,
SIZES
};

View file

@ -0,0 +1,398 @@
/** @odoo-module **/
import { getPyEnv, startServer } from '@bus/../tests/helpers/mock_python_environment';
import { nextTick } from '@mail/utils/utils';
import { getAdvanceTime } from '@mail/../tests/helpers/time_control';
import { getWebClientReady } from '@mail/../tests/helpers/webclient_setup';
import { wowlServicesSymbol } from "@web/legacy/utils";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { getFixture, makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils";
import { doAction, getActionManagerServerData } from "@web/../tests/webclient/helpers";
const { App, EventBus } = owl;
const { afterNextRender } = App;
//------------------------------------------------------------------------------
// 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()));
});
}
//------------------------------------------------------------------------------
// 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({ afterNextRender }) {
return async function click(selector) {
await afterNextRender(() => {
if (typeof selector === "string") {
$(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(afterEvent, webClient, { context = {}, params, ...props } = {}) {
return async function openDiscuss({ waitUntilMessagesLoaded = true } = {}) {
const actionOpenDiscuss = {
// hardcoded actionId, required for discuss_container props validation.
id: 104,
context,
params,
tag: 'mail.action_discuss',
type: 'ir.actions.client',
};
if (waitUntilMessagesLoaded) {
let threadId = context.active_id;
if (typeof threadId === 'string') {
threadId = parseInt(threadId.split('_')[1]);
}
return afterNextRender(() => afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => doAction(webClient, actionOpenDiscuss, { props }),
message: "should wait until discuss loaded its messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
(!threadId || threadViewer.thread.id === threadId)
);
},
}));
}
return afterNextRender(() => doAction(webClient, actionOpenDiscuss, { props }));
};
}
function getOpenFormView(afterEvent, openView) {
return async function openFormView(action, { props, waitUntilDataLoaded = true, waitUntilMessagesLoaded = true } = {}) {
action['views'] = [[false, 'form']];
const func = () => openView(action, props);
const waitData = func => afterNextRender(() => afterEvent({
eventName: 'o-thread-loaded-data',
func,
message: "should wait until chatter loaded its data",
predicate: ({ thread }) => {
return (
thread.model === action.res_model &&
thread.id === action.res_id
);
},
}));
const waitMessages = func => afterNextRender(() => afterEvent({
eventName: 'o-thread-loaded-messages',
func,
message: "should wait until chatter loaded its messages",
predicate: ({ thread }) => {
return (
thread.model === action.res_model &&
thread.id === action.res_id
);
},
}));
if (waitUntilDataLoaded && waitUntilMessagesLoaded) {
return waitData(() => waitMessages(func));
}
if (waitUntilDataLoaded) {
return waitData(func);
}
if (waitUntilMessagesLoaded) {
return waitMessages(func);
}
return func();
};
}
//------------------------------------------------------------------------------
// Public: start function helpers
//------------------------------------------------------------------------------
/**
* Main function used to make a mocked environment with mocked messaging env.
*
* @param {Object} [param0={}]
* @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]
* @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
* Deferred that let tests block messaging creation and simulate resolution.
* Useful for testing working components when messaging is not yet created.
* @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines
* the condition of messaging when this function is resolved.
* Supported values: ['none', 'created', 'initialized'].
* - 'none': the function resolves regardless of whether messaging is created.
* - 'created': the function resolves when messaging is created, but
* regardless of whether messaging is initialized.
* - 'initialized' (default): the function resolves when messaging is
* initialized.
* To guarantee messaging is not created, test should pass a pending deferred
* as param of `messagingBeforeCreationDeferred`. To make sure messaging is
* not initialized, test should mock RPC `mail/init_messaging` and block its
* resolution.
* @throws {Error} in case some provided parameters are wrong, such as
* `waitUntilMessagingCondition`.
* @returns {Object}
*/
async function start(param0 = {}) {
// patch _.debounce and _.throttle to be fast and synchronous.
patchWithCleanup(_, {
debounce: func => func,
throttle: func => func,
});
const {
discuss = {},
hasTimeControl,
waitUntilMessagingCondition = 'initialized',
} = param0;
const advanceTime = hasTimeControl ? getAdvanceTime() : undefined;
const target = param0['target'] || getFixture();
param0['target'] = target;
if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) {
throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`);
}
const messagingBus = new EventBus();
const afterEvent = getAfterEvent({ messagingBus });
const pyEnv = await getPyEnv();
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 (waitUntilMessagingCondition === 'created') {
await webClient.env.services.messaging.modelManager.messagingCreatedPromise;
}
if (waitUntilMessagingCondition === 'initialized') {
await webClient.env.services.messaging.modelManager.messagingCreatedPromise;
await webClient.env.services.messaging.modelManager.messagingInitializedPromise;
}
});
registerCleanup(async () => {
await webClient.env.services.messaging.modelManager.messagingInitializedPromise;
webClient.env.services.messaging.modelManager.destroy();
delete webClient.env.services.messaging;
delete owl.Component.env.services.messaging;
delete owl.Component.env[wowlServicesSymbol].messaging;
delete owl.Component.env;
});
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({ afterNextRender }),
env: webClient.env,
insertText,
messaging: webClient.env.services.messaging.modelManager.messaging,
mouseenter: getMouseenter({ afterNextRender }),
openDiscuss: getOpenDiscuss(afterEvent, webClient, discuss),
openView,
openFormView: getOpenFormView(afterEvent, openView),
pyEnv,
webClient,
};
}
//------------------------------------------------------------------------------
// 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);
}
/**
* 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
*/
async function insertText(selector, content) {
await afterNextRender(() => {
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;
}
//------------------------------------------------------------------------------
// Export
//------------------------------------------------------------------------------
export {
afterNextRender,
dragenterFiles,
dropFiles,
insertText,
isScrolledToBottom,
nextAnimationFrame,
nextTick,
pasteFiles,
start,
startServer,
};

View file

@ -0,0 +1,54 @@
/** @odoo-module **/
import { nextTick } from '@mail/utils/utils';
import { browser } from '@web/core/browser/browser';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
export function getAdvanceTime() {
// list of timeout ids that have timed out.
let timedOutIds = [];
// key: timeoutId, value: func + remaining duration
const timeouts = new Map();
patchWithCleanup(browser, {
clearTimeout: id => {
timeouts.delete(id);
timedOutIds = timedOutIds.filter(i => i !== id);
},
setTimeout: (func, duration) => {
const timeoutId = _.uniqueId('timeout_');
const timeout = {
id: timeoutId,
isTimedOut: false,
func,
duration,
};
timeouts.set(timeoutId, timeout);
if (duration === 0) {
timedOutIds.push(timeoutId);
timeout.isTimedOut = true;
}
return timeoutId;
},
});
return async function (duration) {
await nextTick();
for (const id of timeouts.keys()) {
const timeout = timeouts.get(id);
if (timeout.isTimedOut) {
continue;
}
timeout.duration = Math.max(timeout.duration - duration, 0);
if (timeout.duration === 0) {
timedOutIds.push(id);
}
}
while (timedOutIds.length > 0) {
const id = timedOutIds.shift();
const timeout = timeouts.get(id);
timeouts.delete(id);
timeout.func();
await nextTick();
}
};
}

View file

@ -0,0 +1,144 @@
/** @odoo-module **/
import { busService } from '@bus/services/bus_service';
import { imStatusService } from '@bus/im_status_service';
import { multiTabService } from '@bus/multi_tab_service';
import { makeMultiTabToLegacyEnv } from '@bus/services/legacy/make_multi_tab_to_legacy_env';
import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env';
import { makeFakePresenceService } from '@bus/../tests/helpers/mock_services';
import { ChatWindowManagerContainer } from '@mail/components/chat_window_manager_container/chat_window_manager_container';
import { DialogManagerContainer } from '@mail/components/dialog_manager_container/dialog_manager_container';
import { DiscussContainer } from '@mail/components/discuss_container/discuss_container';
import { PopoverManagerContainer } from '@mail/components/popover_manager_container/popover_manager_container';
import { messagingService } from '@mail/services/messaging_service';
import { systrayService } from '@mail/services/systray_service';
import { makeMessagingToLegacyEnv } from '@mail/utils/make_messaging_to_legacy_env';
import { registry } from '@web/core/registry';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { createWebClient } from "@web/../tests/webclient/helpers";
const ROUTES_TO_IGNORE = [
'/web/webclient/load_menus',
'/web/dataset/call_kw/res.users/load_views',
'/web/dataset/call_kw/res.users/systray_get_activities'
];
const WEBCLIENT_PARAMETER_NAMES = new Set(['legacyParams', 'mockRPC', 'serverData', 'target', 'webClientClass']);
const SERVICES_PARAMETER_NAMES = new Set([
'legacyServices', 'loadingBaseDelayDuration', 'messagingBeforeCreationDeferred',
'messagingBus', 'services',
]);
/**
* Add required components to the main component registry.
*/
function setupMainComponentRegistry() {
const mainComponentRegistry = registry.category('main_components');
mainComponentRegistry.add('ChatWindowManagerContainer', { Component: ChatWindowManagerContainer });
mainComponentRegistry.add('DialogManagerContainer', { Component: DialogManagerContainer });
registry.category('actions').add('mail.action_discuss', DiscussContainer);
mainComponentRegistry.add('PopoverManagerContainer', { Component: PopoverManagerContainer });
}
/**
* Setup both legacy and new service registries.
*
* @param {Object} param0
* @param {Object} [param0.services]
* @param {number} [param0.loadingBaseDelayDuration=0]
* @param {Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
* Deferred that let tests block messaging creation and simulate resolution.
* Useful for testing components behavior when messaging is not yet created.
* @param {EventBus} [param0.messagingBus]
* @returns {LegacyRegistry} The registry containing all the legacy services that will be passed
* to the webClient as a legacy parameter.
*/
function setupMessagingServiceRegistries({
loadingBaseDelayDuration = 0,
messagingBeforeCreationDeferred = Promise.resolve(),
messagingBus,
services,
}) {
const serviceRegistry = registry.category('services');
patchWithCleanup(messagingService, {
async _startModelManager(modelManager, messagingValues) {
modelManager.isDebug = true;
const _super = this._super.bind(this);
await messagingBeforeCreationDeferred;
return _super(modelManager, messagingValues);
},
});
const messagingValues = {
start() {
return {
isInQUnitTest: true,
disableAnimation: true,
loadingBaseDelayDuration,
messagingBus,
userNotificationManager: { canPlayAudio: false },
};
}
};
services = {
bus_service: busService,
im_status: imStatusService,
messaging: messagingService,
messagingValues,
presence: makeFakePresenceService({
isOdooFocused: () => true,
}),
systrayService,
multi_tab: multiTabService,
...services,
};
Object.entries(services).forEach(([serviceName, service]) => {
serviceRegistry.add(serviceName, service);
});
registry.category('wowlToLegacyServiceMappers').add('bus_service_to_legacy_env', makeBusServiceToLegacyEnv);
registry.category('wowlToLegacyServiceMappers').add('multi_tab_to_legacy_env', makeMultiTabToLegacyEnv);
registry.category('wowlToLegacyServiceMappers').add('messaging_service_to_legacy_env', makeMessagingToLegacyEnv);
}
/**
* Creates a properly configured instance of WebClient, with the messaging service and all it's
* dependencies initialized.
*
* @param {Object} param0
* @param {Object} [param0.serverData]
* @param {Object} [param0.services]
* @param {Object} [param0.loadingBaseDelayDuration]
* @param {Object} [param0.messagingBeforeCreationDeferred]
* @param {EventBus} [param0.messagingBus] The event bus to be used by messaging.
* @returns {WebClient}
*/
async function getWebClientReady(param0) {
setupMainComponentRegistry();
const servicesParameters = {};
const param0Entries = Object.entries(param0);
for (const [parameterName, value] of param0Entries) {
if (SERVICES_PARAMETER_NAMES.has(parameterName)) {
servicesParameters[parameterName] = value;
}
}
setupMessagingServiceRegistries(servicesParameters);
const webClientParameters = {};
for (const [parameterName, value] of param0Entries) {
if (WEBCLIENT_PARAMETER_NAMES.has(parameterName)) {
webClientParameters[parameterName] = value;
}
}
return createWebClient(webClientParameters);
}
export {
getWebClientReady,
ROUTES_TO_IGNORE,
};