[FIX] web_editor: strengthen collab stale detection & recovery

Before this commit, the stale document detection was imprecise and
incomplete.

The stale document could happen in different scenario:
1)  Client B save when client A is disconnected (therefore client A
    should be notified that the document is stale upon reconnection):

    - For that scenario, the code had a heuristic to detect if the
    internet connection disconnected and reconnected but was imprecise
    as the browser api is not 100% reliable on all browser and
    platforms. That made the code under-checking or over-checking with
    the server.

2)  The user has not yet focused on the element and before this
    commit, the stale document was not detected.

3) If there is a partition in the peer to peer network and a member of
   another partition that save, all other partition will not be aware
   that they are working on a stale document.

4) The stale document happen before 2 peers could be fully connected.

For any of those scenarios, if a stale document was not detected and
the user tried to save changes, only then, a popup would inform that
the version was stale. And if the user closed the tab thinking the
document would automatically save, the change would not be saved
(without warning).

This commit:
1) improve detection of stale document
To detect the document this commit add a message in the odoo bus.bus
for the channel of the record for each write. That will ensure the
message to be received as soon as a user is connected to odoo.

2) improve recovery from stale document
Before this commit, the recovery was only made from the server.

As a detection is now direct (rather than delayed by the previous
heuristic), a document could be detected stale before receiving a step
that would not make it stale. Therefore, we cannot just reset from the
server as a recovery mechanism.

To recover, this commit first tries to reset from missing steps from
all peers. If unsuccessful, it will tries to reset from a snapshot. If
unsuccessful, it will reset from the server.

3) add a mechanism for aborting requests
To prevent unexpected concurrency behavior, this commit add a mechanism
to abort requests.

4) a framework for testing the collaboration of the wysiwyg
As it could be quite tedious and error prone to manually tests
concurrency issues related to the wysiwyg, this commit add a framework
for testing the collaboration of the wysiwyg and test the detection
and recovery of stale document.

task-3217965

