diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/bus/static | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/bus/static')
| -rw-r--r-- | addons/bus/static/src/js/crosstab_bus.js | 363 | ||||
| -rw-r--r-- | addons/bus/static/src/js/longpolling_bus.js | 262 | ||||
| -rw-r--r-- | addons/bus/static/src/js/services/bus_service.js | 154 | ||||
| -rw-r--r-- | addons/bus/static/src/js/web_client_bus.js | 109 | ||||
| -rw-r--r-- | addons/bus/static/tests/bus_tests.js | 391 | ||||
| -rw-r--r-- | addons/bus/static/tests/bus_tests_tour.js | 25 |
6 files changed, 1304 insertions, 0 deletions
diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js new file mode 100644 index 00000000..bb2b2a00 --- /dev/null +++ b/addons/bus/static/src/js/crosstab_bus.js @@ -0,0 +1,363 @@ +odoo.define('bus.CrossTab', function (require) { +"use strict"; + +var Longpolling = require('bus.Longpolling'); + +var session = require('web.session'); + +/** + * CrossTab + * + * This is an extension of the longpolling bus with browser cross-tab synchronization. + * It uses a Master/Slaves with Leader Election architecture: + * - a single tab handles longpolling. + * - tabs are synchronized by means of the local storage. + * + * localStorage used keys are: + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.channels : shared public channel list to listen during the poll + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.options : shared options + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.notification : the received notifications from the last poll + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_list : list of opened tab ids + * - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.tab_master : generated id of the master tab + * + * trigger: + * - window_focus : when the window is focused + * - notification : when a notification is receive from the long polling + * - become_master : when this tab became the master + * - no_longer_master : when this tab is not longer the master (the user swith tab) + */ +var CrossTabBus = Longpolling.extend({ + // constants + TAB_HEARTBEAT_PERIOD: 10000, // 10 seconds + MASTER_TAB_HEARTBEAT_PERIOD: 1500, // 1.5 seconds + HEARTBEAT_OUT_OF_DATE_PERIOD: 5000, // 5 seconds + HEARTBEAT_KILL_OLD_PERIOD: 15000, // 15 seconds + LOCAL_STORAGE_PREFIX: 'bus', + + // properties + _isMasterTab: false, + _isRegistered: false, + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + var now = new Date().getTime(); + // used to prefix localStorage keys + this._sanitizedOrigin = session.origin.replace(/:\/{0,2}/g, '_'); + // prevents collisions between different tabs and in tests + this._id = _.uniqueId(this.LOCAL_STORAGE_PREFIX) + ':' + now; + if (this._callLocalStorage('getItem', 'last_ts', 0) + 50000 < now) { + this._callLocalStorage('removeItem', 'last'); + } + this._lastNotificationID = this._callLocalStorage('getItem', 'last', 0); + this.call('local_storage', 'onStorage', this, this._onStorage); + }, + destroy: function () { + this._super(); + clearTimeout(this._heartbeatTimeout); + }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Share the bus channels with the others tab by the local storage + * + * @override + */ + addChannel: function () { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'channels', this._channels); + }, + /** + * Share the bus channels with the others tab by the local storage + * + * @override + */ + deleteChannel: function () { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'channels', this._channels); + }, + /** + * @return {string} + */ + getTabId: function () { + return this._id; + }, + /** + * Tells whether this bus is related to the master tab. + * + * @returns {boolean} + */ + isMasterTab: function () { + return this._isMasterTab; + }, + /** + * Use the local storage to share the long polling from the master tab. + * + * @override + */ + startPolling: function () { + if (this._isActive === null) { + this._heartbeat = this._heartbeat.bind(this); + } + if (!this._isRegistered) { + this._isRegistered = true; + + var peers = this._callLocalStorage('getItem', 'peers', {}); + peers[this._id] = new Date().getTime(); + this._callLocalStorage('setItem', 'peers', peers); + + this._registerWindowUnload(); + + if (!this._callLocalStorage('getItem', 'master')) { + this._startElection(); + } + + this._heartbeat(); + + if (this._isMasterTab) { + this._callLocalStorage('setItem', 'channels', this._channels); + this._callLocalStorage('setItem', 'options', this._options); + } else { + this._channels = this._callLocalStorage('getItem', 'channels', this._channels); + this._options = this._callLocalStorage('getItem', 'options', this._options); + } + return; // startPolling will be called again on tab registration + } + + if (this._isMasterTab) { + this._super.apply(this, arguments); + } + }, + /** + * Share the option with the local storage + * + * @override + */ + updateOption: function () { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'options', this._options); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * Call local_storage service + * + * @private + * @param {string} method (getItem, setItem, removeItem, on) + * @param {string} key + * @param {any} param + * @returns service information + */ + _callLocalStorage: function (method, key, param) { + return this.call('local_storage', method, this._generateKey(key), param); + }, + /** + * Generates localStorage keys prefixed by bus. (LOCAL_STORAGE_PREFIX = the name + * of this addon), and the sanitized origin, to prevent keys from + * conflicting when several bus instances (polling different origins) + * co-exist. + * + * @private + * @param {string} key + * @returns key prefixed with the origin + */ + _generateKey: function (key) { + return this.LOCAL_STORAGE_PREFIX + '.' + this._sanitizedOrigin + '.' + key; + }, + /** + * @override + * @returns {integer} number of milliseconds since 1 January 1970 00:00:00 + */ + _getLastPresence: function () { + return this._callLocalStorage('getItem', 'lastPresence') || this._super(); + }, + /** + * Check all the time (according to the constants) if the tab is the master tab and + * check if it is active. Use the local storage for this checks. + * + * @private + * @see _startElection method + */ + _heartbeat: function () { + var now = new Date().getTime(); + var heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0)); + var peers = this._callLocalStorage('getItem', 'peers', {}); + + if ((heartbeatValue + this.HEARTBEAT_OUT_OF_DATE_PERIOD) < now) { + // Heartbeat is out of date. Electing new master + this._startElection(); + heartbeatValue = parseInt(this._callLocalStorage('getItem', 'heartbeat', 0)); + } + + if (this._isMasterTab) { + //walk through all peers and kill old + var cleanedPeers = {}; + for (var peerName in peers) { + if (peers[peerName] + this.HEARTBEAT_KILL_OLD_PERIOD > now) { + cleanedPeers[peerName] = peers[peerName]; + } + } + + if (heartbeatValue !== this.lastHeartbeat) { + // someone else is also master... + // it should not happen, except in some race condition situation. + this._isMasterTab = false; + this.lastHeartbeat = 0; + peers[this._id] = now; + this._callLocalStorage('setItem', 'peers', peers); + this.stopPolling(); + this.trigger('no_longer_master'); + } else { + this.lastHeartbeat = now; + this._callLocalStorage('setItem', 'heartbeat', now); + this._callLocalStorage('setItem', 'peers', cleanedPeers); + } + } else { + //update own heartbeat + peers[this._id] = now; + this._callLocalStorage('setItem', 'peers', peers); + } + + // Write lastPresence in local storage if it has been updated since last heartbeat + var hbPeriod = this._isMasterTab ? this.MASTER_TAB_HEARTBEAT_PERIOD : this.TAB_HEARTBEAT_PERIOD; + if (this._lastPresenceTime + hbPeriod > now) { + this._callLocalStorage('setItem', 'lastPresence', this._lastPresenceTime); + } + + this._heartbeatTimeout = setTimeout(this._heartbeat.bind(this), hbPeriod); + }, + /** + * @private + */ + _registerWindowUnload: function () { + $(window).on('unload.' + this._id, this._onUnload.bind(this)); + }, + /** + * Check with the local storage if the current tab is the master tab. + * If this tab became the master, trigger 'become_master' event + * + * @private + */ + _startElection: function () { + if (this._isMasterTab) { + return; + } + //check who's next + var now = new Date().getTime(); + var peers = this._callLocalStorage('getItem', 'peers', {}); + var heartbeatKillOld = now - this.HEARTBEAT_KILL_OLD_PERIOD; + var newMaster; + for (var peerName in peers) { + //check for dead peers + if (peers[peerName] < heartbeatKillOld) { + continue; + } + newMaster = peerName; + break; + } + + if (newMaster === this._id) { + //we're next in queue. Electing as master + this.lastHeartbeat = now; + this._callLocalStorage('setItem', 'heartbeat', this.lastHeartbeat); + this._callLocalStorage('setItem', 'master', true); + this._isMasterTab = true; + this.startPolling(); + this.trigger('become_master'); + + //removing master peer from queue + delete peers[newMaster]; + this._callLocalStorage('setItem', 'peers', peers); + } + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * @override + */ + _onFocusChange: function (params) { + this._super.apply(this, arguments); + this._callLocalStorage('setItem', 'focus', params.focus); + }, + /** + * If it's the master tab, the notifications ares broadcasted to other tabs by the + * local storage. + * + * @override + */ + _onPoll: function (notifications) { + var notifs = this._super(notifications); + if (this._isMasterTab && notifs.length) { + this._callLocalStorage('setItem', 'last', this._lastNotificationID); + this._callLocalStorage('setItem', 'last_ts', new Date().getTime()); + this._callLocalStorage('setItem', 'notification', notifs); + } + }, + /** + * Handler when the local storage is updated + * + * @private + * @param {OdooEvent} event + * @param {string} event.key + * @param {string} event.newValue + */ + _onStorage: function (e) { + var value = JSON.parse(e.newValue); + var key = e.key; + + if (this._isRegistered && key === this._generateKey('master') && !value) { + //master was unloaded + this._startElection(); + } + + // last notification id changed + if (key === this._generateKey('last')) { + this._lastNotificationID = value || 0; + } + // notifications changed + else if (key === this._generateKey('notification')) { + if (!this._isMasterTab) { + this.trigger("notification", value); + } + } + // update channels + else if (key === this._generateKey('channels')) { + this._channels = value; + } + // update options + else if (key === this._generateKey('options')) { + this._options = value; + } + // update focus + else if (key === this._generateKey('focus')) { + this._isOdooFocused = value; + this.trigger('window_focus', this._isOdooFocused); + } + }, + /** + * Handler when unload the window + * + * @private + */ + _onUnload: function () { + // unload peer + var peers = this._callLocalStorage('getItem', 'peers') || {}; + delete peers[this._id]; + this._callLocalStorage('setItem', 'peers', peers); + + // unload master + if (this._isMasterTab) { + this._callLocalStorage('removeItem', 'master'); + } + }, +}); + +return CrossTabBus; + +}); + diff --git a/addons/bus/static/src/js/longpolling_bus.js b/addons/bus/static/src/js/longpolling_bus.js new file mode 100644 index 00000000..9c8df877 --- /dev/null +++ b/addons/bus/static/src/js/longpolling_bus.js @@ -0,0 +1,262 @@ +odoo.define('bus.Longpolling', function (require) { +"use strict"; + +var Bus = require('web.Bus'); +var ServicesMixin = require('web.ServicesMixin'); + + +/** + * Event Longpolling bus used to bind events on the server long polling return + * + * trigger: + * - window_focus : when the window focus change (true for focused, false for blur) + * - notification : when a notification is receive from the long polling + * + * @class Longpolling + */ +var LongpollingBus = Bus.extend(ServicesMixin, { + // constants + PARTNERS_PRESENCE_CHECK_PERIOD: 30000, // don't check presence more than once every 30s + ERROR_RETRY_DELAY: 10000, // 10 seconds + POLL_ROUTE: '/longpolling/poll', + + // properties + _isActive: null, + _lastNotificationID: 0, + _isOdooFocused: true, + _pollRetryTimeout: null, + + /** + * @override + */ + init: function (parent, params) { + this._super.apply(this, arguments); + this._id = _.uniqueId('bus'); + + // 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 = []; + + // bus presence + this._lastPresenceTime = new Date().getTime(); + $(window).on("focus." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: true})); + $(window).on("blur." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false})); + $(window).on("unload." + this._longPollingBusId, this._onFocusChange.bind(this, {focus: false})); + + $(window).on("click." + this._longPollingBusId, this._onPresence.bind(this)); + $(window).on("keydown." + this._longPollingBusId, this._onPresence.bind(this)); + $(window).on("keyup." + this._longPollingBusId, this._onPresence.bind(this)); + }, + /** + * @override + */ + destroy: function () { + this.stopPolling(); + $(window).off("focus." + this._longPollingBusId); + $(window).off("blur." + this._longPollingBusId); + $(window).off("unload." + this._longPollingBusId); + $(window).off("click." + this._longPollingBusId); + $(window).off("keydown." + this._longPollingBusId); + $(window).off("keyup." + this._longPollingBusId); + this._super(); + }, + //-------------------------------------------------------------------------- + // 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: function (channel) { + if (this._channels.indexOf(channel) === -1) { + this._channels.push(channel); + if (this._pollRpc) { + this._pollRpc.abort(); + } else { + this.startPolling(); + } + } + }, + /** + * 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: function (channel) { + var index = this._channels.indexOf(channel); + if (index !== -1) { + this._channels.splice(index, 1); + if (this._pollRpc) { + this._pollRpc.abort(); + } + } + }, + /** + * Tell whether odoo is focused or not + * + * @returns {boolean} + */ + isOdooFocused: function () { + return this._isOdooFocused; + }, + /** + * Start a long polling, i.e. it continually opens a long poll + * connection as long as it is not stopped (@see `stopPolling`) + */ + startPolling: function () { + 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: function () { + 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: function (key, value) { + this._options[key] = value; + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * returns the last recorded presence + * + * @private + * @returns {integer} number of milliseconds since 1 January 1970 00:00:00 + */ + _getLastPresence: function () { + return this._lastPresenceTime; + }, + /** + * 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: function () { + var self = this; + if (!this._isActive) { + return; + } + var now = new Date().getTime(); + var options = _.extend({}, this._options, { + bus_inactivity: now - this._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(); + }).guardedCatch(function (result) { + self._pollRpc = false; + // no error popup if request is interrupted or fails for any reason + result.event.preventDefault(); + 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: function(data) { + return this._rpc({route: this.POLL_ROUTE, params: data}, {shadow : true, timeout: 60000}); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * Handler when the focus of the window change. + * Trigger the 'window_focus' event. + * + * @private + * @param {Object} params + * @param {Boolean} params.focus + */ + _onFocusChange: function (params) { + this._isOdooFocused = params.focus; + if (params.focus) { + this._lastPresenceTime = new Date().getTime(); + this.trigger('window_focus', this._isOdooFocused); + } + }, + /** + * 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: function (notifications) { + var self = this; + var notifs = _.map(notifications, function (notif) { + if (notif.id > self._lastNotificationID) { + self._lastNotificationID = notif.id; + } + return [notif.channel, notif.message]; + }); + this.trigger("notification", notifs); + return notifs; + }, + /** + * Handler when they are an activity on the window (click, keydown, keyup) + * Update the last presence date. + * + * @private + */ + _onPresence: function () { + this._lastPresenceTime = new Date().getTime(); + }, +}); + +return LongpollingBus; + +}); diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js new file mode 100644 index 00000000..39b61da5 --- /dev/null +++ b/addons/bus/static/src/js/services/bus_service.js @@ -0,0 +1,154 @@ +odoo.define('bus.BusService', function (require) { +"use strict"; + +var CrossTab = require('bus.CrossTab'); +var core = require('web.core'); +var ServicesMixin = require('web.ServicesMixin'); +const session = require('web.session'); + +var BusService = CrossTab.extend(ServicesMixin, { + dependencies : ['local_storage'], + + // properties + _audio: null, + + /** + * As the BusService doesn't extend AbstractService, we have to replicate + * here what is done in AbstractService + * + * @param {Object} env + */ + init: function (env) { + this.env = env; + this._super(); + }, + + /** + * Replicate the behavior of AbstractService: + * + * Directly calls the requested service, instead of triggering a + * 'call_service' event up, which wouldn't work as services have no parent. + * + * @param {OdooEvent} ev + */ + _trigger_up: function (ev) { + if (ev.name === 'call_service') { + const payload = ev.data; + let args = payload.args || []; + if (payload.service === 'ajax' && payload.method === 'rpc') { + // ajax service uses an extra 'target' argument for rpc + args = args.concat(ev.target); + } + const service = this.env.services[payload.service]; + const result = service[payload.method].apply(service, args); + payload.callback(result); + } + }, + /** + * This method is necessary in order for this Class to be used to instantiate services + * + * @abstract + */ + start: function () {}, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Send a notification, and notify once per browser's tab + * + * @param {string} title + * @param {string} content + * @param {function} [callback] if given callback will be called when user clicks on notification + */ + sendNotification: function (title, content, callback) { + if (window.Notification && Notification.permission === "granted") { + if (this.isMasterTab()) { + try { + this._sendNativeNotification(title, content, callback); + } catch (error) { + // Notification without Serviceworker in Chrome Android doesn't works anymore + // So we fallback to do_notify() in this case + // https://bugs.chromium.org/p/chromium/issues/detail?id=481856 + if (error.message.indexOf('ServiceWorkerRegistration') > -1) { + this.do_notify(title, content); + this._beep(); + } else { + throw error; + } + } + } + } else { + this.do_notify(title, content); + if (this.isMasterTab()) { + this._beep(); + } + } + }, + /** + * Register listeners on notifications received on this bus service + * + * @param {Object} receiver + * @param {function} func + */ + onNotification: function () { + this.on.apply(this, ["notification"].concat(Array.prototype.slice.call(arguments))); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Lazily play the 'beep' audio on sent notification + * + * @private + */ + _beep: function () { + if (typeof(Audio) !== "undefined") { + if (!this._audio) { + this._audio = new Audio(); + var ext = this._audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3"; + this._audio.src = session.url("/mail/static/src/audio/ting" + ext); + } + Promise.resolve(this._audio.play()).catch(_.noop); + } + }, + /** + * Show a browser notification + * + * @private + * @param {string} title + * @param {string} content + * @param {function} [callback] if given callback will be called when user clicks on notification + */ + _sendNativeNotification: function (title, content, callback) { + var notification = new Notification( + // The native Notification API works with plain text and not HTML + // unescaping is safe because done only at the **last** step + _.unescape(title), + { + body: _.unescape(content), + icon: "/mail/static/src/img/odoobot_transparent.png" + }); + notification.onclick = function () { + window.focus(); + if (this.cancel) { + this.cancel(); + } else if (this.close) { + this.close(); + } + if (callback) { + callback(); + } + }; + }, + +}); + +core.serviceRegistry.add('bus_service', BusService); + +return BusService; + +}); diff --git a/addons/bus/static/src/js/web_client_bus.js b/addons/bus/static/src/js/web_client_bus.js new file mode 100644 index 00000000..6683b565 --- /dev/null +++ b/addons/bus/static/src/js/web_client_bus.js @@ -0,0 +1,109 @@ +odoo.define('bus.WebClient', function (require) { + "use strict"; + + const core = require('web.core'); + const WebClient = require('web.WebClient'); + + const _t = core._t; + + WebClient.include({ + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Detects the presence of assets in DOM's HEAD + * + * @override + */ + async start() { + this._assetsChangedNotificationId = null; + this._assets = {}; + await this._super(...arguments); + }, + /** + * Assigns handler to bus notification + * + * @override + */ + show_application() { + const shown = this._super(...arguments); + document.querySelectorAll('*[data-asset-xmlid]').forEach(el => { + this._assets[el.getAttribute('data-asset-xmlid')] = el.getAttribute('data-asset-version'); + }); + this.call('bus_service', 'onNotification', this, this._onNotification); + this.call('bus_service', 'addChannel', 'bundle_changed'); + return shown; + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Displays one notification on user's screen when assets have changed + * + * @private + */ + _displayBundleChangedNotification() { + if (!this._assetsChangedNotificationId) { + // Wrap the notification inside a delay. + // The server may be overwhelmed with recomputing assets + // We wait until things settle down + clearTimeout(this._bundleNotifTimerID); + this._bundleNotifTimerID = setTimeout(() => { + this._assetsChangedNotificationId = this.call('notification', 'notify', { + title: _t('Refresh'), + message: _t('The page appears to be out of date.'), + sticky: true, + onClose: () => { + this._assetsChangedNotificationId = null; + }, + buttons: [{ + text: _t('Refresh'), + primary: true, + click: () => { + window.location.reload(true); + } + }], + }); + }, this._getBundleNotificationDelay()); + } + }, + /** + * Computes a random delay to avoid hammering the server + * when bundles change with all the users reloading + * at the same time + * + * @private + * @return {number} delay in milliseconds + */ + _getBundleNotificationDelay() { + return 10000 + Math.floor(Math.random()*50) * 1000; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reacts to bus's notification + * + * @private + * @param {Array} notifications: list of received notifications + */ + _onNotification(notifications) { + for (const notif of notifications) { + if (notif[0][1] === 'bundle_changed') { + const bundleXmlId = notif[1][0]; + const bundleVersion = notif[1][1]; + if (bundleXmlId in this._assets && bundleVersion !== this._assets[bundleXmlId]) { + this._displayBundleChangedNotification(); + break; + } + } + } + } + }); +}); diff --git a/addons/bus/static/tests/bus_tests.js b/addons/bus/static/tests/bus_tests.js new file mode 100644 index 00000000..449e3ba2 --- /dev/null +++ b/addons/bus/static/tests/bus_tests.js @@ -0,0 +1,391 @@ +odoo.define('web.bus_tests', function (require) { +"use strict"; + +var BusService = require('bus.BusService'); +var CrossTabBus = require('bus.CrossTab'); +var AbstractStorageService = require('web.AbstractStorageService'); +var RamStorage = require('web.RamStorage'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + + +var LocalStorageServiceMock; + +BusService = BusService.extend({ + TAB_HEARTBEAT_PERIOD: 10, + MASTER_TAB_HEARTBEAT_PERIOD: 1, +}); + + +QUnit.module('Bus', { + beforeEach: function () { + LocalStorageServiceMock = AbstractStorageService.extend({storage: new RamStorage()}); + }, +}, function () { + QUnit.test('notifications received from the longpolling channel', async function (assert) { + assert.expect(6); + + var pollPromise = testUtils.makeTestPromise(); + + var parent = new Widget(); + await testUtils.mock.addMockEnvironment(parent, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step(route + ' - ' + args.channels.join(',')); + + pollPromise = testUtils.makeTestPromise(); + pollPromise.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromise); + return pollPromise; + } + return this._super.apply(this, arguments); + } + }); + + var widget = new Widget(parent); + await widget.appendTo($('#qunit-fixture')); + + widget.call('bus_service', 'onNotification', this, function (notifications) { + assert.step('notification - ' + notifications.toString()); + }); + widget.call('bus_service', 'addChannel', 'lambda'); + + pollPromise.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + await testUtils.nextTick(); + + pollPromise.resolve([{ + id: 2, + channel: 'lambda', + message: 'epsilon', + }]); + await testUtils.nextTick(); + + assert.verifySteps([ + '/longpolling/poll - lambda', + 'notification - lambda,beta', + '/longpolling/poll - lambda', + 'notification - lambda,epsilon', + '/longpolling/poll - lambda', + ]); + + parent.destroy(); + }); + + 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 + testUtils.mock.patch(LocalStorageServiceMock, { + getItem: function (key) { + if (key === 'last_ts') { + return 0; + } + return this._super.apply(this, arguments); + }, + }); + + var pollPromise = testUtils.makeTestPromise(); + var parent = new Widget(); + await testUtils.mock.addMockEnvironment(parent, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step(route); + assert.strictEqual(args.last, 0, + "provided last notification ID should be 0"); + + pollPromise = testUtils.makeTestPromise(); + pollPromise.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromise); + return pollPromise; + } + return this._super.apply(this, arguments); + } + }); + + var widget = new Widget(parent); + await widget.appendTo($('#qunit-fixture')); + + // trigger longpolling poll RPC + widget.call('bus_service', 'addChannel', 'lambda'); + assert.verifySteps(['/longpolling/poll']); + + testUtils.mock.unpatch(LocalStorageServiceMock); + parent.destroy(); + }); + + QUnit.test('cross tab bus share message from a channel', async function (assert) { + assert.expect(5); + + // master + + var pollPromiseMaster = testUtils.makeTestPromise(); + + var parentMaster = new Widget(); + await testUtils.mock.addMockEnvironment(parentMaster, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step('master' + ' - ' + route + ' - ' + args.channels.join(',')); + + pollPromiseMaster = testUtils.makeTestPromise(); + pollPromiseMaster.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromiseMaster); + return pollPromiseMaster; + } + return this._super.apply(this, arguments); + } + }); + + var master = new Widget(parentMaster); + await master.appendTo($('#qunit-fixture')); + + master.call('bus_service', 'onNotification', master, function (notifications) { + assert.step('master - notification - ' + notifications.toString()); + }); + master.call('bus_service', 'addChannel', 'lambda'); + + // slave + await testUtils.nextTick(); + var parentSlave = new Widget(); + await testUtils.mock.addMockEnvironment(parentSlave, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + throw new Error("Can not use the longpolling of the slave client"); + } + return this._super.apply(this, arguments); + } + }); + + var slave = new Widget(parentSlave); + await slave.appendTo($('#qunit-fixture')); + + slave.call('bus_service', 'onNotification', slave, function (notifications) { + assert.step('slave - notification - ' + notifications.toString()); + }); + slave.call('bus_service', 'addChannel', 'lambda'); + + pollPromiseMaster.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + await testUtils.nextTick(); + + assert.verifySteps([ + 'master - /longpolling/poll - lambda', + 'master - notification - lambda,beta', + 'slave - notification - lambda,beta', + 'master - /longpolling/poll - lambda', + ]); + + parentMaster.destroy(); + parentSlave.destroy(); + }); + + QUnit.test('cross tab bus elect new master on master unload', async function (assert) { + assert.expect(8); + + // master + var pollPromiseMaster = testUtils.makeTestPromise(); + + var parentMaster = new Widget(); + await testUtils.mock.addMockEnvironment(parentMaster, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step('master - ' + route + ' - ' + args.channels.join(',')); + + pollPromiseMaster = testUtils.makeTestPromise(); + pollPromiseMaster.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromiseMaster); + return pollPromiseMaster; + } + return this._super.apply(this, arguments); + } + }); + + var master = new Widget(parentMaster); + await master.appendTo($('#qunit-fixture')); + + master.call('bus_service', 'onNotification', master, function (notifications) { + assert.step('master - notification - ' + notifications.toString()); + }); + master.call('bus_service', 'addChannel', 'lambda'); + + // slave + await testUtils.nextTick(); + var parentSlave = new Widget(); + var pollPromiseSlave = testUtils.makeTestPromise(); + await testUtils.mock.addMockEnvironment(parentSlave, { + data: {}, + services: { + bus_service: BusService, + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route, args) { + if (route === '/longpolling/poll') { + assert.step('slave - ' + route + ' - ' + args.channels.join(',')); + + pollPromiseSlave = testUtils.makeTestPromise(); + pollPromiseSlave.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromiseSlave); + return pollPromiseSlave; + } + return this._super.apply(this, arguments); + } + }); + + var slave = new Widget(parentSlave); + await slave.appendTo($('#qunit-fixture')); + + slave.call('bus_service', 'onNotification', slave, function (notifications) { + assert.step('slave - notification - ' + notifications.toString()); + }); + slave.call('bus_service', 'addChannel', 'lambda'); + + pollPromiseMaster.resolve([{ + id: 1, + channel: 'lambda', + message: 'beta', + }]); + await testUtils.nextTick(); + + // simulate unloading master + master.call('bus_service', '_onUnload'); + + pollPromiseSlave.resolve([{ + id: 2, + channel: 'lambda', + message: 'gamma', + }]); + await testUtils.nextTick(); + + assert.verifySteps([ + 'master - /longpolling/poll - lambda', + 'master - notification - lambda,beta', + 'slave - notification - lambda,beta', + 'master - /longpolling/poll - lambda', + 'slave - /longpolling/poll - lambda', + 'slave - notification - lambda,gamma', + 'slave - /longpolling/poll - lambda', + ]); + + parentMaster.destroy(); + parentSlave.destroy(); + }); + + QUnit.test('two tabs calling addChannel simultaneously', async function (assert) { + assert.expect(5); + + let id = 1; + testUtils.patch(CrossTabBus, { + init: function () { + this._super.apply(this, arguments); + this.__tabId__ = id++; + }, + addChannel: function (channel) { + assert.step('Tab ' + this.__tabId__ + ': addChannel ' + channel); + this._super.apply(this, arguments); + }, + deleteChannel: function (channel) { + assert.step('Tab ' + this.__tabId__ + ': deleteChannel ' + channel); + this._super.apply(this, arguments); + }, + }); + + let pollPromise; + const parentTab1 = new Widget(); + await testUtils.addMockEnvironment(parentTab1, { + data: {}, + services: { + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route) { + if (route === '/longpolling/poll') { + pollPromise = testUtils.makeTestPromise(); + pollPromise.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromise); + return pollPromise; + } + return this._super.apply(this, arguments); + } + }); + const parentTab2 = new Widget(); + await testUtils.addMockEnvironment(parentTab2, { + data: {}, + services: { + local_storage: LocalStorageServiceMock, + }, + mockRPC: function (route) { + if (route === '/longpolling/poll') { + pollPromise = testUtils.makeTestPromise(); + pollPromise.abort = (function () { + this.reject({message: "XmlHttpRequestError abort"}, $.Event()); + }).bind(pollPromise); + return pollPromise; + } + return this._super.apply(this, arguments); + } + }); + + const tab1 = new CrossTabBus(parentTab1); + const tab2 = new CrossTabBus(parentTab2); + + tab1.addChannel("alpha"); + tab2.addChannel("alpha"); + tab1.addChannel("beta"); + tab2.addChannel("beta"); + + assert.verifySteps([ + "Tab 1: addChannel alpha", + "Tab 2: addChannel alpha", + "Tab 1: addChannel beta", + "Tab 2: addChannel beta", + ]); + + testUtils.unpatch(CrossTabBus); + parentTab1.destroy(); + parentTab2.destroy(); + }); +}); + +}); diff --git a/addons/bus/static/tests/bus_tests_tour.js b/addons/bus/static/tests/bus_tests_tour.js new file mode 100644 index 00000000..2248e0a1 --- /dev/null +++ b/addons/bus/static/tests/bus_tests_tour.js @@ -0,0 +1,25 @@ +odoo.define("bus.tour", function (require) { + "use strict"; + + const tour = require("web_tour.tour"); + + tour.register("bundle_changed_notification", { + test: true, + url: '/web', + }, [{ + trigger: '.o_web_client', + run() { + const webClient = odoo.__DEBUG__.services['web.web_client']; + const _delayFn = webClient._getBundleNotificationDelay; + webClient._getBundleNotificationDelay = () => 0; + this.call('bus_service', 'trigger', + 'notification', + [[['db_name', 'bundle_changed'], ['web.assets_backend', 'hash']]] + ); + webClient._getBundleNotificationDelay = _delayFn; + } + }, { + trigger: '.o_notification_title:contains(Refresh)', + }] + ); +}); |
