oca-ocb-core/odoo-bringout-oca-ocb-bus/bus/static/tests/bus_service.test.js
Ernad Husremovic 2d3ee4855a 19.0 vanilla
2026-03-09 09:30:27 +01:00

563 lines
22 KiB
JavaScript

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 - []"]);
});