[IMP] *: adapt client code to use websocket instead of longpolling
*: bus, calendar, hr_attendance, iap_mail, im_livechat, project, snailmail,
snailmail_account, survey, web, web_editor, website_livechat.
This commit is part of the websocket integration in Odoo.
This bus service now communicates with a shared worker in order to provide
a single websocket connection for multiple tabs. It is designed to
be used as a websocket except that events are slightly different,
re-connection is handled automatically. If the browser does not support shared
worker (Safari), the service fallback on a simple web worker.
Available events are:
- connect : fired upon a successful connection.
- disconnect : fired upon reception of the websocket close event.
The close code and reason are given to the listeners callback.
- reconnect : fired upon a successful re-connection.
- reconnecting : triggered when the worker starts to try reconnecting.
- notification : fired upon the reception of notifications.
Since multiple tabs are now handled by a worker, the cross_tab bus is no longer
required and has been removed.
Part-of: odoo/odoo#75510
This commit is contained in:
committed by
tsm-odoo
parent
de6de48deb
commit
fe699a241d
@@ -9,11 +9,11 @@
|
||||
],
|
||||
'installable': True,
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'bus/static/src/**/*',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'bus/static/src/**/*.js',
|
||||
'web.assets_common': [
|
||||
'bus/static/src/*.js',
|
||||
'bus/static/src/services/**/*.js',
|
||||
'bus/static/src/workers/websocket_worker.js',
|
||||
'bus/static/src/workers/websocket_worker_utils.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'bus/static/tests/**/*.js',
|
||||
@@ -21,6 +21,11 @@
|
||||
'web.qunit_mobile_suite_tests': [
|
||||
'bus/static/tests/helpers/*.js',
|
||||
],
|
||||
'bus.websocket_worker_assets': [
|
||||
'web/static/src/legacy/js/promise_extension.js',
|
||||
'web/static/src/boot.js',
|
||||
'bus/static/src/workers/*',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
|
||||
from odoo.http import Controller, request, route
|
||||
from odoo.addons.base.models.assetsbundle import AssetsBundle
|
||||
from ..models.bus import channel_with_db
|
||||
from ..websocket import WebsocketConnectionHandler
|
||||
|
||||
@@ -37,3 +38,11 @@ class WebsocketController(Controller):
|
||||
@route('/websocket/update_bus_presence', type='http', auth='public', cors='*')
|
||||
def update_bus_presence(self, inactivity_period):
|
||||
request.env['ir.websocket']._update_bus_presence(inactivity_period)
|
||||
|
||||
@route('/bus/websocket_worker_bundle', type='http', auth='public', cors='*')
|
||||
def get_websocket_worker_bundle(self):
|
||||
bundle = 'bus.websocket_worker_assets'
|
||||
files, _ = request.env["ir.qweb"]._get_asset_content(bundle)
|
||||
asset = AssetsBundle(bundle, files)
|
||||
stream = request.env['ir.binary']._get_stream_from(asset.js())
|
||||
return stream.get_response()
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from '@web/core/browser/browser';
|
||||
|
||||
import { Longpolling } from '@bus/longpolling_bus';
|
||||
|
||||
/**
|
||||
* CrossTab
|
||||
*
|
||||
* This is an extension of the longpolling bus with browser cross-tab synchronization.
|
||||
* It uses the multi_tab service.
|
||||
* - only the main tab handles longpolling.
|
||||
* - tabs are synchronized by means of the multi_tab service.
|
||||
*
|
||||
* multi_tab shared values are:
|
||||
* - channels : shared public channel list to listen during the poll
|
||||
* - options : shared options
|
||||
* - notification : the received notifications from the last poll
|
||||
*
|
||||
* trigger:
|
||||
* - notification : when a notification is receive from the long polling
|
||||
*/
|
||||
export class CrossTab extends Longpolling {
|
||||
constructor(env, services) {
|
||||
super(env, services);
|
||||
|
||||
// properties
|
||||
this._isRegistered = false;
|
||||
|
||||
var now = new Date().getTime();
|
||||
this._currentTabChannels = new Set();
|
||||
if (this.env.services['multi_tab'].getSharedValue('last_ts', 0) + 50000 < now) {
|
||||
this.env.services['multi_tab'].removeSharedValue('last');
|
||||
}
|
||||
this._lastNotificationID = this.env.services['multi_tab'].getSharedValue('last', 0);
|
||||
|
||||
const onSharedValueUpdated = (event) => this._onSharedValueUpdated(event);
|
||||
const stopPolling = () => this.stopPolling();
|
||||
const startPolling = () => this.startPolling();
|
||||
services['multi_tab'].bus.addEventListener('shared_value_updated', onSharedValueUpdated);
|
||||
services['multi_tab'].bus.addEventListener('no_longer_main_tab', stopPolling);
|
||||
services['multi_tab'].bus.addEventListener('become_main_tab', startPolling);
|
||||
|
||||
browser.addEventListener('unload', () => {
|
||||
services['multi_tab'].bus.removeEventListener('shared_value_updated', onSharedValueUpdated);
|
||||
services['multi_tab'].bus.removeEventListener('no_longer_main_tab', stopPolling);
|
||||
services['multi_tab'].bus.removeEventListener('become_main_tab', startPolling);
|
||||
this._currentTabChannels.clear();
|
||||
this._updateChannels();
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Share the bus channels with the others tab by the local storage
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
addChannel(channel) {
|
||||
this._currentTabChannels.add(channel);
|
||||
super.addChannel(channel);
|
||||
this._updateChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the bus channels with the others tab by the local storage
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
deleteChannel(channel) {
|
||||
this._currentTabChannels.delete(channel);
|
||||
super.deleteChannel(channel);
|
||||
this._updateChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the local storage to share the long polling from the master tab.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
startPolling() {
|
||||
if (!this._isRegistered) {
|
||||
this._isRegistered = true;
|
||||
|
||||
if (this.env.services['multi_tab'].isOnMainTab()) {
|
||||
this.env.services['multi_tab'].setSharedValue('options', this._options);
|
||||
} else {
|
||||
this._options = this.env.services['multi_tab'].getSharedValue('options', this._options);
|
||||
}
|
||||
this._updateChannels();
|
||||
}
|
||||
|
||||
if (this.env.services['multi_tab'].isOnMainTab()) {
|
||||
super.startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the option with the local storage
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
updateOption(key, value) {
|
||||
super.updateOption(key, value);
|
||||
this.env.services['multi_tab'].setSharedValue('options', this._options);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update localstorage channels of with the channels of this tab.
|
||||
*
|
||||
* @private
|
||||
* @return {boolean} true if the aggregated channels has changed.
|
||||
*/
|
||||
_updateChannels() {
|
||||
const currentPeerIds = new Set(Object.keys(this.env.services['multi_tab'].getSharedValue('lastPresenceByTab', {})));
|
||||
const peerChannels = this.env.services['multi_tab'].getSharedValue('channels', {});
|
||||
const peerChannelsBefore = JSON.stringify(peerChannels);
|
||||
peerChannels[this._id] = Array.from(this._currentTabChannels);
|
||||
|
||||
// Clean outdated channels.
|
||||
for (const channelPeerId of Object.keys(peerChannels)) {
|
||||
if (!currentPeerIds.has(channelPeerId)) {
|
||||
delete peerChannels[channelPeerId];
|
||||
}
|
||||
}
|
||||
|
||||
const peerChannelsAfter = JSON.stringify(peerChannels);
|
||||
if (peerChannelsBefore !== peerChannelsAfter) {
|
||||
this.env.services['multi_tab'].setSharedValue('channels', peerChannels);
|
||||
}
|
||||
|
||||
const allChannels = new Set();
|
||||
for (const channels of Object.values(peerChannels)) {
|
||||
for (const channel of channels) {
|
||||
allChannels.add(channel);
|
||||
}
|
||||
}
|
||||
// Insure the current tab channels are always in the aggregated channels
|
||||
// in case this tab is not in the currentPeerIds nor peerChannels.
|
||||
for (const channel of this._currentTabChannels) {
|
||||
allChannels.add(channel);
|
||||
}
|
||||
const allChannelsSorted = Array.from(allChannels).sort();
|
||||
if (JSON.stringify(allChannelsSorted) === JSON.stringify(this._channels.sort())) {
|
||||
return false;
|
||||
} else {
|
||||
this._channels = allChannelsSorted;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If it's the master tab, the notifications ares broadcasted to other tabs by the
|
||||
* local storage.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_onPoll(notifications) {
|
||||
var notifs = super._onPoll(notifications);
|
||||
if (this.env.services['multi_tab'].isOnMainTab() && notifs.length) {
|
||||
this.env.services['multi_tab'].setSharedValue('last', this._lastNotificationID);
|
||||
this.env.services['multi_tab'].setSharedValue('last_ts', new Date().getTime());
|
||||
this.env.services['multi_tab'].setSharedValue('notification', notifs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when a shared value is updated.
|
||||
*
|
||||
* @private
|
||||
* @param {CustomEvent} ev
|
||||
* @param {object} [ev.detail]
|
||||
* @param {string} [ev.detail.key] Key of the value that have changed.
|
||||
* @param {any} [ev.detail.newValue] New value associated to the key.
|
||||
*/
|
||||
_onSharedValueUpdated({ detail }) {
|
||||
const { key, newValue } = detail;
|
||||
const value = JSON.parse(newValue);
|
||||
|
||||
// last notification id changed
|
||||
if (key === 'last') {
|
||||
this._lastNotificationID = value || 0;
|
||||
}
|
||||
// notifications changed
|
||||
else if (key === 'notification') {
|
||||
if (!this.env.services['multi_tab'].isOnMainTab()) {
|
||||
this.trigger("notification", value);
|
||||
}
|
||||
}
|
||||
// update channels
|
||||
else if (key === 'channels') {
|
||||
if (this._updateChannels()) {
|
||||
this._restartPolling();
|
||||
};
|
||||
}
|
||||
// update options
|
||||
else if (key === 'options') {
|
||||
this._options = value;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
/**
|
||||
* This service updates periodically the user presence in order for the
|
||||
* im_status to be up to date.
|
||||
*/
|
||||
export const imStatusService = {
|
||||
dependencies: ['bus_service', 'multi_tab', 'presence'],
|
||||
|
||||
start(env, { bus_service, multi_tab, presence }) {
|
||||
const UPDATE_BUS_PRESENCE_DELAY = 30000;
|
||||
let updateBusPresenceInterval;
|
||||
|
||||
function updateBusPresence() {
|
||||
const now = new Date().getTime();
|
||||
bus_service.send("update_presence", now - presence.getLastPresence());
|
||||
}
|
||||
|
||||
function stopUpdatingBusPresence() {
|
||||
clearInterval(updateBusPresenceInterval);
|
||||
updateBusPresenceInterval = null;
|
||||
}
|
||||
|
||||
function startUpdatingBusPresence() {
|
||||
if (updateBusPresenceInterval || !multi_tab.isOnMainTab()) {
|
||||
return;
|
||||
}
|
||||
updateBusPresence();
|
||||
updateBusPresenceInterval = setInterval(
|
||||
updateBusPresence,
|
||||
UPDATE_BUS_PRESENCE_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
if (multi_tab.isOnMainTab()) {
|
||||
startUpdatingBusPresence();
|
||||
}
|
||||
env.bus.addEventListener('become_main_tab', startUpdatingBusPresence);
|
||||
bus_service.addEventListener('reconnect', startUpdatingBusPresence);
|
||||
env.bus.addEventListener('no_longer_main_tab', stopUpdatingBusPresence);
|
||||
bus_service.addEventListener('disconnect', stopUpdatingBusPresence);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category('services').add('im_status', imStatusService);
|
||||
@@ -1,202 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
const { EventBus } = owl;
|
||||
|
||||
/**
|
||||
* Event Longpolling bus used to bind events on the server long polling return
|
||||
*
|
||||
* trigger:
|
||||
* - notification : when a notification is receive from the long polling
|
||||
*/
|
||||
export class Longpolling extends EventBus {
|
||||
constructor(env, { multi_tab }) {
|
||||
super();
|
||||
this.env = env;
|
||||
|
||||
// CONSTANTS
|
||||
this.PARTNERS_PRESENCE_CHECK_PERIOD = 30000; // don't check presence more than once every 30s
|
||||
this.ERROR_RETRY_DELAY = 10000; // 10 seconds
|
||||
this.POLL_ROUTE = '/longpolling/poll';
|
||||
|
||||
// PROPERTIES
|
||||
this._isActive = null;
|
||||
this._id = multi_tab.currentTabId;
|
||||
this._lastNotificationID = 0;
|
||||
this._pollRetryTimeout = null;
|
||||
|
||||
// the _id is modified by crosstab_bus, so we can't use it to unbind the events in the destroy.
|
||||
this._longPollingBusId = this._id;
|
||||
this._options = {};
|
||||
this._channels = [];
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a new channel to listen on the longpoll (ignore if already
|
||||
* listening on this channel).
|
||||
* Aborts a pending longpoll, in order to re-start another longpoll, so
|
||||
* that we can immediately get notifications on newly registered channel.
|
||||
*
|
||||
* @param {string} channel
|
||||
*/
|
||||
addChannel(channel) {
|
||||
if (this._channels.indexOf(channel) === -1) {
|
||||
this._channels.push(channel);
|
||||
this._restartPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a channel from listening on the longpoll.
|
||||
*
|
||||
* Aborts a pending longpoll, in order to re-start another longpoll, so
|
||||
* that we immediately remove ourselves from listening on notifications
|
||||
* on this channel.
|
||||
*
|
||||
* @param {string} channel
|
||||
*/
|
||||
deleteChannel(channel) {
|
||||
var index = this._channels.indexOf(channel);
|
||||
if (index !== -1) {
|
||||
this._channels.splice(index, 1);
|
||||
if (this._pollRpc) {
|
||||
this._pollRpc.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a long polling, i.e. it continually opens a long poll
|
||||
* connection as long as it is not stopped (@see `stopPolling`)
|
||||
*/
|
||||
startPolling() {
|
||||
if (this._isActive === null) {
|
||||
this._poll = this._poll.bind(this);
|
||||
}
|
||||
if (!this._isActive) {
|
||||
this._isActive = true;
|
||||
this._poll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops any started long polling
|
||||
*
|
||||
* Aborts a pending longpoll so that we immediately remove ourselves
|
||||
* from listening on notifications on this channel.
|
||||
*/
|
||||
stopPolling() {
|
||||
this._isActive = false;
|
||||
this._channels = [];
|
||||
clearTimeout(this._pollRetryTimeout);
|
||||
if (this._pollRpc) {
|
||||
this._pollRpc.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an option on the longpoll bus.
|
||||
* Stored options are sent to the server whenever a poll is started.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
updateOption(key, value) {
|
||||
this._options[key] = value;
|
||||
}
|
||||
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Continually start a poll:
|
||||
*
|
||||
* A poll is a connection that is kept open for a relatively long period
|
||||
* (up to 1 minute). Local bus data are sent to the server each time a poll
|
||||
* is initiated, and the server may return some "real-time" notifications
|
||||
* about registered channels.
|
||||
*
|
||||
* A poll ends on timeout, on abort, on receiving some notifications, or on
|
||||
* receiving an error. Another poll usually starts afterward, except if the
|
||||
* poll is aborted or stopped (@see stopPolling).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_poll() {
|
||||
var self = this;
|
||||
if (!this._isActive) {
|
||||
return;
|
||||
}
|
||||
var now = new Date().getTime();
|
||||
var options = _.extend({}, this._options, {
|
||||
bus_inactivity: now - this.env.services['presence'].getLastPresence(),
|
||||
});
|
||||
var data = {channels: this._channels, last: this._lastNotificationID, options: options};
|
||||
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
|
||||
this._pollRpc = this._makePoll(data);
|
||||
this._pollRpc.then(function (result) {
|
||||
self._pollRpc = false;
|
||||
self._onPoll(result);
|
||||
self._poll();
|
||||
}).catch(function (result) {
|
||||
self._pollRpc = false;
|
||||
if (result.message === "XmlHttpRequestError abort") {
|
||||
self._poll();
|
||||
} else {
|
||||
// random delay to avoid massive longpolling
|
||||
self._pollRetryTimeout = setTimeout(self._poll, self.ERROR_RETRY_DELAY + (Math.floor((Math.random()*20)+1)*1000));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param data: object with poll parameters
|
||||
*/
|
||||
_makePoll(params) {
|
||||
return this.env.services.rpc(this.POLL_ROUTE, params, { silent: true, timeout: 60000 });
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler when the long polling receive the new notifications
|
||||
* Update the last notification id received.
|
||||
* Triggered the 'notification' event with a list [channel, message] from notifications.
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} notifications, Input notifications have an id, channel, message
|
||||
* @returns {Array[]} Output arrays have notification's channel and message
|
||||
*/
|
||||
_onPoll(notifications) {
|
||||
var self = this;
|
||||
var notifs = _.map(notifications, function (notif) {
|
||||
if (notif.id > self._lastNotificationID) {
|
||||
self._lastNotificationID = notif.id;
|
||||
}
|
||||
return notif.message;
|
||||
});
|
||||
this.trigger("notification", notifs);
|
||||
return notifs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart polling.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_restartPolling() {
|
||||
if (this._pollRpc) {
|
||||
this._pollRpc.abort();
|
||||
} else {
|
||||
this.startPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export const assetsWatchdogService = {
|
||||
let bundleNotifTimerID = null;
|
||||
|
||||
bus_service.addEventListener('notification', onNotification.bind(this));
|
||||
bus_service.startPolling();
|
||||
|
||||
/**
|
||||
* Displays one notification on user's screen when assets have changed
|
||||
|
||||
@@ -1,13 +1,93 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { CrossTab } from '@bus/crosstab_bus';
|
||||
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
const { EventBus } = owl;
|
||||
const NO_POPUP_CLOSE_CODES = [WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED, WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT];
|
||||
|
||||
/**
|
||||
* Communicate with a SharedWorker in order to provide a single websocket
|
||||
* connection shared across multiple tabs.
|
||||
*
|
||||
* @emits connect
|
||||
* @emits disconnect
|
||||
* @emits reconnect
|
||||
* @emits reconnecting
|
||||
* @emits notification
|
||||
*/
|
||||
export const busService = {
|
||||
dependencies: ['notification', 'presence', 'rpc', 'multi_tab'],
|
||||
start(env, services) {
|
||||
return new CrossTab(env, services);
|
||||
dependencies: ['localization', 'notification'],
|
||||
|
||||
start(env) {
|
||||
const bus = new EventBus();
|
||||
const workerClass = 'SharedWorker' in window ? browser.SharedWorker : browser.Worker;
|
||||
const worker = new workerClass('/bus/websocket_worker_bundle', {
|
||||
name: 'SharedWorker' in window ? 'odoo:websocket_shared_worker' : 'odoo:websocket_worker',
|
||||
});
|
||||
let removeConnectionLostNotification;
|
||||
|
||||
/**
|
||||
* Send a message to the worker.
|
||||
*
|
||||
* @param {WorkerAction} action Action to be
|
||||
* executed by the worker.
|
||||
* @param {Object|undefined} data Data required for the action to be
|
||||
* executed.
|
||||
*/
|
||||
function send(action, data) {
|
||||
const message = { action, data };
|
||||
if ('SharedWorker' in window) {
|
||||
worker.port.postMessage(message);
|
||||
} else {
|
||||
worker.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages received from the shared worker and fires an
|
||||
* event according to the message type.
|
||||
*
|
||||
* @param {MessageEvent} messageEv
|
||||
* @param {{type: WorkerEvent, data: any}[]} messageEv.data
|
||||
*/
|
||||
function handleMessage(messageEv) {
|
||||
const { type, data } = messageEv.data;
|
||||
// Do not trigger the connection lost pop up if the reconnecting
|
||||
// event is caused by a session expired/keep_alive_timeout.
|
||||
if (type === 'reconnecting' && !NO_POPUP_CLOSE_CODES.includes(data.closeCode)) {
|
||||
removeConnectionLostNotification = env.services.notification.add(
|
||||
env._t("Websocket connection lost. Trying to reconnect..."),
|
||||
{ sticky: true },
|
||||
);
|
||||
} else if (type === 'reconnect' && removeConnectionLostNotification) {
|
||||
removeConnectionLostNotification();
|
||||
removeConnectionLostNotification = null;
|
||||
}
|
||||
bus.trigger(type, data);
|
||||
}
|
||||
|
||||
if ('SharedWorker' in window) {
|
||||
worker.port.start();
|
||||
worker.port.addEventListener('message', handleMessage);
|
||||
} else {
|
||||
worker.addEventListener('message', handleMessage);
|
||||
}
|
||||
browser.addEventListener('unload', () => send('leave'));
|
||||
|
||||
|
||||
return {
|
||||
addEventListener: bus.addEventListener.bind(bus),
|
||||
addChannel: channel => send('add_channel', channel),
|
||||
deleteChannel: channel => send('delete_channel', channel),
|
||||
forceUpdateChannels: () => send('force_update_channels'),
|
||||
trigger: bus.trigger.bind(bus),
|
||||
removeEventListener: bus.removeEventListener.bind(bus),
|
||||
send: (eventName, data) => send('send', { event_name: eventName, data }),
|
||||
stop: () => send('leave'),
|
||||
};
|
||||
},
|
||||
};
|
||||
registry.category('services').add('bus_service', busService);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { debounce } from '@bus/workers/websocket_worker_utils';
|
||||
|
||||
/**
|
||||
* Type of events that can be sent from the worker to its clients.
|
||||
*
|
||||
* @typedef { 'connect' | 'reconnect' | 'disconnect' | 'reconnecting' | 'notification' } WorkerEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type of action that can be sent from the client to the worker.
|
||||
*
|
||||
* @typedef {'add_channel' | 'delete_channel' | 'force_update_channels' | 'send' | 'leave' } WorkerAction
|
||||
*/
|
||||
|
||||
export const WEBSOCKET_CLOSE_CODES = Object.freeze({
|
||||
CLEAN: 1000,
|
||||
GOING_AWAY: 1001,
|
||||
PROTOCOL_ERROR: 1002,
|
||||
INCORRECT_DATA: 1003,
|
||||
ABNORMAL_CLOSURE: 1006,
|
||||
INCONSISTENT_DATA: 1007,
|
||||
MESSAGE_VIOLATING_POLICY: 1008,
|
||||
MESSAGE_TOO_BIG: 1009,
|
||||
EXTENSION_NEGOTIATION_FAILED: 1010,
|
||||
SERVER_ERROR: 1011,
|
||||
RESTART: 1012,
|
||||
TRY_LATER: 1013,
|
||||
BAD_GATEWAY: 1014,
|
||||
SESSION_EXPIRED: 4001,
|
||||
KEEP_ALIVE_TIMEOUT: 4002,
|
||||
});
|
||||
|
||||
/**
|
||||
* This class regroups the logic necessary in order for the
|
||||
* SharedWorker/Worker to work. Indeed, Safari and some minor browsers
|
||||
* do not support SharedWorker. In order to solve this issue, a Worker
|
||||
* is used in this case. The logic is almost the same than the one used
|
||||
* for SharedWorker and this class implements it.
|
||||
*/
|
||||
export class WebsocketWorker {
|
||||
constructor(websocketURL) {
|
||||
this.websocketURL = websocketURL;
|
||||
this.channelsByClient = new Map();
|
||||
this.connectRetryDelay = 0;
|
||||
this.connectTimeout = null;
|
||||
this.isReconnecting = false;
|
||||
this.lastChannelSubscription = null;
|
||||
this.lastNotificationId = 0;
|
||||
this.messageWaitQueue = [];
|
||||
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300, true);
|
||||
this._start();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send the message to all the clients that are connected to the
|
||||
* worker.
|
||||
*
|
||||
* @param {WorkerEvent} type Event to broadcast to connected
|
||||
* clients.
|
||||
* @param {Object} data
|
||||
*/
|
||||
broadcast(type, data) {
|
||||
for (const client of this.channelsByClient.keys()) {
|
||||
client.postMessage({ type, data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a client handled by this worker.
|
||||
*
|
||||
* @param {MessagePort} messagePort
|
||||
*/
|
||||
registerClient(messagePort) {
|
||||
messagePort.onmessage = ev => {
|
||||
this._onClientMessage(messagePort, ev.data);
|
||||
};
|
||||
this.channelsByClient.set(messagePort, []);
|
||||
this._updateChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to the given client.
|
||||
*
|
||||
* @param {number} client
|
||||
* @param {WorkerEvent} type
|
||||
* @param {Object} data
|
||||
*/
|
||||
sendToClient(client, type, data) {
|
||||
client.postMessage({ type, data });
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// PRIVATE
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when a message is posted to the worker by a client (i.e. a
|
||||
* MessagePort connected to this worker).
|
||||
*
|
||||
* @param {MessagePort} client
|
||||
* @param {Object} message
|
||||
* @param {WorkerAction} [message.action]
|
||||
* Action to execute.
|
||||
* @param {Object|undefined} [message.data] Data required by the
|
||||
* action.
|
||||
*/
|
||||
_onClientMessage(client, { action, data }) {
|
||||
switch (action) {
|
||||
case 'send':
|
||||
return this._sendToServer(data);
|
||||
case 'leave':
|
||||
return this._unregisterClient(client);
|
||||
case 'add_channel':
|
||||
return this._addChannel(client, data);
|
||||
case 'delete_channel':
|
||||
return this._deleteChannel(client, data);
|
||||
case 'force_update_channels':
|
||||
return this._forceUpdateChannels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a channel for the given client. If this channel is not yet
|
||||
* known, update the subscription on the server.
|
||||
*
|
||||
* @param {MessagePort} client
|
||||
* @param {string} channel
|
||||
*/
|
||||
_addChannel(client, channel) {
|
||||
const clientChannels = this.channelsByClient.get(client);
|
||||
if (!clientChannels.includes(channel)) {
|
||||
clientChannels.push(channel);
|
||||
this.channelsByClient.set(client, clientChannels);
|
||||
this._updateChannels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a channel for the given client. If this channel is not
|
||||
* used anymore, update the subscription on the server.
|
||||
*
|
||||
* @param {MessagePort} client
|
||||
* @param {string} channel
|
||||
*/
|
||||
_deleteChannel(client, channel) {
|
||||
const clientChannels = this.channelsByClient.get(client);
|
||||
if (!clientChannels) {
|
||||
return;
|
||||
}
|
||||
const channelIndex = clientChannels.indexOf(channel);
|
||||
if (channelIndex !== -1) {
|
||||
clientChannels.splice(channelIndex, 1);
|
||||
this._updateChannels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the channels on the server side even if the channels on
|
||||
* the client side are the same than the last time we subscribed.
|
||||
*/
|
||||
_forceUpdateChannels() {
|
||||
this._updateChannels({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given client from this worker client list as well as
|
||||
* its channels. If some of its channels are not used anymore,
|
||||
* update the subscription on the server.
|
||||
*
|
||||
* @param {MessagePort} client
|
||||
*/
|
||||
_unregisterClient(client) {
|
||||
this.channelsByClient.delete(client);
|
||||
this._updateChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the websocket associated to this worker
|
||||
* is connected.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isWebsocketConnected() {
|
||||
return this.websocket && this.websocket.readyState === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when a connection is closed. If closure was not clean ,
|
||||
* try to reconnect after indicating to the clients that the
|
||||
* connection was closed.
|
||||
*
|
||||
* @param {CloseEvent} ev
|
||||
* @param {number} code close code indicating why the connection
|
||||
* was closed.
|
||||
* @param {string} reason reason indicating why the connection was
|
||||
* closed.
|
||||
*/
|
||||
_onWebsocketClose({ code, reason }) {
|
||||
if (this.isReconnecting) {
|
||||
// Connection was not established but the close event was
|
||||
// triggered anyway. Let the onWebsocketError method handle
|
||||
// this case.
|
||||
return;
|
||||
}
|
||||
this.broadcast('disconnect', { code, reason });
|
||||
if (code === WEBSOCKET_CLOSE_CODES.CLEAN) {
|
||||
// WebSocket was closed on purpose, do not try to reconnect.
|
||||
return;
|
||||
}
|
||||
// WebSocket was not closed cleanly, let's try to reconnect.
|
||||
this.broadcast('reconnecting', { closeCode: code });
|
||||
this.isReconnecting = true;
|
||||
this._onWebsocketError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when a connection failed or failed to established.
|
||||
* Apply an exponential back off to the reconnect attempts.
|
||||
*/
|
||||
_onWebsocketError() {
|
||||
this.connectRetryDelay = this.connectRetryDelay * 1.5 + 500 * Math.random();
|
||||
this.connectTimeout = setTimeout(this._start.bind(this), this.connectRetryDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle data received from the bus.
|
||||
*
|
||||
* @param {MessageEvent} messageEv
|
||||
*/
|
||||
_onWebsocketMessage(messageEv) {
|
||||
const notifications = JSON.parse(messageEv.data);
|
||||
this.lastNotificationId = notifications[notifications.length - 1].id;
|
||||
this.broadcast('notification', notifications.map(notification => notification.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered on websocket open. Send message that were waiting for
|
||||
* the connection to open.
|
||||
*/
|
||||
_onWebsocketOpen() {
|
||||
this.messageWaitQueue.forEach(msg => this.websocket.send(msg));
|
||||
this.messageWaitQueue = [];
|
||||
this.broadcast(this.isReconnecting ? 'reconnect' : 'connect');
|
||||
if (this.isReconnecting) {
|
||||
this._forceUpdateChannels();
|
||||
}
|
||||
this.connectRetryDelay = 0;
|
||||
this.connectTimeout = null;
|
||||
this.isReconnecting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the server through the websocket connection.
|
||||
* If the websocket is not open, enqueue the message and send it
|
||||
* upon the next reconnection.
|
||||
*
|
||||
* @param {any} message Message to send to the server.
|
||||
*/
|
||||
_sendToServer(message) {
|
||||
const payload = JSON.stringify(message);
|
||||
if (!this._isWebsocketConnected()) {
|
||||
this.messageWaitQueue.push(payload);
|
||||
} else {
|
||||
this.websocket.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the worker by opening a websocket connection.
|
||||
*/
|
||||
_start() {
|
||||
this.websocket = new WebSocket(this.websocketURL);
|
||||
this.websocket.addEventListener('open', this._onWebsocketOpen.bind(this));
|
||||
this.websocket.addEventListener('error', this._onWebsocketError.bind(this));
|
||||
this.websocket.addEventListener('message', this._onWebsocketMessage.bind(this));
|
||||
this.websocket.addEventListener('close', this._onWebsocketClose.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the channel subscription on the server. Ignore if the channels
|
||||
* did not change since the last subscription.
|
||||
*
|
||||
* @param {boolean} force Whether or not we should update the subscription
|
||||
* event if the channels haven't change since last subscription.
|
||||
*/
|
||||
_updateChannels({ force = false } = {}) {
|
||||
const allTabsChannels = [...new Set([].concat.apply([], [...this.channelsByClient.values()]))].sort();
|
||||
const allTabsChannelsString = JSON.stringify(allTabsChannels);
|
||||
const shouldUpdateChannelSubscription = allTabsChannelsString !== this.lastChannelSubscription;
|
||||
if (force || shouldUpdateChannelSubscription) {
|
||||
this.lastChannelSubscription = allTabsChannelsString;
|
||||
this._sendToServer({ event_name: 'subscribe', data: { channels: allTabsChannels, last: this.lastNotificationId } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** @odoo-module **/
|
||||
/* eslint-env worker */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
import { WebsocketWorker } from "./websocket_worker";
|
||||
|
||||
(function () {
|
||||
const websocketWorker = new WebsocketWorker(
|
||||
`${self.location.protocol === 'https:' ? 'wss' : 'ws'}://${self.location.host}/websocket`
|
||||
);
|
||||
|
||||
if (self.name.includes('shared')) {
|
||||
// The script is running in a shared worker: let's register every
|
||||
// tab connection to the worker in order to relay notifications
|
||||
// coming from the websocket.
|
||||
onconnect = function (ev) {
|
||||
const currentClient = ev.ports[0];
|
||||
websocketWorker.registerClient(currentClient);
|
||||
};
|
||||
} else {
|
||||
// The script is running in a simple web worker.
|
||||
websocketWorker.registerClient(self);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Returns a function, that, as long as it continues to be invoked, will not
|
||||
* be triggered. The function will be called after it stops being called for
|
||||
* N milliseconds. If `immediate` is passed, trigger the function on the
|
||||
* leading edge, instead of the trailing.
|
||||
*
|
||||
* Inspired by https://davidwalsh.name/javascript-debounce-function
|
||||
*/
|
||||
export function debounce(func, wait, immediate) {
|
||||
let timeout;
|
||||
return function () {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
function later() {
|
||||
timeout = null;
|
||||
if (!immediate) {
|
||||
func.apply(context, args);
|
||||
}
|
||||
}
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) {
|
||||
func.apply(context, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,16 +2,18 @@ odoo.define('web.bus_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var { busService } = require('@bus/services/bus_service');
|
||||
const { startServer } = require('@bus/../tests/helpers/mock_python_environment');
|
||||
const { presenceService } = require('@bus/services/presence_service');
|
||||
const { multiTabService } = require('@bus/multi_tab_service');
|
||||
const { WEBSOCKET_CLOSE_CODES } = require("@bus/workers/websocket_worker");
|
||||
const { startServer } = require('@bus/../tests/helpers/mock_python_environment');
|
||||
const { patchWebsocketWorkerWithCleanup } = require("@bus/../tests/helpers/mock_websocket");
|
||||
|
||||
var { CrossTab } = require('@bus/crosstab_bus');
|
||||
var testUtils = require('web.test_utils');
|
||||
const { browser } = require("@web/core/browser/browser");
|
||||
const { registry } = require("@web/core/registry");
|
||||
const { patchWithCleanup, nextTick } = require("@web/../tests/helpers/utils");
|
||||
const { makeTestEnv } = require('@web/../tests/helpers/mock_env');
|
||||
const { createWebClient } = require("@web/../tests/webclient/helpers");
|
||||
|
||||
QUnit.module('Bus', {
|
||||
beforeEach: function () {
|
||||
@@ -29,7 +31,7 @@ QUnit.module('Bus', {
|
||||
registry.category('services').add('multi_tab', customMultiTabService);
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test('notifications received from the longpolling channel', async function (assert) {
|
||||
QUnit.test('notifications received from the channel', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
@@ -50,6 +52,37 @@ QUnit.module('Bus', {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('notifications not received after stoping the service', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const firstTabEnv = await makeTestEnv({ activateMockServer: true });
|
||||
const secondTabEnv = await makeTestEnv({ activateMockServer: true });
|
||||
|
||||
firstTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
assert.step('1 - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
firstTabEnv.services['bus_service'].addChannel('lambda');
|
||||
secondTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
assert.step('2 - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
// both tabs should receive the notification
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
|
||||
await nextTick();
|
||||
secondTabEnv.services['bus_service'].stop();
|
||||
await nextTick();
|
||||
// only first tab should receive the notification since the
|
||||
// second tab has called the stop method.
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'epsilon');
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'1 - notification - beta',
|
||||
'2 - notification - beta',
|
||||
'1 - notification - epsilon',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('notifications still received after disconnect/reconnect', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
@@ -69,9 +102,12 @@ QUnit.module('Bus', {
|
||||
});
|
||||
env.services['bus_service'].addChannel('lambda');
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
|
||||
pyEnv.simulateConnectionLostAndRecovered();
|
||||
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
// Give websocket worker a tick to try to restart
|
||||
await nextTick();
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma');
|
||||
// Give longpolling bus a tick to try to restart polling
|
||||
// Give bus service a tick to receive the notification from
|
||||
// postMessage.
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
@@ -80,44 +116,6 @@ QUnit.module('Bus', {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('provide notification ID of 0 by default', async function (assert) {
|
||||
// This test is important in order to ensure that we provide the correct
|
||||
// sentinel value 0 when we are not aware of the last notification ID
|
||||
// that we have received. We cannot provide an ID of -1, otherwise it
|
||||
// may likely be handled incorrectly (before this test was written,
|
||||
// it was providing -1 to the server, which in return sent every stored
|
||||
// notifications related to this user).
|
||||
assert.expect(3);
|
||||
|
||||
// Simulate no ID of last notification in the local storage
|
||||
patchWithCleanup(browser, {
|
||||
getItem(key) {
|
||||
if (key === 'last_ts') {
|
||||
return 0;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
var pollPromise = testUtils.makeTestPromise();
|
||||
const env = await makeTestEnv({
|
||||
mockRPC(route, args) {
|
||||
if (route === '/longpolling/poll') {
|
||||
assert.step(route);
|
||||
assert.strictEqual(args.last, 0,
|
||||
"provided last notification ID should be 0");
|
||||
|
||||
pollPromise = testUtils.makeTestPromise();
|
||||
return pollPromise;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// trigger longpolling poll RPC
|
||||
env.services['bus_service'].addChannel('lambda');
|
||||
assert.verifySteps(['/longpolling/poll']);
|
||||
});
|
||||
|
||||
QUnit.test('tabs share message from a channel', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
@@ -130,13 +128,8 @@ QUnit.module('Bus', {
|
||||
mainEnv.services['bus_service'].addChannel('lambda');
|
||||
|
||||
// slave
|
||||
const slaveEnv = await makeTestEnv({
|
||||
mockRPC(route, args) {
|
||||
if (route === '/longpolling/poll') {
|
||||
throw new Error("Can not use the longpolling of the slave client");
|
||||
}
|
||||
}
|
||||
});
|
||||
const slaveEnv = await makeTestEnv();
|
||||
|
||||
slaveEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
assert.step('slave - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
@@ -151,44 +144,6 @@ QUnit.module('Bus', {
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('secondEnv start polling after main unload', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
// main tab.
|
||||
const mainEnv = await makeTestEnv({
|
||||
mockRPC(route) {
|
||||
if (route === '/longpolling/poll') {
|
||||
assert.step('main - poll');
|
||||
}
|
||||
}
|
||||
});
|
||||
// prevent second tab from receiving unload event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName === 'unload') {
|
||||
return;
|
||||
}
|
||||
this._super(eventName, callback);
|
||||
},
|
||||
});
|
||||
// second tab
|
||||
await makeTestEnv({
|
||||
mockRPC(route) {
|
||||
if (route === '/longpolling/poll') {
|
||||
assert.step('second - poll');
|
||||
}
|
||||
}
|
||||
});
|
||||
mainEnv.services['bus_service'].startPolling();
|
||||
// simulate main unload.
|
||||
window.dispatchEvent(new Event('unload'));
|
||||
|
||||
assert.verifySteps([
|
||||
'main - poll',
|
||||
'second - poll',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('second tab still receives notifications after main unload', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
@@ -220,6 +175,7 @@ QUnit.module('Bus', {
|
||||
|
||||
// simulate unloading main
|
||||
window.dispatchEvent(new Event('unload'));
|
||||
await nextTick();
|
||||
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma');
|
||||
await testUtils.nextTick();
|
||||
@@ -234,7 +190,7 @@ QUnit.module('Bus', {
|
||||
QUnit.test('two tabs calling addChannel simultaneously', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
patchWithCleanup(CrossTab.prototype, {
|
||||
const channelPatch = {
|
||||
addChannel(channel) {
|
||||
assert.step('Tab ' + this.__tabId__ + ': addChannel ' + channel);
|
||||
this._super.apply(this, arguments);
|
||||
@@ -243,12 +199,13 @@ QUnit.module('Bus', {
|
||||
assert.step('Tab ' + this.__tabId__ + ': deleteChannel ' + channel);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
};
|
||||
const firstTabEnv = await makeTestEnv({ activateMockServer: true });
|
||||
const secondTabEnv = await makeTestEnv({ activateMockServer: true });
|
||||
firstTabEnv.services['bus_service'].__tabId__ = 1;
|
||||
secondTabEnv.services['bus_service'].__tabId__ = 2;
|
||||
patchWithCleanup(firstTabEnv.services['bus_service'], channelPatch);
|
||||
patchWithCleanup(secondTabEnv.services['bus_service'], channelPatch);
|
||||
firstTabEnv.services['bus_service'].addChannel('alpha');
|
||||
secondTabEnv.services['bus_service'].addChannel('alpha');
|
||||
firstTabEnv.services['bus_service'].addChannel('beta');
|
||||
@@ -288,6 +245,106 @@ QUnit.module('Bus', {
|
||||
'second - notification - alpha,beta',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('channel management from multiple tabs', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
patchWebsocketWorkerWithCleanup({
|
||||
_sendToServer({ event_name, data }) {
|
||||
assert.step(`${event_name} - [${data.channels.toString()}]`);
|
||||
},
|
||||
});
|
||||
|
||||
const firstTabEnv = await makeTestEnv();
|
||||
const secTabEnv = await makeTestEnv();
|
||||
firstTabEnv.services['bus_service'].addChannel('channel1');
|
||||
await nextTick();
|
||||
// this should not trigger a subscription since the channel1 was
|
||||
// aleady known.
|
||||
secTabEnv.services['bus_service'].addChannel('channel1');
|
||||
await nextTick();
|
||||
// removing channel1 from first tab should not trigger
|
||||
// re-subscription since the second tab still listens to this
|
||||
// channel.
|
||||
firstTabEnv.services['bus_service'].deleteChannel('channel1');
|
||||
await nextTick();
|
||||
// this should trigger a subscription since the channel2 was not
|
||||
// known.
|
||||
secTabEnv.services['bus_service'].addChannel('channel2');
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'subscribe - []',
|
||||
'subscribe - [channel1]',
|
||||
'subscribe - [channel1,channel2]',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('channels subscription after disconnection', async function (assert) {
|
||||
// Patch setTimeout in order for the worker to reconnect immediatly.
|
||||
patchWithCleanup(window, {
|
||||
setTimeout: fn => fn(),
|
||||
});
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
_sendToServer({ event_name, data }) {
|
||||
assert.step(`${event_name} - [${data.channels.toString()}]`);
|
||||
},
|
||||
});
|
||||
|
||||
await makeTestEnv();
|
||||
// wait for the websocket to connect and the first subscription
|
||||
// to occur.
|
||||
await nextTick();
|
||||
worker.websocket.close(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
|
||||
// wait for the websocket to re-connect.
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'subscribe - []',
|
||||
'subscribe - []',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('displays reconnect notification', async (assert) => {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
await createWebClient({});
|
||||
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await nextTick();
|
||||
|
||||
assert.containsOnce(document.body, '.o_notification');
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_notification .o_notification_content').textContent,
|
||||
'Websocket connection lost. Trying to reconnect...'
|
||||
);
|
||||
// Wait for the worker to reconnect, post a message and the
|
||||
// bus_service to receive it and remove the notification.
|
||||
const { afterNextRender } = owl.App;
|
||||
await afterNextRender(() => {});
|
||||
assert.containsNone(document.body, '.o_notification');
|
||||
});
|
||||
|
||||
QUnit.test('does not display connection lost popup when refreshing the session', async (assert) => {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
await createWebClient({});
|
||||
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED);
|
||||
await nextTick();
|
||||
assert.containsNone(document.body, '.o_notification');
|
||||
});
|
||||
|
||||
|
||||
QUnit.test('does not display connection lost popup when refreshing the connection upon keep_alive_timeout', async (assert) => {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
await createWebClient({});
|
||||
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
|
||||
await nextTick();
|
||||
assert.containsNone(document.body, '.o_notification');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -113,8 +113,8 @@ let pyEnv;
|
||||
getViews() {
|
||||
return views;
|
||||
},
|
||||
simulateConnectionLostAndRecovered() {
|
||||
this.mockServer._simulateConnectionLostAndRecovered();
|
||||
simulateConnectionLost(closeCode) {
|
||||
this.mockServer._simulateConnectionLost(closeCode);
|
||||
},
|
||||
...TEST_USER_IDS,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { TEST_USER_IDS } from "@bus/../tests/helpers/test_constants";
|
||||
import { patchWebsocketWorkerWithCleanup } from '@bus/../tests/helpers/mock_websocket';
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { ConnectionLostError } from "@web/core/network/rpc_service";
|
||||
@@ -11,6 +12,7 @@ patch(MockServer.prototype, 'bus', {
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
Object.assign(this, TEST_USER_IDS);
|
||||
this.websocketWorker = patchWebsocketWorkerWithCleanup();
|
||||
this.pendingLongpollingPromise = null;
|
||||
this.notificationsToBeResolved = [];
|
||||
},
|
||||
@@ -57,26 +59,21 @@ patch(MockServer.prototype, 'bus', {
|
||||
const values = [];
|
||||
for (const notification of notifications) {
|
||||
const [type, payload] = notification.slice(1, notification.length);
|
||||
values.push({ message: { payload, type } });
|
||||
}
|
||||
if (this.pendingLongpollingPromise) {
|
||||
this.pendingLongpollingPromise.resolve(values);
|
||||
this.pendingLongpollingPromise = null;
|
||||
} else {
|
||||
this.notificationsToBeResolved.push(...values);
|
||||
values.push({ payload, type });
|
||||
}
|
||||
this.websocketWorker.broadcast('notification', values);
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Simulate the lost of the connection by rejecting the pending
|
||||
* promise with ConnectionLostError. If there is no pending promise,
|
||||
* reject the next one.
|
||||
* Simulate the lost of the connection by simulating a closeEvent on
|
||||
* the worker websocket.
|
||||
*
|
||||
* @param {number} clodeCode the code to close the connection with.
|
||||
*/
|
||||
_simulateConnectionLostAndRecovered() {
|
||||
if (this.pendingLongpollingPromise) {
|
||||
this.pendingLongpollingPromise.reject(new ConnectionLostError());
|
||||
} else {
|
||||
this.hasLostConnection = true;
|
||||
}
|
||||
_simulateConnectionLost(closeCode) {
|
||||
this.websocketWorker.websocket.dispatchEvent(new CloseEvent('close', {
|
||||
code: closeCode,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { WebsocketWorker } from "@bus/workers/websocket_worker";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
|
||||
class WebSocketMock extends EventTarget {
|
||||
constructor(url) {
|
||||
super();
|
||||
this.readyState = 0;
|
||||
this.url = url;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
const openEv = new Event('open');
|
||||
this.onopen(openEv);
|
||||
this.dispatchEvent(openEv);
|
||||
});
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
this.readyState = 3;
|
||||
const closeEv = new CloseEvent('close', {
|
||||
code,
|
||||
reason,
|
||||
wasClean: code === 1000,
|
||||
});
|
||||
this.onclose(closeEv);
|
||||
this.dispatchEvent(closeEv);
|
||||
}
|
||||
|
||||
onclose(closeEv) {}
|
||||
onerror(errorEv) {}
|
||||
onopen(openEv) {}
|
||||
|
||||
send(data) {
|
||||
if (this.readyState !== 1) {
|
||||
const errorEv = new Event('error');
|
||||
this.onerror(errorEv);
|
||||
this.dispatchEvent(errorEv);
|
||||
throw new DOMException("Failed to execute 'send' on 'WebSocket': State is not OPEN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SharedWorkerMock {
|
||||
constructor(websocketWorker) {
|
||||
this._websocketWorker = websocketWorker;
|
||||
this._messageChannel = new MessageChannel();
|
||||
this.port = this._messageChannel.port1;
|
||||
// port 1 should be started by the service itself.
|
||||
this._messageChannel.port2.start();
|
||||
this._websocketWorker.registerClient(this._messageChannel.port2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let websocketWorker;
|
||||
/**
|
||||
* @param {*} params Parameters used to patch the websocket worker.
|
||||
* @returns {WebsocketWorker} Instance of the worker which will run during the
|
||||
* test. Usefull to interact with the worker in order to test the
|
||||
* websocket behavior.
|
||||
*/
|
||||
export function patchWebsocketWorkerWithCleanup(params = {}) {
|
||||
patchWithCleanup(window, {
|
||||
WebSocket: function () {
|
||||
return new WebSocketMock();
|
||||
},
|
||||
}, { pure: true });
|
||||
patchWithCleanup(websocketWorker || WebsocketWorker.prototype, params);
|
||||
websocketWorker = websocketWorker || new WebsocketWorker('wss://odoo.com/websocket');
|
||||
patchWithCleanup(browser, {
|
||||
SharedWorker: function () {
|
||||
return new SharedWorkerMock(websocketWorker);
|
||||
},
|
||||
}, { pure: true });
|
||||
registerCleanup(() => {
|
||||
clearTimeout(websocketWorker.connectTimeout);
|
||||
websocketWorker = null;
|
||||
});
|
||||
return websocketWorker;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
|
||||
import { patchWebsocketWorkerWithCleanup } from '@bus/../tests/helpers/mock_websocket';
|
||||
|
||||
import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
|
||||
QUnit.module('Websocket Worker');
|
||||
QUnit.test('connect event is broadcasted', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
patchWebsocketWorkerWithCleanup({
|
||||
broadcast(type) {
|
||||
assert.step(`broadcast ${type}`);
|
||||
},
|
||||
});
|
||||
// Wait for the websocket to connect.
|
||||
await nextTick();
|
||||
assert.verifySteps(['broadcast connect']);
|
||||
});
|
||||
|
||||
QUnit.test('disconnect event is broadcasted', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
broadcast(type) {
|
||||
assert.step(`broadcast ${type}`);
|
||||
},
|
||||
});
|
||||
// Wait for the websocket to connect.
|
||||
await nextTick();
|
||||
worker.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN);
|
||||
// Wait for the websocket to disconnect.
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'broadcast connect',
|
||||
'broadcast disconnect',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('reconnecting/reconnect event is broadcasted', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
// Patch setTimeout in order for the worker to reconnect immediatly.
|
||||
patchWithCleanup(window, {
|
||||
setTimeout: fn => fn(),
|
||||
});
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
broadcast(type) {
|
||||
assert.step(`broadcast ${type}`);
|
||||
},
|
||||
});
|
||||
// Wait for the websocket to connect.
|
||||
await nextTick();
|
||||
worker.websocket.close(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
// Wait for the disconnect/reconnecting/reconnect events.
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'broadcast connect',
|
||||
'broadcast disconnect',
|
||||
'broadcast reconnecting',
|
||||
'broadcast reconnect',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('notification event is broadcasted', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const notifications = [{
|
||||
id: 70,
|
||||
message: {
|
||||
type: "bundle_changed",
|
||||
payload: {
|
||||
server_version: '15.5alpha1+e',
|
||||
},
|
||||
},
|
||||
}];
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
broadcast(type, message) {
|
||||
if (type === 'notification') {
|
||||
assert.step(`broadcast ${type}`);
|
||||
assert.deepEqual(message, notifications.map(notif => notif.message));
|
||||
}
|
||||
},
|
||||
});
|
||||
// Wait for the websocket to connect.
|
||||
await nextTick();
|
||||
|
||||
worker.websocket.dispatchEvent(new MessageEvent('message', {
|
||||
data: JSON.stringify(notifications),
|
||||
}));
|
||||
|
||||
assert.verifySteps([
|
||||
'broadcast notification',
|
||||
]);
|
||||
});
|
||||
@@ -19,7 +19,6 @@ export const calendarNotificationService = {
|
||||
}
|
||||
}
|
||||
});
|
||||
bus_service.startPolling();
|
||||
|
||||
/**
|
||||
* Displays the Calendar notification on user's screen
|
||||
|
||||
@@ -40,8 +40,8 @@ var KioskMode = AbstractAction.extend({
|
||||
},
|
||||
|
||||
on_attach_callback: function () {
|
||||
// Stop polling to avoid notifications in kiosk mode
|
||||
this.call('bus_service', 'stopPolling');
|
||||
// Stop the bus_service to avoid notifications in kiosk mode
|
||||
this.call('bus_service', 'stop');
|
||||
$('body').find('.o_ChatWindowHeader_commandClose').click();
|
||||
},
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export const iapNotificationService = {
|
||||
}
|
||||
}
|
||||
});
|
||||
bus_service.startPolling();
|
||||
|
||||
/**
|
||||
* Displays the IAP success notification on user's screen
|
||||
|
||||
@@ -177,10 +177,12 @@ Help your customers with this chat, and analyse their feedback.
|
||||
'web/static/src/legacy/js/services/ajax_service.js',
|
||||
'web/static/src/legacy/js/services/local_storage_service.js',
|
||||
# Bus, Mail, Livechat
|
||||
'bus/static/src/longpolling_bus.js',
|
||||
'bus/static/src/crosstab_bus.js',
|
||||
'bus/static/src/services/bus_service.js',
|
||||
'bus/static/src/im_status_service.js',
|
||||
'bus/static/src/multi_tab_service.js',
|
||||
'bus/static/src/services/bus_service.js',
|
||||
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
|
||||
'bus/static/src/workers/websocket_worker.js',
|
||||
'bus/static/src/workers/websocket_worker_utils.js',
|
||||
'mail/static/src/js/utils.js',
|
||||
'im_livechat/static/src/legacy/public_livechat_chatbot.js',
|
||||
|
||||
@@ -202,9 +204,7 @@ Help your customers with this chat, and analyse their feedback.
|
||||
'mail/static/src/services/messaging_service.js',
|
||||
# Framework JS
|
||||
'bus/static/src/*.js',
|
||||
'bus/static/src/services/bus_service.js',
|
||||
'bus/static/src/services/presence_service.js',
|
||||
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
|
||||
'web/static/lib/luxon/luxon.js',
|
||||
'web/static/src/core/**/*',
|
||||
# FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder
|
||||
|
||||
@@ -12,7 +12,6 @@ registerModel({
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.env.services['bus_service'].addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid);
|
||||
this.env.services['bus_service'].startPolling();
|
||||
this.env.services['bus_service'].addEventListener('notification', this._onNotification);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -152,11 +152,6 @@ For more specific needs, you may also assign custom-defined actions
|
||||
('remove', 'mail/static/src/services/systray_service.js'),
|
||||
'mail/static/src/utils/*.js',
|
||||
# Framework JS
|
||||
'bus/static/src/*.js',
|
||||
'bus/static/src/services/bus_service.js',
|
||||
'bus/static/src/services/presence_service.js',
|
||||
'bus/static/src/multi_tab_service.js',
|
||||
'bus/static/src/services/legacy/make_bus_service_to_legacy_env.js',
|
||||
'web/static/lib/luxon/luxon.js',
|
||||
'web/static/src/core/**/*',
|
||||
# FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder
|
||||
|
||||
@@ -133,7 +133,7 @@ registerModel({
|
||||
},
|
||||
/**
|
||||
* Mark provided messages as read. Messages that have been marked as
|
||||
* read are acknowledged by server with response as longpolling
|
||||
* read are acknowledged by server with response as bus.
|
||||
* notification of following format:
|
||||
*
|
||||
* [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }]
|
||||
|
||||
@@ -221,7 +221,7 @@ registerModel({
|
||||
}
|
||||
if (partnerIds.length !== 0 || guestIds.length !== 0) {
|
||||
const dataList = await this.messaging.rpc({
|
||||
route: '/longpolling/im_status',
|
||||
route: '/bus/im_status',
|
||||
params: {
|
||||
partner_ids: partnerIds,
|
||||
guest_ids: guestIds,
|
||||
@@ -310,6 +310,14 @@ registerModel({
|
||||
_computeNotificationHandler() {
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onChangeAllCurrentClientThreads() {
|
||||
if (this.isInitialized) {
|
||||
this.env.services.bus_service.forceUpdateChannels();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -333,6 +341,12 @@ registerModel({
|
||||
inverse: 'messaging',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* This field contains all current client channels.
|
||||
*/
|
||||
allCurrentClientThreads: many('Thread', {
|
||||
inverse: 'messagingAsAllCurrentClientThreads',
|
||||
}),
|
||||
browser: attr({
|
||||
compute: '_computeBrowser',
|
||||
}),
|
||||
@@ -513,5 +527,9 @@ registerModel({
|
||||
dependencies: ['ringingThreads'],
|
||||
methodName: '_onChangeRingingThreads',
|
||||
},
|
||||
{
|
||||
dependencies: ['allCurrentClientThreads'],
|
||||
methodName: '_onChangeAllCurrentClientThreads',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ registerModel({
|
||||
lifecycleHooks: {
|
||||
_willDelete() {
|
||||
this.env.services['bus_service'].removeEventListener('notification', this._handleNotifications);
|
||||
this.env.services['bus_service'].stopPolling();
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
@@ -26,7 +25,6 @@ registerModel({
|
||||
*/
|
||||
start() {
|
||||
this.env.services['bus_service'].addEventListener('notification', this._handleNotifications);
|
||||
this.env.services['bus_service'].startPolling();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -1430,6 +1430,25 @@ registerModel({
|
||||
return total + 1;
|
||||
}, baseCounter);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @return {FieldCommand}
|
||||
*/
|
||||
_computeMessagingAsAllCurrentClientThreads() {
|
||||
// The current client is linked to this thread if any of
|
||||
// those conditions is met:
|
||||
// - `isServerPinned` is true.
|
||||
// - `messaging.currentPartner` is set and this partner is
|
||||
// a member of this thread.
|
||||
// - `messaging.currentGuest` is set and this guest is a
|
||||
// guest member of this thread.
|
||||
const isPartnerMember = this.members.includes(this.messaging.currentPartner);
|
||||
const isGuestMember = this.guestMembers.includes(this.messaging.currentGuest);
|
||||
if (!this.messaging || !isPartnerMember && !isGuestMember && !this.isServerPinned) {
|
||||
return clear();
|
||||
}
|
||||
return this.messaging;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {FieldCommand}
|
||||
@@ -2129,6 +2148,10 @@ registerModel({
|
||||
inverse: 'thread',
|
||||
isCausal: true,
|
||||
}),
|
||||
messagingAsAllCurrentClientThreads: one('Messaging', {
|
||||
compute: '_computeMessagingAsAllCurrentClientThreads',
|
||||
inverse: 'allCurrentClientThreads',
|
||||
}),
|
||||
messagingAsRingingThread: one('Messaging', {
|
||||
compute: '_computeMessagingAsRingingThread',
|
||||
inverse: 'ringingThreads',
|
||||
|
||||
@@ -270,7 +270,7 @@ registerModel({
|
||||
* cache (@see messages field for that): it just contains list
|
||||
* of successive messages that have been explicitly fetched by this
|
||||
* cache. For all non-main caches, this corresponds to all messages.
|
||||
* For the main cache, however, messages received from longpolling
|
||||
* For the main cache, however, messages received from the bus
|
||||
* should be displayed on main cache but they have not been explicitly
|
||||
* fetched by cache, so they ARE NOT in this list (at least, not until a
|
||||
* fetch on this thread cache contains this message).
|
||||
|
||||
@@ -62,7 +62,7 @@ patch(MockServer.prototype, 'mail', {
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
// routes
|
||||
if (route === '/longpolling/im_status') {
|
||||
if (route === '/bus/im_status') {
|
||||
const { partner_ids } = args;
|
||||
return {
|
||||
'partners': this.pyEnv['res.partner'].searchRead([['id', 'in', partner_ids]], { context: { 'active_test': false }, fields: ['im_status'] })
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { busService } from '@bus/services/bus_service';
|
||||
import { multiTabService } from '@bus/multi_tab_service';
|
||||
import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env';
|
||||
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';
|
||||
@@ -19,7 +19,6 @@ import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { createWebClient } from "@web/../tests/webclient/helpers";
|
||||
|
||||
const ROUTES_TO_IGNORE = [
|
||||
'/longpolling/poll',
|
||||
'/web/webclient/load_menus',
|
||||
'/web/dataset/call_kw/res.users/load_views',
|
||||
'/web/dataset/call_kw/res.users/systray_get_activities'
|
||||
@@ -86,20 +85,8 @@ function setupMessagingServiceRegistries({
|
||||
}
|
||||
};
|
||||
|
||||
const customBusService = {
|
||||
...busService,
|
||||
start() {
|
||||
const originalService = busService.start(...arguments);
|
||||
Object.assign(originalService, {
|
||||
_registerWindowUnload() {}, // Do nothing
|
||||
updateOption() {},
|
||||
});
|
||||
return originalService;
|
||||
},
|
||||
};
|
||||
|
||||
services = {
|
||||
bus_service: customBusService,
|
||||
bus_service: busService,
|
||||
messaging: messagingService,
|
||||
messagingValues,
|
||||
presence: makeFakePresenceService({
|
||||
|
||||
@@ -21,7 +21,7 @@ QUnit.test('list activity widget with no activity', async function (assert) {
|
||||
mockRPC: function (route, args) {
|
||||
if (
|
||||
args.method !== 'get_views' &&
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
) {
|
||||
assert.step(route);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ QUnit.test('list activity widget with activities', async function (assert) {
|
||||
mockRPC: function (route, args) {
|
||||
if (
|
||||
args.method !== 'get_views' &&
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
) {
|
||||
assert.step(route);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ QUnit.test('list activity widget with exception', async function (assert) {
|
||||
mockRPC: function (route, args) {
|
||||
if (
|
||||
args.method !== 'get_views' &&
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
) {
|
||||
assert.step(route);
|
||||
}
|
||||
@@ -172,7 +172,7 @@ QUnit.test('list activity widget: open dropdown', async function (assert) {
|
||||
mockRPC: function (route, args) {
|
||||
if (
|
||||
args.method !== 'get_views' &&
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/longpolling/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
!['/mail/init_messaging', '/mail/load_message_failures', '/bus/im_status', ...ROUTES_TO_IGNORE].includes(route)
|
||||
) {
|
||||
assert.step(args.method || route);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
('include', 'web.assets_backend'),
|
||||
|
||||
# Remove Longpolling bus and packages needed this bus
|
||||
('remove', 'bus/static/src/services/assets_watchdog_service.js'),
|
||||
('remove', 'mail/static/src/services/messaging_service.js'),
|
||||
|
||||
('remove', 'mail/static/src/components/dialog_manager/dialog_manager.js'),
|
||||
|
||||
@@ -11,7 +11,7 @@ addRecordMethods('Message', {
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
async cancelLetter() {
|
||||
// the result will come from longpolling: message_notification_update
|
||||
// the result will come from the bus: message_notification_update
|
||||
await this.messaging.rpc({
|
||||
model: 'mail.message',
|
||||
method: 'cancel_letter',
|
||||
@@ -53,7 +53,7 @@ addRecordMethods('Message', {
|
||||
* Retries to send the 'snailmail.letter' corresponding to this message.
|
||||
*/
|
||||
async resendLetter() {
|
||||
// the result will come from longpolling: message_notification_update
|
||||
// the result will come from the bus: message_notification_update
|
||||
await this.messaging.rpc({
|
||||
model: 'mail.message',
|
||||
method: 'send_letter',
|
||||
|
||||
@@ -956,7 +956,6 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
|
||||
var self = this;
|
||||
if (this.options.surveyToken && this.options.sessionInProgress) {
|
||||
this.call('bus_service', 'addChannel', this.options.surveyToken);
|
||||
this.call('bus_service', 'startPolling');
|
||||
|
||||
if (!this._checkisOnMainTab()) {
|
||||
this.shouldReloadMasterTab = true;
|
||||
|
||||
@@ -37,11 +37,10 @@
|
||||
|
||||
var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/gm;
|
||||
var cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g;
|
||||
|
||||
if (!window.odoo) {
|
||||
window.odoo = {};
|
||||
if (!globalThis.odoo) {
|
||||
globalThis.odoo = {};
|
||||
}
|
||||
var odoo = window.odoo;
|
||||
var odoo = globalThis.odoo;
|
||||
var debug = odoo.debug;
|
||||
|
||||
var didLogInfoResolve;
|
||||
@@ -223,9 +222,9 @@
|
||||
});
|
||||
|
||||
if (debug || failed.length || unloaded.length) {
|
||||
var log = window.console[
|
||||
var log = globalThis.console[
|
||||
!failed.length || !unloaded.length ? "info" : "error"
|
||||
].bind(window.console);
|
||||
].bind(globalThis.console);
|
||||
log(
|
||||
(failed.length ? "error" : unloaded.length ? "warning" : "info") +
|
||||
": Some modules could not be started"
|
||||
@@ -360,7 +359,7 @@
|
||||
};
|
||||
|
||||
// Automatically log errors detected when loading modules
|
||||
window.addEventListener("load", function logWhenLoaded() {
|
||||
globalThis.addEventListener("load", function logWhenLoaded() {
|
||||
const len = jobPromises.length;
|
||||
Promise.all(jobPromises).then(function () {
|
||||
if (len === jobPromises.length) {
|
||||
|
||||
@@ -34,6 +34,8 @@ export const browser = {
|
||||
navigator: navigator,
|
||||
Notification: window.Notification,
|
||||
open: window.open.bind(window),
|
||||
SharedWorker: window.SharedWorker,
|
||||
Worker: window.Worker,
|
||||
XMLHttpRequest: window.XMLHttpRequest,
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
|
||||
@@ -9,7 +9,7 @@ Odoo Web Editor widget.
|
||||
==========================
|
||||
|
||||
""",
|
||||
'depends': ['web'],
|
||||
'depends': ['bus', 'web'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/editor_assets.xml',
|
||||
|
||||
@@ -397,7 +397,6 @@ const Wysiwyg = Widget.extend({
|
||||
}
|
||||
});
|
||||
this.call('bus_service', 'addChannel', this._collaborationChannelName);
|
||||
this.call('bus_service', 'startPolling');
|
||||
|
||||
// const syncHistory = async (fromClientId) => {
|
||||
// }
|
||||
|
||||
@@ -51,7 +51,6 @@ patchRecordMethods('LivechatButtonView', {
|
||||
this.widget._sendWelcomeMessage();
|
||||
this.widget._renderMessages();
|
||||
this.env.services.bus_service.addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid);
|
||||
this.env.services.bus_service.startPolling();
|
||||
set_cookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60);
|
||||
this.update({ isOpeningChat: false });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user