Part-of: odoo/odoo#136277
This commit is contained in:
Nicolas Bayet
2023-09-27 14:57:13 +00:00
parent 804b824f8a
commit 2d05f703d7
7 changed files with 1324 additions and 166 deletions
+25 -2
View File
@@ -42,6 +42,8 @@ def ensure_no_history_divergence(record, html_field_name, incoming_history_ids):
logger.warning('The document was already saved from someone with a different history for model %r, field %r with id %r.', record._name, html_field_name, record.id)
raise ValidationError(_('The document was already saved from someone with a different history for model %r, field %r with id %r.', record._name, html_field_name, record.id))
# This method must be called in a context that has write access to the record as
# it will write to the bus.
def handle_history_divergence(record, html_field_name, vals):
# Do not handle history divergence if the field is not in the values.
if html_field_name not in vals:
@@ -52,16 +54,37 @@ def handle_history_divergence(record, html_field_name, vals):
# comes from the odoo editor or the collaboration was not activated. In
# project, it could come from the collaboration pad. In that case, we do not
# handle history divergences.
if request:
channel = (request.db, 'editor_collaboration', record._name, html_field_name, record.id)
if incoming_history_matches is None:
if request:
bus_data = {
'model_name': record._name,
'field_name': html_field_name,
'res_id': record.id,
'notificationName': 'html_field_write',
'notificationPayload': {'last_step_id': None},
}
request.env['bus.bus']._sendone(channel, 'editor_collaboration', bus_data)
return
incoming_history_ids = incoming_history_matches[1].split(',')
incoming_last_history_id = incoming_history_ids[-1]
last_step_id = incoming_history_ids[-1]
bus_data = {
'model_name': record._name,
'field_name': html_field_name,
'res_id': record.id,
'notificationName': 'html_field_write',
'notificationPayload': {'last_step_id': last_step_id},
}
if request:
request.env['bus.bus']._sendone(channel, 'editor_collaboration', bus_data)
if record[html_field_name]:
ensure_no_history_divergence(record, html_field_name, incoming_history_ids)
# Save only the latest id.
vals[html_field_name] = incoming_html[0:incoming_history_matches.start(1)] + incoming_last_history_id + incoming_html[incoming_history_matches.end(1):]
vals[html_field_name] = incoming_html[0:incoming_history_matches.start(1)] + last_step_id + incoming_html[incoming_history_matches.end(1):]
class Web_Editor(http.Controller):
#------------------------------------------------------
@@ -1199,7 +1199,7 @@ export class OdooEditor extends EventTarget {
}
historyGetMissingSteps({fromStepId, toStepId}) {
const fromIndex = this._historySteps.findIndex(x => x.id === fromStepId);
const toIndex = this._historySteps.findIndex(x => x.id === toStepId);
const toIndex = toStepId ? this._historySteps.findIndex(x => x.id === toStepId) : this._historySteps.length;
if (fromIndex === -1 || toIndex === -1) {
return -1;
}
@@ -8,20 +8,11 @@ import {
setTestSelection,
targetDeepest,
undo,
patchEditorIframe,
unformat,
} from '../utils.js';
import { Deferred } from "@web/core/utils/concurrency";
const overridenDomClass = [
'HTMLBRElement',
'HTMLHeadingElement',
'HTMLParagraphElement',
'HTMLPreElement',
'HTMLQuoteElement',
'HTMLTableCellElement',
'Text',
];
const applyConcurentActions = (clientInfos, concurentActions) => {
const clientInfosList = Object.values(clientInfos);
for (const clientInfo of clientInfosList) {
@@ -91,24 +82,11 @@ const testMultiEditor = async spec => {
clientInfo.iframe.setAttribute('src', ' javascript:void(0);');
}
document.body.appendChild(clientInfo.iframe);
patchEditorIframe(clientInfo.iframe);
clientInfo.editable = document.createElement('div');
clientInfo.editable.setAttribute('contenteditable', 'true');
clientInfo.editable.innerHTML = spec.contentBefore;
const iframeWindow = clientInfo.iframe.contentWindow;
for (const overridenClass of overridenDomClass) {
const windowClassPrototype = window[overridenClass].prototype;
const iframeWindowClassPrototype = iframeWindow[overridenClass].prototype;
const iframePrototypeMethodNames = Object.keys(iframeWindowClassPrototype);
for (const methodName of Object.keys(windowClassPrototype)) {
if (!iframePrototypeMethodNames.includes(methodName)) {
iframeWindowClassPrototype[methodName] = windowClassPrototype[methodName];
}
}
}
}
const clientInfosList = Object.values(clientInfos);
@@ -155,7 +155,7 @@ export function setTestSelection(selection, doc = document) {
domRange.setEnd(selection.anchorNode, selection.anchorOffset);
domRange.collapse(false);
}
const domSelection = selection.anchorNode.ownerDocument.getSelection();
const domSelection = doc.getSelection();
domSelection.removeAllRanges();
domSelection.addRange(domRange);
try {
@@ -244,8 +244,8 @@ export function nodeLength(node) {
*
* This is used in the function `testEditor`.
*/
export function renderTextualSelection() {
const selection = document.getSelection();
export function renderTextualSelection(doc = document) {
const selection = doc.getSelection();
if (selection.rangeCount === 0) {
return;
}
@@ -655,5 +655,30 @@ async function pasteData (editor, text, type) {
export const pasteText = async (editor, text) => pasteData(editor, text, 'text/plain');
export const pasteHtml = async (editor, html) => pasteData(editor, html, 'text/html');
export const pasteOdooEditorHtml = async (editor, html) => pasteData(editor, html, 'text/odoo-editor');
const overridenDomClass = [
'HTMLBRElement',
'HTMLHeadingElement',
'HTMLParagraphElement',
'HTMLPreElement',
'HTMLQuoteElement',
'HTMLTableCellElement',
'Text',
];
export function patchEditorIframe(iframe) {
const iframeWindow = iframe.contentWindow;
for (const overridenClass of overridenDomClass) {
const windowClassPrototype = window[overridenClass].prototype;
const iframeWindowClassPrototype = iframeWindow[overridenClass].prototype;
const iframePrototypeMethodNames = Object.keys(iframeWindowClassPrototype);
for (const methodName of Object.keys(windowClassPrototype)) {
if (!iframePrototypeMethodNames.includes(methodName)) {
iframeWindowClassPrototype[methodName] = windowClassPrototype[methodName];
}
}
}
}
export class BasicEditor extends OdooEditor {}
@@ -282,7 +282,7 @@ export class PeerToPeer {
if (this._stopped) {
return;
}
this.handleNotification({ notificationName, notificationPayload });
return this.handleNotification({ notificationName, notificationPayload });
}
handleNotification(notification) {
@@ -332,10 +332,10 @@ export class PeerToPeer {
}
const baseMethod = baseNotificationMethods[notification.notificationName];
if (baseMethod) {
baseMethod.call(this, notification);
return baseMethod.call(this, notification);
}
if (this.options.onNotification) {
this.options.onNotification(notification);
return this.options.onNotification(notification);
}
}
}
@@ -347,14 +347,20 @@ export class PeerToPeer {
return new Promise((resolve, reject) => {
const requestId = this._getRequestId();
const rejectTimeout = setTimeout(() => {
reject('Request took too long (more than 10 seconds).');
const abort = (reason) => {
clearTimeout(rejectTimeout);
delete this._pendingRequestResolver[requestId];
}, 10000);
reject(new RequestError(reason || 'Request was aborted.'));
};
const rejectTimeout = setTimeout(
() => abort('Request took too long (more than 10 seconds).'),
10000
);
this._pendingRequestResolver[requestId] = {
resolve,
rejectTimeout,
abort,
};
this.notifyClient(
@@ -370,7 +376,11 @@ export class PeerToPeer {
);
});
}
abortCurrentRequests() {
for (const { abort } of Object.values(this._pendingRequestResolver)) {
abort();
}
}
_createClient(clientId, { makeOffer = true } = {}) {
if (this._stopped) {
return;
@@ -635,3 +645,10 @@ export class PeerToPeer {
}, 10000);
}
}
export class RequestError extends Error {
constructor(message) {
super(message);
this.name = "RequestError";
}
}
+431 -129
View File
@@ -15,8 +15,8 @@ import { ImageCrop } from '@web_editor/js/wysiwyg/widgets/image_crop';
import * as wysiwygUtils from "@web_editor/js/common/wysiwyg_utils";
import weUtils from "@web_editor/js/common/utils";
import { isSelectionInSelectors } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
import { PeerToPeer } from "@web_editor/js/wysiwyg/PeerToPeer";
import { isSelectionInSelectors, peek } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
import { PeerToPeer, RequestError } from "@web_editor/js/wysiwyg/PeerToPeer";
import { uniqueId } from "@web/core/utils/functions";
import { groupBy } from "@web/core/utils/arrays";
import { debounce } from "@web/core/utils/timing";
@@ -97,6 +97,13 @@ const PTP_CLIENT_DISCONNECTED_STATES = [
'disconnected',
];
// Time in ms to wait when trying to aggregate snapshots from other peers and
// potentially recover from a missing step before trying to apply those
// snapshots or recover from the server.
const PTP_MAX_RECOVERY_TIME = 500;
const REQUEST_ERROR = Symbol('REQUEST_ERROR');
// this is a local cache for ice server descriptions
let ICE_SERVERS = null;
@@ -310,7 +317,28 @@ export class Wysiwyg extends Component {
if (options.value) {
this.$editable.html(options.value);
}
const initialHistoryId = options.value && this._getInitialHistoryId(options.value);
this._isDocumentStale = false;
// Each time a reset of the document is triggered, it is assigned a
// unique identifier. Since resetting the editor involves asynchronous
// requests, it is possible that subsequent resets are triggered before
// the previous one is complete. This property identifies the latest
// reset and can be compared against to cancel the processing of late
// responses from previous resets.
this._lastCollaborationResetId = 0;
// This ID correspond to the peer that initiated the document and set
// the initial oid for all nodes in the tree. It is not the same as
// document that had a step id at some point. If a step comes from a
// different history, we should not apply it.
this._historyShareId = Math.floor(Math.random() * Math.pow(2,52)).toString();
// The ID is the latest step ID that the server knows through
// `data-last-history-steps`. We cannot save to the server if we do not
// have that ID in our history ids as it means that our version is
// stale.
this._serverLastStepId = options.value && this._getLastHistoryStepId(options.value);
this.$editable.data('wysiwyg', this);
this.$editable.data('oe-model', options.recordInfo.res_model);
this.$editable.data('oe-id', options.recordInfo.res_id);
@@ -356,7 +384,7 @@ export class Wysiwyg extends Component {
powerboxFilters: this.options.powerboxFilters || [],
showEmptyElementHint: this.options.showEmptyElementHint,
controlHistoryFromDocument: this.options.controlHistoryFromDocument,
initialHistoryId,
initialHistoryId: this._serverLastStepId,
getContentEditableAreas: this.options.getContentEditableAreas,
getReadOnlyAreas: this.options.getReadOnlyAreas,
getUnremovableElements: this.options.getUnremovableElements,
@@ -412,7 +440,7 @@ export class Wysiwyg extends Component {
categories: powerboxOptions.categories,
plugins: options.editorPlugins,
direction: options.direction || localization.direction || 'ltr',
collaborationClientAvatarUrl: `${browser.location.origin}/web/image?model=res.users&field=avatar_128&id=${encodeURIComponent(session.uid)}`,
collaborationClientAvatarUrl: this._getCollaborationClientAvatarUrl(),
renderingClasses: ['o_dirty', 'o_transform_removal', 'oe_edited_link', 'o_menu_loading'],
dropImageAsAttachment: options.dropImageAsAttachment,
foldSnippets: !!options.foldSnippets,
@@ -635,6 +663,7 @@ export class Wysiwyg extends Component {
}
this._collaborationChannelName = channelName;
this._historyStepsBuffer = [];
Wysiwyg.activeCollaborationChannelNames.add(channelName);
const collaborationBusListener = ({ detail: notifications}) => {
@@ -645,7 +674,11 @@ export class Wysiwyg extends Component {
payload.field_name === fieldName &&
payload.res_id === resId
) {
this._peerToPeerLoading.then(() => this.ptp.handleNotification(payload));
if (payload.notificationName === 'html_field_write') {
this._onServerLastIdUpdate(payload.notificationPayload.last_step_id);
} else if (this._ptpJoined) {
this._peerToPeerLoading.then(() => this.ptp.handleNotification(payload));
}
}
}
}
@@ -661,7 +694,6 @@ export class Wysiwyg extends Component {
this._startCollaborationTime = new Date().getTime();
this._checkConnectionChange = () => {
this._navigatorCheckOnlineWorking = true;
if (!this.ptp) {
return;
}
@@ -726,7 +758,7 @@ export class Wysiwyg extends Component {
}, 50),
onHistoryMissingParentSteps: async ({ step, fromStepId }) => {
if (!this.ptp) return;
const missingSteps = await this.ptp.requestClient(
const missingSteps = await this.requestClient(
step.clientId,
'get_missing_steps', {
fromStepId: fromStepId,
@@ -734,14 +766,8 @@ export class Wysiwyg extends Component {
},
{ transport: 'rtc' }
);
// If missing steps === -1, it means that either the
// step.clientId has a stale document or the step.clientId has a
// snapshot and does not includes the step in its history.
if (missingSteps === -1 || !missingSteps.length) {
console.warn('Editor get_missing_steps result is erroneous.');
return;
}
this.ptp && this.odooEditor.onExternalHistorySteps(missingSteps.concat([step]));
if (missingSteps === REQUEST_ERROR) return;
this._processMissingSteps(Array.isArray(missingSteps) ? missingSteps.concat(step) : missingSteps);
},
};
if (this.options.postProcessExternalSteps) {
@@ -760,14 +786,12 @@ export class Wysiwyg extends Component {
*/
destroy() {
Wysiwyg.activeWysiwygs.delete(this);
if (this._collaborationChannelName) {
Wysiwyg.activeCollaborationChannelNames.delete(this._collaborationChannelName);
}
this._stopPeerToPeer();
document.removeEventListener("mousemove", this._signalOnline, true);
document.removeEventListener("keydown", this._signalOnline, true);
document.removeEventListener("keyup", this._signalOnline, true);
this._collaborationStopBus && this._collaborationStopBus();
if (this.odooEditor) {
this.odooEditor.document.removeEventListener("mousemove", this._signalOnline, true);
this.odooEditor.document.removeEventListener("keydown", this._signalOnline, true);
@@ -781,8 +805,8 @@ export class Wysiwyg extends Component {
// If peer to peer is initializing, wait for properly closing it.
if (this._peerToPeerLoading) {
this._peerToPeerLoading.then(()=> {
this._collaborationStopBus();
this.ptp.closeAllConnections();
this._stopPeerToPeer();
this._collaborationStopBus && this._collaborationStopBus();
});
}
clearInterval(this._collaborationInterval);
@@ -2646,76 +2670,66 @@ export class Wysiwyg extends Component {
}
}
_signalOffline() {
if (!this._isOnline) {
return;
}
this._isOnline = false;
this.preSavePromise = new Promise((resolve, reject) => {
this.preSavePromiseResolve = resolve;
this.preSavePromiseReject = reject;
});
}
async _signalOnline() {
clearTimeout(this._offlineTimeout);
this._offlineTimeout = undefined;
if (this._isOnline || !this.preSavePromise || !navigator.onLine) {
if (this._isOnline || !navigator.onLine) {
return;
}
this._isOnline = true;
if (!this.ptp) return;
if (this._removeSignalDisconnectCallback) {
this._removeSignalDisconnectCallback();
}
const resetPreSavePromise = () => {
this.preSavePromise = undefined;
this.preSavePromiseResolve = undefined;
this.preSavePromiseReject = undefined;
}
try {
const serverContent = await this._ensureCommonHistory();
if (serverContent) {
const content = markup(this.odooEditor.editable.cloneNode(true).outerHTML);
this.env.services.dialog.add(ConflictDialog, { content });
await this.resetEditor(serverContent);
// We were in a peer to peer session before the conflict, join
// it again immediately.
this._joinPeerToPeer();
}
this.preSavePromiseResolve();
resetPreSavePromise();
} catch (e) {
this.preSavePromiseReject && this.preSavePromiseReject(e);
resetPreSavePromise();
}
}
_getInitialHistoryId(value) {
const matchId = value.match(/data-last-history-steps="(?:[0-9]+,)*([0-9]+)"/);
return matchId && matchId[1];
// Ask for potential missing steps from all peers.
return Promise.all(this._getPtpClients().map(client => {
return this.requestClient(
client.id,
'get_missing_steps', {
fromStepId: peek(this.odooEditor.historyGetBranchIds()).id,
},
{ transport: 'rtc' }
).then(missingSteps => {
if (missingSteps === REQUEST_ERROR) return;
this._processMissingSteps(missingSteps);
});
}));
}
/**
* When the collaboration is active, ensure that we do not try to save with
* a different history branch to the database. If the history is different,
* return the database html content.
* Process missing steps received from a peer.
*
* See `_historyIds` in `historyReset` in OdooEditor.
*
* @return {string} The database html content if the history is different.
* @private
* @param {Array<Object>|-1} missingSteps
* @return {Promise<boolean>} true if missing steps have been processed
*/
async _ensureCommonHistory() {
if (!this.ptp) return;
const historyIds = this.odooEditor.historyGetBranchIds();
return this._serviceRpc(
'/web_editor/ensure_common_history',
{
history_ids: historyIds,
model_name: this.options.collaborationChannel.collaborationModelName,
field_name: this.options.collaborationChannel.collaborationFieldName,
res_id: this.options.collaborationChannel.collaborationResId,
},
);
async _processMissingSteps(missingSteps) {
// If missing steps === -1, it means that either:
// - the step.clientId has a stale document
// - the step.clientId has a snapshot and does not includes the step in
// its history
// - if another share history id
// - because the step.clientId has reset from the server and
// step.clientId is not synced with this client
// - because the step.clientId is in a network partition
if (missingSteps === -1 || !missingSteps.length) {
return false;
}
this.ptp && this.odooEditor.onExternalHistorySteps(missingSteps);
return true;
}
_showConflictDialog() {
if (this._conflictDialogOpened) return;
const content = markup(this.odooEditor.editable.cloneNode(true).outerHTML);
this._conflictDialogOpened = true;
this.env.services.dialog.add(ConflictDialog, {
content,
close: () => this._conflictDialogOpened = false,
});
}
_getLastHistoryStepId(value) {
const matchId = value.match(/data-last-history-steps="(?:[0-9]+,)*([0-9]+)"/);
return matchId && matchId[1];
}
_generateClientId() {
// No need for secure random number.
@@ -2731,8 +2745,6 @@ export class Wysiwyg extends Component {
// Wether or not the history has been sent or received at least
// once.
this._historySyncAtLeastOnce = false;
this._historySyncFinished = false;
this._historyStepsBuffer = [];
return new PeerToPeer({
peerConnectionConfig: { iceServers: this._iceServers },
@@ -2762,34 +2774,33 @@ export class Wysiwyg extends Component {
},
get_client_avatar: () => `${browser.location.origin}/web/image?model=res.users&field=avatar_128&id=${encodeURIComponent(session.uid)}`,
get_missing_steps: (params) => this.odooEditor.historyGetMissingSteps(params.requestPayload),
get_history_from_snapshot: () => this.odooEditor.historyGetSnapshotSteps(),
get_history_from_snapshot: () => this._getHistorySnapshot(),
get_collaborative_selection: () => this.odooEditor.getCurrentCollaborativeSelection(),
recover_document: (params) => {
const { serverDocumentId, fromStepId } = params.requestPayload;
if (!this.odooEditor.historyGetBranchIds().includes(serverDocumentId)) {
return;
}
return {
missingSteps: this.odooEditor.historyGetMissingSteps({ fromStepId }),
snapshot: this._getHistorySnapshot(),
};
},
},
onNotification: async ({ fromClientId, notificationName, notificationPayload }) => {
switch (notificationName) {
case 'ptp_remove':
this.odooEditor.multiselectionRemove(notificationPayload);
break;
case 'rtc_signal_description':
const pc = this.ptp.clientsInfos[fromClientId].peerConnection;
if (this._couldBeDisconnected && this._navigatorCheckOnlineWorking && (!pc || pc.connectionState === 'closed')) {
this._signalOnline();
}
break;
case 'ptp_disconnect':
this.ptp.removeClient(fromClientId);
this.odooEditor.multiselectionRemove(fromClientId);
break;
case 'rtc_data_channel_open': {
fromClientId = notificationPayload.connectionClientId;
const remoteStartTime = await this.ptp.requestClient(fromClientId, 'get_start_time', undefined, { transport: 'rtc' });
const remoteStartTime = await this.requestClient(fromClientId, 'get_start_time', undefined, { transport: 'rtc' });
if (remoteStartTime === REQUEST_ERROR) return;
this.ptp.clientsInfos[fromClientId].startTime = remoteStartTime;
this.ptp.requestClient(fromClientId, 'get_client_name', undefined, { transport: 'rtc' }).then((clientName) => {
this.ptp.clientsInfos[fromClientId].clientName = clientName;
});
this.ptp.requestClient(fromClientId, 'get_client_avatar', undefined, { transport: 'rtc' }).then(clientAvatarUrl => {
this.ptp.clientsInfos[fromClientId].clientAvatarUrl = clientAvatarUrl;
});
if (!this._historySyncAtLeastOnce) {
const localClient = { id: this._currentClientId, startTime: this._startCollaborationTime };
@@ -2797,40 +2808,31 @@ export class Wysiwyg extends Component {
if (isClientFirst(localClient, remoteClient)) {
this._historySyncAtLeastOnce = true;
} else {
const { steps, historyIds } = await this.ptp.requestClient(fromClientId, 'get_history_from_snapshot', undefined, { transport: 'rtc' });
// Ensure that the history hasn't been
// synced by another client before this
// `get_history_from_snapshot` finished.
if (this._historySyncAtLeastOnce) {
this._resetCollabRequests();
const response = await this._resetFromClient(fromClientId, this._lastCollaborationResetId);
if (response !== REQUEST_ERROR) {
return;
}
const firstStepId = this.odooEditor.historyGetBranchIds()[0];
const staleDocument = !historyIds.includes(firstStepId);
if (staleDocument) {
return false;
}
this._historySyncAtLeastOnce = true;
this.odooEditor.historyResetFromSteps(steps, historyIds);
const remoteSelection = await this.ptp.requestClient(fromClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
if (remoteSelection) {
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
}
this.options.onHistoryResetFromSteps();
}
// In case there are steps received in the meantime, process them.
if (this._historyStepsBuffer.length) {
this.odooEditor.onExternalHistorySteps(this._historyStepsBuffer);
this._historyStepsBuffer = [];
}
this._historySyncFinished = true;
} else {
const currentStep = this.odooEditor._historySteps[this.odooEditor._historySteps.length - 1];
const remoteSelection = await this.ptp.requestClient(fromClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
if (remoteSelection) {
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
}
this._setCollaborativeSelection(fromClientId);
this.ptp.notifyClient(fromClientId, 'oe_history_step', currentStep, { transport: 'rtc' });
}
this.requestClient(fromClientId, 'get_client_name', undefined, { transport: 'rtc' }).then((clientName) => {
if (clientName === REQUEST_ERROR) return;
this.ptp.clientsInfos[fromClientId].clientName = clientName;
this.odooEditor.multiselectionRefresh();
});
this.requestClient(fromClientId, 'get_client_avatar', undefined, { transport: 'rtc' }).then(clientAvatarUrl => {
if (clientAvatarUrl === REQUEST_ERROR) return;
this.ptp.clientsInfos[fromClientId].clientAvatarUrl = clientAvatarUrl;
this.odooEditor.multiselectionRefresh();
});
break;
}
case 'oe_history_step':
@@ -2855,16 +2857,296 @@ export class Wysiwyg extends Component {
}
});
}
_getCollaborationClientAvatarUrl() {
return `${browser.location.origin}/web/image?model=res.users&field=avatar_128&id=${encodeURIComponent(session.uid)}`
}
_stopPeerToPeer() {
this._joiningPtp = false;
this._ptpJoined = false;
this._resetCollabRequests();
this.ptp && this.ptp.stop();
this._collaborationStopBus && this._collaborationStopBus();
}
_joinPeerToPeer() {
this.$editable[0].removeEventListener('focus', this._joinPeerToPeer);
if (this._peerToPeerLoading) {
this._peerToPeerLoading.then(() => this.ptp.notifyAllClients('ptp_join'));
return this._peerToPeerLoading.then(async () => {
this._joiningPtp = true;
if (this._isDocumentStale) {
const success = await this._resetFromServerAndResyncWithClients();
if (!success) return;
}
this.ptp.notifyAllClients('ptp_join');
this._joiningPtp = false;
this._ptpJoined = true;
});
}
}
async _setCollaborativeSelection(fromClientId) {
const remoteSelection = await this.requestClient(fromClientId, 'get_collaborative_selection', undefined, { transport: 'rtc' });
if (remoteSelection === REQUEST_ERROR) return;
if (remoteSelection) {
this.odooEditor.onExternalMultiselectionUpdate(remoteSelection);
}
}
/**
* Get peer to peer clients.
*/
_getPtpClients() {
const clients = Object.entries(this.ptp.clientsInfos).map(([clientId, clientInfo]) => ({id: clientId, ...clientInfo}));
return clients.sort((a, b) => isClientFirst(a, b) ? -1 : 1);
}
async _getCurrentRecord() {
const [record] = await this.orm.read(
this.options.collaborationChannel.collaborationModelName,
[this.options.collaborationChannel.collaborationResId],
[this.options.collaborationChannel.collaborationFieldName],
);
return record;
}
_isLastDocumentStale() {
return !this.odooEditor.historyGetBranchIds().includes(this._serverLastStepId);
}
/**
* Update the server document last step id and recover from a stale document
* if this client does not have that step in its history.
*/
_onServerLastIdUpdate(last_step_id) {
this._serverLastStepId = last_step_id;
// Check if the current document is stale.
this._isDocumentStale = this._isLastDocumentStale();
if (this._isDocumentStale && this._ptpJoined) {
return this._recoverFromStaleDocument();
} else if (this._isDocumentStale && this._joiningPtp) {
// In case there is a stale document while a previous recovery is
// ongoing.
this._resetCollabRequests();
this._joinPeerToPeer();
}
}
/**
* Try to recover from a stale document.
*
* The strategy is:
*
* 1. Try to get a converging document from the other peers.
*
* 1.1 By recovery from missing steps: it is the best possible case of
* retrieval.
*
* 1.2 By recovery from snapshot: it reset the whole editor (destroying
* changes and selection made by the user).
*
* 2. Reset from the server:
* If the recovery from the other peers fails, reset from the server.
*
* As we know we have a stale document, we need to reset it at least from
* the server. We shouldn't wait too long for peers to respond because
* the longer we wait for an unresponding peer, the longer a user can
* edit a stale document.
*
* The peers timeout is set to PTP_MAX_RECOVERY_TIME.
*/
async _recoverFromStaleDocument() {
return new Promise((resolve) => {
// 1. Try to recover a converging document from other peers.
const resetCollabCount = this._lastCollaborationResetId;
const allPeers = this._getPtpClients().map(client => client.id);
if (allPeers.length === 0) {
if (this._isDocumentStale) {
this._showConflictDialog();
resolve();
return this._resetFromServerAndResyncWithClients();
}
}
let hasRetrievalBudgetTimeout = false;
let snapshots = [];
let nbPendingResponses = allPeers.length;
const success = () => {
resolve();
clearTimeout(timeout);
};
for (const peerId of allPeers) {
this.requestClient(
peerId,
'recover_document', {
serverDocumentId: this._serverLastStepId,
fromStepId: peek(this.odooEditor.historyGetBranchIds()),
},
{ transport: 'rtc' }
).then((response) => {
nbPendingResponses--;
if (
response === REQUEST_ERROR ||
resetCollabCount !== this._lastCollaborationResetId ||
hasRetrievalBudgetTimeout ||
!response ||
!this._isDocumentStale
) {
if (nbPendingResponses <= 0) {
processSnapshots();
}
return;
}
this._processMissingSteps(response.missingSteps);
this._isDocumentStale = this._isLastDocumentStale();
snapshots.push(response.snapshot);
if (nbPendingResponses < 1) {
processSnapshots();
}
});
}
// Only process the snapshots after having received a response from all
// the peers or after PTP_MAX_RECOVERY_TIME in order to try to recover
// from missing steps.
const processSnapshots = async () => {
this._isDocumentStale = this._isLastDocumentStale();
if (!this._isDocumentStale) {
return success();
}
if (snapshots[0]) {
this._showConflictDialog();
}
for (const snapshot of snapshots) {
this._applySnapshot(snapshot);
this._isDocumentStale = this._isLastDocumentStale();
// Prevent reseting from another snapshot if the document
// converge.
if (!this._isDocumentStale) {
return success();
}
}
// 2. If the document is still stale, try to recover from the server.
if (this._isDocumentStale) {
this._showConflictDialog();
await this._resetFromServerAndResyncWithClients();
}
success();
}
// Wait PTP_MAX_RECOVERY_TIME to retrieve data from other peers to
// avoid reseting from the server if possible.
const timeout = setTimeout(() => {
if (resetCollabCount !== this._lastCollaborationResetId) return;
hasRetrievalBudgetTimeout = true;
this._onRecoveryClientTimeout(processSnapshots);
}, PTP_MAX_RECOVERY_TIME);
});
}
/**
* Callback for when the timeout PTP_MAX_RECOVERY_TIME fires.
*
* Used to be hooked in tests.
*
* @param {Function} processSnapshots The snapshot processing function.
*/
async _onRecoveryClientTimeout(processSnapshots) {
processSnapshots();
}
/**
* Reset the document from the server and resync with the clients.
*/
async _resetFromServerAndResyncWithClients() {
let collaborationResetId = this._lastCollaborationResetId;
const record = await this._getCurrentRecord();
if (collaborationResetId !== this._lastCollaborationResetId) return;
const content = record[this.options.collaborationChannel.collaborationFieldName];
const lastHistoryId = content && this._getLastHistoryStepId(content);
// If a change was made in the document while retrieving it, the
// lastHistoryId will be different if the odoo bus did not have time to
// notify the user.
if (this._serverLastStepId !== lastHistoryId) {
// todo: instrument it to ensure it never happens
throw new Error('Concurency detected while recovering from a stale document. The last history id of the server is different from the history id received by the html_field_write event.');
}
this._isDocumentStale = false;
this.resetValue(content);
// After resetting from the server, try to resynchronise with a peer as
// if it was the first time connecting to a peer in order to retrieve a
// proper snapshot (e.g. This case could arise if we tried to recover
// from a client but the timeout (PTP_MAX_RECOVERY_TIME) was reached
// before receiving a response).
this._historySyncAtLeastOnce = false;
this._resetCollabRequests();
collaborationResetId = this._lastCollaborationResetId;
this._startCollaborationTime = new Date().getTime();
await Promise.all(this._getPtpClients().map((client) => {
// Reset from the fastest client. The first client to reset will set
// this._historySyncAtLeastOnce to true canceling the other peers
// resets.
return this._resetFromClient(client.id, collaborationResetId);
}));
return true;
}
_resetCollabRequests() {
this._lastCollaborationResetId++;
// By aborting the current requests from ptp, we ensure that the ongoing
// `Wysiwyg.requestClient` will return REQUEST_ERROR. Most requests that
// calls `Wysiwyg.requestClient` might want to check if the response is
// REQUEST_ERROR.
this.ptp && this.ptp.abortCurrentRequests();
}
async _resetFromClient(fromClientId, resetCollabCount) {
this._historySyncFinished = false;
this._historyStepsBuffer = [];
const snapshot = await this.requestClient(fromClientId, 'get_history_from_snapshot', undefined, { transport: 'rtc' });
if (snapshot === REQUEST_ERROR) {
return REQUEST_ERROR;
}
if (resetCollabCount !== this._lastCollaborationResetId) {
return;
}
// Ensure that the history hasn't been synced by another client before
// this `get_history_from_snapshot` finished.
if (this._historySyncAtLeastOnce) {
return;
}
const applied = this._applySnapshot(snapshot);
if (!applied) {
return;
}
this._historySyncFinished = true;
// In case there are steps received in the meantime, process them.
if (this._historyStepsBuffer.length) {
this.odooEditor.onExternalHistorySteps(this._historyStepsBuffer);
this._historyStepsBuffer = [];
}
this._setCollaborativeSelection(fromClientId);
}
async requestClient(clientId, requestName, requestPayload, params) {
return this.ptp.requestClient(clientId, requestName, requestPayload, params).catch((e) => {
if (e instanceof RequestError) {
return REQUEST_ERROR;
} else {
throw e;
}
});
}
/**
* Reset the value and history of the editor.
*/
async resetValue(value) {
this.setValue(value);
this.odooEditor.historyReset();
this._historyShareId = Math.floor(Math.random() * Math.pow(2,52)).toString();
this._serverLastStepId = value && this._getLastHistoryStepId(value);
if (this._serverLastStepId) {
this.odooEditor.historySetInitialId(this._serverLastStepId);
}
}
/**
* Reset the editor with a new value and potientially new options.
*/
async resetEditor(value, options) {
await this._peerToPeerLoading;
this.$editable[0].removeEventListener('focus', this._joinPeerToPeer);
@@ -2873,29 +3155,47 @@ export class Wysiwyg extends Component {
}
const {collaborationChannel} = this.options;
this._stopPeerToPeer();
this._collaborationStopBus && this._collaborationStopBus();
this._isDocumentStale = false;
this._rulesCache = undefined; // Reset the cache of rules.
// If there is no collaborationResId, the record has been deleted.
if (!this._isCollaborationEnabled(this.options)) {
this.setValue(value);
this.odooEditor.historyReset();
this.resetValue(value);
return;
}
this.setValue(value);
this.resetValue(value);
this.setupCollaboration(collaborationChannel);
this.odooEditor.collaborationSetClientId(this._currentClientId);
this.odooEditor.historyReset();
if (this.options.collaborativeTrigger === 'start') {
this._joinPeerToPeer();
} else if (this.options.collaborativeTrigger === 'focus') {
// Wait until editor is focused to join the peer to peer network.
this.$editable[0].addEventListener('focus', this._joinPeerToPeer);
}
const initialHistoryId = value && this._getInitialHistoryId(value);
if (initialHistoryId) {
this.odooEditor.historySetInitialId(initialHistoryId);
}
await this._peerToPeerLoading;
}
_getHistorySnapshot() {
return Object.assign(
{},
this.odooEditor.historyGetSnapshotSteps(),
{ historyShareId: this._historyShareId }
);
}
_applySnapshot(snapshot) {
const { steps, historyIds, historyShareId } = snapshot;
// If there is no serverLastStepId, it means that we use a document
// that is not versionned yet.
const isStaleDocument = this._serverLastStepId && !historyIds.includes(this._serverLastStepId);
if (isStaleDocument) {
return;
}
this._historyShareId = historyShareId;
this._historySyncAtLeastOnce = true;
this.odooEditor.historyResetFromSteps(steps, historyIds);
this.odooEditor.historyResetLatestComputedSelection();
return true;
}
/**
* Set `contenteditable` according to `.o_not_editable` and `.o_editable`.
*
@@ -3123,7 +3423,9 @@ Wysiwyg.setRange = function (startNode, startOffset = 0, endNode = startNode, en
// Check wether clientA is before clientB.
function isClientFirst(clientA, clientB) {
if (clientA.startTime === clientB.startTime) {
return clientA.id.localeCompare(clientB.id) < 1;
return clientA.id.localeCompare(clientB.id) === -1;
} if (clientA.startTime === undefined || clientB.startTime === undefined) {
return Boolean(clientA.startTime);
} else {
return clientA.startTime < clientB.startTime;
}
@@ -0,0 +1,813 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import {
parseTextualSelection,
setTestSelection,
renderTextualSelection,
patchEditorIframe,
} from '@web_editor/js/editor/odoo-editor/test/utils';
import { Wysiwyg, stripHistoryIds } from '@web_editor/js/wysiwyg/wysiwyg';
import { Mutex } from '@web/core/utils/concurrency';
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeNotificationService } from "@web/../tests/helpers/mock_services";
import { mount, getFixture } from "@web/../tests/helpers/utils";
import { registry } from "@web/core/registry";
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(infos) {
this.peerId = infos.peerId;
this.wysiwyg = infos.wysiwyg;
this.iframe = infos.iframe;
this.document = this.iframe.contentWindow.document;
this.wrapper = infos.wrapper;
this.pool = infos.pool;
this.peers = infos.pool.peers;
this._connections = new Set();
this.onlineMutex = new Mutex();
this.isOnline = true;
}
async startEditor() {
this._started = this.wysiwyg.startEdition();
this.started = true;
await this._started;
if (this.initialParsedSelection) {
setTestSelection(this.initialParsedSelection, this.document);
this.wysiwyg.odooEditor._recordHistorySelection();
} else {
document.getSelection().removeAllRanges();
}
clearInterval(this.wysiwyg._collaborationInterval);
return this._started;
}
async destroyEditor() {
for (const peer of this._connections) {
peer._connections.delete(this);
}
this.wysiwyg.destroy();
}
async focus() {
await this.started;
return this.wysiwyg._joinPeerToPeer();
}
async openDataChannel(peer) {
this._connections.add(peer);
peer._connections.add(this);
const ptpFrom = this.wysiwyg.ptp;
const ptpTo = peer.wysiwyg.ptp;
ptpFrom.clientsInfos[peer.peerId] = {};
ptpTo.clientsInfos[this.peerId] = {};
// Simulate the rtc_data_channel_open on both peers.
await this.wysiwyg.ptp.notifySelf('rtc_data_channel_open', {
connectionClientId: peer.peerId,
});
await peer.wysiwyg.ptp.notifySelf('rtc_data_channel_open', {
connectionClientId: this.peerId,
});
}
makeStep(fn) {
fn(this);
}
getValue() {
this.wysiwyg.odooEditor.observerUnactive('PeerTest.getValue');
renderTextualSelection(this.document);
const html = this.wysiwyg.$editable[0].innerHTML;
const selection = parseTextualSelection(this.wysiwyg.$editable[0]);
if (selection) {
setTestSelection(selection, this.document);
}
this.wysiwyg.odooEditor.observerActive('PeerTest.getValue');
return stripHistoryIds(html);
}
writeToServer() {
this.pool.lastRecordSaved = this.wysiwyg.getValue();
const lastId = this.wysiwyg._getLastHistoryStepId(this.pool.lastRecordSaved);
for (const peer of Object.values(this.peers)) {
if (peer === this || !peer._started) continue;
peer.onlineMutex.exec(() => {
return peer.wysiwyg._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();
}
});
});
}
}
function insert(string) {
return (peer) => {
peer.wysiwyg.odooEditor.execCommand('insert', string);
}
}
const initialValue = '<p data-last-history-steps="1">a[]</p>';
class PeerPool {
constructor(peers) {
this.peers = {};
}
}
async function createPeers(peers) {
const pool = new PeerPool();
for (const peerId of peers) {
const iframe = document.createElement('iframe');
if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) {
// Firefox reset the page without this hack.
// With this hack, chrome does not render content.
iframe.setAttribute('src', ' javascript:void(0);');
}
getFixture().append(iframe);
patchEditorIframe(iframe);
iframe.contentDocument.body.innerHTML = `<div class="peer_wysiwyg_wrapper client_${peerId}"></div>`;
const peerWysiwygWrapper = iframe.contentDocument.querySelector('.peer_wysiwyg_wrapper');
iframe.contentWindow.$ = $;
registry.category("services").add("notification", makeFakeNotificationService(), {
force: true,
});
registry.category("services").add("popover", { start: () => ({ }) }, {
force: true,
});
const env = await makeTestEnv({
mockRPC(route) {
if (route === "/web/dataset/call_kw/res.users/read") {
return [{ id: 0, name: "admin" }];
}
}
});
const wysiwyg = await mount(Wysiwyg, peerWysiwygWrapper, {
env,
props: {
startWysiwyg: () => {},
options: {
value: initialValue,
collaborative: true,
collaborationChannel: {
collaborationFieldName: "fake_field",
collaborationModelName: "fake.model",
collaborationResId: 1
},
document: iframe.contentWindow.document,
}
}
});
patch(wysiwyg, {
_generateClientId() {
return peerId;
},
// Hacky hook as we know this method is called after setting the value in the wysiwyg start and before sending the value to odooEditor.
_getLastHistoryStepId() {
pool.peers[peerId].initialParsedSelection = parseTextualSelection(wysiwyg.$editable[0]);
return super._getLastHistoryStepId(...arguments);
},
_serviceRpc(route, params) {
if (route === '/web_editor/get_ice_servers') {
return [];
} else if (route === '/web_editor/bus_broadcast') {
const currentPeer = pool.peers[peerId];
for (const peer of currentPeer._connections) {
peer.wysiwyg.ptp.handleNotification(structuredClone(params.bus_data));
}
}
},
_getNewPtp() {
const ptp = super._getNewPtp(...arguments);
patch(ptp, {
removeClient(peerId) {
this.notifySelf('ptp_remove', peerId);
delete this.clientsInfos[peerId];
},
notifyAllClients(...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;
}
super.notifyAllClients(...args);
},
_getPtpClients() {
return pool.peers[peerId]._connections.map((peer) => {
return { id: peer.peerId }
});
},
async _channelNotify(peerId, transportPayload) {
if (!pool.peers[peerId].isOnline) return;
pool.peers[peerId].wysiwyg.ptp.handleNotification(structuredClone(transportPayload));
},
_createClient() {
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.');
},
});
return ptp;
},
_getCurrentRecord() {
return {
id: 1,
fake_field: pool.lastRecordSaved,
}
},
_getCollaborationClientAvatarUrl() {
return '';
},
_hasICEServers() {
return true;
},
_showConflictDialog() {},
});
pool.peers[peerId] = new PeerTest({
peerId,
wysiwyg,
iframe,
wrapper: peerWysiwygWrapper,
pool,
});
}
return pool;
}
function removePeers(peers) {
for (const peer of Object.values(peers)) {
peer.wysiwyg.destroy();
peer.wrapper.remove();
}
}
const unpatchs = [];
QUnit.module('web_editor', {
before() {
unpatchs.push(
patch(Wysiwyg, {
activeCollaborationChannelNames: {
has: () => false,
add: () => {},
delete: () => {},
},
})
);
unpatchs.push(
patch(Wysiwyg.prototype, {
setup() {
const result = super.setup(...arguments);
this.busService = {
addEventListener: () => {},
removeEventListener: () => {},
addChannel: () => {},
deleteChannel: () => {},
};
return result;
},
})
);
},
after() {
for (const unpatch of unpatchs) {
unpatch();
}
}
}, () => {
QUnit.module('Collaboration', {}, () => {
/**
* Detect stale when <already focused | not already focused>
*/
QUnit.module('Focus', {}, () => {
QUnit.test('Focused client should not receive step if no data channel is open', async (assert) => {
assert.expect(3);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.makeStep(insert('b'));
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the document changed');
assert.equal(peers.p2.getValue(), `<p>a[]</p>`, 'p2 should not have the document changed');
assert.equal(peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the document changed');
removePeers(peers);
});
QUnit.test('Focused client should receive step while unfocused should not (if the datachannel is open before the step)', async (assert) => {
assert.expect(3);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.makeStep(insert('b'));
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the document changed');
removePeers(peers);
});
QUnit.test('Focused client should receive step while unfocused should not (if the datachannel is open after the step)', async (assert) => {
assert.expect(3);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.makeStep(insert('b'));
await peers.p1.openDataChannel(peers.p2);
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the document changed because it has not focused');
removePeers(peers);
});
});
QUnit.module('Stale detection & recovery', {}, () => {
QUnit.module('detect stale while unfocused', async () => {
QUnit.test('should do nothing until focus', async (assert) => {
assert.expect(10);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.makeStep(insert('b'));
await peers.p1.writeToServer();
assert.equal(peers.p1.wysiwyg._isDocumentStale, false, 'p1 should not have a stale document');
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.wysiwyg._isDocumentStale, false, 'p2 should not have a stale document');
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.wysiwyg._isDocumentStale, true, 'p3 should have a stale document');
assert.equal(peers.p3.getValue(), `<p>a[]</p>`, '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));
assert.equal(peers.p3.wysiwyg._isDocumentStale, false, 'p3 should not have a stale document');
assert.equal(peers.p3.getValue(), `<p>[]ab</p>`, 'p3 should have the same document as p1');
await peers.p1.makeStep(insert('c'));
assert.equal(peers.p1.getValue(), `<p>abc[]</p>`, 'p1 should have the same document as p3');
assert.equal(peers.p3.getValue(), `<p>[]abc</p>`, 'p3 should have the same document as p1');
removePeers(peers);
});
});
QUnit.module('detect stale while focused', async () => {
QUnit.module('recover from missing steps', async () => {
QUnit.test('should recover from missing steps', async (assert) => {
assert.expect(18);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
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.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
]);
assert.equal(peers.p1.wysiwyg._historyShareId, peers.p2.wysiwyg._historyShareId, 'p1 and p2 should have the same _historyShareId');
assert.equal(peers.p1.wysiwyg._historyShareId, peers.p3.wysiwyg._historyShareId, 'p1 and p3 should have the same _historyShareId');
assert.equal(peers.p1.getValue(), `<p>a[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.getValue(), `[]<p>a</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `[]<p>a</p>`, 'p3 should have the same document as p1');
await peers.p3.setOffline();
await peers.p1.makeStep(insert('b'));
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.getValue(), `<p>[]ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
await peers.p1.writeToServer();
assert.equal(peers.p1.wysiwyg._isDocumentStale, false, 'p1 should not have a stale document');
assert.equal(peers.p2.wysiwyg._isDocumentStale, false, 'p2 should not have a stale document');
assert.equal(peers.p3.wysiwyg._isDocumentStale, false, 'p3 should not have a stale document');
await peers.p3.setOnline();
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._processMissingSteps.callCount, 1, 'p3 _processMissingSteps should have been called once');
assert.equal(p3Spies._applySnapshot.callCount, 0, 'p3 _applySnapshot should not have been called');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p3 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.getValue(), `<p>[]ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>[]ab</p>`, 'p3 should have the same document as p1');
removePeers(peers);
});
});
QUnit.module('recover from snapshot', async () => {
QUnit.test('should wait for all peer to recover from snapshot', async (assert) => {
assert.expect(19);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
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.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
]);
const p3Spies = makeSpies(peers.p3.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
'_onRecoveryClientTimeout',
]);
await peers.p1.makeStep(insert('b'));
await peers.p1.writeToServer();
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(peers.p2.getValue(), `[]<p>a</p>`, 'p2 should not have the same document as p1');
assert.equal(peers.p3.getValue(), `[]<p>a</p>`, 'p3 should not have the same document as p1');
peers.p1.destroyEditor();
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p2.setOnline();
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p3.setOnline();
assert.equal(peers.p3.getValue(), `[]<p>ab</p>`, 'p3 should have the same document as p1');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p3 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p3Spies._processMissingSteps.callCount, 1, 'p3 _processMissingSteps should have been called once');
assert.equal(p3Spies._applySnapshot.callCount, 1, 'p3 _applySnapshot should have been called once');
assert.equal(p3Spies._onRecoveryClientTimeout.callCount, 0, 'p3 _onRecoveryClientTimeout should not have been called');
removePeers(peers);
});
QUnit.test('should recover from snapshot after PTP_MAX_RECOVERY_TIME if some peer do not respond', async (assert) => {
assert.expect(19);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
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.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
]);
const p3Spies = makeSpies(peers.p3.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
'_onRecoveryClientTimeout',
]);
await peers.p1.makeStep(insert('b'));
await peers.p1.writeToServer();
peers.p1.setOffline();
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(peers.p2.getValue(), `[]<p>a</p>`, 'p2 should not have the same document as p1');
assert.equal(peers.p3.getValue(), `[]<p>a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p2.setOnline();
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p3.setOnline();
assert.equal(peers.p3.getValue(), `[]<p>ab</p>`, 'p3 should have the same document as p1');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p3 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p3Spies._processMissingSteps.callCount, 1, 'p3 _processMissingSteps should have been called once');
assert.equal(p3Spies._applySnapshot.callCount, 1, 'p3 _applySnapshot should have been called once');
assert.equal(p3Spies._onRecoveryClientTimeout.callCount, 1, 'p3 _onRecoveryClientTimeout should have been called once');
removePeers(peers);
});
});
QUnit.module('recover from server', async () => {
QUnit.test('should recover from server if no snapshot have been processed', async (assert) => {
assert.expect(16);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
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.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
'_onRecoveryClientTimeout',
'_resetFromClient',
]);
const p3Spies = makeSpies(peers.p3.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
'_onRecoveryClientTimeout',
'_resetFromClient',
]);
await peers.p1.makeStep(insert('b'));
await peers.p1.writeToServer();
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(peers.p2.getValue(), `[]<p>a</p>`, 'p2 should not have the same document as p1');
assert.equal(peers.p3.getValue(), `[]<p>a</p>`, 'p3 should not have the same document as p1');
peers.p1.destroyEditor();
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._onRecoveryClientTimeout.callCount, 0, 'p2 _onRecoveryClientTimeout should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient 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();
assert.equal(peers.p3.getValue(), `[]<p>ab</p>`, 'p3 should have the same document as p1');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p3 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p3Spies._processMissingSteps.callCount, 0, 'p3 _processMissingSteps should not have been called');
assert.equal(p3Spies._applySnapshot.callCount, 1, 'p3 _applySnapshot should have been called once');
assert.equal(p3Spies._onRecoveryClientTimeout.callCount, 0, 'p3 _onRecoveryClientTimeout should not have been called');
assert.equal(p3Spies._resetFromClient.callCount, 1, 'p3 _resetFromClient should have been called once');
removePeers(peers);
});
QUnit.test('should recover from server if there is no peer connected', async (assert) => {
assert.expect(14);
const pool = await createPeers(['p1', 'p2']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
peers.p2.setOffline();
const p2Spies = makeSpies(peers.p2.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
'_onRecoveryClientTimeout',
'_resetFromClient',
]);
await peers.p1.makeStep(insert('b'));
await peers.p1.writeToServer();
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(peers.p2.getValue(), `[]<p>a</p>`, 'p2 should not have the same document as p1');
peers.p1.destroyEditor();
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
await peers.p2.setOnline();
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._onRecoveryClientTimeout.callCount, 0, 'p2 _onRecoveryClientTimeout should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
removePeers(peers);
});
QUnit.test('should recover from server if there is no response after PTP_MAX_RECOVERY_TIME', async (assert) => {
assert.expect(16);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
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.wysiwyg, [
'_recoverFromStaleDocument',
'_resetFromServerAndResyncWithClients',
'_processMissingSteps',
'_applySnapshot',
'_onRecoveryClientTimeout',
'_resetFromClient',
]);
await peers.p1.makeStep(insert('b'));
await peers.p1.writeToServer();
peers.p1.setOffline();
assert.equal(peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(peers.p2.getValue(), `[]<p>a</p>`, 'p2 should not have the same document as p1');
assert.equal(peers.p3.getValue(), `[]<p>a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
await peers.p2.setOnline();
assert.equal(peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._onRecoveryClientTimeout.callCount, 1, 'p2 _onRecoveryClientTimeout 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 _resetFromClient is properly taken.
assert.equal(p2Spies._resetFromClient.callCount, 2, 'p2 _resetFromClient should have been called twice');
removePeers(peers);
});
});
});
});
});
});