mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 23:52:02 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
19
odoo-bringout-oca-ocb-bus/bus/static/src/@types/services.d.ts
vendored
Normal file
19
odoo-bringout-oca-ocb-bus/bus/static/src/@types/services.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
declare module "services" {
|
||||
import { busParametersService } from "@bus/bus_parameters_service";
|
||||
import { legacyMultiTabService } from "@bus/legacy_multi_tab_service";
|
||||
import { outdatedPageWatcherService } from "@bus/outdated_page_watcher_service";
|
||||
import { busMonitoringservice } from "@bus/services/bus_monitoring_service";
|
||||
import { busService } from "@bus/services/bus_service";
|
||||
import { busLogsService } from "@bus/services/debug/bus_logs_service";
|
||||
import { presenceService } from "@bus/services/presence_service";
|
||||
|
||||
export interface Services {
|
||||
"bus.monitoring_service": typeof busMonitoringservice,
|
||||
"bus.outdated_page_watcher": typeof outdatedPageWatcherService,
|
||||
"bus.parameters": typeof busParametersService,
|
||||
bus_service: typeof busService,
|
||||
"bus.logs_service": typeof busLogsService,
|
||||
legacy_multi_tab: typeof legacyMultiTabService,
|
||||
presence: typeof presenceService,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const busParametersService = {
|
||||
start() {
|
||||
return {
|
||||
serverURL: window.origin,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bus.parameters", busParametersService);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Component, useRef } from "@odoo/owl";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class BusLogsMenuItem extends Component {
|
||||
static components = { DropdownItem };
|
||||
static template = "bus.BusLogsMenuItem";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.busLogsService = useService("bus.logs_service");
|
||||
this.downloadButton = useRef("downloadButton");
|
||||
}
|
||||
|
||||
onClickToggle() {
|
||||
this.busLogsService.toggleLogging();
|
||||
}
|
||||
|
||||
onClickDownload() {
|
||||
this.env.services.bus_service.downloadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("debug")
|
||||
.category("default")
|
||||
.add("bus.download_logs", () => ({
|
||||
Component: BusLogsMenuItem,
|
||||
sequence: 550,
|
||||
section: "tools",
|
||||
type: "component",
|
||||
}));
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="bus.BusLogsMenuItem">
|
||||
<DropdownItem>
|
||||
<div class="d-flex align-items-center justify-content-between" style="padding-left: 12px;">
|
||||
<div class="form-check form-switch">
|
||||
<label class="form-check-label" t-on-click.prevent.stop="onClickToggle">
|
||||
Enable Bus Logging <input class="form-check-input" t-att-checked="busLogsService.enabled" type="checkbox"/>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-light text-muted ms-2" title="Download logs" t-ref="downloadButton" t-on-click="onClickDownload"><i class="fa fa-download"/></button>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { reactive } from "@odoo/owl";
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const busLogsService = {
|
||||
dependencies: ["bus_service", "legacy_multi_tab", "worker_service"],
|
||||
/**
|
||||
* @param {import("@web/env").OdooEnv}
|
||||
* @param {Partial<import("services").Services>} services
|
||||
*/
|
||||
start(env, { bus_service, legacy_multi_tab, worker_service }) {
|
||||
const state = reactive({
|
||||
enabled: legacy_multi_tab.getSharedValue("bus_log_menu.enabled", false),
|
||||
toggleLogging() {
|
||||
state.enabled = !state.enabled;
|
||||
if (bus_service.isActive) {
|
||||
bus_service.setLoggingEnabled(state.enabled);
|
||||
}
|
||||
legacy_multi_tab.setSharedValue("bus_log_menu.enabled", state.enabled);
|
||||
},
|
||||
});
|
||||
legacy_multi_tab.bus.addEventListener("shared_value_updated", ({ detail }) => {
|
||||
if (detail.key === "bus_log_menu.enabled") {
|
||||
state.enabled = JSON.parse(detail.newValue);
|
||||
}
|
||||
});
|
||||
worker_service.connectionInitializedDeferred.then(() => {
|
||||
bus_service.setLoggingEnabled(state.enabled);
|
||||
});
|
||||
odoo.busLogging = {
|
||||
stop: () => state.enabled && state.toggleLogging(),
|
||||
start: () => !state.enabled && state.toggleLogging(),
|
||||
download: () => bus_service.downloadLogs(),
|
||||
};
|
||||
if (state.enabled) {
|
||||
console.log(
|
||||
"Bus logging is enabled. To disable it, use `odoo.busLogging.stop()`. To download the logs, use `odoo.busLogging.download()`."
|
||||
);
|
||||
}
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bus.logs_service", busLogsService);
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from '@web/core/browser/browser';
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
export const UPDATE_BUS_PRESENCE_DELAY = 60000;
|
||||
/**
|
||||
* This service updates periodically the user presence in order for the
|
||||
* im_status to be up to date.
|
||||
*
|
||||
* In order to receive bus notifications related to im_status, one must
|
||||
* register model/ids to monitor to this service.
|
||||
*/
|
||||
export const imStatusService = {
|
||||
dependencies: ['bus_service', 'multi_tab', 'presence'],
|
||||
|
||||
start(env, { bus_service, multi_tab, presence }) {
|
||||
const imStatusModelToIds = {};
|
||||
let updateBusPresenceTimeout;
|
||||
const throttledUpdateBusPresence = _.throttle(
|
||||
function updateBusPresence() {
|
||||
clearTimeout(updateBusPresenceTimeout);
|
||||
if (!multi_tab.isOnMainTab()) {
|
||||
return;
|
||||
}
|
||||
const now = new Date().getTime();
|
||||
bus_service.send("update_presence", {
|
||||
inactivity_period: now - presence.getLastPresence(),
|
||||
im_status_ids_by_model: { ...imStatusModelToIds },
|
||||
});
|
||||
updateBusPresenceTimeout = browser.setTimeout(throttledUpdateBusPresence, UPDATE_BUS_PRESENCE_DELAY);
|
||||
},
|
||||
UPDATE_BUS_PRESENCE_DELAY
|
||||
);
|
||||
|
||||
bus_service.addEventListener('connect', () => {
|
||||
// wait for im_status model/ids to be registered before starting.
|
||||
browser.setTimeout(throttledUpdateBusPresence, 250);
|
||||
});
|
||||
multi_tab.bus.addEventListener('become_main_tab', throttledUpdateBusPresence);
|
||||
bus_service.addEventListener('reconnect', throttledUpdateBusPresence);
|
||||
multi_tab.bus.addEventListener('no_longer_main_tab', () => clearTimeout(updateBusPresenceTimeout));
|
||||
bus_service.addEventListener('disconnect', () => clearTimeout(updateBusPresenceTimeout));
|
||||
|
||||
return {
|
||||
/**
|
||||
* Register model/ids whose im_status should be monitored.
|
||||
* Notification related to the im_status are then sent
|
||||
* through the bus. Overwrite registration if already
|
||||
* present.
|
||||
*
|
||||
* @param {string} model model related to the given ids.
|
||||
* @param {Number[]} ids ids whose im_status should be
|
||||
* monitored.
|
||||
*/
|
||||
registerToImStatus(model, ids) {
|
||||
if (!ids.length) {
|
||||
return this.unregisterFromImStatus(model);
|
||||
}
|
||||
imStatusModelToIds[model] = ids;
|
||||
},
|
||||
/**
|
||||
* Unregister model from im_status notifications.
|
||||
*
|
||||
* @param {string} model model to unregister.
|
||||
*/
|
||||
unregisterFromImStatus(model) {
|
||||
delete imStatusModelToIds[model];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category('services').add('im_status', imStatusService);
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { EventBus } from "@odoo/owl";
|
||||
|
||||
export const legacyMultiTabService = {
|
||||
start() {
|
||||
const bus = new EventBus();
|
||||
|
||||
// PROPERTIES
|
||||
const sanitizedOrigin = location.origin.replace(/:\/{0,2}/g, "_");
|
||||
const localStoragePrefix = `${this.name}.${sanitizedOrigin}.`;
|
||||
|
||||
function generateLocalStorageKey(baseKey) {
|
||||
return localStoragePrefix + baseKey;
|
||||
}
|
||||
|
||||
function getItemFromStorage(key, defaultValue) {
|
||||
const item = browser.localStorage.getItem(generateLocalStorageKey(key));
|
||||
try {
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
function setItemInStorage(key, value) {
|
||||
browser.localStorage.setItem(generateLocalStorageKey(key), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function onStorage({ key, newValue }) {
|
||||
if (key && key.includes(localStoragePrefix)) {
|
||||
// Only trigger the shared_value_updated event if the key is
|
||||
// related to this service/origin.
|
||||
const baseKey = key.replace(localStoragePrefix, "");
|
||||
bus.trigger("shared_value_updated", { key: baseKey, newValue });
|
||||
}
|
||||
}
|
||||
|
||||
browser.addEventListener("storage", onStorage);
|
||||
|
||||
return {
|
||||
bus,
|
||||
generateLocalStorageKey,
|
||||
getItemFromStorage,
|
||||
setItemInStorage,
|
||||
/**
|
||||
* Get value shared between all the tabs.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} defaultValue Value to be returned if this
|
||||
* key does not exist.
|
||||
*/
|
||||
getSharedValue(key, defaultValue) {
|
||||
return getItemFromStorage(key, defaultValue);
|
||||
},
|
||||
/**
|
||||
* Set value shared between all the tabs.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
setSharedValue(key, value) {
|
||||
if (value === undefined) {
|
||||
return this.removeSharedValue(key);
|
||||
}
|
||||
setItemInStorage(key, value);
|
||||
},
|
||||
/**
|
||||
* Remove value shared between all the tabs.
|
||||
*
|
||||
* @param {string} key
|
||||
*/
|
||||
removeSharedValue(key) {
|
||||
browser.localStorage.removeItem(generateLocalStorageKey(key));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("legacy_multi_tab", legacyMultiTabService);
|
||||
63
odoo-bringout-oca-ocb-bus/bus/static/src/misc.js
Normal file
63
odoo-bringout-oca-ocb-bus/bus/static/src/misc.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
/**
|
||||
* Returns a function, that, when invoked, will only be triggered at most once
|
||||
* during a given window of time. Normally, the throttled function will run
|
||||
* as much as it can, without ever going more than once per `wait` duration;
|
||||
* but if you'd like to disable the execution on the leading edge, pass
|
||||
* `{leading: false}`. To disable execution on the trailing edge, ditto.
|
||||
*
|
||||
* credit to `underscore.js`
|
||||
*/
|
||||
function throttle(func, wait, options) {
|
||||
let timeout, context, args, result;
|
||||
let previous = 0;
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
const later = function () {
|
||||
previous = options.leading === false ? 0 : luxon.DateTime.now().ts;
|
||||
timeout = null;
|
||||
result = func.apply(context, args);
|
||||
if (!timeout) {
|
||||
context = args = null;
|
||||
}
|
||||
};
|
||||
|
||||
const throttled = function () {
|
||||
const _now = luxon.DateTime.now().ts;
|
||||
if (!previous && options.leading === false) {
|
||||
previous = _now;
|
||||
}
|
||||
const remaining = wait - (_now - previous);
|
||||
context = this;
|
||||
args = arguments;
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
browser.clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
previous = _now;
|
||||
result = func.apply(context, args);
|
||||
if (!timeout) {
|
||||
context = args = null;
|
||||
}
|
||||
} else if (!timeout && options.trailing !== false) {
|
||||
timeout = browser.setTimeout(later, remaining);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
throttled.cancel = function () {
|
||||
browser.clearTimeout(timeout);
|
||||
previous = 0;
|
||||
timeout = context = args = null;
|
||||
};
|
||||
|
||||
return throttled;
|
||||
}
|
||||
|
||||
export const timings = {
|
||||
throttle,
|
||||
};
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
import { EventBus } from "@odoo/owl";
|
||||
|
||||
let multiTabId = 0;
|
||||
/**
|
||||
* This service uses a Master/Slaves with Leader Election architecture in
|
||||
* order to keep track of the main tab. Tabs are synchronized thanks to the
|
||||
* localStorage.
|
||||
*
|
||||
* localStorage used keys are:
|
||||
* - multi_tab_service.lastPresenceByTab: mapping of tab ids to their last
|
||||
* recorded presence.
|
||||
* - multi_tab_service.main: a boolean indicating whether a main tab is already
|
||||
* present.
|
||||
* - multi_tab_service.heartbeat: last main tab heartbeat time.
|
||||
*
|
||||
* trigger:
|
||||
* - become_main_tab : when this tab became the main.
|
||||
* - no_longer_main_tab : when this tab is no longer the main.
|
||||
*/
|
||||
export const multiTabFallbackService = {
|
||||
start(env) {
|
||||
const bus = new EventBus();
|
||||
|
||||
// CONSTANTS
|
||||
const TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
|
||||
const MAIN_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 seconds
|
||||
const HEARTBEAT_OUT_OF_DATE_PERIOD = 5000; // 5 seconds
|
||||
const HEARTBEAT_KILL_OLD_PERIOD = 15000; // 15 seconds
|
||||
|
||||
// PROPERTIES
|
||||
let _isOnMainTab = false;
|
||||
let lastHeartbeat = 0;
|
||||
let heartbeatTimeout;
|
||||
const now = new Date().getTime();
|
||||
const tabId = `${this.name}${multiTabId++}:${now}`;
|
||||
|
||||
function startElection() {
|
||||
if (_isOnMainTab) {
|
||||
return;
|
||||
}
|
||||
// Check who's next.
|
||||
const now = new Date().getTime();
|
||||
const lastPresenceByTab =
|
||||
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
|
||||
const heartbeatKillOld = now - HEARTBEAT_KILL_OLD_PERIOD;
|
||||
let newMain;
|
||||
for (const [tab, lastPresence] of Object.entries(lastPresenceByTab)) {
|
||||
// Check for dead tabs.
|
||||
if (lastPresence < heartbeatKillOld) {
|
||||
continue;
|
||||
}
|
||||
newMain = tab;
|
||||
break;
|
||||
}
|
||||
if (newMain === tabId) {
|
||||
// We're next in queue. Electing as main.
|
||||
lastHeartbeat = now;
|
||||
localStorage.setItem("multi_tab_service.heartbeat", lastHeartbeat);
|
||||
localStorage.setItem("multi_tab_service.main", true);
|
||||
_isOnMainTab = true;
|
||||
bus.trigger("become_main_tab");
|
||||
// Removing main peer from queue.
|
||||
delete lastPresenceByTab[newMain];
|
||||
localStorage.setItem(
|
||||
"multi_tab_service.lastPresenceByTab",
|
||||
JSON.stringify(lastPresenceByTab)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function heartbeat() {
|
||||
const now = new Date().getTime();
|
||||
let heartbeatValue = parseInt(localStorage.getItem("multi_tab_service.heartbeat") ?? 0);
|
||||
const lastPresenceByTab =
|
||||
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
|
||||
if (heartbeatValue + HEARTBEAT_OUT_OF_DATE_PERIOD < now) {
|
||||
// Heartbeat is out of date. Electing new main.
|
||||
startElection();
|
||||
heartbeatValue = parseInt(localStorage.getItem("multi_tab_service.heartbeat") ?? 0);
|
||||
}
|
||||
if (_isOnMainTab) {
|
||||
// Walk through all tabs and kill old ones.
|
||||
const cleanedTabs = {};
|
||||
for (const [tabId, lastPresence] of Object.entries(lastPresenceByTab)) {
|
||||
if (lastPresence + HEARTBEAT_KILL_OLD_PERIOD > now) {
|
||||
cleanedTabs[tabId] = lastPresence;
|
||||
}
|
||||
}
|
||||
if (heartbeatValue !== lastHeartbeat) {
|
||||
// Someone else is also main...
|
||||
// It should not happen, except in some race condition situation.
|
||||
_isOnMainTab = false;
|
||||
lastHeartbeat = 0;
|
||||
lastPresenceByTab[tabId] = now;
|
||||
localStorage.setItem(
|
||||
"multi_tab_service.lastPresenceByTab",
|
||||
JSON.stringify(lastPresenceByTab)
|
||||
);
|
||||
bus.trigger("no_longer_main_tab");
|
||||
} else {
|
||||
lastHeartbeat = now;
|
||||
localStorage.setItem("multi_tab_service.heartbeat", now);
|
||||
localStorage.setItem(
|
||||
"multi_tab_service.lastPresenceByTab",
|
||||
JSON.stringify(cleanedTabs)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Update own heartbeat.
|
||||
lastPresenceByTab[tabId] = now;
|
||||
localStorage.setItem(
|
||||
"multi_tab_service.lastPresenceByTab",
|
||||
JSON.stringify(lastPresenceByTab)
|
||||
);
|
||||
}
|
||||
const hbPeriod = _isOnMainTab ? MAIN_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
|
||||
heartbeatTimeout = browser.setTimeout(heartbeat, hbPeriod);
|
||||
}
|
||||
|
||||
function onStorage({ key, newValue }) {
|
||||
if (key === "multi_tab_service.main" && !newValue) {
|
||||
// Main was unloaded.
|
||||
startElection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister this tab from the multi-tab service. It will no longer
|
||||
* be able to become the main tab.
|
||||
*/
|
||||
function unregister() {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
const lastPresenceByTab =
|
||||
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
|
||||
delete lastPresenceByTab[tabId];
|
||||
localStorage.setItem(
|
||||
"multi_tab_service.lastPresenceByTab",
|
||||
JSON.stringify(lastPresenceByTab)
|
||||
);
|
||||
|
||||
// Unload main.
|
||||
if (_isOnMainTab) {
|
||||
_isOnMainTab = false;
|
||||
bus.trigger("no_longer_main_tab");
|
||||
browser.localStorage.removeItem("multi_tab_service.main");
|
||||
}
|
||||
}
|
||||
|
||||
browser.addEventListener("pagehide", unregister);
|
||||
browser.addEventListener("storage", onStorage);
|
||||
|
||||
// REGISTER THIS TAB
|
||||
const lastPresenceByTab =
|
||||
JSON.parse(localStorage.getItem("multi_tab_service.lastPresenceByTab")) ?? {};
|
||||
lastPresenceByTab[tabId] = now;
|
||||
localStorage.setItem(
|
||||
"multi_tab_service.lastPresenceByTab",
|
||||
JSON.stringify(lastPresenceByTab)
|
||||
);
|
||||
|
||||
if (!localStorage.getItem("multi_tab_service.main")) {
|
||||
startElection();
|
||||
}
|
||||
heartbeat();
|
||||
|
||||
return {
|
||||
bus,
|
||||
/**
|
||||
* Determine whether or not this tab is the main one.
|
||||
* it's intentionally an async function to match the API of
|
||||
* multiTabSharedWorkerService
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async isOnMainTab() {
|
||||
return _isOnMainTab;
|
||||
},
|
||||
/**
|
||||
* Unregister this tab from the multi-tab service. It will no longer
|
||||
* be able to become the main tab.
|
||||
*/
|
||||
unregister,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,224 +1,10 @@
|
|||
/** @odoo-module **/
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { multiTabFallbackService } from "@bus/multi_tab_fallback_service";
|
||||
import { multiTabSharedWorkerService } from "@bus/multi_tab_shared_worker_service";
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { browser } from '@web/core/browser/browser';
|
||||
export const multiTabService = browser.SharedWorker
|
||||
? multiTabSharedWorkerService
|
||||
: multiTabFallbackService;
|
||||
|
||||
const { EventBus } = owl;
|
||||
|
||||
let multiTabId = 0;
|
||||
/**
|
||||
* This service uses a Master/Slaves with Leader Election architecture in
|
||||
* order to keep track of the main tab. Tabs are synchronized thanks to the
|
||||
* localStorage.
|
||||
*
|
||||
* localStorage used keys are:
|
||||
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.lastPresenceByTab:
|
||||
* mapping of tab ids to their last recorded presence.
|
||||
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.main : id of the current
|
||||
* main tab.
|
||||
* - {LOCAL_STORAGE_PREFIX}.{sanitizedOrigin}.heartbeat : last main tab
|
||||
* heartbeat time.
|
||||
*
|
||||
* trigger:
|
||||
* - become_main_tab : when this tab became the main.
|
||||
* - no_longer_main_tab : when this tab is no longer the main.
|
||||
* - shared_value_updated: when one of the shared values changes.
|
||||
*/
|
||||
export const multiTabService = {
|
||||
start() {
|
||||
const bus = new EventBus();
|
||||
|
||||
// CONSTANTS
|
||||
const TAB_HEARTBEAT_PERIOD = 10000; // 10 seconds
|
||||
const MAIN_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 seconds
|
||||
const HEARTBEAT_OUT_OF_DATE_PERIOD = 5000; // 5 seconds
|
||||
const HEARTBEAT_KILL_OLD_PERIOD = 15000; // 15 seconds
|
||||
// Keys that should not trigger the `shared_value_updated` event.
|
||||
const PRIVATE_LOCAL_STORAGE_KEYS = ['main', 'heartbeat'];
|
||||
|
||||
// PROPERTIES
|
||||
let _isOnMainTab = false;
|
||||
let lastHeartbeat = 0;
|
||||
let heartbeatTimeout;
|
||||
const sanitizedOrigin = location.origin.replace(/:\/{0,2}/g, '_');
|
||||
const localStoragePrefix = `${this.name}.${sanitizedOrigin}.`;
|
||||
const now = new Date().getTime();
|
||||
const tabId = `${this.name}${multiTabId++}:${now}`;
|
||||
|
||||
function generateLocalStorageKey(baseKey) {
|
||||
return localStoragePrefix + baseKey;
|
||||
}
|
||||
|
||||
function getItemFromStorage(key, defaultValue) {
|
||||
const item = browser.localStorage.getItem(generateLocalStorageKey(key));
|
||||
try {
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
function setItemInStorage(key, value) {
|
||||
browser.localStorage.setItem(generateLocalStorageKey(key), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function startElection() {
|
||||
if (_isOnMainTab) {
|
||||
return;
|
||||
}
|
||||
// Check who's next.
|
||||
const now = new Date().getTime();
|
||||
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
|
||||
const heartbeatKillOld = now - HEARTBEAT_KILL_OLD_PERIOD;
|
||||
let newMain;
|
||||
for (const [tab, lastPresence] of Object.entries(lastPresenceByTab)) {
|
||||
// Check for dead tabs.
|
||||
if (lastPresence < heartbeatKillOld) {
|
||||
continue;
|
||||
}
|
||||
newMain = tab;
|
||||
break;
|
||||
}
|
||||
if (newMain === tabId) {
|
||||
// We're next in queue. Electing as main.
|
||||
lastHeartbeat = now;
|
||||
setItemInStorage('heartbeat', lastHeartbeat);
|
||||
setItemInStorage('main', true);
|
||||
_isOnMainTab = true;
|
||||
bus.trigger('become_main_tab');
|
||||
// Removing main peer from queue.
|
||||
delete lastPresenceByTab[newMain];
|
||||
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
|
||||
}
|
||||
}
|
||||
|
||||
function heartbeat() {
|
||||
const now = new Date().getTime();
|
||||
let heartbeatValue = getItemFromStorage('heartbeat', 0);
|
||||
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
|
||||
if (heartbeatValue + HEARTBEAT_OUT_OF_DATE_PERIOD < now) {
|
||||
// Heartbeat is out of date. Electing new main.
|
||||
startElection();
|
||||
heartbeatValue = getItemFromStorage('heartbeat', 0);
|
||||
}
|
||||
if (_isOnMainTab) {
|
||||
// Walk through all tabs and kill old ones.
|
||||
const cleanedTabs = {};
|
||||
for (const [tabId, lastPresence] of Object.entries(lastPresenceByTab)) {
|
||||
if (lastPresence + HEARTBEAT_KILL_OLD_PERIOD > now) {
|
||||
cleanedTabs[tabId] = lastPresence;
|
||||
}
|
||||
}
|
||||
if (heartbeatValue !== lastHeartbeat) {
|
||||
// Someone else is also main...
|
||||
// It should not happen, except in some race condition situation.
|
||||
_isOnMainTab = false;
|
||||
lastHeartbeat = 0;
|
||||
lastPresenceByTab[tabId] = now;
|
||||
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
|
||||
bus.trigger('no_longer_main_tab');
|
||||
} else {
|
||||
lastHeartbeat = now;
|
||||
setItemInStorage('heartbeat', now);
|
||||
setItemInStorage('lastPresenceByTab', cleanedTabs);
|
||||
}
|
||||
} else {
|
||||
// Update own heartbeat.
|
||||
lastPresenceByTab[tabId] = now;
|
||||
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
|
||||
}
|
||||
const hbPeriod = _isOnMainTab ? MAIN_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
|
||||
heartbeatTimeout = browser.setTimeout(heartbeat, hbPeriod);
|
||||
}
|
||||
|
||||
function onStorage({ key, newValue }) {
|
||||
if (key === generateLocalStorageKey('main') && !newValue) {
|
||||
// Main was unloaded.
|
||||
startElection();
|
||||
}
|
||||
if (PRIVATE_LOCAL_STORAGE_KEYS.includes(key)) {
|
||||
return;
|
||||
}
|
||||
if (key && key.includes(localStoragePrefix)) {
|
||||
// Only trigger the shared_value_updated event if the key is
|
||||
// related to this service/origin.
|
||||
const baseKey = key.replace(localStoragePrefix, '');
|
||||
bus.trigger('shared_value_updated', { key: baseKey, newValue });
|
||||
}
|
||||
}
|
||||
|
||||
function onPagehide() {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
|
||||
delete lastPresenceByTab[tabId];
|
||||
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
|
||||
|
||||
// Unload main.
|
||||
if (_isOnMainTab) {
|
||||
_isOnMainTab = false;
|
||||
bus.trigger('no_longer_main_tab');
|
||||
browser.localStorage.removeItem(generateLocalStorageKey('main'));
|
||||
}
|
||||
}
|
||||
|
||||
browser.addEventListener('pagehide', onPagehide);
|
||||
browser.addEventListener('storage', onStorage);
|
||||
|
||||
// REGISTER THIS TAB
|
||||
const lastPresenceByTab = getItemFromStorage('lastPresenceByTab', {});
|
||||
lastPresenceByTab[tabId] = now;
|
||||
setItemInStorage('lastPresenceByTab', lastPresenceByTab);
|
||||
|
||||
if (!getItemFromStorage('main')) {
|
||||
startElection();
|
||||
}
|
||||
heartbeat();
|
||||
|
||||
return {
|
||||
bus,
|
||||
get currentTabId() {
|
||||
return tabId;
|
||||
},
|
||||
/**
|
||||
* Determine whether or not this tab is the main one.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOnMainTab() {
|
||||
return _isOnMainTab;
|
||||
},
|
||||
/**
|
||||
* Get value shared between all the tabs.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} defaultValue Value to be returned if this
|
||||
* key does not exist.
|
||||
*/
|
||||
getSharedValue(key, defaultValue) {
|
||||
return getItemFromStorage(key, defaultValue);
|
||||
},
|
||||
/**
|
||||
* Set value shared between all the tabs.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
setSharedValue(key, value) {
|
||||
if (value === undefined) {
|
||||
return this.removeSharedValue(key);
|
||||
}
|
||||
setItemInStorage(key, value);
|
||||
},
|
||||
/**
|
||||
* Remove value shared between all the tabs.
|
||||
*
|
||||
* @param {string} key
|
||||
*/
|
||||
removeSharedValue(key) {
|
||||
browser.localStorage.removeItem(generateLocalStorageKey(key));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category('services').add('multi_tab', multiTabService);
|
||||
registry.category("services").add("multi_tab", multiTabService);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { EventBus } from "@odoo/owl";
|
||||
|
||||
const STATE = Object.freeze({
|
||||
INIT: "INIT",
|
||||
MASTER: "MASTER",
|
||||
REGISTERED: "REGISTERED",
|
||||
UNREGISTERED: "UNREGISTERED",
|
||||
});
|
||||
|
||||
export const multiTabSharedWorkerService = {
|
||||
dependencies: ["worker_service"],
|
||||
start(env, { worker_service: workerService }) {
|
||||
const bus = new EventBus();
|
||||
let responseDeferred = null;
|
||||
let state = STATE.INIT;
|
||||
browser.addEventListener("pagehide", unregister);
|
||||
|
||||
function messageHandler(messageEv) {
|
||||
const { type, data } = messageEv.data;
|
||||
if (!type?.startsWith("ELECTION:")) {
|
||||
return;
|
||||
}
|
||||
switch (type) {
|
||||
case "ELECTION:IS_MASTER_RESPONSE":
|
||||
responseDeferred?.resolve(data.answer);
|
||||
responseDeferred = null;
|
||||
break;
|
||||
case "ELECTION:HEARTBEAT_REQUEST":
|
||||
workerService.send("ELECTION:HEARTBEAT");
|
||||
break;
|
||||
case "ELECTION:ASSIGN_MASTER":
|
||||
state = STATE.MASTER;
|
||||
bus.trigger("become_main_tab");
|
||||
break;
|
||||
case "ELECTION:UNASSIGN_MASTER":
|
||||
if (state !== STATE.UNREGISTERED) {
|
||||
state = STATE.REGISTERED;
|
||||
}
|
||||
bus.trigger("no_longer_main_tab");
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
"multiTabSharedWorkerService received unknown message type:",
|
||||
type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWorker() {
|
||||
await workerService.ensureWorkerStarted();
|
||||
await workerService.registerHandler(messageHandler);
|
||||
workerService.send("ELECTION:REGISTER");
|
||||
state = STATE.REGISTERED;
|
||||
}
|
||||
|
||||
function unregister() {
|
||||
workerService.send("ELECTION:UNREGISTER");
|
||||
state = STATE.UNREGISTERED;
|
||||
}
|
||||
|
||||
return {
|
||||
bus,
|
||||
isOnMainTab: async () => {
|
||||
if (state === STATE.UNREGISTERED) {
|
||||
return false;
|
||||
}
|
||||
if (state === STATE.INIT) {
|
||||
await startWorker();
|
||||
}
|
||||
responseDeferred = new Deferred();
|
||||
workerService.send("ELECTION:IS_MASTER?");
|
||||
return responseDeferred;
|
||||
},
|
||||
unregister,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class OutdatedPageWatcherService {
|
||||
constructor(env, services) {
|
||||
this.setup(env, services);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("@web/env").OdooEnv}
|
||||
* @param {Partial<import("services").Services>} services
|
||||
*/
|
||||
setup(env, { bus_service, multi_tab, legacy_multi_tab, notification }) {
|
||||
this.notification = notification;
|
||||
this.multi_tab = multi_tab;
|
||||
this.legacy_multi_tab = legacy_multi_tab;
|
||||
this.lastNotificationId = legacy_multi_tab.getSharedValue("last_notification_id");
|
||||
this.closeNotificationFn;
|
||||
let wasBusAlreadyConnected;
|
||||
bus_service.addEventListener(
|
||||
"BUS:WORKER_STATE_UPDATED",
|
||||
({ detail: state }) => {
|
||||
wasBusAlreadyConnected = state !== "IDLE";
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
bus_service.addEventListener(
|
||||
"BUS:DISCONNECT",
|
||||
() =>
|
||||
(this.lastNotificationId = legacy_multi_tab.getSharedValue("last_notification_id"))
|
||||
);
|
||||
bus_service.addEventListener("BUS:CONNECT", async () => {
|
||||
if (wasBusAlreadyConnected) {
|
||||
this.checkHasMissedNotifications();
|
||||
}
|
||||
wasBusAlreadyConnected = true;
|
||||
});
|
||||
bus_service.addEventListener("BUS:RECONNECT", () => this.checkHasMissedNotifications());
|
||||
legacy_multi_tab.bus.addEventListener("shared_value_updated", ({ detail: { key } }) => {
|
||||
if (key === "bus.has_missed_notifications") {
|
||||
this.showOutdatedPageNotification();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async checkHasMissedNotifications() {
|
||||
if (!this.lastNotificationId || !(await this.multi_tab.isOnMainTab())) {
|
||||
return;
|
||||
}
|
||||
const hasMissedNotifications = await rpc(
|
||||
"/bus/has_missed_notifications",
|
||||
{ last_notification_id: this.lastNotificationId },
|
||||
{ silent: true }
|
||||
);
|
||||
if (hasMissedNotifications) {
|
||||
this.showOutdatedPageNotification();
|
||||
this.legacy_multi_tab.setSharedValue("bus.has_missed_notifications", Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
showOutdatedPageNotification() {
|
||||
this.closeNotificationFn?.();
|
||||
this.closeNotificationFn = this.notification.add(
|
||||
_t("Save your work and refresh to get the latest updates and avoid potential issues."),
|
||||
{
|
||||
title: _t("The page is out of date"),
|
||||
type: "warning",
|
||||
sticky: true,
|
||||
buttons: [
|
||||
{
|
||||
name: _t("Refresh"),
|
||||
primary: true,
|
||||
onClick: () => browser.location.reload(),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const outdatedPageWatcherService = {
|
||||
dependencies: ["bus_service", "multi_tab", "legacy_multi_tab", "notification"],
|
||||
start(env, services) {
|
||||
return new OutdatedPageWatcherService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bus.outdated_page_watcher", outdatedPageWatcherService);
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
|
|
@ -11,7 +10,11 @@ export const assetsWatchdogService = {
|
|||
let isNotificationDisplayed = false;
|
||||
let bundleNotifTimerID = null;
|
||||
|
||||
bus_service.addEventListener('notification', onNotification.bind(this));
|
||||
bus_service.subscribe("bundle_changed", ({ server_version }) => {
|
||||
if (server_version !== session.server_version) {
|
||||
displayBundleChangedNotification();
|
||||
}
|
||||
});
|
||||
bus_service.start();
|
||||
|
||||
/**
|
||||
|
|
@ -24,26 +27,23 @@ export const assetsWatchdogService = {
|
|||
// We wait until things settle down
|
||||
browser.clearTimeout(bundleNotifTimerID);
|
||||
bundleNotifTimerID = browser.setTimeout(() => {
|
||||
notification.add(
|
||||
env._t("The page appears to be out of date."),
|
||||
{
|
||||
title: env._t("Refresh"),
|
||||
type: "warning",
|
||||
sticky: true,
|
||||
buttons: [
|
||||
{
|
||||
name: env._t("Refresh"),
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
browser.location.reload();
|
||||
},
|
||||
notification.add(_t("The page appears to be out of date."), {
|
||||
title: _t("Refresh"),
|
||||
type: "warning",
|
||||
sticky: true,
|
||||
buttons: [
|
||||
{
|
||||
name: _t("Refresh"),
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
browser.location.reload();
|
||||
},
|
||||
],
|
||||
onClose: () => {
|
||||
isNotificationDisplayed = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
],
|
||||
onClose: () => {
|
||||
isNotificationDisplayed = false;
|
||||
},
|
||||
});
|
||||
isNotificationDisplayed = true;
|
||||
}, getBundleNotificationDelay());
|
||||
}
|
||||
|
|
@ -59,23 +59,6 @@ export const assetsWatchdogService = {
|
|||
function getBundleNotificationDelay() {
|
||||
return 10000 + Math.floor(Math.random() * 50) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reacts to bus's notification
|
||||
*
|
||||
* @param {CustomEvent} ev
|
||||
* @param {Array} [ev.detail] list of received notifications
|
||||
*/
|
||||
function onNotification({ detail: notifications }) {
|
||||
for (const { payload, type } of notifications) {
|
||||
if (type === 'bundle_changed') {
|
||||
if (payload.server_version !== session.server_version) {
|
||||
displayBundleChangedNotification();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { WORKER_STATE } from "@bus/workers/websocket_worker";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* Detect lost connections to the bus. A connection is considered as lost if it
|
||||
* couldn't be established after a reconnect attempt.
|
||||
*/
|
||||
export class BusMonitoringService {
|
||||
isConnectionLost = false;
|
||||
|
||||
constructor(env, services) {
|
||||
const reactiveThis = reactive(this);
|
||||
reactiveThis.setup(env, services);
|
||||
return reactiveThis;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("@web/env").OdooEnv} env
|
||||
* @param {Partial<import("services").Services>} services
|
||||
*/
|
||||
setup(env, { bus_service }) {
|
||||
bus_service.addEventListener("BUS:WORKER_STATE_UPDATED", ({ detail }) =>
|
||||
this.workerStateOnChange(detail)
|
||||
);
|
||||
browser.addEventListener("offline", () => (this.isReconnecting = false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state changes for the WebSocket worker.
|
||||
*
|
||||
* @param {WORKER_STATE[keyof WORKER_STATE]} state
|
||||
*/
|
||||
workerStateOnChange(state) {
|
||||
switch (state) {
|
||||
case WORKER_STATE.CONNECTING: {
|
||||
this.isReconnecting = true;
|
||||
break;
|
||||
}
|
||||
case WORKER_STATE.CONNECTED: {
|
||||
this.isReconnecting = false;
|
||||
this.isConnectionLost = false;
|
||||
break;
|
||||
}
|
||||
case WORKER_STATE.DISCONNECTED: {
|
||||
if (this.isReconnecting) {
|
||||
this.isConnectionLost = true;
|
||||
this.isReconnecting = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const busMonitoringservice = {
|
||||
dependencies: ["bus_service"],
|
||||
start(env, services) {
|
||||
return new BusMonitoringService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bus.monitoring_service", busMonitoringservice);
|
||||
|
|
@ -1,56 +1,57 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { registry } from '@web/core/registry';
|
||||
import { session } from '@web/session';
|
||||
import { isIosApp } from '@web/core/browser/feature_detection';
|
||||
import { WORKER_VERSION } from "@bus/workers/websocket_worker";
|
||||
import legacySession from "web.session";
|
||||
|
||||
const { EventBus } = owl;
|
||||
import { registry } from "@web/core/registry";
|
||||
import { session } from "@web/session";
|
||||
import { EventBus, reactive } from "@odoo/owl";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
// List of worker events that should not be broadcasted.
|
||||
const INTERNAL_EVENTS = new Set([
|
||||
"BUS:INITIALIZED",
|
||||
"BUS:OUTDATED",
|
||||
"BUS:NOTIFICATION",
|
||||
"BUS:PROVIDE_LOGS",
|
||||
]);
|
||||
// Slightly delay the reconnection when coming back online as the network is not
|
||||
// ready yet and the exponential backoff would delay the reconnection by a lot.
|
||||
export const BACK_ONLINE_RECONNECT_DELAY = 5000;
|
||||
/**
|
||||
* 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
|
||||
* @emits BUS:CONNECT
|
||||
* @emits BUS:DISCONNECT
|
||||
* @emits BUS:RECONNECT
|
||||
* @emits BUS:RECONNECTING
|
||||
* @emits BUS:WORKER_STATE_UPDATED
|
||||
*/
|
||||
export const busService = {
|
||||
dependencies: ['localization', 'multi_tab'],
|
||||
dependencies: [
|
||||
"bus.parameters",
|
||||
"localization",
|
||||
"multi_tab",
|
||||
"legacy_multi_tab",
|
||||
"notification",
|
||||
"worker_service",
|
||||
],
|
||||
|
||||
async start(env, { multi_tab: multiTab }) {
|
||||
const bus = new EventBus();
|
||||
let worker;
|
||||
let isActive = false;
|
||||
let isInitialized = false;
|
||||
let isUsingSharedWorker = browser.SharedWorker && !isIosApp();
|
||||
const startTs = new Date().getTime();
|
||||
const connectionInitializedDeferred = new Deferred();
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
const message = { action, data };
|
||||
if (isUsingSharedWorker) {
|
||||
worker.port.postMessage(message);
|
||||
} else {
|
||||
worker.postMessage(message);
|
||||
}
|
||||
start(
|
||||
env,
|
||||
{
|
||||
multi_tab: multiTab,
|
||||
legacy_multi_tab: legacyMultiTab,
|
||||
notification,
|
||||
"bus.parameters": params,
|
||||
worker_service: workerService,
|
||||
}
|
||||
) {
|
||||
const bus = new EventBus();
|
||||
const notificationBus = new EventBus();
|
||||
const subscribeFnToWrapper = new Map();
|
||||
let backOnlineTimeout;
|
||||
const startedAt = luxon.DateTime.now().set({ milliseconds: 0 });
|
||||
let connectionInitializedDeferred;
|
||||
|
||||
/**
|
||||
* Handle messages received from the shared worker and fires an
|
||||
|
|
@ -60,129 +61,186 @@ export const busService = {
|
|||
* @param {{type: WorkerEvent, data: any}[]} messageEv.data
|
||||
*/
|
||||
function handleMessage(messageEv) {
|
||||
const { type } = messageEv.data;
|
||||
let { data } = messageEv.data;
|
||||
if (type === 'notification') {
|
||||
multiTab.setSharedValue('last_notification_id', data[data.length - 1].id);
|
||||
data = data.map(notification => notification.message);
|
||||
} else if (type === 'initialized') {
|
||||
isInitialized = true;
|
||||
connectionInitializedDeferred.resolve();
|
||||
return;
|
||||
}
|
||||
bus.trigger(type, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the connection to the worker by sending it usefull
|
||||
* initial informations (last notification id, debug mode,
|
||||
* ...).
|
||||
*/
|
||||
function initializeWorkerConnection() {
|
||||
// User_id has different values according to its origin:
|
||||
// - frontend: number or false,
|
||||
// - backend: array with only one number
|
||||
// - guest page: array containing null or number
|
||||
// - public pages: undefined
|
||||
// Let's format it in order to ease its usage:
|
||||
// - number if user is logged, false otherwise, keep
|
||||
// undefined to indicate session_info is not available.
|
||||
let uid = Array.isArray(session.user_id) ? session.user_id[0] : session.user_id;
|
||||
if (!uid && uid !== undefined) {
|
||||
uid = false;
|
||||
}
|
||||
send('initialize_connection', {
|
||||
websocketURL: `${legacySession.prefix.replace("http", "ws")}/websocket`,
|
||||
debug: odoo.debug,
|
||||
lastNotificationId: multiTab.getSharedValue('last_notification_id', 0),
|
||||
uid,
|
||||
startTs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the "bus_service" worker.
|
||||
*/
|
||||
function startWorker() {
|
||||
let workerURL = `${legacySession.prefix}/bus/websocket_worker_bundle?v=${WORKER_VERSION}`;
|
||||
if (legacySession.prefix !== window.origin) {
|
||||
// Bus service is loaded from a different origin than the bundle
|
||||
// URL. The Worker expects an URL from this origin, give it a base64
|
||||
// URL that will then load the bundle via "importScripts" which
|
||||
// allows cross origin.
|
||||
const source = `importScripts("${workerURL}");`;
|
||||
workerURL = 'data:application/javascript;base64,' + window.btoa(source);
|
||||
}
|
||||
const workerClass = isUsingSharedWorker ? browser.SharedWorker : browser.Worker;
|
||||
worker = new workerClass(workerURL, {
|
||||
name: isUsingSharedWorker
|
||||
? 'odoo:websocket_shared_worker'
|
||||
: 'odoo:websocket_worker',
|
||||
});
|
||||
worker.addEventListener("error", (e) => {
|
||||
if (!isInitialized && workerClass === browser.SharedWorker) {
|
||||
console.warn(
|
||||
'Error while loading "bus_service" SharedWorker, fallback on Worker.'
|
||||
);
|
||||
isUsingSharedWorker = false;
|
||||
startWorker();
|
||||
} else if (!isInitialized) {
|
||||
isInitialized = true;
|
||||
connectionInitializedDeferred.resolve();
|
||||
console.warn('Bus service failed to initialized.');
|
||||
const { type, data } = messageEv.data;
|
||||
switch (type) {
|
||||
case "BUS:PROVIDE_LOGS": {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `bus_logs_${luxon.DateTime.now().toFormat(
|
||||
"yyyy-LL-dd-HH-mm-ss"
|
||||
)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
break;
|
||||
}
|
||||
case "BUS:NOTIFICATION": {
|
||||
const notifications = data.map(({ id, message }) => ({ id, ...message }));
|
||||
state.lastNotificationId = notifications.at(-1).id;
|
||||
legacyMultiTab.setSharedValue("last_notification_id", state.lastNotificationId);
|
||||
for (const { id, type, payload } of notifications) {
|
||||
notificationBus.trigger(type, { id, payload });
|
||||
busService._onMessage(env, id, type, payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "BUS:INITIALIZED": {
|
||||
connectionInitializedDeferred.resolve();
|
||||
break;
|
||||
}
|
||||
case "BUS:WORKER_STATE_UPDATED":
|
||||
state.workerState = data;
|
||||
break;
|
||||
case "BUS:OUTDATED": {
|
||||
multiTab.unregister();
|
||||
notification.add(
|
||||
_t(
|
||||
"Save your work and refresh to get the latest updates and avoid potential issues."
|
||||
),
|
||||
{
|
||||
title: _t("The page is out of date"),
|
||||
type: "warning",
|
||||
sticky: true,
|
||||
buttons: [
|
||||
{
|
||||
name: _t("Refresh"),
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
browser.location.reload();
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (isUsingSharedWorker) {
|
||||
worker.port.start();
|
||||
worker.port.addEventListener('message', handleMessage);
|
||||
} else {
|
||||
worker.addEventListener('message', handleMessage);
|
||||
}
|
||||
initializeWorkerConnection();
|
||||
if (!INTERNAL_EVENTS.has(type)) {
|
||||
bus.trigger(type, data);
|
||||
}
|
||||
}
|
||||
browser.addEventListener('pagehide', ({ persisted }) => {
|
||||
|
||||
/**
|
||||
* Start the "bus_service" workerService.
|
||||
*/
|
||||
async function ensureWorkerStarted() {
|
||||
if (!connectionInitializedDeferred) {
|
||||
connectionInitializedDeferred = new Deferred();
|
||||
let uid = Array.isArray(session.user_id) ? session.user_id[0] : user.userId;
|
||||
if (!uid && uid !== undefined) {
|
||||
uid = false;
|
||||
}
|
||||
await workerService.ensureWorkerStarted();
|
||||
await workerService.registerHandler(handleMessage);
|
||||
workerService.send("BUS:INITIALIZE_CONNECTION", {
|
||||
websocketURL: `${params.serverURL.replace("http", "ws")}/websocket?version=${
|
||||
session.websocket_worker_version
|
||||
}`,
|
||||
db: session.db,
|
||||
debug: odoo.debug,
|
||||
lastNotificationId: legacyMultiTab.getSharedValue("last_notification_id", 0),
|
||||
uid,
|
||||
startTs: startedAt.valueOf(),
|
||||
});
|
||||
}
|
||||
await connectionInitializedDeferred;
|
||||
}
|
||||
|
||||
browser.addEventListener("pagehide", ({ persisted }) => {
|
||||
if (!persisted) {
|
||||
// Page is gonna be unloaded, disconnect this client
|
||||
// from the worker.
|
||||
send('leave');
|
||||
workerService.send("BUS:LEAVE");
|
||||
}
|
||||
});
|
||||
browser.addEventListener('online', () => {
|
||||
if (isActive) {
|
||||
send('start');
|
||||
}
|
||||
});
|
||||
browser.addEventListener('offline', () => send('stop'));
|
||||
|
||||
return {
|
||||
addEventListener: bus.addEventListener.bind(bus),
|
||||
addChannel: async channel => {
|
||||
if (!worker) {
|
||||
startWorker();
|
||||
await connectionInitializedDeferred;
|
||||
}
|
||||
send('add_channel', channel);
|
||||
send('start');
|
||||
isActive = true;
|
||||
browser.addEventListener(
|
||||
"online",
|
||||
() => {
|
||||
backOnlineTimeout = browser.setTimeout(() => {
|
||||
if (state.isActive) {
|
||||
workerService.send("BUS:START");
|
||||
}
|
||||
}, BACK_ONLINE_RECONNECT_DELAY);
|
||||
},
|
||||
deleteChannel: channel => send('delete_channel', channel),
|
||||
forceUpdateChannels: () => send('force_update_channels'),
|
||||
{ capture: true }
|
||||
);
|
||||
browser.addEventListener(
|
||||
"offline",
|
||||
() => {
|
||||
clearTimeout(backOnlineTimeout);
|
||||
workerService.send("BUS:STOP");
|
||||
},
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
const state = reactive({
|
||||
addEventListener: bus.addEventListener.bind(bus),
|
||||
addChannel: async (channel) => {
|
||||
await ensureWorkerStarted();
|
||||
workerService.send("BUS:ADD_CHANNEL", channel);
|
||||
workerService.send("BUS:START");
|
||||
state.isActive = true;
|
||||
},
|
||||
deleteChannel: (channel) => {
|
||||
workerService.send("BUS:DELETE_CHANNEL", channel);
|
||||
},
|
||||
setLoggingEnabled: (isEnabled) =>
|
||||
workerService.send("BUS:SET_LOGGING_ENABLED", isEnabled),
|
||||
downloadLogs: () => workerService.send("BUS:REQUEST_LOGS"),
|
||||
forceUpdateChannels: () => workerService.send("BUS:FORCE_UPDATE_CHANNELS"),
|
||||
trigger: bus.trigger.bind(bus),
|
||||
removeEventListener: bus.removeEventListener.bind(bus),
|
||||
send: (eventName, data) => send('send', { event_name: eventName, data }),
|
||||
send: (eventName, data) =>
|
||||
workerService.send("BUS:SEND", { event_name: eventName, data }),
|
||||
start: async () => {
|
||||
if (!worker) {
|
||||
startWorker();
|
||||
await connectionInitializedDeferred;
|
||||
}
|
||||
send('start');
|
||||
isActive = true;
|
||||
await ensureWorkerStarted();
|
||||
workerService.send("BUS:START");
|
||||
state.isActive = true;
|
||||
},
|
||||
stop: () => {
|
||||
send('leave');
|
||||
isActive = false;
|
||||
workerService.send("BUS:LEAVE");
|
||||
state.isActive = false;
|
||||
},
|
||||
};
|
||||
isActive: false,
|
||||
/**
|
||||
* Subscribe to a single notification type.
|
||||
*
|
||||
* @param {string} notificationType
|
||||
* @param {function} callback
|
||||
*/
|
||||
subscribe(notificationType, callback) {
|
||||
const wrapper = ({ detail }) => {
|
||||
const { id, payload } = detail;
|
||||
callback(JSON.parse(JSON.stringify(payload)), { id });
|
||||
};
|
||||
subscribeFnToWrapper.set(callback, wrapper);
|
||||
notificationBus.addEventListener(notificationType, wrapper);
|
||||
},
|
||||
/**
|
||||
* Unsubscribe from a single notification type.
|
||||
*
|
||||
* @param {string} notificationType
|
||||
* @param {function} callback
|
||||
*/
|
||||
unsubscribe(notificationType, callback) {
|
||||
notificationBus.removeEventListener(
|
||||
notificationType,
|
||||
subscribeFnToWrapper.get(callback)
|
||||
);
|
||||
subscribeFnToWrapper.delete(callback);
|
||||
},
|
||||
startedAt,
|
||||
workerState: null,
|
||||
/** The id of the last notification received by this tab. */
|
||||
lastNotificationId: null,
|
||||
});
|
||||
return state;
|
||||
},
|
||||
/** Overriden to provide logs in tests. Use subscribe() in production. */
|
||||
_onMessage(env, id, type, payload) {},
|
||||
};
|
||||
registry.category('services').add('bus_service', busService);
|
||||
registry.category("services").add("bus_service", busService);
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
export function makeBusServiceToLegacyEnv(legacyEnv) {
|
||||
return {
|
||||
dependencies: ['bus_service'],
|
||||
start(_, { bus_service }) {
|
||||
legacyEnv.services['bus_service'] = bus_service;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registry.category('wowlToLegacyServiceMappers').add('bus_service_to_legacy_env', makeBusServiceToLegacyEnv);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
export function makeMultiTabToLegacyEnv(legacyEnv) {
|
||||
return {
|
||||
dependencies: ['multi_tab'],
|
||||
start(_, { multi_tab }) {
|
||||
legacyEnv.services['multi_tab'] = multi_tab;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registry.category('wowlToLegacyServiceMappers').add('multi_tab_to_legacy_env', makeMultiTabToLegacyEnv);
|
||||
|
|
@ -1,66 +1,66 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from '@web/core/browser/browser';
|
||||
import { registry } from '@web/core/registry';
|
||||
import core from 'web.core';
|
||||
import { EventBus } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const presenceService = {
|
||||
start(env) {
|
||||
const LOCAL_STORAGE_PREFIX = 'presence';
|
||||
|
||||
// map window_focus event from the wowlBus to the legacy one.
|
||||
env.bus.addEventListener('window_focus', isOdooFocused => {
|
||||
core.bus.trigger('window_focus', isOdooFocused);
|
||||
});
|
||||
|
||||
const LOCAL_STORAGE_PREFIX = "presence";
|
||||
const bus = new EventBus();
|
||||
let isOdooFocused = true;
|
||||
let lastPresenceTime = (
|
||||
browser.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`)
|
||||
|| new Date().getTime()
|
||||
);
|
||||
let lastPresenceTime =
|
||||
browser.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`) ||
|
||||
luxon.DateTime.now().ts;
|
||||
|
||||
function onPresence() {
|
||||
lastPresenceTime = new Date().getTime();
|
||||
lastPresenceTime = luxon.DateTime.now().ts;
|
||||
browser.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}.lastPresence`, lastPresenceTime);
|
||||
bus.trigger("presence");
|
||||
}
|
||||
|
||||
function onFocusChange(isFocused) {
|
||||
try {
|
||||
isFocused = parent.document.hasFocus();
|
||||
} catch {}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
isOdooFocused = isFocused;
|
||||
browser.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}.focus`, isOdooFocused);
|
||||
if (isOdooFocused) {
|
||||
lastPresenceTime = new Date().getTime();
|
||||
env.bus.trigger('window_focus', isOdooFocused);
|
||||
lastPresenceTime = luxon.DateTime.now().ts;
|
||||
env.bus.trigger("window_focus", isOdooFocused);
|
||||
}
|
||||
}
|
||||
|
||||
function onStorage({ key, newValue }) {
|
||||
if (key === `${LOCAL_STORAGE_PREFIX}.focus`) {
|
||||
isOdooFocused = JSON.parse(newValue);
|
||||
env.bus.trigger('window_focus', newValue);
|
||||
env.bus.trigger("window_focus", newValue);
|
||||
}
|
||||
if (key === `${LOCAL_STORAGE_PREFIX}.lastPresence`) {
|
||||
lastPresenceTime = JSON.parse(newValue);
|
||||
bus.trigger("presence");
|
||||
}
|
||||
}
|
||||
browser.addEventListener('storage', onStorage);
|
||||
browser.addEventListener('focus', () => onFocusChange(true));
|
||||
browser.addEventListener('blur', () => onFocusChange(false));
|
||||
browser.addEventListener('pagehide', () => onFocusChange(false));
|
||||
browser.addEventListener('click', onPresence);
|
||||
browser.addEventListener('keydown', onPresence);
|
||||
browser.addEventListener("storage", onStorage);
|
||||
browser.addEventListener("focus", () => onFocusChange(true));
|
||||
browser.addEventListener("blur", () => onFocusChange(false));
|
||||
browser.addEventListener("pagehide", () => onFocusChange(false));
|
||||
browser.addEventListener("click", onPresence, true);
|
||||
browser.addEventListener("keydown", onPresence, true);
|
||||
|
||||
return {
|
||||
bus,
|
||||
getLastPresence() {
|
||||
return lastPresenceTime;
|
||||
},
|
||||
isOdooFocused() {
|
||||
return isOdooFocused;
|
||||
}
|
||||
},
|
||||
getInactivityPeriod() {
|
||||
return luxon.DateTime.now().ts - this.getLastPresence();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category('services').add('presence', presenceService);
|
||||
registry.category("services").add("presence", presenceService);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { session } from "@web/session";
|
||||
|
||||
export const WORKER_STATE = Object.freeze({
|
||||
UNINITIALIZED: "UNINITIALIZED",
|
||||
INITIALIZING: "INITIALIZING",
|
||||
INITIALIZED: "INITIALIZED",
|
||||
FAILED: "FAILED",
|
||||
});
|
||||
|
||||
export class WorkerService {
|
||||
constructor(env, services) {
|
||||
this.params = services["bus.parameters"];
|
||||
this.worker = null;
|
||||
this.isUsingSharedWorker = Boolean(browser.SharedWorker);
|
||||
this._state = WORKER_STATE.UNINITIALIZED;
|
||||
this.connectionInitializedDeferred = new Deferred();
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
this._state = WORKER_STATE.INITIALIZING;
|
||||
let workerURL = `${this.params.serverURL}/bus/websocket_worker_bundle?v=${session.websocket_worker_version}`;
|
||||
if (this.params.serverURL !== window.origin) {
|
||||
// Worker service can be loaded from a different origin than the
|
||||
// bundle URL. The Worker expects an URL from this origin, give
|
||||
// it a base64 URL that will then load the bundle via "importScripts"
|
||||
// which allows cross origin.
|
||||
const source = `importScripts("${workerURL}");`;
|
||||
workerURL = "data:application/javascript;base64," + window.btoa(source);
|
||||
}
|
||||
const workerClass = this.isUsingSharedWorker ? browser.SharedWorker : browser.Worker;
|
||||
this.worker = new workerClass(workerURL, {
|
||||
name: this.isUsingSharedWorker ? "odoo:bus_shared_worker" : "odoo:bus_worker",
|
||||
});
|
||||
this.worker.onerror = (e) => this.onInitError(e);
|
||||
this._registerHandler((ev) => {
|
||||
if (ev.data.type === "BASE:INITIALIZED") {
|
||||
this._state = WORKER_STATE.INITIALIZED;
|
||||
this.connectionInitializedDeferred.resolve();
|
||||
}
|
||||
});
|
||||
if (this.isUsingSharedWorker) {
|
||||
this.worker.port.start();
|
||||
}
|
||||
this._send("BASE:INIT");
|
||||
}
|
||||
|
||||
async ensureWorkerStarted() {
|
||||
if (this._state === WORKER_STATE.UNINITIALIZED) {
|
||||
this.startWorker();
|
||||
}
|
||||
await this.connectionInitializedDeferred;
|
||||
}
|
||||
|
||||
onInitError(e) {
|
||||
// FIXME: SharedWorker can still fail for unknown reasons even when it is supported.
|
||||
if (this._state === WORKER_STATE.INITIALIZING && this.isUsingSharedWorker) {
|
||||
console.warn("Error while loading SharedWorker, fallback on Worker: ", e);
|
||||
this.isUsingSharedWorker = false;
|
||||
this.worker?.port?.close?.();
|
||||
this.startWorker();
|
||||
} else if (this._state === WORKER_STATE.INITIALIZING) {
|
||||
this._state = WORKER_STATE.FAILED;
|
||||
this.connectionInitializedDeferred.resolve();
|
||||
console.warn("Worker service failed to initialize: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandler(handler) {
|
||||
if (this.isUsingSharedWorker) {
|
||||
this.worker.port.addEventListener("message", handler);
|
||||
} else {
|
||||
this.worker.addEventListener("message", handler);
|
||||
}
|
||||
}
|
||||
|
||||
_send(action, data) {
|
||||
const message = { action, data };
|
||||
if (this.isUsingSharedWorker) {
|
||||
this.worker.port.postMessage(message);
|
||||
} else {
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the worker. If the worker is not yet started,
|
||||
* ignore the message. One should call `ensureWorkerStarted` if one
|
||||
* really needs the message to reach the worker.
|
||||
*
|
||||
* @param {String} action Action to be executed by the worker.
|
||||
* @param {Object|undefined} data Data required for the action to be
|
||||
* executed.
|
||||
*/
|
||||
async send(action, data) {
|
||||
if (this._state === WORKER_STATE.UNINITIALIZED) {
|
||||
return;
|
||||
}
|
||||
await this.connectionInitializedDeferred;
|
||||
if (this._state === WORKER_STATE.FAILED) {
|
||||
console.warn("Worker service failed to initialize, cannot send message.");
|
||||
}
|
||||
this._send(action, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a function to handle messages from the worker.
|
||||
*
|
||||
* @param {function} handler
|
||||
*/
|
||||
async registerHandler(handler) {
|
||||
if (this._state === WORKER_STATE.UNINITIALIZED) {
|
||||
this.startWorker();
|
||||
}
|
||||
await this.connectionInitializedDeferred;
|
||||
if (this._state === WORKER_STATE.FAILED) {
|
||||
console.warn("Worker service failed to initialize, cannot register handler.");
|
||||
}
|
||||
this._registerHandler(handler);
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
}
|
||||
|
||||
export const workerService = {
|
||||
dependencies: ["bus.parameters"],
|
||||
start(env, services) {
|
||||
return new WorkerService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("worker_service", workerService);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const simpleNotificationService = {
|
||||
dependencies: ["bus_service", "notification"],
|
||||
start(env, { bus_service, notification: notificationService }) {
|
||||
bus_service.subscribe("simple_notification", ({ message, sticky, title, type }) => {
|
||||
notificationService.add(message, { sticky, title, type });
|
||||
});
|
||||
bus_service.start();
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("simple_notification", simpleNotificationService);
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export class BaseWorker {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.client = null; // only for testing purposes
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
const { action } = event.data;
|
||||
if (action === "BASE:INIT") {
|
||||
if (this.name.includes("shared")) {
|
||||
event.target.postMessage({ type: "BASE:INITIALIZED" });
|
||||
} else {
|
||||
(this.client || globalThis).postMessage({ type: "BASE:INITIALIZED" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/* eslint-env worker */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
import { BaseWorker } from "./base_worker";
|
||||
import { ElectionWorker } from "./election_worker";
|
||||
import { WebsocketWorker } from "./websocket_worker";
|
||||
|
||||
(function () {
|
||||
const baseWorker = new BaseWorker(self.name);
|
||||
const websocketWorker = new WebsocketWorker(self.name);
|
||||
const electionWorker = new ElectionWorker();
|
||||
|
||||
if (self.name.includes("shared")) {
|
||||
// The script is running in a shared worker.
|
||||
onconnect = (ev) => {
|
||||
const client = ev.ports[0];
|
||||
// Register the base worker to handle first init message.
|
||||
// Register the current client for main tab election.
|
||||
client.addEventListener("message", (ev) => {
|
||||
baseWorker.handleMessage(ev);
|
||||
electionWorker.handleMessage(ev);
|
||||
});
|
||||
// let's register every tab connection to the worker in order to relay
|
||||
// notifications coming from the websocket.
|
||||
websocketWorker.registerClient(client);
|
||||
client.start();
|
||||
};
|
||||
} else {
|
||||
// The script is running in a simple web worker.
|
||||
self.addEventListener("message", (ev) => baseWorker.handleMessage(ev));
|
||||
websocketWorker.registerClient(self);
|
||||
}
|
||||
})();
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferred is basically a resolvable/rejectable extension of Promise.
|
||||
*/
|
||||
export class Deferred extends Promise {
|
||||
constructor() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const prom = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return Object.assign(prom, { resolve, reject });
|
||||
}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
static LOG_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
static gcInterval = null;
|
||||
static instances = [];
|
||||
_db;
|
||||
|
||||
static async gcOutdatedLogs() {
|
||||
const threshold = Date.now() - Logger.LOG_TTL;
|
||||
for (const logger of this.instances) {
|
||||
try {
|
||||
await logger._ensureDatabaseAvailable();
|
||||
await new Promise((res, rej) => {
|
||||
const transaction = logger._db.transaction("logs", "readwrite");
|
||||
const store = transaction.objectStore("logs");
|
||||
const req = store
|
||||
.index("timestamp")
|
||||
.openCursor(IDBKeyRange.upperBound(threshold));
|
||||
req.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
req.onerror = (e) => rej(e.target.error);
|
||||
transaction.oncomplete = res;
|
||||
transaction.onerror = (e) => rej(e.target.error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to clear logs for logger "${logger._name}":`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(name) {
|
||||
this._name = name;
|
||||
Logger.instances.push(this);
|
||||
Logger.gcOutdatedLogs();
|
||||
clearInterval(Logger.gcInterval);
|
||||
Logger.gcInterval = setInterval(() => Logger.gcOutdatedLogs(), Logger.LOG_TTL);
|
||||
}
|
||||
|
||||
async _ensureDatabaseAvailable() {
|
||||
if (this._db) {
|
||||
return;
|
||||
}
|
||||
return new Promise((res, rej) => {
|
||||
const request = indexedDB.open(this._name, 1);
|
||||
request.onsuccess = (event) => {
|
||||
this._db = event.target.result;
|
||||
res();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
if (!event.target.result.objectStoreNames.contains("logs")) {
|
||||
const store = event.target.result.createObjectStore("logs", {
|
||||
autoIncrement: true,
|
||||
});
|
||||
store.createIndex("timestamp", "timestamp", { unique: false });
|
||||
}
|
||||
};
|
||||
request.onerror = rej;
|
||||
});
|
||||
}
|
||||
|
||||
async log(message) {
|
||||
await this._ensureDatabaseAvailable();
|
||||
const transaction = this._db.transaction("logs", "readwrite");
|
||||
const store = transaction.objectStore("logs");
|
||||
const addRequest = store.add({ timestamp: Date.now(), message });
|
||||
return new Promise((res, rej) => {
|
||||
addRequest.onsuccess = res;
|
||||
addRequest.onerror = rej;
|
||||
});
|
||||
}
|
||||
|
||||
async getLogs() {
|
||||
await Logger.gcOutdatedLogs();
|
||||
await this._ensureDatabaseAvailable();
|
||||
const transaction = this._db.transaction("logs", "readonly");
|
||||
const store = transaction.objectStore("logs");
|
||||
const request = store.getAll();
|
||||
return new Promise((res, rej) => {
|
||||
request.onsuccess = (ev) => res(ev.target.result.map(({ message }) => message));
|
||||
request.onerror = rej;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { Deferred } from "@bus/workers/bus_worker_utils";
|
||||
|
||||
export class ElectionWorker {
|
||||
MAIN_TAB_TIMEOUT_PERIOD = 3000;
|
||||
|
||||
/** @type {Set<MessagePort>} */
|
||||
candidates = new Set();
|
||||
/** @type {Deferred|null} */
|
||||
electionDeferred = null;
|
||||
/** @type {number|null} */
|
||||
heartbeatRequestInterval = null;
|
||||
lastHeartbeat = Date.now();
|
||||
/** @type {Deferred|null} */
|
||||
masterReplyDeferred = null;
|
||||
/** @type {MessagePort|null} */
|
||||
masterTab = null;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => {
|
||||
if (Date.now() - this.lastHeartbeat > this.MAIN_TAB_TIMEOUT_PERIOD) {
|
||||
this.startElection();
|
||||
}
|
||||
}, this.MAIN_TAB_TIMEOUT_PERIOD);
|
||||
}
|
||||
|
||||
requestHeartbeat(messagePort) {
|
||||
if (messagePort) {
|
||||
messagePort.postMessage({ type: "ELECTION:HEARTBEAT_REQUEST" });
|
||||
return;
|
||||
}
|
||||
for (const candidate of this.candidates) {
|
||||
candidate.postMessage({ type: "ELECTION:HEARTBEAT_REQUEST" });
|
||||
}
|
||||
}
|
||||
|
||||
async ensureMasterPresence() {
|
||||
this.masterReplyDeferred ??= new Deferred();
|
||||
if (this.masterTab) {
|
||||
this.requestHeartbeat(this.masterTab);
|
||||
} else {
|
||||
this.startElection();
|
||||
}
|
||||
await this.masterReplyDeferred;
|
||||
}
|
||||
|
||||
startElection() {
|
||||
clearInterval(this.heartbeatRequestInterval);
|
||||
this.masterTab?.postMessage({ type: "ELECTION:UNASSIGN_MASTER" });
|
||||
this.masterTab = null;
|
||||
this.electionDeferred ??= new Deferred();
|
||||
this.requestHeartbeat();
|
||||
}
|
||||
|
||||
finishElection(messagePort) {
|
||||
this.masterTab = messagePort;
|
||||
messagePort.postMessage({ type: "ELECTION:ASSIGN_MASTER" });
|
||||
this.electionDeferred.resolve();
|
||||
this.electionDeferred = null;
|
||||
this.heartbeatRequestInterval = setInterval(
|
||||
() => this.requestHeartbeat(this.masterTab),
|
||||
this.MAIN_TAB_TIMEOUT_PERIOD / 2
|
||||
);
|
||||
}
|
||||
|
||||
async handleMessage(event) {
|
||||
const { action } = event.data;
|
||||
if (!action?.startsWith("ELECTION:")) {
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case "ELECTION:REGISTER":
|
||||
this.candidates.add(event.target);
|
||||
await this.electionDeferred;
|
||||
if (!this.masterTab) {
|
||||
this.startElection();
|
||||
}
|
||||
break;
|
||||
case "ELECTION:UNREGISTER":
|
||||
this.candidates.delete(event.target);
|
||||
if (this.masterTab === event.target) {
|
||||
this.startElection();
|
||||
}
|
||||
break;
|
||||
case "ELECTION:IS_MASTER?":
|
||||
await this.ensureMasterPresence();
|
||||
event.target.postMessage({
|
||||
type: "ELECTION:IS_MASTER_RESPONSE",
|
||||
data: { answer: this.masterTab === event.target },
|
||||
});
|
||||
break;
|
||||
case "ELECTION:HEARTBEAT":
|
||||
if (this.electionDeferred) {
|
||||
this.finishElection(event.target);
|
||||
}
|
||||
if (this.masterTab === event.target) {
|
||||
this.lastHeartbeat = Date.now();
|
||||
this.masterReplyDeferred?.resolve();
|
||||
this.masterReplyDeferred = null;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown message action:", action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { debounce } from '@bus/workers/websocket_worker_utils';
|
||||
import { debounce, Deferred, Logger } from "@bus/workers/bus_worker_utils";
|
||||
|
||||
/**
|
||||
* Type of events that can be sent from the worker to its clients.
|
||||
*
|
||||
* @typedef { 'connect' | 'reconnect' | 'disconnect' | 'reconnecting' | 'notification' | 'initialized' } WorkerEvent
|
||||
* @typedef { 'BUS:CONNECT' | 'BUS:RECONNECT' | 'BUS:DISCONNECT' | 'BUS:RECONNECTING' | 'BUS:NOTIFICATION' | 'BUS:INITIALIZED' | 'BUS:OUTDATED'| 'BUS:WORKER_STATE_UPDATED' | 'BUS:PROVIDE_LOGS' } WorkerEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type of action that can be sent from the client to the worker.
|
||||
*
|
||||
* @typedef {'add_channel' | 'delete_channel' | 'force_update_channels' | 'initialize_connection' | 'send' | 'leave' | 'stop' | 'start' } WorkerAction
|
||||
* @typedef {'BUS:ADD_CHANNEL' | 'BUS:DELETE_CHANNEL' | 'BUS:FORCE_UPDATE_CHANNELS' | 'BUS:INITIALIZE_CONNECTION' | 'BUS:REQUEST_LOGS' | 'BUS:SEND' | 'BUS:SET_LOGGING_ENABLED' | 'BUS:LEAVE' | 'BUS:STOP' | 'BUS:START'} WorkerAction
|
||||
*/
|
||||
|
||||
export const WEBSOCKET_CLOSE_CODES = Object.freeze({
|
||||
|
|
@ -31,12 +29,17 @@ export const WEBSOCKET_CLOSE_CODES = Object.freeze({
|
|||
SESSION_EXPIRED: 4001,
|
||||
KEEP_ALIVE_TIMEOUT: 4002,
|
||||
RECONNECTING: 4003,
|
||||
CLOSING_HANDSHAKE_ABORTED: 4004,
|
||||
});
|
||||
export const WORKER_STATE = Object.freeze({
|
||||
CONNECTED: "CONNECTED",
|
||||
DISCONNECTED: "DISCONNECTED",
|
||||
IDLE: "IDLE",
|
||||
CONNECTING: "CONNECTING",
|
||||
});
|
||||
// Should be incremented on every worker update in order to force
|
||||
// update of the worker in browser cache.
|
||||
export const WORKER_VERSION = '1.0.5';
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const MAXIMUM_RECONNECT_DELAY = 60000;
|
||||
const UUID = Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
const logger = new Logger("bus_websocket_worker");
|
||||
|
||||
/**
|
||||
* This class regroups the logic necessary in order for the
|
||||
|
|
@ -46,27 +49,49 @@ const MAXIMUM_RECONNECT_DELAY = 60000;
|
|||
* for SharedWorker and this class implements it.
|
||||
*/
|
||||
export class WebsocketWorker {
|
||||
constructor() {
|
||||
INITIAL_RECONNECT_DELAY = 1000;
|
||||
RECONNECT_JITTER = 1000;
|
||||
CONNECTION_CHECK_DELAY = 60_000;
|
||||
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
// Timestamp of start of most recent bus service sender
|
||||
this.newestStartTs = undefined;
|
||||
this.websocketURL = "";
|
||||
this.currentUID = null;
|
||||
this.currentDB = null;
|
||||
this.isWaitingForNewUID = true;
|
||||
this.channelsByClient = new Map();
|
||||
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
|
||||
this.connectRetryDelay = this.INITIAL_RECONNECT_DELAY;
|
||||
this.connectTimeout = null;
|
||||
this.debugModeByClient = new Map();
|
||||
this.isDebug = false;
|
||||
this.active = true;
|
||||
this.state = WORKER_STATE.IDLE;
|
||||
this.isReconnecting = false;
|
||||
this.lastChannelSubscription = null;
|
||||
this.loggingEnabled = null;
|
||||
this.firstSubscribeDeferred = new Deferred();
|
||||
this.lastNotificationId = 0;
|
||||
this.messageWaitQueue = [];
|
||||
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300, true);
|
||||
this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300);
|
||||
this._debouncedUpdateChannels = debounce(this._updateChannels, 300);
|
||||
this._debouncedSendToServer = debounce(this._sendToServer, 300);
|
||||
|
||||
this._onWebsocketClose = this._onWebsocketClose.bind(this);
|
||||
this._onWebsocketError = this._onWebsocketError.bind(this);
|
||||
this._onWebsocketMessage = this._onWebsocketMessage.bind(this);
|
||||
this._onWebsocketOpen = this._onWebsocketOpen.bind(this);
|
||||
|
||||
globalThis.addEventListener("error", ({ error }) => {
|
||||
const params = error instanceof Error ? [error.constructor.name, error.stack] : [error];
|
||||
this._logDebug("Unhandled error", ...params);
|
||||
});
|
||||
globalThis.addEventListener("unhandledrejection", ({ reason }) => {
|
||||
const params =
|
||||
reason instanceof Error ? [reason.constructor.name, reason.stack] : [reason];
|
||||
this._logDebug("Unhandled rejection", params);
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
|
@ -82,8 +107,9 @@ export class WebsocketWorker {
|
|||
* @param {Object} data
|
||||
*/
|
||||
broadcast(type, data) {
|
||||
this._logDebug("broadcast", type, data);
|
||||
for (const client of this.channelsByClient.keys()) {
|
||||
client.postMessage({ type, data });
|
||||
client.postMessage({ type, data: data ? JSON.parse(JSON.stringify(data)) : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,9 +119,9 @@ export class WebsocketWorker {
|
|||
* @param {MessagePort} messagePort
|
||||
*/
|
||||
registerClient(messagePort) {
|
||||
messagePort.onmessage = ev => {
|
||||
messagePort.addEventListener("message", (ev) => {
|
||||
this._onClientMessage(messagePort, ev.data);
|
||||
};
|
||||
});
|
||||
this.channelsByClient.set(messagePort, []);
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +133,10 @@ export class WebsocketWorker {
|
|||
* @param {Object} data
|
||||
*/
|
||||
sendToClient(client, type, data) {
|
||||
client.postMessage({ type, data });
|
||||
if (type !== "BUS:PROVIDE_LOGS") {
|
||||
this._logDebug("sendToClient", type, data);
|
||||
}
|
||||
client.postMessage({ type, data: data ? JSON.parse(JSON.stringify(data)) : undefined });
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
|
@ -126,22 +155,52 @@ export class WebsocketWorker {
|
|||
* action.
|
||||
*/
|
||||
_onClientMessage(client, { action, data }) {
|
||||
this._logDebug("_onClientMessage", action, data);
|
||||
switch (action) {
|
||||
case 'send':
|
||||
return this._sendToServer(data);
|
||||
case 'start':
|
||||
case "BUS:SEND": {
|
||||
if (data["event_name"] === "update_presence") {
|
||||
this._debouncedSendToServer(data);
|
||||
} else {
|
||||
this._sendToServer(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "BUS:START":
|
||||
return this._start();
|
||||
case 'stop':
|
||||
case "BUS:STOP":
|
||||
return this._stop();
|
||||
case 'leave':
|
||||
case "BUS:LEAVE":
|
||||
return this._unregisterClient(client);
|
||||
case 'add_channel':
|
||||
case "BUS:ADD_CHANNEL":
|
||||
return this._addChannel(client, data);
|
||||
case 'delete_channel':
|
||||
case "BUS:DELETE_CHANNEL":
|
||||
return this._deleteChannel(client, data);
|
||||
case 'force_update_channels':
|
||||
case "BUS:FORCE_UPDATE_CHANNELS":
|
||||
return this._forceUpdateChannels();
|
||||
case 'initialize_connection':
|
||||
case "BUS:SET_LOGGING_ENABLED":
|
||||
this.loggingEnabled = data;
|
||||
break;
|
||||
case "BUS:REQUEST_LOGS":
|
||||
logger.getLogs().then((logs) => {
|
||||
const workerInfo = {
|
||||
UUID,
|
||||
active: this.active,
|
||||
channels: [
|
||||
...new Set([].concat.apply([], [...this.channelsByClient.values()])),
|
||||
].sort(),
|
||||
db: this.currentDB,
|
||||
is_reconnecting: this.isReconnecting,
|
||||
last_subscription: this.lastChannelSubscription,
|
||||
name: this.name,
|
||||
number_of_clients: this.channelsByClient.size,
|
||||
reconnect_delay: this.connectRetryDelay,
|
||||
uid: this.currentUID,
|
||||
websocket_url: this.websocketURL,
|
||||
};
|
||||
this.sendToClient(client, "BUS:PROVIDE_LOGS", { workerInfo, logs });
|
||||
});
|
||||
break;
|
||||
case "BUS:INITIALIZE_CONNECTION":
|
||||
return this._initializeConnection(client, data);
|
||||
}
|
||||
}
|
||||
|
|
@ -154,12 +213,8 @@ export class WebsocketWorker {
|
|||
* @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();
|
||||
}
|
||||
this.channelsByClient.get(client).push(channel);
|
||||
this._debouncedUpdateChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -177,7 +232,7 @@ export class WebsocketWorker {
|
|||
const channelIndex = clientChannels.indexOf(channel);
|
||||
if (channelIndex !== -1) {
|
||||
clientChannels.splice(channelIndex, 1);
|
||||
this._updateChannels();
|
||||
this._debouncedUpdateChannels();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,14 +254,15 @@ export class WebsocketWorker {
|
|||
_unregisterClient(client) {
|
||||
this.channelsByClient.delete(client);
|
||||
this.debugModeByClient.delete(client);
|
||||
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
|
||||
this._updateChannels();
|
||||
this.isDebug = [...this.debugModeByClient.values()].some(Boolean);
|
||||
this._debouncedUpdateChannels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a client connection to this worker.
|
||||
*
|
||||
* @param {Object} param0
|
||||
* @param {string} [param0.db] Database name.
|
||||
* @param {String} [param0.debug] Current debugging mode for the
|
||||
* given client.
|
||||
* @param {Number} [param0.lastNotificationId] Last notification id
|
||||
|
|
@ -218,31 +274,38 @@ export class WebsocketWorker {
|
|||
* - undefined: not available (e.g. livechat support page)
|
||||
* @param {Number} param0.startTs Timestamp of start of bus service sender.
|
||||
*/
|
||||
_initializeConnection(client, { debug, lastNotificationId, uid, websocketURL, startTs }) {
|
||||
_initializeConnection(client, { db, debug, lastNotificationId, uid, websocketURL, startTs }) {
|
||||
if (this.newestStartTs && this.newestStartTs > startTs) {
|
||||
this.debugModeByClient[client] = debug;
|
||||
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
|
||||
this.sendToClient(client, "initialized");
|
||||
this.debugModeByClient.set(client, debug);
|
||||
this.isDebug = [...this.debugModeByClient.values()].some(Boolean);
|
||||
this.sendToClient(client, "BUS:WORKER_STATE_UPDATED", this.state);
|
||||
this.sendToClient(client, "BUS:INITIALIZED");
|
||||
return;
|
||||
}
|
||||
this.newestStartTs = startTs;
|
||||
this.websocketURL = websocketURL;
|
||||
this.lastNotificationId = lastNotificationId;
|
||||
this.debugModeByClient[client] = debug;
|
||||
this.isDebug = Object.values(this.debugModeByClient).some(debugValue => debugValue !== '');
|
||||
this.debugModeByClient.set(client, debug);
|
||||
this.isDebug = [...this.debugModeByClient.values()].some(Boolean);
|
||||
const isCurrentUserKnown = uid !== undefined;
|
||||
if (this.isWaitingForNewUID && isCurrentUserKnown) {
|
||||
this.isWaitingForNewUID = false;
|
||||
this.currentUID = uid;
|
||||
}
|
||||
if (this.currentUID !== uid && isCurrentUserKnown) {
|
||||
this.currentDB ||= db;
|
||||
if ((this.currentUID !== uid && isCurrentUserKnown) || (db && this.currentDB !== db)) {
|
||||
this.currentUID = uid;
|
||||
this.currentDB = db || this.currentDB;
|
||||
if (this.websocket) {
|
||||
this.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN);
|
||||
}
|
||||
this.channelsByClient.forEach((_, key) => this.channelsByClient.set(key, []));
|
||||
}
|
||||
this.sendToClient(client, 'initialized');
|
||||
this.sendToClient(client, "BUS:WORKER_STATE_UPDATED", this.state);
|
||||
this.sendToClient(client, "BUS:INITIALIZED");
|
||||
if (!this.active) {
|
||||
this.sendToClient(client, "BUS:OUTDATED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -287,26 +350,39 @@ export class WebsocketWorker {
|
|||
* closed.
|
||||
*/
|
||||
_onWebsocketClose({ code, reason }) {
|
||||
if (this.isDebug) {
|
||||
console.debug(`%c${new Date().toLocaleString()} - [onClose]`, 'color: #c6e; font-weight: bold;', code, reason);
|
||||
}
|
||||
clearInterval(this._connectionCheckInterval);
|
||||
this._logDebug("_onWebsocketClose", code, reason);
|
||||
this._updateState(WORKER_STATE.DISCONNECTED);
|
||||
this.lastChannelSubscription = null;
|
||||
this.firstSubscribeDeferred = new Deferred();
|
||||
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 });
|
||||
this.broadcast("BUS:DISCONNECT", { code, reason });
|
||||
if (code === WEBSOCKET_CLOSE_CODES.CLEAN) {
|
||||
if (reason === "OUTDATED_VERSION") {
|
||||
console.warn("Worker deactivated due to an outdated version.");
|
||||
this.active = false;
|
||||
this.broadcast("BUS:OUTDATED");
|
||||
}
|
||||
// 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.broadcast("BUS:RECONNECTING", { closeCode: code });
|
||||
this.isReconnecting = true;
|
||||
if (code === WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT) {
|
||||
// Don't wait to reconnect on keep alive timeout.
|
||||
if (
|
||||
[
|
||||
WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT,
|
||||
WEBSOCKET_CLOSE_CODES.CLOSING_HANDSHAKE_ABORTED,
|
||||
].includes(code)
|
||||
) {
|
||||
// Don't wait to reconnect: keep-alive shouldn't be noticed, and the
|
||||
// closing handshake was aborted because the client explicitly tried
|
||||
// to connect while the socket was stuck in the closing state.
|
||||
this.connectRetryDelay = 0;
|
||||
}
|
||||
if (code === WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED) {
|
||||
|
|
@ -319,24 +395,36 @@ export class WebsocketWorker {
|
|||
* Triggered when a connection failed or failed to established.
|
||||
*/
|
||||
_onWebsocketError() {
|
||||
if (this.isDebug) {
|
||||
console.debug(`%c${new Date().toLocaleString()} - [onError]`, 'color: #c6e; font-weight: bold;');
|
||||
}
|
||||
this._logDebug("_onWebsocketError");
|
||||
this._retryConnectionWithDelay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle data received from the bus.
|
||||
*
|
||||
* @param {MessageEvent} messageEv
|
||||
*/
|
||||
* Handle data received from the bus.
|
||||
*
|
||||
* @param {MessageEvent} messageEv
|
||||
*/
|
||||
_onWebsocketMessage(messageEv) {
|
||||
this._restartConnectionCheckInterval();
|
||||
const notifications = JSON.parse(messageEv.data);
|
||||
if (this.isDebug) {
|
||||
console.debug(`%c${new Date().toLocaleString()} - [onMessage]`, 'color: #c6e; font-weight: bold;', notifications);
|
||||
}
|
||||
this._logDebug("_onWebsocketMessage", notifications);
|
||||
this.lastNotificationId = notifications[notifications.length - 1].id;
|
||||
this.broadcast('notification', notifications);
|
||||
this.broadcast("BUS:NOTIFICATION", notifications);
|
||||
}
|
||||
|
||||
async _logDebug(title, ...args) {
|
||||
if (this.loggingEnabled) {
|
||||
try {
|
||||
await logger.log({
|
||||
dt: new Date().toISOString(),
|
||||
event: title,
|
||||
args,
|
||||
worker: UUID,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -344,16 +432,41 @@ export class WebsocketWorker {
|
|||
* the connection to open.
|
||||
*/
|
||||
_onWebsocketOpen() {
|
||||
if (this.isDebug) {
|
||||
console.debug(`%c${new Date().toLocaleString()} - [onOpen]`, 'color: #c6e; font-weight: bold;');
|
||||
}
|
||||
this._updateChannels();
|
||||
this.messageWaitQueue.forEach(msg => this.websocket.send(msg));
|
||||
this.messageWaitQueue = [];
|
||||
this.broadcast(this.isReconnecting ? 'reconnect' : 'connect');
|
||||
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
|
||||
this._logDebug("_onWebsocketOpen");
|
||||
this._updateState(WORKER_STATE.CONNECTED);
|
||||
this.broadcast(this.isReconnecting ? "BUS:RECONNECT" : "BUS:CONNECT");
|
||||
this._debouncedUpdateChannels();
|
||||
this.connectRetryDelay = this.INITIAL_RECONNECT_DELAY;
|
||||
this.connectTimeout = null;
|
||||
this.isReconnecting = false;
|
||||
this.firstSubscribeDeferred.then(() => {
|
||||
if (!this.websocket) {
|
||||
return;
|
||||
}
|
||||
this.messageWaitQueue.forEach((msg) => this.websocket.send(msg));
|
||||
this.messageWaitQueue = [];
|
||||
});
|
||||
this._restartConnectionCheckInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a custom application-level message to perform a connection check
|
||||
* on the WebSocket.
|
||||
*
|
||||
* Browsers rely on the OS's TCP mechanism, which can take minutes or
|
||||
* hours to detect a dead connection. Sending data triggers an immediate
|
||||
* I/O operation, quickly revealing any network-level failure. This must be
|
||||
* implemented at the application level because the browser WebSocket API
|
||||
* does not expose the built-in ping/pong mechanism.
|
||||
*/
|
||||
_restartConnectionCheckInterval() {
|
||||
clearInterval(this._connectionCheckInterval);
|
||||
this._connectionCheckInterval = setInterval(() => {
|
||||
if (this._isWebsocketConnected()) {
|
||||
this.websocket.send(new Uint8Array([0x00]));
|
||||
this._logDebug("connection_checked");
|
||||
}
|
||||
}, this.CONNECTION_CHECK_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -361,7 +474,10 @@ export class WebsocketWorker {
|
|||
* applied to the reconnect attempts.
|
||||
*/
|
||||
_retryConnectionWithDelay() {
|
||||
this.connectRetryDelay = Math.min(this.connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) + 1000 * Math.random();
|
||||
this.connectRetryDelay =
|
||||
Math.min(this.connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) +
|
||||
this.RECONNECT_JITTER * Math.random();
|
||||
this._logDebug("_retryConnectionWithDelay", this.connectRetryDelay);
|
||||
this.connectTimeout = setTimeout(this._start.bind(this), this.connectRetryDelay);
|
||||
}
|
||||
|
||||
|
|
@ -370,53 +486,79 @@ export class WebsocketWorker {
|
|||
* 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.
|
||||
* @param {{event_name: string, data: any }} message Message to send to the server.
|
||||
*/
|
||||
_sendToServer(message) {
|
||||
this._logDebug("_sendToServer", message);
|
||||
const payload = JSON.stringify(message);
|
||||
if (!this._isWebsocketConnected()) {
|
||||
this.messageWaitQueue.push(payload);
|
||||
if (message["event_name"] === "subscribe") {
|
||||
this.messageWaitQueue = this.messageWaitQueue.filter(
|
||||
(msg) => JSON.parse(msg).event_name !== "subscribe"
|
||||
);
|
||||
this.messageWaitQueue.unshift(payload);
|
||||
} else {
|
||||
this.messageWaitQueue.push(payload);
|
||||
}
|
||||
} else {
|
||||
this.websocket.send(payload);
|
||||
if (message["event_name"] === "subscribe") {
|
||||
this.websocket.send(payload);
|
||||
} else {
|
||||
this.firstSubscribeDeferred.then(() => this.websocket.send(payload));
|
||||
}
|
||||
this._restartConnectionCheckInterval();
|
||||
}
|
||||
}
|
||||
|
||||
_removeWebsocketListeners() {
|
||||
this.websocket?.removeEventListener("open", this._onWebsocketOpen);
|
||||
this.websocket?.removeEventListener("message", this._onWebsocketMessage);
|
||||
this.websocket?.removeEventListener("error", this._onWebsocketError);
|
||||
this.websocket?.removeEventListener("close", this._onWebsocketClose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the worker by opening a websocket connection.
|
||||
*/
|
||||
_start() {
|
||||
if (this._isWebsocketConnected() || this._isWebsocketConnecting()) {
|
||||
this._logDebug("_start");
|
||||
if (!this.active || this._isWebsocketConnected() || this._isWebsocketConnecting()) {
|
||||
return;
|
||||
}
|
||||
if (this.websocket) {
|
||||
this.websocket.removeEventListener('open', this._onWebsocketOpen);
|
||||
this.websocket.removeEventListener('message', this._onWebsocketMessage);
|
||||
this.websocket.removeEventListener('error', this._onWebsocketError);
|
||||
this.websocket.removeEventListener('close', this._onWebsocketClose);
|
||||
}
|
||||
this._removeWebsocketListeners();
|
||||
if (this._isWebsocketClosing()) {
|
||||
// close event was not triggered and will never be, broadcast the
|
||||
// disconnect event for consistency sake.
|
||||
this.lastChannelSubscription = null;
|
||||
this.broadcast("disconnect", { code: WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE });
|
||||
// The close event didn’t trigger. Trigger manually to maintain
|
||||
// correct state and lifecycle handling.
|
||||
this._onWebsocketClose(
|
||||
new CloseEvent("close", { code: WEBSOCKET_CLOSE_CODES.CLOSING_HANDSHAKE_ABORTED })
|
||||
);
|
||||
this.websocket = null;
|
||||
return;
|
||||
}
|
||||
this._updateState(WORKER_STATE.CONNECTING);
|
||||
this.websocket = new WebSocket(this.websocketURL);
|
||||
this.websocket.addEventListener('open', this._onWebsocketOpen);
|
||||
this.websocket.addEventListener('error', this._onWebsocketError);
|
||||
this.websocket.addEventListener('message', this._onWebsocketMessage);
|
||||
this.websocket.addEventListener('close', this._onWebsocketClose);
|
||||
this.websocket.addEventListener("open", this._onWebsocketOpen);
|
||||
this.websocket.addEventListener("error", this._onWebsocketError);
|
||||
this.websocket.addEventListener("message", this._onWebsocketMessage);
|
||||
this.websocket.addEventListener("close", this._onWebsocketClose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the worker.
|
||||
*/
|
||||
_stop() {
|
||||
this._logDebug("_stop");
|
||||
clearTimeout(this.connectTimeout);
|
||||
this.connectRetryDelay = INITIAL_RECONNECT_DELAY;
|
||||
this.connectRetryDelay = this.INITIAL_RECONNECT_DELAY;
|
||||
this.isReconnecting = false;
|
||||
this.lastChannelSubscription = null;
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
const shouldBroadcastClose =
|
||||
this.websocket && this.websocket.readyState !== WebSocket.CLOSED;
|
||||
this.websocket?.close();
|
||||
this._removeWebsocketListeners();
|
||||
this.websocket = null;
|
||||
if (shouldBroadcastClose) {
|
||||
this.broadcast("BUS:DISCONNECT", { code: WEBSOCKET_CLOSE_CODES.CLEAN });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -428,12 +570,28 @@ export class WebsocketWorker {
|
|||
* event if the channels haven't change since last subscription.
|
||||
*/
|
||||
_updateChannels({ force = false } = {}) {
|
||||
const allTabsChannels = [...new Set([].concat.apply([], [...this.channelsByClient.values()]))].sort();
|
||||
const allTabsChannels = [
|
||||
...new Set([].concat.apply([], [...this.channelsByClient.values()])),
|
||||
].sort();
|
||||
const allTabsChannelsString = JSON.stringify(allTabsChannels);
|
||||
const shouldUpdateChannelSubscription = allTabsChannelsString !== this.lastChannelSubscription;
|
||||
const shouldUpdateChannelSubscription =
|
||||
allTabsChannelsString !== this.lastChannelSubscription;
|
||||
if (force || shouldUpdateChannelSubscription) {
|
||||
this.lastChannelSubscription = allTabsChannelsString;
|
||||
this._sendToServer({ event_name: 'subscribe', data: { channels: allTabsChannels, last: this.lastNotificationId } });
|
||||
this._sendToServer({
|
||||
event_name: "subscribe",
|
||||
data: { channels: allTabsChannels, last: this.lastNotificationId },
|
||||
});
|
||||
this.firstSubscribeDeferred.resolve();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Update the worker state and broadcast the new state to its clients.
|
||||
*
|
||||
* @param {WORKER_STATE[keyof WORKER_STATE]} newState
|
||||
*/
|
||||
_updateState(newState) {
|
||||
this.state = newState;
|
||||
this.broadcast("BUS:WORKER_STATE_UPDATED", newState);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
/* eslint-env worker */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
import { WebsocketWorker } from "./websocket_worker";
|
||||
|
||||
(function () {
|
||||
const websocketWorker = new WebsocketWorker();
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/** @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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, test } from "@odoo/hoot";
|
||||
import { runAllTimers, waitFor } from "@odoo/hoot-dom";
|
||||
import {
|
||||
asyncStep,
|
||||
contains,
|
||||
getService,
|
||||
MockServer,
|
||||
mountWithCleanup,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { defineBusModels } from "./bus_test_helpers";
|
||||
|
||||
defineBusModels();
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("can listen on bus and display notifications in DOM", async () => {
|
||||
browser.location.addEventListener("reload", () => asyncStep("reload-page"));
|
||||
await mountWithCleanup(WebClient);
|
||||
getService("bus_service").subscribe("bundle_changed", () => asyncStep("bundle_changed"));
|
||||
MockServer.env["bus.bus"]._sendone("broadcast", "bundle_changed", {
|
||||
server_version: "NEW_MAJOR_VERSION",
|
||||
});
|
||||
await waitForSteps(["bundle_changed"]);
|
||||
await runAllTimers();
|
||||
await waitFor(".o_notification", { contains: "The page appears to be out of date." });
|
||||
await contains(".o_notification button:contains(Refresh)").click();
|
||||
await waitForSteps(["reload-page"]);
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { busService } from "@bus/services/bus_service";
|
||||
import { presenceService } from "@bus/services/presence_service";
|
||||
import { multiTabService } from "@bus/multi_tab_service";
|
||||
import { getPyEnv } from '@bus/../tests/helpers/mock_python_environment';
|
||||
|
||||
import { createWebClient } from "@web/../tests/webclient/helpers";
|
||||
import { assetsWatchdogService } from "@bus/services/assets_watchdog_service";
|
||||
import { click, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
QUnit.module("Bus Assets WatchDog", (hooks) => {
|
||||
let target;
|
||||
hooks.beforeEach((assert) => {
|
||||
serviceRegistry.add("assetsWatchdog", assetsWatchdogService);
|
||||
serviceRegistry.add("bus_service", busService);
|
||||
serviceRegistry.add("presence", presenceService);
|
||||
serviceRegistry.add("multi_tab", multiTabService);
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout(fn) {
|
||||
return this._super(fn, 0);
|
||||
},
|
||||
location: {
|
||||
reload: () => assert.step("reloadPage"),
|
||||
},
|
||||
});
|
||||
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.test("can listen on bus and displays notifications in DOM", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
await createWebClient({});
|
||||
const pyEnv = await getPyEnv();
|
||||
const { afterNextRender } = owl.App;
|
||||
await afterNextRender(() => {
|
||||
pyEnv['bus.bus']._sendone("broadcast", "bundle_changed", {
|
||||
server_version: "NEW_MAJOR_VERSION"
|
||||
});
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_notification_body");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_notification_body .o_notification_content").textContent,
|
||||
"The page appears to be out of date."
|
||||
);
|
||||
|
||||
// reload by clicking on the reload button
|
||||
await click(target, ".o_notification_buttons .btn-primary");
|
||||
assert.verifySteps(["reloadPage"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Logger } from "@bus/workers/bus_worker_utils";
|
||||
|
||||
import { after, before, describe, expect, test } from "@odoo/hoot";
|
||||
import { advanceTime } from "@odoo/hoot-dom";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
before(() => indexedDB.deleteDatabase("test_db"));
|
||||
after(() => indexedDB.deleteDatabase("test_db"));
|
||||
|
||||
test("logs are saved and garbage-collected after TTL", async () => {
|
||||
indexedDB.deleteDatabase("test_db");
|
||||
const logger = new Logger("test_db");
|
||||
await logger.log("foo");
|
||||
await logger.log("bar");
|
||||
expect(await logger.getLogs()).toEqual(["foo", "bar"]);
|
||||
await advanceTime(Logger.LOG_TTL + 1000);
|
||||
expect(await logger.getLogs()).toEqual([]);
|
||||
indexedDB.deleteDatabase("test_db");
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
addBusServiceListeners,
|
||||
defineBusModels,
|
||||
lockWebsocketConnect,
|
||||
} from "@bus/../tests/bus_test_helpers";
|
||||
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { manuallyDispatchProgrammaticEvent, runAllTimers } from "@odoo/hoot-dom";
|
||||
import {
|
||||
asyncStep,
|
||||
getService,
|
||||
makeMockEnv,
|
||||
MockServer,
|
||||
mockService,
|
||||
patchWithCleanup,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
defineBusModels();
|
||||
describe.current.tags("desktop");
|
||||
|
||||
function stepConnectionStateChanges() {
|
||||
mockService("bus.monitoring_service", {
|
||||
get isConnectionLost() {
|
||||
return this._isConnectionLost;
|
||||
},
|
||||
set isConnectionLost(value) {
|
||||
if (value !== this._isConnectionLost) {
|
||||
asyncStep(`isConnectionLost - ${value}`);
|
||||
}
|
||||
this._isConnectionLost = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("connection considered as lost after failed reconnect attempt", async () => {
|
||||
stepConnectionStateChanges();
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
await waitForSteps(["isConnectionLost - false", "BUS:CONNECT"]);
|
||||
const unlockWebsocket = lockWebsocketConnect();
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["isConnectionLost - true"]);
|
||||
unlockWebsocket();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["isConnectionLost - false"]);
|
||||
});
|
||||
|
||||
test("brief disconect not considered lost", async () => {
|
||||
stepConnectionStateChanges();
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
await waitForSteps(["isConnectionLost - false", "BUS:CONNECT"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.SESSION_EXPIRED);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:RECONNECT"]); // Only reconnect step, which means the monitoring state didn't change.
|
||||
});
|
||||
|
||||
test("computer sleep doesn't mark connection as lost", async () => {
|
||||
stepConnectionStateChanges();
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
await waitForSteps(["isConnectionLost - false", "BUS:CONNECT"]);
|
||||
const unlockWebsocket = lockWebsocketConnect();
|
||||
patchWithCleanup(navigator, { onLine: false });
|
||||
await manuallyDispatchProgrammaticEvent(window, "offline"); // Offline event is triggered when the computer goes to sleep.
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
patchWithCleanup(navigator, { onLine: true });
|
||||
await manuallyDispatchProgrammaticEvent(window, "online"); // Online event is triggered when the computer wakes up.
|
||||
unlockWebsocket();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
expect(getService("bus.monitoring_service").isConnectionLost).toBe(false);
|
||||
});
|
||||
563
odoo-bringout-oca-ocb-bus/bus/static/tests/bus_service.test.js
Normal file
563
odoo-bringout-oca-ocb-bus/bus/static/tests/bus_service.test.js
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
import {
|
||||
addBusServiceListeners,
|
||||
defineBusModels,
|
||||
startBusService,
|
||||
stepWorkerActions,
|
||||
waitForChannels,
|
||||
waitNotifications,
|
||||
} from "@bus/../tests/bus_test_helpers";
|
||||
import {
|
||||
WEBSOCKET_CLOSE_CODES,
|
||||
WebsocketWorker,
|
||||
WORKER_STATE,
|
||||
} from "@bus/workers/websocket_worker";
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { Deferred, manuallyDispatchProgrammaticEvent, runAllTimers, waitFor } from "@odoo/hoot-dom";
|
||||
import { mockWebSocket } from "@odoo/hoot-mock";
|
||||
import {
|
||||
asyncStep,
|
||||
contains,
|
||||
getService,
|
||||
makeMockEnv,
|
||||
makeMockServer,
|
||||
MockServer,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
restoreRegistry,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { getWebSocketWorker, onWebsocketEvent } from "./mock_websocket";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { session } from "@web/session";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
|
||||
defineBusModels();
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("notifications not received after stoping the service", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
stepWorkerActions("BUS:LEAVE");
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
startBusService(firstTabEnv);
|
||||
startBusService(secondTabEnv);
|
||||
firstTabEnv.services.bus_service.addChannel("lambda");
|
||||
await waitForChannels(["lambda"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "beta");
|
||||
await waitNotifications(
|
||||
[firstTabEnv, "notifType", "beta"],
|
||||
[secondTabEnv, "notifType", "beta"]
|
||||
);
|
||||
secondTabEnv.services.bus_service.stop();
|
||||
await waitForSteps(["BUS:LEAVE"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "epsilon");
|
||||
await waitNotifications(
|
||||
[firstTabEnv, "notifType", "epsilon"],
|
||||
[secondTabEnv, "notifType", "epsilon", { received: false }]
|
||||
);
|
||||
});
|
||||
|
||||
test("notifications still received after disconnect/reconnect", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
getService("bus_service").addChannel("lambda");
|
||||
await waitForChannels(["lambda"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "beta");
|
||||
await waitNotifications(["notifType", "beta"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:RECONNECT"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "gamma");
|
||||
await waitNotifications(["notifType", "gamma"]);
|
||||
});
|
||||
|
||||
test("notifications are received by each tab", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
firstTabEnv.services.bus_service.addChannel("lambda");
|
||||
secondTabEnv.services.bus_service.addChannel("lambda");
|
||||
await waitForChannels(["lambda"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "beta");
|
||||
await waitNotifications(
|
||||
[firstTabEnv, "notifType", "beta"],
|
||||
[secondTabEnv, "notifType", "beta"]
|
||||
);
|
||||
});
|
||||
|
||||
test("second tab still receives notifications after main pagehide", async () => {
|
||||
const mainEnv = await makeMockEnv();
|
||||
stepWorkerActions("BUS:LEAVE");
|
||||
mainEnv.services.bus_service.addChannel("lambda");
|
||||
// Prevent second tab from receiving pagehide event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName !== "pagehide") {
|
||||
super.addEventListener(eventName, callback);
|
||||
}
|
||||
},
|
||||
});
|
||||
const worker = getWebSocketWorker();
|
||||
patchWithCleanup(worker, {
|
||||
_unregisterClient(client) {
|
||||
// Ensure that the worker does not receive any messages from the main tab
|
||||
// after pagehide, mimicking real-world behavior.
|
||||
client.onmessage = null;
|
||||
super._unregisterClient(client);
|
||||
},
|
||||
});
|
||||
restoreRegistry(registry);
|
||||
const secondEnv = await makeMockEnv(null, { makeNew: true });
|
||||
secondEnv.services.bus_service.addChannel("lambda");
|
||||
await waitForChannels(["lambda"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "beta");
|
||||
await waitNotifications([mainEnv, "notifType", "beta"], [secondEnv, "notifType", "beta"]);
|
||||
// simulate unloading main
|
||||
await manuallyDispatchProgrammaticEvent(window, "pagehide");
|
||||
await waitForSteps(["BUS:LEAVE"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "gamma");
|
||||
await waitNotifications(
|
||||
[mainEnv, "notifType", "gamma", { received: false }],
|
||||
[secondEnv, "notifType", "gamma"]
|
||||
);
|
||||
});
|
||||
|
||||
test("add two different channels from different tabs", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
firstTabEnv.services.bus_service.addChannel("alpha");
|
||||
secondTabEnv.services.bus_service.addChannel("beta");
|
||||
await waitForChannels(["alpha", "beta"]);
|
||||
MockServer.env["bus.bus"]._sendmany([
|
||||
["alpha", "notifType", "alpha"],
|
||||
["beta", "notifType", "beta"],
|
||||
]);
|
||||
await waitNotifications(
|
||||
[firstTabEnv, "notifType", "alpha"],
|
||||
[secondTabEnv, "notifType", "alpha"],
|
||||
[firstTabEnv, "notifType", "beta"],
|
||||
[secondTabEnv, "notifType", "beta"]
|
||||
);
|
||||
});
|
||||
|
||||
test("channel management from multiple tabs", async () => {
|
||||
onWebsocketEvent("subscribe", (data) => asyncStep(`subscribe - [${data.channels.toString()}]`));
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
firstTabEnv.services.bus_service.addChannel("channel1");
|
||||
await waitForSteps(["subscribe - [channel1]"]);
|
||||
// Already known: no subscription.
|
||||
secondTabEnv.services.bus_service.addChannel("channel1");
|
||||
// Remove from tab1, but tab2 still listens: no subscription.
|
||||
firstTabEnv.services.bus_service.deleteChannel("channel1");
|
||||
// New channel: subscription.
|
||||
secondTabEnv.services.bus_service.addChannel("channel2");
|
||||
await waitForSteps(["subscribe - [channel1,channel2]"]);
|
||||
// Removing last listener of channel1: subscription.
|
||||
secondTabEnv.services.bus_service.deleteChannel("channel1");
|
||||
await waitForSteps(["subscribe - [channel2]"]);
|
||||
});
|
||||
|
||||
test("re-subscribe on reconnect", async () => {
|
||||
onWebsocketEvent("subscribe", (data) => asyncStep(`subscribe - [${data.channels.toString()}]`));
|
||||
addBusServiceListeners(["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]);
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await waitForSteps(["subscribe - []"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:RECONNECT", "subscribe - []"]);
|
||||
});
|
||||
|
||||
test("pass last notification id on initialization", async () => {
|
||||
patchWithCleanup(WebsocketWorker.prototype, {
|
||||
_onClientMessage(_client, { action, data }) {
|
||||
if (action === "BUS:INITIALIZE_CONNECTION") {
|
||||
asyncStep(`${action} - ${data["lastNotificationId"]}`);
|
||||
}
|
||||
return super._onClientMessage(...arguments);
|
||||
},
|
||||
});
|
||||
const firstEnv = await makeMockEnv();
|
||||
startBusService(firstEnv);
|
||||
await waitForSteps(["BUS:INITIALIZE_CONNECTION - 0"]);
|
||||
firstEnv.services.bus_service.addChannel("lambda");
|
||||
await waitForChannels(["lambda"]);
|
||||
MockServer.env["bus.bus"]._sendone("lambda", "notifType", "beta");
|
||||
await waitNotifications([firstEnv, "notifType", "beta"]);
|
||||
restoreRegistry(registry);
|
||||
const secondEnv = await makeMockEnv(null, { makeNew: true });
|
||||
startBusService(secondEnv);
|
||||
await waitForSteps([`BUS:INITIALIZE_CONNECTION - 1`]);
|
||||
});
|
||||
|
||||
test("websocket disconnects when user logs out", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
patchWithCleanup(session, { user_id: null, db: "openerp" });
|
||||
patchWithCleanup(user, { userId: 1 });
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
await startBusService(firstTabEnv);
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
// second tab connects to the worker, omitting the DB name. Consider same DB.
|
||||
patchWithCleanup(session, { db: undefined });
|
||||
restoreRegistry(registry);
|
||||
const env2 = await makeMockEnv(null, { makeNew: true });
|
||||
await startBusService(env2);
|
||||
await waitForSteps([]);
|
||||
// third tab connects to the worker after disconnection: userId is now false.
|
||||
patchWithCleanup(user, { userId: false });
|
||||
restoreRegistry(registry);
|
||||
const env3 = await makeMockEnv(null, { makeNew: true });
|
||||
await startBusService(env3);
|
||||
await waitForSteps(["BUS:DISCONNECT", "BUS:CONNECT"]);
|
||||
});
|
||||
|
||||
test("websocket reconnects upon user log in", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
patchWithCleanup(session, { user_id: null });
|
||||
patchWithCleanup(user, { userId: false });
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
patchWithCleanup(user, { userId: 1 });
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
startBusService(secondTabEnv);
|
||||
await waitForSteps(["BUS:DISCONNECT", "BUS:CONNECT"]);
|
||||
});
|
||||
|
||||
test("websocket connects with URL corresponding to given serverURL", async () => {
|
||||
const serverURL = "http://random-website.com";
|
||||
mockService("bus.parameters", { serverURL });
|
||||
await makeMockEnv();
|
||||
mockWebSocket((ws) => asyncStep(ws.url));
|
||||
startBusService();
|
||||
await waitForSteps([
|
||||
`${serverURL.replace("http", "ws")}/websocket?version=${session.websocket_worker_version}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("disconnect on offline, re-connect on online", async () => {
|
||||
browser.addEventListener("online", () => asyncStep("online"));
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
manuallyDispatchProgrammaticEvent(window, "offline");
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
manuallyDispatchProgrammaticEvent(window, "online");
|
||||
await waitForSteps(["online"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
});
|
||||
|
||||
test("no disconnect on offline/online when bus is inactive", async () => {
|
||||
browser.addEventListener("online", () => asyncStep("online"));
|
||||
browser.addEventListener("offline", () => asyncStep("offline"));
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
mockService("bus_service", {
|
||||
addChannel() {},
|
||||
});
|
||||
await makeMockEnv();
|
||||
expect(getService("bus_service").isActive).toBe(false);
|
||||
manuallyDispatchProgrammaticEvent(window, "offline");
|
||||
await waitForSteps(["offline"]);
|
||||
manuallyDispatchProgrammaticEvent(window, "online");
|
||||
await waitForSteps(["online"]);
|
||||
});
|
||||
|
||||
test("can reconnect after late close event", async () => {
|
||||
browser.addEventListener("online", () => asyncStep("online"));
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")],
|
||||
["BUS:RECONNECTING", () => asyncStep("BUS:RECONNECTING")]
|
||||
);
|
||||
const closeDeferred = new Deferred();
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
patchWithCleanup(getWebSocketWorker().websocket, {
|
||||
async close(code = WEBSOCKET_CLOSE_CODES.CLEAN, reason) {
|
||||
this._readyState = 2; // WebSocket.CLOSING
|
||||
if (code === WEBSOCKET_CLOSE_CODES.CLEAN) {
|
||||
// Simulate that the connection could not be closed cleanly.
|
||||
await closeDeferred;
|
||||
code = WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE;
|
||||
}
|
||||
return super.close(code, reason);
|
||||
},
|
||||
});
|
||||
// Connection will be closed when passing offline. But the close event will
|
||||
// be delayed to come after the next open event. The connection will thus be
|
||||
// in the closing state in the meantime (Simulates pending TCP closing
|
||||
// handshake).
|
||||
manuallyDispatchProgrammaticEvent(window, "offline");
|
||||
// Worker reconnects upon the reception of the online event.
|
||||
manuallyDispatchProgrammaticEvent(window, "online");
|
||||
await waitForSteps(["online"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:DISCONNECT", "BUS:CONNECT"]);
|
||||
// Trigger the close event, it shouldn't have any effect since it is
|
||||
// related to an old connection that is no longer in use.
|
||||
closeDeferred.resolve();
|
||||
await waitForSteps([]);
|
||||
// Server closes the connection, the worker should reconnect.
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
|
||||
await waitForSteps(["BUS:DISCONNECT", "BUS:RECONNECTING", "BUS:RECONNECT"]);
|
||||
});
|
||||
|
||||
test("fallback on simple worker when shared worker failed to initialize", async () => {
|
||||
addBusServiceListeners(["BUS:CONNECT", () => asyncStep("BUS:CONNECT")]);
|
||||
// Starting the server first, the following patch would be overwritten otherwise.
|
||||
await makeMockServer();
|
||||
patchWithCleanup(browser, {
|
||||
SharedWorker: class extends browser.SharedWorker {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
asyncStep("shared-worker-creation");
|
||||
setTimeout(() => this.dispatchEvent(new Event("error")));
|
||||
}
|
||||
},
|
||||
Worker: class extends browser.Worker {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
asyncStep("worker-creation");
|
||||
}
|
||||
},
|
||||
});
|
||||
patchWithCleanup(console, {
|
||||
warn: (message) => asyncStep(message),
|
||||
});
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await waitForSteps([
|
||||
"shared-worker-creation",
|
||||
"Error while loading SharedWorker, fallback on Worker: ",
|
||||
"worker-creation",
|
||||
"BUS:CONNECT",
|
||||
]);
|
||||
});
|
||||
|
||||
test("subscribe to single notification", async () => {
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
getService("bus_service").addChannel("my_channel");
|
||||
await waitForChannels(["my_channel"]);
|
||||
getService("bus_service").subscribe("message_type", (payload) =>
|
||||
asyncStep(`message - ${JSON.stringify(payload)}`)
|
||||
);
|
||||
MockServer.env["bus.bus"]._sendone("my_channel", "message_type", { body: "hello", id: 1 });
|
||||
await waitForSteps(['message - {"body":"hello","id":1}']);
|
||||
});
|
||||
|
||||
test("do not reconnect when worker version is outdated", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
const worker = getWebSocketWorker();
|
||||
expect(worker.state).toBe(WORKER_STATE.CONNECTED);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:RECONNECT"]);
|
||||
expect(worker.state).toBe(WORKER_STATE.CONNECTED);
|
||||
patchWithCleanup(console, { warn: (message) => asyncStep(message) });
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(
|
||||
WEBSOCKET_CLOSE_CODES.CLEAN,
|
||||
"OUTDATED_VERSION"
|
||||
);
|
||||
await waitForSteps(["Worker deactivated due to an outdated version.", "BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
stepWorkerActions("BUS:START");
|
||||
startBusService();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:START"]);
|
||||
// Verify the worker state instead of the steps as the connect event is
|
||||
// asynchronous and may not be fired at this point.
|
||||
expect(worker.state).toBe(WORKER_STATE.DISCONNECTED);
|
||||
});
|
||||
|
||||
test("reconnect on demande after clean close code", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:RECONNECT"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.CLEAN);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
});
|
||||
|
||||
test("remove from main tab candidates when version is outdated", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
patchWithCleanup(console, { warn: (message) => asyncStep(message) });
|
||||
getService("multi_tab").bus.addEventListener("no_longer_main_tab", () =>
|
||||
asyncStep("no_longer_main_tab")
|
||||
);
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
expect(await getService("multi_tab").isOnMainTab()).toBe(true);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(
|
||||
WEBSOCKET_CLOSE_CODES.CLEAN,
|
||||
"OUTDATED_VERSION"
|
||||
);
|
||||
await waitForSteps([
|
||||
"Worker deactivated due to an outdated version.",
|
||||
"BUS:DISCONNECT",
|
||||
"no_longer_main_tab",
|
||||
]);
|
||||
});
|
||||
|
||||
test("show notification when version is outdated", async () => {
|
||||
browser.location.addEventListener("reload", () => asyncStep("reload"));
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
patchWithCleanup(console, { warn: (message) => asyncStep(message) });
|
||||
await mountWithCleanup(WebClient);
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(
|
||||
WEBSOCKET_CLOSE_CODES.CLEAN,
|
||||
"OUTDATED_VERSION"
|
||||
);
|
||||
await waitForSteps(["Worker deactivated due to an outdated version.", "BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
await waitFor(".o_notification", {
|
||||
contains:
|
||||
"Save your work and refresh to get the latest updates and avoid potential issues.",
|
||||
});
|
||||
await contains(".o_notification button:contains(Refresh)").click();
|
||||
await waitForSteps(["reload"]);
|
||||
});
|
||||
|
||||
test("subscribe message is sent first", async () => {
|
||||
addBusServiceListeners(["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]);
|
||||
// Starting the server first, the following patch would be overwritten otherwise.
|
||||
await makeMockServer();
|
||||
const ogSocket = window.WebSocket;
|
||||
patchWithCleanup(window, {
|
||||
WebSocket: function () {
|
||||
const ws = new ogSocket(...arguments);
|
||||
ws.send = (message) => {
|
||||
const evName = JSON.parse(message).event_name;
|
||||
if (["subscribe", "some_event", "some_other_event"].includes(evName)) {
|
||||
asyncStep(evName);
|
||||
}
|
||||
};
|
||||
return ws;
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["subscribe"]);
|
||||
getService("bus_service").send("some_event");
|
||||
await waitForSteps(["some_event"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.CLEAN);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
getService("bus_service").send("some_event");
|
||||
getService("bus_service").send("some_other_event");
|
||||
getService("bus_service").addChannel("channel_1");
|
||||
await runAllTimers();
|
||||
await waitForSteps([]);
|
||||
startBusService();
|
||||
await runAllTimers();
|
||||
await waitForSteps(["subscribe", "some_event", "some_other_event"]);
|
||||
});
|
||||
|
||||
test("worker state is available from the bus service", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
await makeMockEnv();
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
expect(getService("bus_service").workerState).toBe(WORKER_STATE.CONNECTED);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.CLEAN);
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
await runAllTimers();
|
||||
expect(getService("bus_service").workerState).toBe(WORKER_STATE.DISCONNECTED);
|
||||
startBusService();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
expect(getService("bus_service").workerState).toBe(WORKER_STATE.CONNECTED);
|
||||
});
|
||||
|
||||
test("channel is kept until deleted as many times as added", async () => {
|
||||
onWebsocketEvent("subscribe", (data) =>
|
||||
expect.step(`subscribe - [${data.channels.toString()}]`)
|
||||
);
|
||||
await makeMockEnv();
|
||||
const worker = getWebSocketWorker();
|
||||
patchWithCleanup(worker, {
|
||||
_deleteChannel() {
|
||||
super._deleteChannel(...arguments);
|
||||
expect.step("delete channel");
|
||||
},
|
||||
_addChannel(client, channel) {
|
||||
super._addChannel(client, channel);
|
||||
expect.step(`add channel - ${channel}`);
|
||||
},
|
||||
});
|
||||
startBusService();
|
||||
const busService = getService("bus_service");
|
||||
await expect.waitForSteps(["subscribe - []"]);
|
||||
busService.addChannel("foo");
|
||||
await expect.waitForSteps(["add channel - foo", "subscribe - [foo]"]);
|
||||
busService.addChannel("foo");
|
||||
await expect.waitForSteps(["add channel - foo"]);
|
||||
await runAllTimers();
|
||||
busService.deleteChannel("foo");
|
||||
await expect.waitForSteps(["delete channel"]);
|
||||
await runAllTimers();
|
||||
await expect.waitForSteps([]);
|
||||
busService.deleteChannel("foo");
|
||||
await expect.waitForSteps(["delete channel", "subscribe - []"]);
|
||||
});
|
||||
328
odoo-bringout-oca-ocb-bus/bus/static/tests/bus_test_helpers.js
Normal file
328
odoo-bringout-oca-ocb-bus/bus/static/tests/bus_test_helpers.js
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { after, expect, registerDebugInfo } from "@odoo/hoot";
|
||||
import { Deferred } from "@odoo/hoot-mock";
|
||||
import {
|
||||
MockServer,
|
||||
asyncStep,
|
||||
defineModels,
|
||||
getMockEnv,
|
||||
getService,
|
||||
mockService,
|
||||
patchWithCleanup,
|
||||
webModels,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { BusBus } from "./mock_server/mock_models/bus_bus";
|
||||
import { IrWebSocket } from "./mock_server/mock_models/ir_websocket";
|
||||
import { getWebSocketWorker, onWebsocketEvent } from "./mock_websocket";
|
||||
|
||||
import { busService } from "@bus/services/bus_service";
|
||||
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
|
||||
import { on, runAllTimers, waitUntil } from "@odoo/hoot-dom";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { deepEqual } from "@web/core/utils/objects";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/**
|
||||
* @typedef {[
|
||||
* env?: OdooEnv,
|
||||
* type: string,
|
||||
* payload: NotificationPayload,
|
||||
* options?: ExpectedNotificationOptions,
|
||||
* ]} ExpectedNotification
|
||||
*
|
||||
* @typedef {{
|
||||
* received?: boolean;
|
||||
* }} ExpectedNotificationOptions
|
||||
*
|
||||
* @typedef {Record<string, any>} NotificationPayload
|
||||
*
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
* @typedef {import("@bus/workers/websocket_worker").WorkerAction} WorkerAction
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Setup
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
patch(busService, {
|
||||
_onMessage(env, id, type, payload) {
|
||||
// Generic handlers (namely: debug info)
|
||||
if (type in busMessageHandlers) {
|
||||
busMessageHandlers[type](env, id, payload);
|
||||
} else {
|
||||
registerDebugInfo("bus message", { id, type, payload });
|
||||
}
|
||||
|
||||
// Notifications
|
||||
if (!busNotifications.has(env)) {
|
||||
busNotifications.set(env, []);
|
||||
after(() => busNotifications.clear());
|
||||
}
|
||||
busNotifications.get(env).push({ id, type, payload });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {ExpectedNotification} notification
|
||||
* @param {boolean} [crashOnFail]
|
||||
*/
|
||||
const expectNotification = ([env, type, payload, options], crashOnFail) => {
|
||||
if (typeof env === "string") {
|
||||
[env, type, payload, options] = [getMockEnv(), env, type, payload];
|
||||
}
|
||||
const shouldHaveReceived = Boolean(options?.received ?? true);
|
||||
const envNotifications = busNotifications.get(env) || [];
|
||||
const hasPayload = payload !== null && payload !== undefined;
|
||||
const found = envNotifications.find(
|
||||
(n) => n.type === type && (!hasPayload || matchPayload(n.payload, payload))
|
||||
);
|
||||
const message = (pass) =>
|
||||
`Notification of type ${type} ${payload ? `with payload ${payload} ` : ""}${
|
||||
pass && shouldHaveReceived ? "" : "not "
|
||||
}received.`;
|
||||
if (found) {
|
||||
envNotifications.splice(envNotifications.indexOf(found), 1);
|
||||
expect(payload).toEqual(payload, { message });
|
||||
} else if (!shouldHaveReceived) {
|
||||
expect(shouldHaveReceived).toBe(false, { message });
|
||||
} else {
|
||||
if (crashOnFail) {
|
||||
throw new Error(message(false, String.raw).join(" "));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {NotificationPayload} payload
|
||||
* @param {NotificationPayload | ((payload: NotificationPayload) => boolean)} matcher
|
||||
*/
|
||||
const matchPayload = (payload, matcher) =>
|
||||
typeof matcher === "function" ? matcher(payload) : deepEqual(payload, matcher);
|
||||
|
||||
class LockedWebSocket extends WebSocket {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.addEventListener("open", (ev) => {
|
||||
ev.stopImmediatePropagation();
|
||||
|
||||
this.dispatchEvent(new Event("error"));
|
||||
this.close(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string, (env: OdooEnv, id: string, payload: any) => any>} */
|
||||
const busMessageHandlers = {};
|
||||
/** @type {Map<OdooEnv, { id: number, type: string, payload: NotificationPayload }[]>} */
|
||||
const busNotifications = new Map();
|
||||
|
||||
const viewsRegistry = registry.category("bus.view.archs");
|
||||
viewsRegistry.category("activity").add(
|
||||
"default",
|
||||
/* xml */ `
|
||||
<activity><templates /></activity>
|
||||
`
|
||||
);
|
||||
viewsRegistry.category("form").add("default", /* xml */ `<form />`);
|
||||
viewsRegistry.category("kanban").add("default", /* xml */ `<kanban><templates /></kanban>`);
|
||||
viewsRegistry.category("list").add("default", /* xml */ `<list />`);
|
||||
viewsRegistry.category("search").add("default", /* xml */ `<search />`);
|
||||
|
||||
viewsRegistry.category("form").add(
|
||||
"res.partner",
|
||||
/* xml */ `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="name" />
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>`
|
||||
);
|
||||
|
||||
// should be enough to decide whether or not notifications/channel
|
||||
// subscriptions... are received.
|
||||
const TIMEOUT = 2000;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Useful to display debug information about bus events in tests.
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {(env: OdooEnv, id: string, payload: any) => any} handler
|
||||
*/
|
||||
export function addBusMessageHandler(type, handler) {
|
||||
busMessageHandlers[type] = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the bus service to add given event listeners immediatly when it starts.
|
||||
*
|
||||
* @param {...[string, (event: CustomEvent) => any]} listeners
|
||||
*/
|
||||
export function addBusServiceListeners(...listeners) {
|
||||
mockService("bus_service", (env, dependencies) => {
|
||||
const busServiceInstance = busService.start(env, dependencies);
|
||||
for (const [type, handler] of listeners) {
|
||||
after(on(busServiceInstance, type, handler));
|
||||
}
|
||||
return busServiceInstance;
|
||||
});
|
||||
}
|
||||
|
||||
export function defineBusModels() {
|
||||
return defineModels({ ...webModels, ...busModels });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deferred that resolves when a websocket subscription is
|
||||
* done.
|
||||
*
|
||||
* @returns {Deferred<void>}
|
||||
*/
|
||||
export function waitUntilSubscribe() {
|
||||
const def = new Deferred();
|
||||
const timeout = setTimeout(() => handleResult(false), TIMEOUT);
|
||||
|
||||
function handleResult(success) {
|
||||
clearTimeout(timeout);
|
||||
offWebsocketEvent();
|
||||
const message = success
|
||||
? "Websocket subscription received."
|
||||
: "Websocket subscription not received.";
|
||||
expect(success).toBe(true, { message });
|
||||
if (success) {
|
||||
def.resolve();
|
||||
} else {
|
||||
def.reject(new Error(message));
|
||||
}
|
||||
}
|
||||
const offWebsocketEvent = onWebsocketEvent("subscribe", () => handleResult(true));
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deferred that resolves when the given channel addition/deletion
|
||||
* occurs. Resolve immediately if the operation was already done.
|
||||
*
|
||||
* @param {string[]} channels
|
||||
* @param {object} [options={}]
|
||||
* @param {"add" | "delete"} [options.operation="add"]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitForChannels(channels, { operation = "add" } = {}) {
|
||||
const { env } = MockServer;
|
||||
const def = new Deferred();
|
||||
let done = false;
|
||||
let failTimeout;
|
||||
|
||||
/**
|
||||
* @param {boolean} crashOnFail
|
||||
*/
|
||||
function check(crashOnFail) {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
const userChannels = new Set(env["bus.bus"].channelsByUser[env.uid]);
|
||||
const success = channels.every((c) =>
|
||||
operation === "add" ? userChannels.has(c) : !userChannels.has(c)
|
||||
);
|
||||
if (!success && !crashOnFail) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(failTimeout);
|
||||
offWebsocketEvent();
|
||||
const message = (pass) =>
|
||||
pass
|
||||
? `Channel(s) ${channels} ${operation === "add" ? `added` : `deleted`}`
|
||||
: `Waited ${TIMEOUT}ms for ${channels} to be ${
|
||||
operation === "add" ? `added` : `deleted`
|
||||
}`;
|
||||
expect(success).toBe(true, { message });
|
||||
if (success) {
|
||||
def.resolve();
|
||||
} else {
|
||||
def.reject(new Error(message(false)));
|
||||
}
|
||||
done = true;
|
||||
}
|
||||
|
||||
after(() => check(true));
|
||||
const offWebsocketEvent = onWebsocketEvent("subscribe", () => check(false));
|
||||
|
||||
await runAllTimers();
|
||||
|
||||
failTimeout = setTimeout(() => check(true), TIMEOUT);
|
||||
check(false);
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the expected notifications to be received/not received. Returns
|
||||
* a deferred that resolves when the assertion is done.
|
||||
*
|
||||
* @param {ExpectedNotification[]} expectedNotifications
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitNotifications(...expectedNotifications) {
|
||||
const remaining = new Set(expectedNotifications);
|
||||
|
||||
await waitUntil(
|
||||
() => {
|
||||
for (const notification of remaining) {
|
||||
if (expectNotification(notification, false)) {
|
||||
remaining.delete(notification);
|
||||
}
|
||||
}
|
||||
return remaining.size === 0;
|
||||
},
|
||||
{ timeout: TIMEOUT }
|
||||
)
|
||||
.then(() => busNotifications.clear())
|
||||
.catch(() => {
|
||||
for (const notification of remaining) {
|
||||
expectNotification(notification, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an asynchronous step on actions received by the websocket worker that
|
||||
* match the given list of target actions.
|
||||
*
|
||||
* @param {WorkerAction[]} targetActions
|
||||
*/
|
||||
export function stepWorkerActions(targetActions) {
|
||||
patchWithCleanup(getWebSocketWorker(), {
|
||||
_onClientMessage(_, { action }) {
|
||||
if (targetActions.includes(action)) {
|
||||
asyncStep(action);
|
||||
}
|
||||
return super._onClientMessage(...arguments);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the websocket connection until the returned function is called. Useful
|
||||
* to simulate server being unavailable.
|
||||
*/
|
||||
export function lockWebsocketConnect() {
|
||||
return patchWithCleanup(window, { WebSocket: LockedWebSocket });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OdooEnv} [env]
|
||||
*/
|
||||
export async function startBusService(env) {
|
||||
const busService = env ? env.services.bus_service : getService("bus_service");
|
||||
busService.start();
|
||||
await runAllTimers();
|
||||
}
|
||||
|
||||
export const busModels = { BusBus, IrWebSocket };
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
odoo.define('web.bus_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var { busService } = require('@bus/services/bus_service');
|
||||
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");
|
||||
const { waitForChannels } = require('@bus/../tests/helpers/websocket_event_deferred');
|
||||
|
||||
const { browser } = require("@web/core/browser/browser");
|
||||
const { registry } = require("@web/core/registry");
|
||||
const { session } = require('@web/session');
|
||||
const { makeDeferred, nextTick, patchWithCleanup } = require("@web/../tests/helpers/utils");
|
||||
const { makeTestEnv } = require('@web/../tests/helpers/mock_env');
|
||||
const legacySession = require('web.session');
|
||||
|
||||
QUnit.module('Bus', {
|
||||
beforeEach: function () {
|
||||
const customMultiTabService = {
|
||||
...multiTabService,
|
||||
start() {
|
||||
const originalMultiTabService = multiTabService.start(...arguments);
|
||||
originalMultiTabService.TAB_HEARTBEAT_PERIOD = 10;
|
||||
originalMultiTabService.MAIN_TAB_HEARTBEAT_PERIOD = 1;
|
||||
return originalMultiTabService;
|
||||
},
|
||||
};
|
||||
registry.category('services').add('bus_service', busService);
|
||||
registry.category('services').add('presence', presenceService);
|
||||
registry.category('services').add('multi_tab', customMultiTabService);
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test('notifications received from the channel', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const env = await makeTestEnv({ activateMockServer: true });
|
||||
await env.services['bus_service'].start();
|
||||
env.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
assert.step('notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
env.services['bus_service'].addChannel('lambda');
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
|
||||
await nextTick();
|
||||
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'epsilon');
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'notification - beta',
|
||||
'notification - epsilon',
|
||||
]);
|
||||
});
|
||||
|
||||
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 });
|
||||
await firstTabEnv.services['bus_service'].start();
|
||||
await secondTabEnv.services['bus_service'].start();
|
||||
|
||||
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);
|
||||
|
||||
const oldSetTimeout = window.setTimeout;
|
||||
patchWithCleanup(
|
||||
window,
|
||||
{
|
||||
setTimeout: callback => oldSetTimeout(callback, 0)
|
||||
},
|
||||
{ pure: true },
|
||||
)
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const env = await makeTestEnv({ activateMockServer: true });
|
||||
await env.services["bus_service"].start();
|
||||
await nextTick();
|
||||
env.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
assert.step('notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
env.services['bus_service'].addChannel('lambda');
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
|
||||
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 bus service a tick to receive the notification from
|
||||
// postMessage.
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
"notification - beta",
|
||||
"notification - gamma",
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('tabs share message from a channel', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const steps = new Set();
|
||||
// main
|
||||
const mainEnv = await makeTestEnv({ activateMockServer: true });
|
||||
mainEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
steps.add('main - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
await mainEnv.services['bus_service'].addChannel('lambda');
|
||||
|
||||
// slave
|
||||
const slaveEnv = await makeTestEnv();
|
||||
await slaveEnv.services['bus_service'].start();
|
||||
|
||||
slaveEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
steps.add('slave - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
await slaveEnv.services['bus_service'].addChannel('lambda');
|
||||
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
|
||||
// Wait one tick for the worker `postMessage` to reach the bus_service.
|
||||
await nextTick();
|
||||
// Wait another tick for the `bus.trigger` to reach the listeners.
|
||||
await nextTick();
|
||||
|
||||
assert.deepEqual(
|
||||
[...steps],
|
||||
["slave - notification - beta", "main - notification - beta"]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('second tab still receives notifications after main pagehide', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const steps = new Set();
|
||||
// main
|
||||
const mainEnv = await makeTestEnv({ activateMockServer: true });
|
||||
await mainEnv.services['bus_service'].start();
|
||||
mainEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
steps.add('main - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
mainEnv.services['bus_service'].addChannel('lambda');
|
||||
|
||||
// second env
|
||||
// prevent second tab from receiving pagehide event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName === 'pagehide') {
|
||||
return;
|
||||
}
|
||||
this._super(eventName, callback);
|
||||
},
|
||||
});
|
||||
const secondEnv = await makeTestEnv({ activateMockServer: true });
|
||||
secondEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
steps.add('slave - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
secondEnv.services['bus_service'].addChannel('lambda');
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'beta');
|
||||
await nextTick();
|
||||
|
||||
// simulate unloading main
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
await nextTick();
|
||||
|
||||
pyEnv['bus.bus']._sendone('lambda', 'notifType', 'gamma');
|
||||
await nextTick();
|
||||
|
||||
assert.deepEqual(
|
||||
[...steps],
|
||||
[
|
||||
'slave - notification - beta',
|
||||
'main - notification - beta',
|
||||
'slave - notification - gamma',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('two tabs calling addChannel simultaneously', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const channelPatch = {
|
||||
addChannel(channel) {
|
||||
assert.step('Tab ' + this.__tabId__ + ': addChannel ' + channel);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
deleteChannel(channel) {
|
||||
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');
|
||||
secondTabEnv.services['bus_service'].addChannel('beta');
|
||||
|
||||
assert.verifySteps([
|
||||
"Tab 1: addChannel alpha",
|
||||
"Tab 2: addChannel alpha",
|
||||
"Tab 1: addChannel beta",
|
||||
"Tab 2: addChannel beta",
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('two tabs adding a different channel', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
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('first - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
secondTabEnv.services['bus_service'].addEventListener('notification', ({ detail: notifications }) => {
|
||||
assert.step('second - notification - ' + notifications.map(notif => notif.payload).toString());
|
||||
});
|
||||
firstTabEnv.services['bus_service'].addChannel("alpha");
|
||||
secondTabEnv.services['bus_service'].addChannel("beta");
|
||||
await nextTick();
|
||||
pyEnv['bus.bus']._sendmany([
|
||||
['alpha', 'notifType', 'alpha'],
|
||||
['beta', 'notifType', 'beta']
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'first - notification - alpha,beta',
|
||||
'second - notification - alpha,beta',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('channel management from multiple tabs', async function (assert) {
|
||||
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 waitForChannels(["channel1"]);
|
||||
// this should not trigger a subscription since the channel1 was
|
||||
// aleady known.
|
||||
secTabEnv.services['bus_service'].addChannel('channel1');
|
||||
await waitForChannels(["channel1"]);
|
||||
// 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 waitForChannels(["channel1"], { operation: "delete" });
|
||||
// this should trigger a subscription since the channel2 was not
|
||||
// known.
|
||||
secTabEnv.services['bus_service'].addChannel('channel2');
|
||||
await waitForChannels(["channel2"]);
|
||||
|
||||
assert.verifySteps([
|
||||
'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 firstSubscribeDeferred = makeDeferred();
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
_sendToServer({ event_name, data }) {
|
||||
assert.step(`${event_name} - [${data.channels.toString()}]`);
|
||||
if (event_name === 'subscribe') {
|
||||
firstSubscribeDeferred.resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const env = await makeTestEnv();
|
||||
env.services["bus_service"].start();
|
||||
// wait for the websocket to connect and the first subscription
|
||||
// to occur.
|
||||
await firstSubscribeDeferred;
|
||||
worker.websocket.close(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
|
||||
// wait for the websocket to re-connect.
|
||||
await nextTick();
|
||||
|
||||
assert.verifySteps([
|
||||
'subscribe - []',
|
||||
'subscribe - []',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('Last notification id is passed to the worker on service start', async function (assert) {
|
||||
const pyEnv = await startServer();
|
||||
let updateLastNotificationDeferred = makeDeferred();
|
||||
patchWebsocketWorkerWithCleanup({
|
||||
_onClientMessage(_, { action, data }) {
|
||||
if (action === 'initialize_connection') {
|
||||
assert.step(`${action} - ${data['lastNotificationId']}`);
|
||||
updateLastNotificationDeferred.resolve();
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
const env1 = await makeTestEnv();
|
||||
await env1.services['bus_service'].start();
|
||||
await updateLastNotificationDeferred;
|
||||
// First bus service has never received notifications thus the
|
||||
// default is 0.
|
||||
assert.verifySteps(['initialize_connection - 0']);
|
||||
|
||||
pyEnv['bus.bus']._sendmany([
|
||||
['lambda', 'notifType', 'beta'],
|
||||
['lambda', 'notifType', 'beta'],
|
||||
]);
|
||||
// let the bus service store the last notification id.
|
||||
await nextTick();
|
||||
|
||||
updateLastNotificationDeferred = makeDeferred();
|
||||
const env2 = await makeTestEnv();
|
||||
await env2.services['bus_service'].start();
|
||||
await updateLastNotificationDeferred;
|
||||
// Second bus service sends the last known notification id.
|
||||
assert.verifySteps([`initialize_connection - 1`]);
|
||||
});
|
||||
|
||||
QUnit.test('Websocket disconnects upon user log out', async function (assert) {
|
||||
// first tab connects to the worker with user logged.
|
||||
patchWithCleanup(session, {
|
||||
user_id: 1,
|
||||
});
|
||||
const connectionInitializedDeferred = makeDeferred();
|
||||
let connectionOpenedDeferred = makeDeferred();
|
||||
patchWebsocketWorkerWithCleanup({
|
||||
_initializeConnection(client, data) {
|
||||
this._super(client, data);
|
||||
connectionInitializedDeferred.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const firstTabEnv = await makeTestEnv();
|
||||
await firstTabEnv.services["bus_service"].start();
|
||||
firstTabEnv.services['bus_service'].addEventListener('connect', () => {
|
||||
if (session.user_id) {
|
||||
assert.step('connect');
|
||||
}
|
||||
connectionOpenedDeferred.resolve();
|
||||
connectionOpenedDeferred = makeDeferred();
|
||||
});
|
||||
firstTabEnv.services['bus_service'].addEventListener('disconnect', () => {
|
||||
assert.step('disconnect');
|
||||
});
|
||||
await connectionInitializedDeferred;
|
||||
await connectionOpenedDeferred;
|
||||
|
||||
// second tab connects to the worker after disconnection: user_id
|
||||
// is now false.
|
||||
patchWithCleanup(session, {
|
||||
user_id: false,
|
||||
});
|
||||
const env2 = await makeTestEnv();
|
||||
await env2.services['bus_service'].start();
|
||||
|
||||
assert.verifySteps([
|
||||
'connect',
|
||||
'disconnect',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('Websocket reconnects upon user log in', async function (assert) {
|
||||
// first tab connects to the worker with no user logged.
|
||||
patchWithCleanup(session, {
|
||||
user_id: false,
|
||||
});
|
||||
const connectionInitializedDeferred = makeDeferred();
|
||||
let websocketConnectedDeferred = makeDeferred();
|
||||
patchWebsocketWorkerWithCleanup({
|
||||
_initializeConnection(client, data) {
|
||||
this._super(client, data);
|
||||
connectionInitializedDeferred.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const firstTabEnv = await makeTestEnv();
|
||||
await firstTabEnv.services['bus_service'].start();
|
||||
firstTabEnv.services['bus_service'].addEventListener('connect', () => {
|
||||
assert.step("connect");
|
||||
websocketConnectedDeferred.resolve();
|
||||
websocketConnectedDeferred = makeDeferred();
|
||||
});
|
||||
firstTabEnv.services['bus_service'].addEventListener('disconnect', () => {
|
||||
assert.step('disconnect');
|
||||
});
|
||||
await connectionInitializedDeferred;
|
||||
await websocketConnectedDeferred;
|
||||
|
||||
// second tab connects to the worker after connection: user_id
|
||||
// is now set.
|
||||
patchWithCleanup(session, {
|
||||
user_id: 1,
|
||||
});
|
||||
const env = await makeTestEnv();
|
||||
await env.services["bus_service"].start();
|
||||
await websocketConnectedDeferred;
|
||||
assert.verifySteps([
|
||||
'connect',
|
||||
'disconnect',
|
||||
'connect',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test("WebSocket connects with URL corresponding to session prefix", async function (assert) {
|
||||
patchWebsocketWorkerWithCleanup();
|
||||
const origin = "http://random-website.com";
|
||||
patchWithCleanup(legacySession, {
|
||||
prefix: origin,
|
||||
});
|
||||
const websocketCreatedDeferred = makeDeferred();
|
||||
patchWithCleanup(window, {
|
||||
WebSocket: function (url) {
|
||||
assert.step(url);
|
||||
websocketCreatedDeferred.resolve();
|
||||
return new EventTarget();
|
||||
},
|
||||
}, { pure: true });
|
||||
const env = await makeTestEnv();
|
||||
env.services["bus_service"].start();
|
||||
await websocketCreatedDeferred;
|
||||
assert.verifySteps([`${origin.replace("http", "ws")}/websocket`]);
|
||||
});
|
||||
|
||||
QUnit.test("Disconnect on offline, re-connect on online", async function (assert) {
|
||||
patchWebsocketWorkerWithCleanup();
|
||||
let websocketConnectedDeferred = makeDeferred();
|
||||
const env = await makeTestEnv();
|
||||
env.services["bus_service"].addEventListener("connect", () => {
|
||||
assert.step("connect");
|
||||
websocketConnectedDeferred.resolve();
|
||||
websocketConnectedDeferred = makeDeferred();
|
||||
});
|
||||
env.services["bus_service"].addEventListener("disconnect", () => assert.step("disconnect"));
|
||||
await env.services["bus_service"].start();
|
||||
await websocketConnectedDeferred;
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
await nextTick();
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await websocketConnectedDeferred;
|
||||
assert.verifySteps(["connect", "disconnect", "connect"]);
|
||||
});
|
||||
|
||||
QUnit.test("No disconnect on change offline/online when bus inactive", async function (assert) {
|
||||
patchWebsocketWorkerWithCleanup();
|
||||
const env = await makeTestEnv();
|
||||
env.services["bus_service"].addEventListener("connect", () => assert.step("connect"));
|
||||
env.services["bus_service"].addEventListener("disconnect", () => assert.step("disconnect"));
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
await nextTick();
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await nextTick();
|
||||
assert.verifySteps([]);
|
||||
});
|
||||
|
||||
QUnit.test("Can reconnect after late close event", async function (assert) {
|
||||
let subscribeSent = 0;
|
||||
const closeDeferred = makeDeferred();
|
||||
let openDeferred = makeDeferred();
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
_onWebsocketOpen() {
|
||||
this._super();
|
||||
openDeferred.resolve();
|
||||
},
|
||||
_sendToServer({ event_name }) {
|
||||
if (event_name === "subscribe") {
|
||||
subscribeSent++;
|
||||
}
|
||||
},
|
||||
});
|
||||
const pyEnv = await startServer();
|
||||
const env = await makeTestEnv();
|
||||
env.services["bus_service"].start();
|
||||
await openDeferred;
|
||||
patchWithCleanup(worker.websocket, {
|
||||
close(code = WEBSOCKET_CLOSE_CODES.CLEAN, reason) {
|
||||
this.readyState = 2;
|
||||
const _super = this._super;
|
||||
if (code === WEBSOCKET_CLOSE_CODES.CLEAN) {
|
||||
closeDeferred.then(() => {
|
||||
// Simulate that the connection could not be closed cleanly.
|
||||
_super(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE, reason);
|
||||
});
|
||||
} else {
|
||||
_super(code, reason);
|
||||
}
|
||||
},
|
||||
});
|
||||
env.services["bus_service"].addEventListener("connect", () => assert.step("connect"));
|
||||
env.services["bus_service"].addEventListener("disconnect", () => assert.step("disconnect"));
|
||||
env.services["bus_service"].addEventListener("reconnecting", () => assert.step("reconnecting"));
|
||||
env.services["bus_service"].addEventListener("reconnect", () => assert.step("reconnect"));
|
||||
// Connection will be closed when passing offline. But the close event
|
||||
// will be delayed to come after the next open event. The connection
|
||||
// will thus be in the closing state in the meantime.
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
await nextTick();
|
||||
openDeferred = makeDeferred();
|
||||
// Worker reconnects upon the reception of the online event.
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await openDeferred;
|
||||
closeDeferred.resolve();
|
||||
// Trigger the close event, it shouldn't have any effect since it is
|
||||
// related to an old connection that is no longer in use.
|
||||
await nextTick();
|
||||
openDeferred = makeDeferred();
|
||||
// Server closes the connection, the worker should reconnect.
|
||||
pyEnv.simulateConnectionLost(WEBSOCKET_CLOSE_CODES.KEEP_ALIVE_TIMEOUT);
|
||||
await openDeferred;
|
||||
await nextTick();
|
||||
// 3 connections were opened, so 3 subscriptions are expected.
|
||||
assert.strictEqual(subscribeSent, 3);
|
||||
assert.verifySteps([
|
||||
"connect",
|
||||
"disconnect",
|
||||
"connect",
|
||||
"disconnect",
|
||||
"reconnecting",
|
||||
"reconnect",
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"Fallback on simple worker when shared worker failed to initialize",
|
||||
async function (assert) {
|
||||
const originalSharedWorker = browser.SharedWorker;
|
||||
const originalWorker = browser.Worker;
|
||||
patchWithCleanup(browser, {
|
||||
SharedWorker: function (url, options) {
|
||||
assert.step("shared-worker creation");
|
||||
const sw = new originalSharedWorker(url, options);
|
||||
// Simulate error during shared worker creation.
|
||||
setTimeout(() => sw.dispatchEvent(new Event("error")));
|
||||
return sw;
|
||||
},
|
||||
Worker: function (url, options) {
|
||||
assert.step("worker creation");
|
||||
return new originalWorker(url, options);
|
||||
},
|
||||
}, { pure: true });
|
||||
patchWithCleanup(window.console, {
|
||||
warn(message) {
|
||||
assert.step(message);
|
||||
},
|
||||
})
|
||||
const env = await makeTestEnv();
|
||||
await env.services['bus_service'].start();
|
||||
assert.verifySteps([
|
||||
"shared-worker creation",
|
||||
"Error while loading \"bus_service\" SharedWorker, fallback on Worker.",
|
||||
"worker creation",
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { TEST_USER_IDS } from '@bus/../tests/helpers/test_constants';
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
import { makeMockServer } from "@web/../tests/helpers/mock_server";
|
||||
import core from 'web.core';
|
||||
|
||||
const modelDefinitionsPromise = new Promise(resolve => {
|
||||
QUnit.begin(() => resolve(getModelDefinitions()));
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch model definitions from the server then insert fields present in the
|
||||
* `bus.model.definitions` registry. Use `addModelNamesToFetch`/`insertModelFields`
|
||||
* helpers in order to add models to be fetched, default values to the fields,
|
||||
* fields to a model definition.
|
||||
*
|
||||
* @return {Map<string, Object>} A map from model names to model fields definitions.
|
||||
* @see model_definitions_setup.js
|
||||
*/
|
||||
async function getModelDefinitions() {
|
||||
const modelDefinitionsRegistry = registry.category('bus.model.definitions');
|
||||
const modelNamesToFetch = modelDefinitionsRegistry.get('modelNamesToFetch');
|
||||
const fieldsToInsertRegistry = modelDefinitionsRegistry.category('fieldsToInsert');
|
||||
|
||||
// fetch the model definitions.
|
||||
const formData = new FormData();
|
||||
formData.append('csrf_token', core.csrf_token);
|
||||
formData.append('model_names_to_fetch', JSON.stringify(modelNamesToFetch));
|
||||
const response = await window.fetch('/bus/get_model_definitions', { body: formData, method: 'POST' });
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Error while fetching required models');
|
||||
}
|
||||
const modelDefinitions = new Map(Object.entries(await response.json()));
|
||||
|
||||
for (const [modelName, fields] of modelDefinitions) {
|
||||
// insert fields present in the fieldsToInsert registry : if the field
|
||||
// exists, update its default value according to the one in the
|
||||
// registry; If it does not exist, add it to the model definition.
|
||||
const fieldNamesToFieldToInsert = fieldsToInsertRegistry.category(modelName).getEntries();
|
||||
for (const [fname, fieldToInsert] of fieldNamesToFieldToInsert) {
|
||||
if (fname in fields) {
|
||||
fields[fname].default = fieldToInsert.default;
|
||||
} else {
|
||||
fields[fname] = fieldToInsert;
|
||||
}
|
||||
}
|
||||
// apply default values for date like fields if none was passed.
|
||||
for (const fname in fields) {
|
||||
const field = fields[fname];
|
||||
if (['date', 'datetime'].includes(field.type) && !field.default) {
|
||||
const defaultFieldValue = field.type === 'date'
|
||||
? () => moment.utc().format('YYYY-MM-DD')
|
||||
: () => moment.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
field.default = defaultFieldValue;
|
||||
} else if (fname === 'active' && !('default' in field)) {
|
||||
// records are active by default.
|
||||
field.default = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// add models present in the fake models registry to the model definitions.
|
||||
const fakeModels = modelDefinitionsRegistry.category('fakeModels').getEntries();
|
||||
for (const [modelName, fields] of fakeModels) {
|
||||
modelDefinitions.set(modelName, fields);
|
||||
}
|
||||
return modelDefinitions;
|
||||
}
|
||||
|
||||
let pyEnv;
|
||||
/**
|
||||
* Creates an environment that can be used to setup test data as well as
|
||||
* creating data after test start.
|
||||
*
|
||||
* @param {Object} serverData serverData to pass to the mockServer.
|
||||
* @param {Object} [serverData.action] actions to be passed to the mock
|
||||
* server.
|
||||
* @param {Object} [serverData.views] views to be passed to the mock
|
||||
* server.
|
||||
* @returns {Object} An environment that can be used to interact with
|
||||
* the mock server (creation, deletion, update of records...)
|
||||
*/
|
||||
export async function startServer({ actions, views = {} } = {}) {
|
||||
const models = {};
|
||||
const modelDefinitions = await modelDefinitionsPromise;
|
||||
const recordsToInsertRegistry = registry.category('bus.model.definitions').category('recordsToInsert');
|
||||
for (const [modelName, fields] of modelDefinitions) {
|
||||
const records = [];
|
||||
if (recordsToInsertRegistry.contains(modelName)) {
|
||||
// prevent tests from mutating the records.
|
||||
records.push(...JSON.parse(JSON.stringify(recordsToInsertRegistry.get(modelName))));
|
||||
}
|
||||
models[modelName] = { fields: { ...fields }, records };
|
||||
|
||||
// generate default views for this model if none were passed.
|
||||
const viewArchsSubRegistries = registry.category('bus.view.archs').subRegistries;
|
||||
for (const [viewType, archsRegistry] of Object.entries(viewArchsSubRegistries)) {
|
||||
views[`${modelName},false,${viewType}`] =
|
||||
views[`${modelName},false,${viewType}`] ||
|
||||
archsRegistry.get(modelName, archsRegistry.get('default'));
|
||||
}
|
||||
}
|
||||
pyEnv = new Proxy(
|
||||
{
|
||||
get currentPartner() {
|
||||
return this.mockServer.currentPartner;
|
||||
},
|
||||
getData() {
|
||||
return this.mockServer.models;
|
||||
},
|
||||
getViews() {
|
||||
return views;
|
||||
},
|
||||
simulateConnectionLost(closeCode) {
|
||||
this.mockServer._simulateConnectionLost(closeCode);
|
||||
},
|
||||
...TEST_USER_IDS,
|
||||
},
|
||||
{
|
||||
get(target, name) {
|
||||
if (target[name]) {
|
||||
return target[name];
|
||||
}
|
||||
const modelAPI = {
|
||||
/**
|
||||
* Simulate a 'create' operation on a model.
|
||||
*
|
||||
* @param {Object[]|Object} values records to be created.
|
||||
* @returns {integer[]|integer} array of ids if more than one value was passed,
|
||||
* id of created record otherwise.
|
||||
*/
|
||||
create(values) {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
values = [values];
|
||||
}
|
||||
const recordIds = values.map(value => target.mockServer.mockCreate(name, value));
|
||||
return recordIds.length === 1 ? recordIds[0] : recordIds;
|
||||
},
|
||||
/**
|
||||
* Simulate a 'search' operation on a model.
|
||||
*
|
||||
* @param {Array} domain
|
||||
* @param {Object} context
|
||||
* @returns {integer[]} array of ids corresponding to the given domain.
|
||||
*/
|
||||
search(domain, context = {}) {
|
||||
return target.mockServer.mockSearch(name, [domain], context);
|
||||
},
|
||||
/**
|
||||
* Simulate a `search_count` operation on a model.
|
||||
*
|
||||
* @param {Array} domain
|
||||
* @return {number} count of records matching the given domain.
|
||||
*/
|
||||
searchCount(domain) {
|
||||
return this.search(domain).length;
|
||||
},
|
||||
/**
|
||||
* Simulate a 'search_read' operation on a model.
|
||||
*
|
||||
* @param {Array} domain
|
||||
* @param {Object} kwargs
|
||||
* @returns {Object[]} array of records corresponding to the given domain.
|
||||
*/
|
||||
searchRead(domain, kwargs = {}) {
|
||||
return target.mockServer.mockSearchRead(name, [domain], kwargs);
|
||||
},
|
||||
/**
|
||||
* Simulate an 'unlink' operation on a model.
|
||||
*
|
||||
* @param {integer[]} ids
|
||||
* @returns {boolean} mockServer 'unlink' method always returns true.
|
||||
*/
|
||||
unlink(ids) {
|
||||
return target.mockServer.mockUnlink(name, [ids]);
|
||||
},
|
||||
/**
|
||||
* Simulate a 'write' operation on a model.
|
||||
*
|
||||
* @param {integer[]} ids ids of records to write on.
|
||||
* @param {Object} values values to write on the records matching given ids.
|
||||
* @returns {boolean} mockServer 'write' method always returns true.
|
||||
*/
|
||||
write(ids, values) {
|
||||
return target.mockServer.mockWrite(name, [ids, values]);
|
||||
},
|
||||
};
|
||||
if (name === 'bus.bus') {
|
||||
modelAPI['_sendone'] = target.mockServer._mockBusBus__sendone.bind(target.mockServer);
|
||||
modelAPI['_sendmany'] = target.mockServer._mockBusBus__sendmany.bind(target.mockServer);
|
||||
}
|
||||
return modelAPI;
|
||||
},
|
||||
set(target, name, value) {
|
||||
return target[name] = value;
|
||||
},
|
||||
},
|
||||
);
|
||||
pyEnv['mockServer'] = await makeMockServer({ actions, models, views });
|
||||
pyEnv['mockServer'].pyEnv = pyEnv;
|
||||
registerCleanup(() => pyEnv = undefined);
|
||||
return pyEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Object} An environment that can be used to interact with the mock
|
||||
* server (creation, deletion, update of records...)
|
||||
*/
|
||||
export function getPyEnv() {
|
||||
return pyEnv || startServer();
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/** @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 { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, 'bus', {
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
Object.assign(this, TEST_USER_IDS);
|
||||
const self = this;
|
||||
this.websocketWorker = patchWebsocketWorkerWithCleanup({
|
||||
_sendToServer(message) {
|
||||
self._performWebsocketRequest(message);
|
||||
this._super(message);
|
||||
},
|
||||
});
|
||||
this.pendingLongpollingPromise = null;
|
||||
this.notificationsToBeResolved = [];
|
||||
this.lastBusNotificationId = 0;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} message Message sent through the websocket to the
|
||||
* server.
|
||||
* @param {string} [message.event_name]
|
||||
* @param {any} [message.data]
|
||||
*/
|
||||
_performWebsocketRequest({ event_name, data }) {
|
||||
if (event_name === 'update_presence') {
|
||||
const { inactivity_period, im_status_ids_by_model } = data;
|
||||
this._mockIrWebsocket__updatePresence(inactivity_period, im_status_ids_by_model);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Simulates `_sendone` on `bus.bus`.
|
||||
*
|
||||
* @param {string} channel
|
||||
* @param {string} notificationType
|
||||
* @param {any} message
|
||||
*/
|
||||
_mockBusBus__sendone(channel, notificationType, message) {
|
||||
this._mockBusBus__sendmany([[channel, notificationType, message]]);
|
||||
},
|
||||
/**
|
||||
* Simulates `_sendmany` on `bus.bus`.
|
||||
*
|
||||
* @param {Array} notifications
|
||||
*/
|
||||
_mockBusBus__sendmany(notifications) {
|
||||
if (!notifications.length) {
|
||||
return;
|
||||
}
|
||||
const values = [];
|
||||
for (const notification of notifications) {
|
||||
const [type, payload] = notification.slice(1, notification.length);
|
||||
values.push({ id: this.lastBusNotificationId++, message: { payload, type }});
|
||||
if (this.debug) {
|
||||
console.log("%c[bus]", "color: #c6e; font-weight: bold;", type, payload);
|
||||
}
|
||||
}
|
||||
this.websocketWorker.broadcast('notification', values);
|
||||
|
||||
},
|
||||
/**
|
||||
* Simulate the lost of the connection by simulating a closeEvent on
|
||||
* the worker websocket.
|
||||
*
|
||||
* @param {number} clodeCode the code to close the connection with.
|
||||
*/
|
||||
_simulateConnectionLost(closeCode) {
|
||||
this.websocketWorker.websocket.close(closeCode);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, 'bus/models/ir_websocket', {
|
||||
/**
|
||||
* Simulates `_update_presence` on `ir.websocket`.
|
||||
*
|
||||
* @param inactivityPeriod
|
||||
* @param imStatusIdsByModel
|
||||
*/
|
||||
_mockIrWebsocket__updatePresence(inactivityPeriod, imStatusIdsByModel) {
|
||||
const imStatusNotifications = this._mockIrWebsocket__getImStatus(imStatusIdsByModel);
|
||||
if (Object.keys(imStatusNotifications).length > 0) {
|
||||
this._mockBusBus__sendone(this.currentPartnerId, 'bus/im_status', imStatusNotifications);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Simulates `_get_im_status` on `ir.websocket`.
|
||||
*
|
||||
* @param {Object} imStatusIdsByModel
|
||||
* @param {Number[]|undefined} res.partner ids of res.partners whose im_status
|
||||
* should be monitored.
|
||||
*/
|
||||
_mockIrWebsocket__getImStatus(imStatusIdsByModel) {
|
||||
const imStatus = {};
|
||||
const { 'res.partner': partnerIds } = imStatusIdsByModel;
|
||||
if (partnerIds) {
|
||||
imStatus['partners'] = this.mockSearchRead('res.partner', [[['id', 'in', partnerIds]]], { context: { 'active_test': false }, fields: ['im_status'] })
|
||||
}
|
||||
return imStatus;
|
||||
},
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { presenceService } from '@bus/services/presence_service';
|
||||
|
||||
export function makeFakePresenceService(params = {}) {
|
||||
return {
|
||||
...presenceService,
|
||||
start(env) {
|
||||
return {
|
||||
...presenceService.start(env),
|
||||
...params,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/** @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() {
|
||||
super();
|
||||
this.readyState = 0;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
const openEv = new Event('open');
|
||||
this.onopen(openEv);
|
||||
this.dispatchEvent(openEv);
|
||||
});
|
||||
}
|
||||
|
||||
close(code = 1000, 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 extends EventTarget {
|
||||
constructor(websocketWorker) {
|
||||
super();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerMock extends SharedWorkerMock {
|
||||
constructor(websocketWorker) {
|
||||
super(websocketWorker);
|
||||
this.port.start();
|
||||
this.postMessage = this.port.postMessage.bind(this.port);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
patchWithCleanup(browser, {
|
||||
SharedWorker: function () {
|
||||
const sharedWorker = new SharedWorkerMock(websocketWorker);
|
||||
registerCleanup(() => {
|
||||
sharedWorker._messageChannel.port1.close();
|
||||
sharedWorker._messageChannel.port2.close();
|
||||
});
|
||||
return sharedWorker;
|
||||
},
|
||||
Worker: function () {
|
||||
const worker = new WorkerMock(websocketWorker);
|
||||
registerCleanup(() => {
|
||||
worker._messageChannel.port1.close();
|
||||
worker._messageChannel.port2.close();
|
||||
});
|
||||
return worker;
|
||||
},
|
||||
}, { pure: true });
|
||||
registerCleanup(() => {
|
||||
if (websocketWorker) {
|
||||
clearTimeout(websocketWorker.connectTimeout);
|
||||
websocketWorker = null;
|
||||
}
|
||||
});
|
||||
return websocketWorker;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
const modelDefinitionsRegistry = registry.category('bus.model.definitions');
|
||||
const customModelFieldsRegistry = modelDefinitionsRegistry.category('fieldsToInsert');
|
||||
const recordsToInsertRegistry = modelDefinitionsRegistry.category('recordsToInsert');
|
||||
const fakeModelsRegistry = modelDefinitionsRegistry.category('fakeModels');
|
||||
/**
|
||||
* Add models whose definitions need to be fetched on the server.
|
||||
*
|
||||
* @param {string[]} modelName
|
||||
*/
|
||||
export function addModelNamesToFetch(modelNames) {
|
||||
if (!modelDefinitionsRegistry.contains('modelNamesToFetch')) {
|
||||
modelDefinitionsRegistry.add('modelNamesToFetch', []);
|
||||
}
|
||||
modelDefinitionsRegistry.get('modelNamesToFetch').push(...modelNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add models that will be added to the model definitions. We should
|
||||
* avoid to rely on fake models and use real models instead.
|
||||
*
|
||||
* @param {string} modelName
|
||||
* @param {Object} fields
|
||||
*/
|
||||
export function addFakeModel(modelName, fields) {
|
||||
fakeModelsRegistry.add(modelName, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add model fields that are not present on the server side model's definitions
|
||||
* but are required to ease testing or add default values for existing fields.
|
||||
*
|
||||
* @param {string} modelName
|
||||
* @param {Object} fieldNamesToFields
|
||||
*/
|
||||
export function insertModelFields(modelName, fieldNamesToFields) {
|
||||
const modelCustomFieldsRegistry = customModelFieldsRegistry.category(modelName);
|
||||
for (const fname in fieldNamesToFields) {
|
||||
modelCustomFieldsRegistry.add(fname, fieldNamesToFields[fname]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add records to the initial server data.
|
||||
*
|
||||
* @param {string} modelName
|
||||
* @param {Object[]} records
|
||||
*/
|
||||
export function insertRecords(modelName, records) {
|
||||
if (!recordsToInsertRegistry.contains(modelName)) {
|
||||
recordsToInsertRegistry.add(modelName, []);
|
||||
}
|
||||
recordsToInsertRegistry.get(modelName).push(...records);
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { TEST_GROUP_IDS, TEST_USER_IDS } from '@bus/../tests/helpers/test_constants';
|
||||
import {
|
||||
addModelNamesToFetch,
|
||||
insertModelFields,
|
||||
insertRecords
|
||||
} from '@bus/../tests/helpers/model_definitions_helpers';
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Models
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
addModelNamesToFetch([
|
||||
'ir.attachment', 'ir.model', 'ir.model.fields', 'res.company', 'res.country',
|
||||
'res.groups', 'res.partner', 'res.users'
|
||||
]);
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Insertion of fields
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
insertModelFields('res.partner', {
|
||||
description: { string: 'description', type: 'text' },
|
||||
});
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Insertion of records
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
insertRecords('res.company', [{ id: 1 }]);
|
||||
insertRecords('res.groups', [
|
||||
{ id: TEST_GROUP_IDS.groupUserId, name: "Internal User" },
|
||||
]);
|
||||
insertRecords('res.users', [
|
||||
{ display_name: "Your Company, Mitchell Admin", id: TEST_USER_IDS.currentUserId, name: "Mitchell Admin", partner_id: TEST_USER_IDS.currentPartnerId, },
|
||||
{ active: false, display_name: "Public user", id: TEST_USER_IDS.publicUserId, name: "Public user", partner_id: TEST_USER_IDS.publicPartnerId, },
|
||||
]);
|
||||
insertRecords('res.partner', [
|
||||
{ active: false, display_name: "Public user", id: TEST_USER_IDS.publicPartnerId, is_public: true },
|
||||
{ display_name: "Your Company, Mitchell Admin", id: TEST_USER_IDS.currentPartnerId, name: "Mitchell Admin", },
|
||||
{ active: false, display_name: "OdooBot", id: TEST_USER_IDS.partnerRootId, name: "OdooBot" },
|
||||
]);
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
export const TEST_GROUP_IDS = {
|
||||
groupUserId: 11,
|
||||
};
|
||||
|
||||
export const TEST_USER_IDS = {
|
||||
partnerRootId: 2,
|
||||
currentPartnerId: 3,
|
||||
currentUserId: 2,
|
||||
publicPartnerId: 4,
|
||||
publicUserId: 3,
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
const viewArchsRegistry = registry.category('bus.view.archs');
|
||||
const activityArchsRegistry = viewArchsRegistry.category('activity');
|
||||
const formArchsRegistry = viewArchsRegistry.category('form');
|
||||
const kanbanArchsRegistry = viewArchsRegistry.category('kanban');
|
||||
const listArchsRegistry = viewArchsRegistry.category('list');
|
||||
const searchArchsRegistry = viewArchsRegistry.category('search');
|
||||
|
||||
activityArchsRegistry.add('default', '<activity><templates></templates></activity>');
|
||||
formArchsRegistry.add('default', '<form/>');
|
||||
kanbanArchsRegistry.add('default', '<kanban><templates></templates>');
|
||||
listArchsRegistry.add('default', '<tree/>');
|
||||
searchArchsRegistry.add('default', '<search/>');
|
||||
|
||||
formArchsRegistry.add(
|
||||
'res.partner',
|
||||
`<form>
|
||||
<sheet>
|
||||
<field name="name"/>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>`
|
||||
);
|
||||
formArchsRegistry.add(
|
||||
'res.fake',
|
||||
`<form>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>`
|
||||
);
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/* @odoo-module */
|
||||
|
||||
import { patchWebsocketWorkerWithCleanup } from "@bus/../tests/helpers/mock_websocket";
|
||||
|
||||
import { makeDeferred } from "@web/../tests/helpers/utils";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
import { patch, unpatch } from "@web/core/utils/patch";
|
||||
|
||||
// Should be enough to decide whether or not notifications/channel
|
||||
// subscriptions... are received.
|
||||
const TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* Returns a deferred that resolves when the given channel(s) addition/deletion
|
||||
* is notified to the websocket worker.
|
||||
*
|
||||
* @param {string[]} channels
|
||||
* @param {object} [options={}]
|
||||
* @param {"add"|"delete"} [options.operation="add"]
|
||||
*
|
||||
* @returns {import("@web/core/utils/concurrency").Deferred} */
|
||||
export function waitForChannels(channels, { operation = "add" } = {}) {
|
||||
const uuid = String(Date.now() + Math.random());
|
||||
const missingChannels = new Set(channels);
|
||||
const deferred = makeDeferred();
|
||||
function check({ crashOnFail = false } = {}) {
|
||||
const success = missingChannels.size === 0;
|
||||
if (!success && !crashOnFail) {
|
||||
return;
|
||||
}
|
||||
unpatch(worker, uuid);
|
||||
clearTimeout(failTimeout);
|
||||
const msg = success
|
||||
? `Channel(s) [${channels.join(", ")}] ${operation === "add" ? "added" : "deleted"}.`
|
||||
: `Waited ${TIMEOUT}ms for [${channels.join(", ")}] to be ${
|
||||
operation === "add" ? "added" : "deleted"
|
||||
}`;
|
||||
QUnit.assert.ok(success, msg);
|
||||
if (success) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
deferred.reject(new Error(msg));
|
||||
}
|
||||
}
|
||||
const failTimeout = setTimeout(() => check({ crashOnFail: true }), TIMEOUT);
|
||||
registerCleanup(() => {
|
||||
if (missingChannels.length > 0) {
|
||||
check({ crashOnFail: true });
|
||||
}
|
||||
});
|
||||
const worker = patchWebsocketWorkerWithCleanup();
|
||||
patch(worker, uuid, {
|
||||
async [operation === "add" ? "_addChannel" : "_deleteChannel"](client, channel) {
|
||||
await this._super(client, channel);
|
||||
missingChannels.delete(channel);
|
||||
check();
|
||||
},
|
||||
});
|
||||
return deferred;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
asyncStep,
|
||||
makeMockEnv,
|
||||
restoreRegistry,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("multi tab allow to share values between tabs", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
firstTabEnv.services.legacy_multi_tab.setSharedValue("foo", 1);
|
||||
expect(secondTabEnv.services.legacy_multi_tab.getSharedValue("foo")).toBe(1);
|
||||
firstTabEnv.services.legacy_multi_tab.setSharedValue("foo", 2);
|
||||
expect(secondTabEnv.services.legacy_multi_tab.getSharedValue("foo")).toBe(2);
|
||||
firstTabEnv.services.legacy_multi_tab.removeSharedValue("foo");
|
||||
expect(secondTabEnv.services.legacy_multi_tab.getSharedValue("foo")).toBe(undefined);
|
||||
});
|
||||
|
||||
test("multi tab triggers shared_value_updated", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
secondTabEnv.services.legacy_multi_tab.bus.addEventListener(
|
||||
"shared_value_updated",
|
||||
({ detail }) => {
|
||||
asyncStep(`${detail.key} - ${JSON.parse(detail.newValue)}`);
|
||||
}
|
||||
);
|
||||
firstTabEnv.services.legacy_multi_tab.setSharedValue("foo", "bar");
|
||||
firstTabEnv.services.legacy_multi_tab.setSharedValue("foo", "foo");
|
||||
firstTabEnv.services.legacy_multi_tab.removeSharedValue("foo");
|
||||
await waitForSteps(["foo - bar", "foo - foo", "foo - null"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { mockWorker } from "@odoo/hoot-mock";
|
||||
import { MockServer } from "@web/../tests/web_test_helpers";
|
||||
import { BaseWorker } from "@bus/workers/base_worker";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/**
|
||||
* @param {SharedWorker | Worker} worker
|
||||
*/
|
||||
function onWorkerConnected(worker) {
|
||||
const baseWorker = new BaseWorker(worker.name);
|
||||
const client = worker._messageChannel.port2;
|
||||
baseWorker.client = client;
|
||||
client.addEventListener("message", (ev) => {
|
||||
baseWorker.handleMessage(ev);
|
||||
});
|
||||
client.start();
|
||||
}
|
||||
|
||||
patch(MockServer.prototype, {
|
||||
start() {
|
||||
mockWorker(onWorkerConnected);
|
||||
return super.start(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { mockWorker } from "@odoo/hoot-mock";
|
||||
import { MockServer } from "@web/../tests/web_test_helpers";
|
||||
import { ElectionWorker } from "@bus/workers/election_worker";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
let electionWorker = null;
|
||||
|
||||
/**
|
||||
* @param {SharedWorker | Worker} worker
|
||||
*/
|
||||
function onWorkerConnected(worker) {
|
||||
const client = worker._messageChannel.port2;
|
||||
client.addEventListener("message", (ev) => {
|
||||
electionWorker.handleMessage(ev);
|
||||
});
|
||||
client.start();
|
||||
}
|
||||
|
||||
function setupElectionWorker() {
|
||||
electionWorker = new ElectionWorker();
|
||||
mockWorker(onWorkerConnected);
|
||||
}
|
||||
|
||||
patch(MockServer.prototype, {
|
||||
start() {
|
||||
setupElectionWorker();
|
||||
return super.start(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { onRpc } from "@web/../tests/web_test_helpers";
|
||||
|
||||
onRpc("/bus/has_missed_notifications", function hasMissedNotifications() {
|
||||
return false;
|
||||
});
|
||||
12
odoo-bringout-oca-ocb-bus/bus/static/tests/mock_server/mock_models/@types/mock_models.d.ts
vendored
Normal file
12
odoo-bringout-oca-ocb-bus/bus/static/tests/mock_server/mock_models/@types/mock_models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
declare module "mock_models" {
|
||||
import { BusBus as BusBus2 } from "@bus/../tests/mock_server/mock_models/bus_bus";
|
||||
import { IrWebSocket as IrWebSocket2 } from "@bus/../tests/mock_server/mock_models/ir_websocket";
|
||||
|
||||
export interface BusBus extends BusBus2 {}
|
||||
export interface IrWebSocket extends IrWebSocket2 {}
|
||||
|
||||
export interface Models {
|
||||
"bus.bus": BusBus,
|
||||
"ir.websocket": IrWebSocket,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { getWebSocketWorker } from "@bus/../tests/mock_websocket";
|
||||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class BusBus extends models.Model {
|
||||
_name = "bus.bus";
|
||||
|
||||
/** @type {Record<number, string[]>} */
|
||||
channelsByUser = {};
|
||||
lastBusNotificationId = 0;
|
||||
|
||||
/**
|
||||
* @param {models.Model | string} channel
|
||||
* @param {string} notificationType
|
||||
* @param {any} message
|
||||
*/
|
||||
_sendone(channel, notificationType, message) {
|
||||
this._sendmany([[channel, notificationType, message]]);
|
||||
}
|
||||
|
||||
/** @param {[models.Model | string, string, any][]} notifications */
|
||||
_sendmany(notifications) {
|
||||
/** @type {import("mock_models").IrWebSocket} */
|
||||
const IrWebSocket = this.env["ir.websocket"];
|
||||
|
||||
if (!notifications.length) {
|
||||
return;
|
||||
}
|
||||
const values = [];
|
||||
const authenticatedUserId =
|
||||
"res.users" in this.env
|
||||
? this.env.cookie.get("authenticated_user_sid") ?? this.env.uid
|
||||
: null;
|
||||
const channels = [
|
||||
...IrWebSocket._build_bus_channel_list(this.channelsByUser[authenticatedUserId] || []),
|
||||
];
|
||||
notifications = notifications.filter(([target]) =>
|
||||
channels.some((channel) => {
|
||||
if (typeof target === "string") {
|
||||
return channel === target;
|
||||
}
|
||||
if (Array.isArray(target) && Array.isArray(channel)) {
|
||||
const [target0, target1] = target;
|
||||
const [channel0, channel1] = channel;
|
||||
return (
|
||||
channel0?._name === target0?.model &&
|
||||
channel0?.id === target0?.id &&
|
||||
channel1 === target1
|
||||
);
|
||||
}
|
||||
return channel?._name === target?.model && channel?.id === target?.id;
|
||||
})
|
||||
);
|
||||
if (notifications.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const notification of notifications) {
|
||||
const [type, payload] = notification.slice(1, notification.length);
|
||||
values.push({
|
||||
id: ++this.lastBusNotificationId,
|
||||
message: { payload: JSON.parse(JSON.stringify(payload)), type },
|
||||
});
|
||||
}
|
||||
getWebSocketWorker().broadcast("BUS:NOTIFICATION", values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current websocket with the given reason and code.
|
||||
*
|
||||
* @param {number} closeCode the code to close the connection with.
|
||||
* @param {string} [reason] the reason to close the connection with.
|
||||
*/
|
||||
_simulateDisconnection(closeCode, reason) {
|
||||
getWebSocketWorker().websocket.close(closeCode, reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { makeKwArgs, models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class IrWebSocket extends models.ServerModel {
|
||||
_name = "ir.websocket";
|
||||
|
||||
/**
|
||||
* @param {number} inactivityPeriod
|
||||
*/
|
||||
_update_presence(inactivityPeriod) {}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_build_bus_channel_list(channels = []) {
|
||||
/** @type {import("mock_models").ResPartner} */
|
||||
const ResPartner = this.env["res.partner"];
|
||||
|
||||
channels = [...channels];
|
||||
channels.push("broadcast");
|
||||
const authenticatedUserId = this.env.cookie.get("authenticated_user_sid");
|
||||
const [authenticatedPartner] = authenticatedUserId
|
||||
? ResPartner.search_read(
|
||||
[["user_ids", "in", [authenticatedUserId]]],
|
||||
makeKwArgs({ context: { active_test: false } })
|
||||
)
|
||||
: [];
|
||||
if (authenticatedPartner) {
|
||||
channels.push(authenticatedPartner);
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
}
|
||||
122
odoo-bringout-oca-ocb-bus/bus/static/tests/mock_websocket.js
Normal file
122
odoo-bringout-oca-ocb-bus/bus/static/tests/mock_websocket.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { after, Deferred, mockWorker } from "@odoo/hoot";
|
||||
import { MockServer, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { WebsocketWorker } from "@bus/workers/websocket_worker";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
function cleanupWebSocketCallbacks() {
|
||||
wsCallbacks?.clear();
|
||||
wsCallbacks = null;
|
||||
}
|
||||
|
||||
function cleanupWebSocketWorker() {
|
||||
if (currentWebSocketWorker.connectTimeout) {
|
||||
clearTimeout(currentWebSocketWorker.connectTimeout);
|
||||
}
|
||||
|
||||
currentWebSocketWorker.firstSubscribeDeferred = new Deferred();
|
||||
currentWebSocketWorker.websocket = null;
|
||||
currentWebSocketWorker = null;
|
||||
}
|
||||
|
||||
function getWebSocketCallbacks() {
|
||||
if (!wsCallbacks) {
|
||||
wsCallbacks = new Map();
|
||||
|
||||
after(cleanupWebSocketCallbacks);
|
||||
}
|
||||
|
||||
return wsCallbacks;
|
||||
}
|
||||
|
||||
function setupWebSocketWorker() {
|
||||
currentWebSocketWorker = new WebsocketWorker();
|
||||
|
||||
mockWorker(function onWorkerConnected(worker) {
|
||||
currentWebSocketWorker.registerClient(worker._messageChannel.port2);
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {WebsocketWorker | null} */
|
||||
let currentWebSocketWorker = null;
|
||||
/** @type {Map<string, (data: any) => any> | null} */
|
||||
let wsCallbacks = null;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function getWebSocketWorker() {
|
||||
return currentWebSocketWorker;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventName
|
||||
* @param {(data: any) => any} callback
|
||||
*/
|
||||
export function onWebsocketEvent(eventName, callback) {
|
||||
const callbacks = getWebSocketCallbacks();
|
||||
if (!callbacks.has(eventName)) {
|
||||
callbacks.set(eventName, new Set());
|
||||
}
|
||||
callbacks.get(eventName).add(callback);
|
||||
|
||||
return function offWebsocketEvent() {
|
||||
callbacks.get(eventName).delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Setup
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
patchWithCleanup(MockServer.prototype, {
|
||||
start() {
|
||||
setupWebSocketWorker();
|
||||
after(cleanupWebSocketWorker);
|
||||
return super.start(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
patch(WebsocketWorker.prototype, {
|
||||
INITIAL_RECONNECT_DELAY: 0,
|
||||
RECONNECT_JITTER: 5,
|
||||
// `runAllTimers` advances time based on the longest registered timeout.
|
||||
// Some tests rely on the fragile assumption that time won’t advance too much.
|
||||
// Disable the interval until those tests are rewritten to be more robust.
|
||||
enableCheckInterval: false,
|
||||
|
||||
_restartConnectionCheckInterval() {
|
||||
if (this.enableCheckInterval) {
|
||||
super._restartConnectionCheckInterval(...arguments);
|
||||
}
|
||||
},
|
||||
|
||||
_sendToServer(message) {
|
||||
const { env } = MockServer;
|
||||
if (!env) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("bus.bus" in env && "ir.websocket" in env) {
|
||||
if (message.event_name === "update_presence") {
|
||||
const { inactivity_period, im_status_ids_by_model } = message.data;
|
||||
env["ir.websocket"]._update_presence(inactivity_period, im_status_ids_by_model);
|
||||
} else if (message.event_name === "subscribe") {
|
||||
const { channels } = message.data;
|
||||
env["bus.bus"].channelsByUser[env.uid] = channels;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom callbacks
|
||||
for (const callback of wsCallbacks?.get(message.event_name) || []) {
|
||||
callback(message.data);
|
||||
}
|
||||
|
||||
return super._sendToServer(message);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { multiTabFallbackService } from "@bus/multi_tab_fallback_service";
|
||||
import { makeMockEnv, patchWithCleanup, restoreRegistry } from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("services").remove("multi_tab");
|
||||
registry.category("services").add("multi_tab", multiTabFallbackService);
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("main tab service(local storage) elects new main on pagehide", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
expect(await firstTabEnv.services.multi_tab.isOnMainTab()).toBe(true);
|
||||
// Prevent second tab from receiving pagehide event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName != "pagehide") {
|
||||
super.addEventListener(eventName, callback);
|
||||
}
|
||||
},
|
||||
});
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
expect(await secondTabEnv.services.multi_tab.isOnMainTab()).toBe(false);
|
||||
firstTabEnv.services.multi_tab.bus.addEventListener("no_longer_main_tab", () =>
|
||||
expect.step("tab1 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services.multi_tab.bus.addEventListener("no_longer_main_tab", () =>
|
||||
expect.step("tab2 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services.multi_tab.bus.addEventListener("become_main_tab", () =>
|
||||
expect.step("tab2 become_main_tab")
|
||||
);
|
||||
browser.dispatchEvent(new Event("pagehide"));
|
||||
|
||||
await expect.waitForSteps(["tab1 no_longer_main_tab", "tab2 become_main_tab"]);
|
||||
expect(await firstTabEnv.services.multi_tab.isOnMainTab()).toBe(false);
|
||||
expect(await secondTabEnv.services.multi_tab.isOnMainTab()).toBe(true);
|
||||
});
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { multiTabService } from '../src/multi_tab_service';
|
||||
|
||||
import { browser } from '@web/core/browser/browser';
|
||||
import { registry } from '@web/core/registry';
|
||||
import { makeTestEnv } from '@web/../tests/helpers/mock_env';
|
||||
import { patchWithCleanup, nextTick } from '@web/../tests/helpers/utils';
|
||||
|
||||
QUnit.module('bus', function () {
|
||||
QUnit.module('multi_tab_service_tests.js');
|
||||
|
||||
QUnit.test('multi tab service elects new master on pagehide', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
registry.category('services').add('multi_tab', multiTabService);
|
||||
|
||||
const firstTabEnv = await makeTestEnv();
|
||||
assert.ok(firstTabEnv.services['multi_tab'].isOnMainTab(), 'only tab should be the main one');
|
||||
|
||||
// prevent second tab from receiving pagehide event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName === 'pagehide') {
|
||||
return;
|
||||
}
|
||||
this._super(eventName, callback);
|
||||
},
|
||||
});
|
||||
const secondTabEnv = await makeTestEnv();
|
||||
firstTabEnv.services["multi_tab"].bus.addEventListener("no_longer_main_tab", () =>
|
||||
assert.step("tab1 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services["multi_tab"].bus.addEventListener("no_longer_main_tab", () =>
|
||||
assert.step("tab2 no_longer_main_tab")
|
||||
);
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
|
||||
// Let the multi tab elect a new main.
|
||||
await nextTick();
|
||||
assert.notOk(firstTabEnv.services['multi_tab'].isOnMainTab());
|
||||
assert.ok(secondTabEnv.services['multi_tab'].isOnMainTab());
|
||||
assert.verifySteps(['tab1 no_longer_main_tab']);
|
||||
});
|
||||
|
||||
QUnit.test('multi tab allow to share values between tabs', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
registry.category('services').add('multi_tab', multiTabService);
|
||||
|
||||
const firstTabEnv = await makeTestEnv();
|
||||
const secondTabEnv = await makeTestEnv();
|
||||
|
||||
firstTabEnv.services['multi_tab'].setSharedValue('foo', 1);
|
||||
assert.deepEqual(secondTabEnv.services['multi_tab'].getSharedValue('foo'), 1);
|
||||
firstTabEnv.services['multi_tab'].setSharedValue('foo', 2);
|
||||
assert.deepEqual(secondTabEnv.services['multi_tab'].getSharedValue('foo'), 2);
|
||||
|
||||
firstTabEnv.services['multi_tab'].removeSharedValue('foo');
|
||||
assert.notOk(secondTabEnv.services['multi_tab'].getSharedValue('foo'));
|
||||
});
|
||||
|
||||
QUnit.test('multi tab triggers shared_value_updated', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
registry.category('services').add('multi_tab', multiTabService);
|
||||
|
||||
const firstTabEnv = await makeTestEnv();
|
||||
const secondTabEnv = await makeTestEnv();
|
||||
|
||||
secondTabEnv.services['multi_tab'].bus.addEventListener('shared_value_updated', ({ detail }) => {
|
||||
assert.step(`${detail.key} - ${JSON.parse(detail.newValue)}`);
|
||||
});
|
||||
firstTabEnv.services['multi_tab'].setSharedValue('foo', 'bar');
|
||||
firstTabEnv.services['multi_tab'].setSharedValue('foo', 'foo');
|
||||
firstTabEnv.services['multi_tab'].removeSharedValue('foo');
|
||||
|
||||
await nextTick();
|
||||
assert.verifySteps([
|
||||
'foo - bar',
|
||||
'foo - foo',
|
||||
'foo - null',
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test('multi tab triggers become_master', async function (assert) {
|
||||
registry.category('services').add('multi_tab', multiTabService);
|
||||
|
||||
await makeTestEnv();
|
||||
// prevent second tab from receiving pagehide event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName === 'pagehide') {
|
||||
return;
|
||||
}
|
||||
this._super(eventName, callback);
|
||||
},
|
||||
});
|
||||
const secondTabEnv = await makeTestEnv();
|
||||
secondTabEnv.services['multi_tab'].bus.addEventListener('become_main_tab', () => assert.step('become_main_tab'));
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
|
||||
// Let the multi tab elect a new main.
|
||||
await nextTick();
|
||||
assert.verifySteps(['become_main_tab']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { multiTabSharedWorkerService } from "@bus/multi_tab_shared_worker_service";
|
||||
import { makeMockEnv, patchWithCleanup, restoreRegistry } from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("services").remove("multi_tab");
|
||||
registry.category("services").add("multi_tab", multiTabSharedWorkerService);
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("main tab service(election worker) elects new main on pagehide", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
expect(await firstTabEnv.services.multi_tab.isOnMainTab()).toBe(true);
|
||||
// Prevent second tab from receiving pagehide event.
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener(eventName, callback) {
|
||||
if (eventName != "pagehide") {
|
||||
super.addEventListener(eventName, callback);
|
||||
}
|
||||
},
|
||||
});
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
expect(await secondTabEnv.services.multi_tab.isOnMainTab()).toBe(false);
|
||||
firstTabEnv.services.multi_tab.bus.addEventListener("become_main_tab", () =>
|
||||
expect.step("tab1 become_main_tab")
|
||||
);
|
||||
firstTabEnv.services.multi_tab.bus.addEventListener("no_longer_main_tab", () =>
|
||||
expect.step("tab1 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services.multi_tab.bus.addEventListener("no_longer_main_tab", () =>
|
||||
expect.step("tab2 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services.multi_tab.bus.addEventListener("become_main_tab", () =>
|
||||
expect.step("tab2 become_main_tab")
|
||||
);
|
||||
browser.dispatchEvent(new Event("pagehide"));
|
||||
|
||||
await expect.waitForSteps(["tab1 no_longer_main_tab", "tab2 become_main_tab"]);
|
||||
expect(await firstTabEnv.services.multi_tab.isOnMainTab()).toBe(false);
|
||||
expect(await secondTabEnv.services.multi_tab.isOnMainTab()).toBe(true);
|
||||
});
|
||||
|
||||
test("main tab service(election worker) elects new main after unregister main tab", async () => {
|
||||
const firstTabEnv = await makeMockEnv();
|
||||
expect(await firstTabEnv.services.multi_tab.isOnMainTab()).toBe(true);
|
||||
restoreRegistry(registry);
|
||||
const secondTabEnv = await makeMockEnv(null, { makeNew: true });
|
||||
expect(await secondTabEnv.services.multi_tab.isOnMainTab()).toBe(false);
|
||||
firstTabEnv.services.multi_tab.bus.addEventListener("become_main_tab", () =>
|
||||
expect.step("tab1 become_main_tab")
|
||||
);
|
||||
firstTabEnv.services.multi_tab.bus.addEventListener("no_longer_main_tab", () =>
|
||||
expect.step("tab1 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services.multi_tab.bus.addEventListener("no_longer_main_tab", () =>
|
||||
expect.step("tab2 no_longer_main_tab")
|
||||
);
|
||||
secondTabEnv.services.multi_tab.bus.addEventListener("become_main_tab", () =>
|
||||
expect.step("tab2 become_main_tab")
|
||||
);
|
||||
firstTabEnv.services.multi_tab.unregister();
|
||||
|
||||
await expect.waitForSteps(["tab1 no_longer_main_tab", "tab2 become_main_tab"]);
|
||||
expect(await firstTabEnv.services.multi_tab.isOnMainTab()).toBe(false);
|
||||
expect(await secondTabEnv.services.multi_tab.isOnMainTab()).toBe(true);
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { runAllTimers, waitFor } from "@odoo/hoot-dom";
|
||||
import {
|
||||
asyncStep,
|
||||
contains,
|
||||
getService,
|
||||
MockServer,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { addBusServiceListeners, defineBusModels, startBusService } from "./bus_test_helpers";
|
||||
|
||||
defineBusModels();
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("disconnect during vacuum should ask for reload", async () => {
|
||||
browser.location.addEventListener("reload", () => asyncStep("reload"));
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")],
|
||||
["BUS:RECONNECTING", () => asyncStep("BUS:RECONNECTING")],
|
||||
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")]
|
||||
);
|
||||
onRpc("/bus/has_missed_notifications", () => true);
|
||||
await mountWithCleanup(WebClient);
|
||||
getService("legacy_multi_tab").setSharedValue("last_notification_id", 1);
|
||||
startBusService();
|
||||
expect(await getService("multi_tab").isOnMainTab()).toBe(true);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await waitForSteps(["BUS:DISCONNECT", "BUS:RECONNECTING"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:RECONNECT"]);
|
||||
await waitFor(".o_notification");
|
||||
expect(".o_notification_content:first").toHaveText(
|
||||
"The page is out of date. Save your work and refresh to get the latest updates and avoid potential issues."
|
||||
);
|
||||
await contains(".o_notification button:contains(Refresh)").click();
|
||||
await waitForSteps(["reload"]);
|
||||
});
|
||||
|
||||
test("reconnect after going offline after bus gc should ask for reload", async () => {
|
||||
addBusServiceListeners(
|
||||
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
|
||||
["BUS:DISCONNECT", () => asyncStep("BUS:DISCONNECT")]
|
||||
);
|
||||
onRpc("/bus/has_missed_notifications", () => true);
|
||||
await mountWithCleanup(WebClient);
|
||||
getService("legacy_multi_tab").setSharedValue("last_notification_id", 1);
|
||||
startBusService();
|
||||
expect(await getService("multi_tab").isOnMainTab()).toBe(true);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
browser.dispatchEvent(new Event("offline"));
|
||||
await waitForSteps(["BUS:DISCONNECT"]);
|
||||
browser.dispatchEvent(new Event("online"));
|
||||
await runAllTimers();
|
||||
await waitForSteps(["BUS:CONNECT"]);
|
||||
await waitFor(".o_notification");
|
||||
expect(".o_notification_content:first").toHaveText(
|
||||
"The page is out of date. Save your work and refresh to get the latest updates and avoid potential issues."
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { queryFirst, waitFor } from "@odoo/hoot-dom";
|
||||
import {
|
||||
asyncStep,
|
||||
makeMockEnv,
|
||||
MockServer,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
serverState,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { defineBusModels } from "./bus_test_helpers";
|
||||
|
||||
defineBusModels();
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("receive and display simple notification", async () => {
|
||||
await mountWithCleanup(WebClient);
|
||||
MockServer.env["bus.bus"]._sendone(serverState.partnerId, "simple_notification", {
|
||||
message: "simple notification",
|
||||
});
|
||||
await waitFor(".o_notification");
|
||||
expect(queryFirst(".o_notification_content")).toHaveText("simple notification");
|
||||
});
|
||||
|
||||
test("receive and display simple notification with specific type", async () => {
|
||||
await mountWithCleanup(WebClient);
|
||||
MockServer.env["bus.bus"]._sendone(serverState.partnerId, "simple_notification", {
|
||||
message: "simple notification",
|
||||
type: "info",
|
||||
});
|
||||
await waitFor(".o_notification");
|
||||
expect(".o_notification_bar").toHaveClass("bg-info");
|
||||
});
|
||||
|
||||
test("receive and display simple notification as sticky", async () => {
|
||||
mockService("notification", {
|
||||
add(_, options) {
|
||||
expect(options.sticky).toBe(true);
|
||||
asyncStep("add notification");
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
MockServer.env["bus.bus"]._sendone(serverState.partnerId, "simple_notification", {
|
||||
message: "simple notification",
|
||||
sticky: true,
|
||||
});
|
||||
await waitForSteps(["add notification"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { getWebSocketWorker } from "@bus/../tests/mock_websocket";
|
||||
import { advanceTime, describe, expect, test } from "@odoo/hoot";
|
||||
import { runAllTimers } from "@odoo/hoot-dom";
|
||||
import {
|
||||
asyncStep,
|
||||
makeMockServer,
|
||||
MockServer,
|
||||
patchWithCleanup,
|
||||
waitForSteps,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { WEBSOCKET_CLOSE_CODES, WebsocketWorker } from "@bus/workers/websocket_worker";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
/**
|
||||
* @param {ReturnType<getWebSocketWorker>} worker
|
||||
* @param {(type: string, message: any) => any} [onBroadcast]
|
||||
*/
|
||||
const startWebSocketWorker = async (onBroadcast) => {
|
||||
await makeMockServer();
|
||||
const worker = getWebSocketWorker();
|
||||
if (onBroadcast) {
|
||||
patchWithCleanup(worker, {
|
||||
broadcast(...args) {
|
||||
onBroadcast(...args);
|
||||
return super.broadcast(...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
worker._start();
|
||||
await runAllTimers();
|
||||
return worker;
|
||||
};
|
||||
|
||||
test("connect event is broadcasted after calling start", async () => {
|
||||
await startWebSocketWorker((type) => {
|
||||
if (type !== "BUS:WORKER_STATE_UPDATED") {
|
||||
asyncStep(`broadcast ${type}`);
|
||||
}
|
||||
});
|
||||
await waitForSteps(["broadcast BUS:CONNECT"]);
|
||||
});
|
||||
|
||||
test("disconnect event is broadcasted", async () => {
|
||||
const worker = await startWebSocketWorker((type) => {
|
||||
if (type !== "BUS:WORKER_STATE_UPDATED") {
|
||||
asyncStep(`broadcast ${type}`);
|
||||
}
|
||||
});
|
||||
await waitForSteps(["broadcast BUS:CONNECT"]);
|
||||
worker.websocket.close(WEBSOCKET_CLOSE_CODES.CLEAN);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["broadcast BUS:DISCONNECT"]);
|
||||
});
|
||||
|
||||
test("reconnecting/reconnect event is broadcasted", async () => {
|
||||
const worker = await startWebSocketWorker((type) => {
|
||||
if (type !== "BUS:WORKER_STATE_UPDATED") {
|
||||
asyncStep(`broadcast ${type}`);
|
||||
}
|
||||
});
|
||||
await waitForSteps(["broadcast BUS:CONNECT"]);
|
||||
worker.websocket.close(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
|
||||
await waitForSteps(["broadcast BUS:DISCONNECT", "broadcast BUS:RECONNECTING"]);
|
||||
await runAllTimers();
|
||||
await waitForSteps(["broadcast BUS:RECONNECT"]);
|
||||
});
|
||||
|
||||
test("notification event is broadcasted", async () => {
|
||||
const notifications = [
|
||||
{
|
||||
id: 70,
|
||||
message: {
|
||||
type: "bundle_changed",
|
||||
payload: {
|
||||
server_version: "15.5alpha1+e",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
await startWebSocketWorker((type, message) => {
|
||||
if (type === "BUS:NOTIFICATION") {
|
||||
expect(message).toEqual(notifications);
|
||||
}
|
||||
if (["BUS:CONNECT", "BUS:NOTIFICATION"].includes(type)) {
|
||||
asyncStep(`broadcast ${type}`);
|
||||
}
|
||||
});
|
||||
await waitForSteps(["broadcast BUS:CONNECT"]);
|
||||
for (const serverWs of MockServer.current._websockets) {
|
||||
serverWs.send(JSON.stringify(notifications));
|
||||
}
|
||||
await waitForSteps(["broadcast BUS:NOTIFICATION"]);
|
||||
});
|
||||
|
||||
test("disconnect event is sent when stopping the worker", async () => {
|
||||
const worker = await startWebSocketWorker((type) => {
|
||||
if (type !== "BUS:WORKER_STATE_UPDATED") {
|
||||
expect.step(`broadcast ${type}`);
|
||||
}
|
||||
});
|
||||
await expect.waitForSteps(["broadcast BUS:CONNECT"]);
|
||||
worker._stop();
|
||||
await runAllTimers();
|
||||
await expect.waitForSteps(["broadcast BUS:DISCONNECT"]);
|
||||
});
|
||||
|
||||
test("check connection health during inactivity", async () => {
|
||||
const ogSocket = window.WebSocket;
|
||||
let waitingForCheck = true;
|
||||
patchWithCleanup(window, {
|
||||
WebSocket: function () {
|
||||
const ws = new ogSocket(...arguments);
|
||||
ws.send = (message) => {
|
||||
if (waitingForCheck && message instanceof Uint8Array) {
|
||||
expect.step("check_connection_health_sent");
|
||||
waitingForCheck = false;
|
||||
}
|
||||
};
|
||||
return ws;
|
||||
},
|
||||
});
|
||||
patchWithCleanup(WebsocketWorker.prototype, {
|
||||
enableCheckInterval: true,
|
||||
_restartConnectionCheckInterval() {
|
||||
expect.step("_restartConnectionCheckInterval");
|
||||
super._restartConnectionCheckInterval();
|
||||
},
|
||||
_sendToServer(payload) {
|
||||
if (payload.event_name === "foo") {
|
||||
super._sendToServer(payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
const worker = await startWebSocketWorker((type) => {
|
||||
if (type === "BUS:CONNECT") {
|
||||
expect.step(`broadcast ${type}`);
|
||||
}
|
||||
});
|
||||
await expect.waitForSteps(["broadcast BUS:CONNECT", "_restartConnectionCheckInterval"]);
|
||||
worker.websocket.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: JSON.stringify([{ id: 70, message: { type: "foo" } }]),
|
||||
})
|
||||
);
|
||||
await expect.waitForSteps(["_restartConnectionCheckInterval"]);
|
||||
worker._sendToServer({ event_name: "foo" });
|
||||
await expect.waitForSteps(["_restartConnectionCheckInterval"]);
|
||||
await advanceTime(worker.CONNECTION_CHECK_DELAY + 1000);
|
||||
await expect.waitForSteps(["check_connection_health_sent"]);
|
||||
});
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/** @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 after calling start', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const worker = patchWebsocketWorkerWithCleanup({
|
||||
broadcast(type) {
|
||||
assert.step(`broadcast ${type}`);
|
||||
},
|
||||
});
|
||||
worker._start();
|
||||
// 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}`);
|
||||
},
|
||||
});
|
||||
worker._start()
|
||||
// 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}`);
|
||||
},
|
||||
});
|
||||
worker._start()
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
worker._start()
|
||||
// Wait for the websocket to connect.
|
||||
await nextTick();
|
||||
|
||||
worker.websocket.dispatchEvent(new MessageEvent('message', {
|
||||
data: JSON.stringify(notifications),
|
||||
}));
|
||||
|
||||
assert.verifySteps([
|
||||
'broadcast notification',
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue