Odoo18-Base/addons/html_editor/static/tests/odoo_collaboration.test.js
2025-01-06 10:57:38 +07:00

1191 lines
48 KiB
JavaScript

/** @odoo-module */
import { stripHistoryIds } from "@html_editor/others/collaboration/collaboration_odoo_plugin";
import { HISTORY_SNAPSHOT_INTERVAL } from "@html_editor/others/collaboration/collaboration_plugin";
import { COLLABORATION_PLUGINS, MAIN_PLUGINS } from "@html_editor/plugin_sets";
import { Wysiwyg } from "@html_editor/wysiwyg";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
import { Mutex } from "@web/core/utils/concurrency";
import { normalizeHTML } from "@html_editor/utils/html";
import { patch } from "@web/core/utils/patch";
import { getContent, getSelection, setSelection } from "./_helpers/selection";
import { insertText } from "./_helpers/user_actions";
import { animationFrame, advanceTime } from "@odoo/hoot-mock";
import { waitUntil } from "@odoo/hoot-dom";
/**
* @typedef PeerPool
* @property {Record<string, PeerTest>} peers
* @property {string} lastRecordSaved
*/
function makeSpy(obj, functionName) {
const spy = {
callCount: 0,
};
patch(obj, {
[functionName]() {
spy.callCount++;
return super[functionName].apply(this, arguments);
},
});
return spy;
}
function makeSpies(obj, methodNames) {
const methods = {};
for (const methodName of methodNames) {
methods[methodName] = makeSpy(obj, methodName);
}
return methods;
}
class PeerTest {
constructor() {
this.connections = new Set();
this.onlineMutex = new Mutex();
this.isOnline = true;
}
setInfos(infos) {
this.peerId = infos.peerId;
this.editor = infos.editor;
this.plugins = infos.plugins;
this.pool = infos.pool;
this.peers = infos.pool.peers;
this.document = this.editor.document;
}
async destroyEditor() {
for (const peer of this.connections) {
peer.connections.delete(this);
}
this.editor.destroy();
}
async focus() {
return this.plugins["collaborationOdoo"].joinPeerToPeer();
}
async openDataChannel(peer) {
this.connections.add(peer);
peer.connections.add(this);
const ptpFrom = this.ptp;
const ptpTo = peer.ptp;
ptpFrom.peersInfos[peer.peerId] ||= {};
ptpTo.peersInfos[this.peerId] ||= {};
// Simulate the rtc_data_channel_open on both peers.
await this.ptp.notifySelf("rtc_data_channel_open", {
connectionPeerId: peer.peerId,
});
await peer.ptp.notifySelf("rtc_data_channel_open", {
connectionPeerId: this.peerId,
});
}
getValue() {
const content = getContent(this.editor.editable);
return normalizeHTML(content, stripHistoryIds);
}
async writeToServer() {
this.pool.lastRecordSaved = this.editor.getContent();
const lastId = this.plugins.collaborationOdoo.getLastHistoryStepId(
this.pool.lastRecordSaved
);
for (const peer of Object.values(this.peers)) {
if (peer === this) {
continue;
}
peer.onlineMutex.exec(async () => {
return peer.plugins.collaborationOdoo.onServerLastIdUpdate(String(lastId));
});
}
}
async setOnline() {
this.isOnline = true;
this.onlineResolver && this.onlineResolver();
return this.onlineMutex.getUnlockedDef();
}
setOffline() {
this.isOnline = false;
if (this.onlineResolver) {
return;
}
this.onlineMutex.exec(async () => {
await new Promise((resolve) => {
this.onlineResolver = () => {
this.onlineResolver = null;
resolve();
};
});
});
}
}
const initialValue = '<p data-last-history-steps="1">a[]</p>';
class Wysiwygs extends Component {
static template = xml`
<div>
<t t-foreach="this.props.peerIds" t-as="peerId" t-key="peerId">
<Wysiwyg
config="getConfig({peerId})"
t-key="peerId"
iframe="true"
onLoad="(editor) => this.onLoad(peerId, editor)"
/>
</t>
</div>
`;
static components = { Wysiwyg };
static props = {
peerIds: Array,
pool: Object,
};
setup() {
this.peerResolvers = {};
this.peerPromises = Promise.all(
this.props.peerIds.map((peerId) => {
return new Promise((resolve) => {
this.peerResolvers[peerId] = resolve;
});
})
);
this.loadedPromise = new Promise((resolve) => {
this.loadedResolver = resolve;
});
this.lastStepId = 0;
}
getConfig({ peerId }) {
const busService = {
subscribe() {},
unsubscribe() {},
addEventListener: () => {},
removeEventListener: () => {},
addChannel: () => {},
deleteChannel: () => {},
};
return {
Plugins: [...MAIN_PLUGINS, ...COLLABORATION_PLUGINS],
content: initialValue.replaceAll("[]", ""),
collaboration: {
peerId,
busService,
collaborationChannel: {
collaborationFieldName: "fake_field",
collaborationModelName: "fake.model",
collaborationResId: 1,
},
collaborativeTrigger: "focus",
},
};
}
onLoad(peerId, editor) {
const oldAttach = editor.attachTo.bind(editor);
const loadedResolver = this.peerResolvers[peerId];
const startPlugins = editor.startPlugins.bind(editor);
editor.startPlugins = () => {
const plugins = Object.fromEntries(editor.plugins.map((p) => [p.constructor.id, p]));
const { pool } = this.props;
const { peers } = this.props.pool;
patch(plugins["collaborationOdoo"], {
getMetadata() {
const result = super.getMetadata();
result.avatarUrl = ``;
return result;
},
getNewPtp() {
this.startCollaborationTime = parseInt(peerId.match(/\d+/));
const ptp = super.getNewPtp();
peers[peerId].ptp = ptp;
const broadcastAll = (params) => {
for (const peer of peers[peerId].connections) {
peer.ptp.handleNotification(structuredClone(params));
}
};
patch(ptp, {
removePeer(peerId) {
this.notifySelf("ptp_remove", peerId);
delete this.peersInfos[peerId];
},
notifyAllPeers(...args) {
// This is not needed because the opening of the
// dataChannel is done through `openDataChannel` and we
// do not want to simulate the events that thrigger the
// openning of the dataChannel.
if (args[0] === "ptp_join") {
return;
}
this.options.broadcastAll = broadcastAll;
super.notifyAllPeers(...args);
},
_getPtpPeers() {
return peers[peerId].connections.map((peer) => {
return { id: peer.peerId };
});
},
async _channelNotify(peerId, transportPayload) {
if (
!peers[peerId].isOnline ||
!peers[transportPayload.fromPeerId].isOnline
) {
return;
}
peers[peerId].ptp.handleNotification(structuredClone(transportPayload));
},
_createPeer() {
throw new Error("Should not be called.");
},
_addIceCandidate() {
throw new Error("Should not be called.");
},
_recoverConnection() {
throw new Error("Should not be called.");
},
_killPotentialZombie() {
throw new Error("Should not be called.");
},
});
loadedResolver();
return ptp;
},
getCurrentRecord() {
return {
id: 1,
fake_field: pool.lastRecordSaved,
};
},
});
patch(plugins["history"], {
generateId: () => {
this.lastStepId++;
return this.lastStepId.toString();
},
});
pool.peers[peerId].setInfos({
peerId,
pool,
editor,
plugins,
});
return startPlugins();
};
editor.attachTo = (el) => {
const editable = document.createElement("div");
el.replaceChildren(editable);
oldAttach(editable);
// const configSelection = getSelection(editable, initialValue);
// if (configSelection) {
// editable.focus();
// }
setSelection(getSelection(editable, initialValue));
};
}
}
async function createPeers(peerIds) {
/**
* @type PeerPool
*/
const pool = {
peers: Object.fromEntries(peerIds.map((peerId) => [peerId, new PeerTest()])),
lastRecordSaved: "",
};
const wysiwygs = await mountWithCleanup(Wysiwygs, {
props: {
peerIds,
pool,
},
});
await wysiwygs.peerPromises;
return pool;
}
async function insertEditorText(editor, text) {
await insertText(editor, text);
editor.shared.history.addStep();
}
beforeEach(() => {
onRpc("/web/dataset/call_kw/res.users/read", () => {
return [{ id: 0, name: "admin" }];
});
onRpc("/html_editor/get_ice_servers", () => {
return [];
});
onRpc("/html_editor/bus_broadcast", (params) => {
throw new Error("Should not be called.");
});
});
describe("Focus", () => {
test("Focused peer should not receive step if no data channel is open", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await insertEditorText(peers.p1.editor, "b");
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 should have the document changed",
});
expect(peers.p2.getValue()).toBe(`<p>a[]</p>`, {
message: "p2 should not have the document changed",
});
expect(peers.p3.getValue()).toBe(`<p>a[]</p>`, {
message: "p3 should not have the document changed",
});
});
test("Focused peer should receive step while unfocused should not (if the datachannel is open before the step)", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await insertEditorText(peers.p1.editor, "b");
await animationFrame();
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 should have the same document as p2",
});
expect(peers.p2.getValue()).toBe(`<p>[]ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>a[]</p>`, {
message: "p3 should not have the document changed",
});
});
test("Focused peer should receive step while unfocused should not (if the datachannel is open after the step)", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await insertEditorText(peers.p1.editor, "b");
await peers.p1.openDataChannel(peers.p2);
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 should have the same document as p2",
});
expect(peers.p2.getValue()).toBe(`<p>[]ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>a[]</p>`, {
message: "p3 should not have the document changed because it has not focused",
});
});
});
describe("Stale detection & recovery", () => {
describe("detect stale while unfocused", () => {
test("should do nothing until focus", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
expect(peers.p1.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p1 should not have a stale document",
});
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 should have the same document as p2",
});
expect(peers.p2.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p2 should not have a stale document",
});
expect(peers.p2.getValue()).toBe(`<p>[]ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.plugins.collaborationOdoo.isDocumentStale).toBe(true, {
message: "p3 should have a stale document",
});
expect(peers.p3.getValue()).toBe(`<p>a[]</p>`, {
message: "p3 should not have the same document as p1",
});
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p3);
// This timeout is necessary for the selection to be set
await new Promise((resolve) => setTimeout(resolve));
expect(peers.p3.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p3 should not have a stale document",
});
expect(peers.p3.getValue()).toBe(`<p>[]ab</p>`, {
message: "p3 should have the same document as p1",
});
await insertEditorText(peers.p1.editor, "c");
expect(peers.p1.getValue()).toBe(`<p>abc[]</p>`, {
message: "p1 should have the same document as p3",
});
expect(peers.p3.getValue()).toBe(`<p>[]abc</p>`, {
message: "p3 should have the same document as p1",
});
});
});
describe("detect stale while focused", () => {
describe("recover from missing steps", () => {
test("should recover from missing steps", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
const p3Spies = makeSpies(peers.p3.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
]);
expect(peers.p1.plugins.collaborationOdoo.historyShareId).toBe(
peers.p2.plugins.collaborationOdoo.historyShareId,
{
message: "p1 and p2 should have the same historyShareId",
}
);
expect(peers.p1.plugins.collaborationOdoo.historyShareId).toBe(
peers.p3.plugins.collaborationOdoo.historyShareId,
{
message: "p1 and p3 should have the same historyShareId",
}
);
expect(peers.p1.getValue()).toBe(`<p>a[]</p>`, {
message: "p1 should have the same document as p2",
});
expect(peers.p2.getValue()).toBe(`<p>[]a</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should have the same document as p1",
});
peers.p3.setOffline();
await insertEditorText(peers.p1.editor, "b");
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 should have the same document as p2",
});
expect(peers.p2.getValue()).toBe(`<p>[]ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
await peers.p1.writeToServer();
expect(peers.p1.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p1 should not have a stale document",
});
expect(peers.p2.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p2 should not have a stale document",
});
expect(peers.p3.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p3 should not have a stale document",
});
await peers.p3.setOnline();
expect(p3Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p3 recoverFromStaleDocument should have been called once",
});
expect(p3Spies.processMissingSteps.callCount).toBe(1, {
message: "p3 processMissingSteps should have been called once",
});
expect(p3Spies.applySnapshot.callCount).toBe(0, {
message: "p3 applySnapshot should not have been called",
});
expect(p3Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p3 resetFromServerAndResyncWithPeers should not have been called",
});
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 should have the same document as p2",
});
expect(peers.p2.getValue()).toBe(`<p>[]ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]ab</p>`, {
message: "p3 should have the same document as p1",
});
});
});
describe("recover from snapshot", () => {
test("should wait for all peer to recover from snapshot", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = makeSpies(peers.p2.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
]);
const p3Spies = makeSpies(peers.p3.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
"onRecoveryPeerTimeout",
]);
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 have inserted char b",
});
expect(peers.p2.getValue()).toBe(`<p>[]a</p>`, {
message: "p2 should not have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
peers.p1.destroyEditor();
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(0, {
message: "p2 recoverFromStaleDocument should not have been called",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p2 resetFromServerAndResyncWithPeers should not have been called",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
await peers.p2.setOnline();
expect(peers.p2.getValue()).toBe(`[]<p>ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p2 recoverFromStaleDocument should have been called once",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(1, {
message: "p2 resetFromServerAndResyncWithPeers should have been called once",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
await peers.p3.setOnline();
expect(peers.p3.getValue()).toBe(`[]<p>ab</p>`, {
message: "p3 should have the same document as p1",
});
expect(p3Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p3 recoverFromStaleDocument should have been called once",
});
expect(p3Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p3 resetFromServerAndResyncWithPeers should not have been called",
});
expect(p3Spies.processMissingSteps.callCount).toBe(1, {
message: "p3 processMissingSteps should have been called once",
});
expect(p3Spies.applySnapshot.callCount).toBe(1, {
message: "p3 applySnapshot should have been called once",
});
expect(p3Spies.onRecoveryPeerTimeout.callCount).toBe(0, {
message: "p3 onRecoveryPeerTimeout should not have been called",
});
});
test("should recover from snapshot after PTP_MAX_RECOVERY_TIME if some peer do not respond", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = makeSpies(peers.p2.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
]);
const p3Spies = makeSpies(peers.p3.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
"onRecoveryPeerTimeout",
]);
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
peers.p1.setOffline();
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 have inserted char b",
});
expect(peers.p2.getValue()).toBe(`<p>[]a</p>`, {
message: "p2 should not have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(0, {
message: "p2 recoverFromStaleDocument should not have been called",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p2 resetFromServerAndResyncWithPeers should not have been called",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
await peers.p2.setOnline();
expect(peers.p2.getValue()).toBe(`[]<p>ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p2 recoverFromStaleDocument should have been called once",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(1, {
message: "p2 resetFromServerAndResyncWithPeers should have been called once",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
await peers.p3.setOnline();
expect(peers.p3.getValue()).toBe(`[]<p>ab</p>`, {
message: "p3 should have the same document as p1",
});
expect(p3Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p3 recoverFromStaleDocument should have been called once",
});
expect(p3Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p3 resetFromServerAndResyncWithPeers should have been called once",
});
expect(p3Spies.processMissingSteps.callCount).toBe(1, {
message: "p3 processMissingSteps should have been called once",
});
expect(p3Spies.applySnapshot.callCount).toBe(1, {
message: "p3 applySnapshot should have been called once",
});
expect(p3Spies.onRecoveryPeerTimeout.callCount).toBe(1, {
message: "p3 onRecoveryPeerTimeout should have been called once",
});
});
});
describe("recover from server", () => {
test("should recover from server if no snapshot have been processed", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = makeSpies(peers.p2.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
"onRecoveryPeerTimeout",
"resetFromPeer",
]);
const p3Spies = makeSpies(peers.p3.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
"onRecoveryPeerTimeout",
"resetFromPeer",
]);
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 have inserted char b",
});
expect(peers.p2.getValue()).toBe(`<p>[]a</p>`, {
message: "p2 should not have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
peers.p1.destroyEditor();
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(0, {
message: "p2 recoverFromStaleDocument should not have been called",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p2 resetFromServerAndResyncWithPeers should not have been called",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
expect(p2Spies.onRecoveryPeerTimeout.callCount).toBe(0, {
message: "p2 onRecoveryPeerTimeout should not have been called",
});
expect(p2Spies.resetFromPeer.callCount).toBe(0, {
message: "p2 resetFromPeer should not have been called",
});
// Because we do not wait for the end of the
// p2.setOnline promise, p3 will not be able to reset
// from p2 wich allow us to test that p3 reset from the
// server as a fallback.
peers.p2.setOnline();
await peers.p3.setOnline();
expect(peers.p3.getValue()).toBe(`<p>[]ab</p>`, {
message: "p3 should have the same document as p1",
});
expect(p3Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p3 recoverFromStaleDocument should have been called once",
});
expect(p3Spies.resetFromServerAndResyncWithPeers.callCount).toBe(1, {
message: "p3 resetFromServerAndResyncWithPeers should have been called once",
});
expect(p3Spies.processMissingSteps.callCount).toBe(0, {
message: "p3 processMissingSteps should not have been called",
});
expect(p3Spies.applySnapshot.callCount).toBe(1, {
message: "p3 applySnapshot should have been called once",
});
expect(p3Spies.onRecoveryPeerTimeout.callCount).toBe(0, {
message: "p3 onRecoveryPeerTimeout should not have been called",
});
expect(p3Spies.resetFromPeer.callCount).toBe(1, {
message: "p3 resetFromPeer should have been called once",
});
});
test("should recover from server if there is no peer connected", async () => {
const pool = await createPeers(["p1", "p2"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
peers.p2.setOffline();
const p2Spies = makeSpies(peers.p2.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
"onRecoveryPeerTimeout",
"resetFromPeer",
]);
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 have inserted char b",
});
expect(peers.p2.getValue()).toBe(`<p>[]a</p>`, {
message: "p2 should not have the same document as p1",
});
peers.p1.destroyEditor();
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(0, {
message: "p2 recoverFromStaleDocument should not have been called",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p2 resetFromServerAndResyncWithPeers should not have been called",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
expect(p2Spies.resetFromPeer.callCount).toBe(0, {
message: "p2 resetFromPeer should not have been called",
});
await peers.p2.setOnline();
expect(peers.p2.getValue()).toBe(`[]<p>ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p2 recoverFromStaleDocument should have been called once",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(1, {
message: "p2 resetFromServerAndResyncWithPeers should have been called once",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
expect(p2Spies.onRecoveryPeerTimeout.callCount).toBe(0, {
message: "p2 onRecoveryPeerTimeout should not have been called",
});
expect(p2Spies.resetFromPeer.callCount).toBe(0, {
message: "p2 resetFromPeer should not have been called",
});
});
test("should recover from server if there is no response after PTP_MAX_RECOVERY_TIME", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = makeSpies(peers.p2.plugins.collaborationOdoo, [
"recoverFromStaleDocument",
"resetFromServerAndResyncWithPeers",
"processMissingSteps",
"applySnapshot",
"onRecoveryPeerTimeout",
"resetFromPeer",
]);
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
peers.p1.setOffline();
expect(peers.p1.getValue()).toBe(`<p>ab[]</p>`, {
message: "p1 have inserted char b",
});
expect(peers.p2.getValue()).toBe(`<p>[]a</p>`, {
message: "p2 should not have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(0, {
message: "p2 recoverFromStaleDocument should not have been called",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(0, {
message: "p2 resetFromServerAndResyncWithPeers should not have been called",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
expect(p2Spies.resetFromPeer.callCount).toBe(0, {
message: "p2 resetFromPeer should not have been called",
});
await peers.p2.setOnline();
expect(peers.p2.getValue()).toBe(`[]<p>ab</p>`, {
message: "p2 should have the same document as p1",
});
expect(peers.p3.getValue()).toBe(`<p>[]a</p>`, {
message: "p3 should not have the same document as p1",
});
expect(p2Spies.recoverFromStaleDocument.callCount).toBe(1, {
message: "p2 recoverFromStaleDocument should have been called once",
});
expect(p2Spies.resetFromServerAndResyncWithPeers.callCount).toBe(1, {
message: "p2 resetFromServerAndResyncWithPeers should have been called once",
});
expect(p2Spies.processMissingSteps.callCount).toBe(0, {
message: "p2 processMissingSteps should not have been called",
});
expect(p2Spies.applySnapshot.callCount).toBe(0, {
message: "p2 applySnapshot should not have been called",
});
expect(p2Spies.onRecoveryPeerTimeout.callCount).toBe(1, {
message: "p2 onRecoveryPeerTimeout should have been called once",
});
// p1 and p3 are considered offline but not
// disconnected. It means that p2 will try to recover
// from p1 and p3 even if they are currently
// unavailable. This test is usefull to check that the
// code path to resetFromPeer is properly taken.
expect(p2Spies.resetFromPeer.callCount).toBe(2, {
message: "p2 resetFromPeer should have been called twice",
});
});
});
});
});
describe("Disconnect & reconnect", () => {
test("should sync history when disconnecting and reconnecting to internet", async () => {
const pool = await createPeers(["p1", "p2"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await insertEditorText(peers.p1.editor, "b");
peers.p1.setOffline();
const setSelection = (peer) => {
const selection = peer.document.getSelection();
const pElement = peer.editor.editable.querySelector("p");
const range = new Range();
range.setStart(pElement.firstChild, 1);
range.setEnd(pElement.firstChild, 1);
selection.removeAllRanges();
selection.addRange(range);
};
const addP = (peer, content) => {
const p = document.createElement("p");
p.textContent = content;
peer.editor.editable.append(p);
peer.editor.shared.history.addStep();
};
setSelection(peers.p1);
await insertEditorText(peers.p1.editor, "c");
addP(peers.p1, "d");
setSelection(peers.p2);
await insertEditorText(peers.p2.editor, "e");
addP(peers.p2, "f");
peers.p1.setOnline();
peers.p2.setOnline();
// todo: p1PromiseForMissingStep and p2PromiseForMissingStep
// should be removed when the fix of undetected missing step
// will be merged. (task-3208277)
const p1PromiseForMissingStep = new Promise((resolve) => {
patch(peers.p2.plugins.collaborationOdoo, {
async processMissingSteps() {
// Wait for the p2PromiseForMissingStep to resolve
// to avoid undetected missing step.
await p2PromiseForMissingStep;
super.processMissingSteps(...arguments);
resolve();
},
});
});
const p2PromiseForMissingStep = new Promise((resolve) => {
patch(peers.p1.plugins.collaborationOdoo, {
async processMissingSteps() {
super.processMissingSteps(...arguments);
resolve();
},
});
});
await peers.p1.openDataChannel(peers.p2);
await p1PromiseForMissingStep;
expect(peers.p1.getValue()).toBe(`<p>ac[]b</p><p>f</p><p>d</p>`, {
message: "p1 should have the value merged with p2",
});
expect(peers.p2.getValue()).toBe(`<p>ac[]b</p><p>f</p><p>d</p>`, {
message: "p2 should have the value merged with p1",
});
});
});
describe("Snapshot", () => {
test("should destroy snapshot interval when the editor is destroyed", async () => {
const pool = await createPeers(["p1"]);
const peers = pool.peers;
const editor = peers.p1.editor;
await peers.p1.focus();
await insertEditorText(peers.p1.editor, "b");
editor.destroy();
await advanceTime(2 * HISTORY_SNAPSHOT_INTERVAL);
expect(peers.p1.plugins.collaboration._snapshotInterval).toBe(false);
});
test("should get the steps from the first made snapshot of a reseted peer", async () => {
const pool = await createPeers(["p1", "p2", "p3"]);
const peers = pool.peers;
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await insertEditorText(peers.p1.editor, "b");
await animationFrame();
await advanceTime(HISTORY_SNAPSHOT_INTERVAL);
await peers.p2.openDataChannel(peers.p3);
expect(peers.p3.getValue()).toBe(`<p>[]ab</p>`, {
message: "p3 should have the steps from the first snapshot of p2",
});
});
});
describe("History steps Ids", () => {
test("should clear history step ids from the DOM at start up", async () => {
const pool = await createPeers(["p1"]);
const peers = pool.peers;
const editor = peers.p1.editor;
await peers.p1.focus();
expect(getContent(editor.editable)).toBe("<p>a[]</p>");
editor.destroy();
});
test("should clear history step ids when resetting from server", async () => {
const pool = await createPeers(["p1", "p2"]);
const peers = pool.peers;
await peers.p1.focus();
await insertEditorText(peers.p1.editor, "b");
await peers.p1.writeToServer();
expect(peers.p2.plugins.collaborationOdoo.isDocumentStale).toBe(true, {
message: "p2 should have a stale document",
});
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
// This timeout is necessary for the selection to be set
await new Promise((resolve) => setTimeout(resolve));
expect(peers.p2.plugins.collaborationOdoo.isDocumentStale).toBe(false, {
message: "p2 should not have a stale document",
});
expect(getContent(peers.p2.editor.editable)).toBe(`<p>[]ab</p>`, {
message:
"p2 should have the same document as p1, without the history steps id attribute",
});
});
test("should not add history step ids to a split block's children", async () => {
const pool = await createPeers(["p1"]);
const peers = pool.peers;
const editor = peers.p1.editor;
await peers.p1.focus();
editor.shared.split.splitBlock();
editor.shared.history.addStep();
expect(getContent(editor.editable)).toBe(
`<p>a</p><p placeholder='Type "/" for commands' class="o-we-hint">[]<br></p>`
);
editor.shared.split.splitBlock();
editor.shared.history.addStep();
expect(getContent(editor.editable)).toBe(
`<p>a</p><p><br></p><p placeholder='Type "/" for commands' class="o-we-hint">[]<br></p>`
);
editor.destroy();
});
});
describe("Selection", () => {
test("Selection should be updated for peer after delete backward", async () => {
const pool = await createPeers(["p1", "p2"]);
// editor content : <p>a</p>
const peers = pool.peers;
await peers.p1.focus(); // <p>a[]</p>
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await animationFrame();
await new Promise((resolve) => setTimeout(resolve));
expect(
peers.p2.plugins.collaborationSelectionAvatar.selectionInfos.get("p1").selection
.anchorOffset
).toBe(1);
expect(
peers.p2.plugins.collaborationSelection.selectionInfos.get("p1").selection.anchorOffset
).toBe(1);
peers.p1.plugins.delete.delete("backward", "character");
await waitUntil(() => {
const selectionInAvatarPlugin =
peers.p2.plugins.collaborationSelectionAvatar.selectionInfos.get("p1").selection.anchorOffset == 0;
const selectionInCollabSelectionPlugin =
peers.p2.plugins.collaborationSelection.selectionInfos.get("p1").selection.anchorOffset == 0;
return selectionInAvatarPlugin && selectionInCollabSelectionPlugin;
});
expect(
peers.p2.plugins.collaborationSelectionAvatar.selectionInfos.get("p1").selection
.anchorOffset
).toBe(0);
expect(
peers.p2.plugins.collaborationSelection.selectionInfos.get("p1").selection.anchorOffset
).toBe(0);
});
});