19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

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

View file

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

View file

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

View file

@ -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);
});

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

View 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 };

View file

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

View file

@ -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();
}

View file

@ -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);
},
});

View file

@ -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;
},
});

View file

@ -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,
};
},
};
}

View file

@ -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;
}

View file

@ -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);
}

View file

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

View file

@ -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,
};

View file

@ -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>`
);

View file

@ -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;
}

View file

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

View file

@ -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);
},
});

View file

@ -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);
},
});

View file

@ -0,0 +1,5 @@
import { onRpc } from "@web/../tests/web_test_helpers";
onRpc("/bus/has_missed_notifications", function hasMissedNotifications() {
return false;
});

View 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,
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View 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 wont 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);
},
});

View file

@ -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);
});

View file

@ -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']);
});
});

View file

@ -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);
});

View file

@ -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."
);
});

View file

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

View file

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

View file

@ -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',
]);
});