1191 lines
48 KiB
JavaScript
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);
|
|
});
|
|
});
|