mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-19 07:12:08 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
2586
odoo-bringout-oca-ocb-mail/mail/static/tests/helpers/mock_server.js
Normal file
2586
odoo-bringout-oca-ocb-mail/mail/static/tests/helpers/mock_server.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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 },
|
||||
]);
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue