From 2d05f703d73cb4f31ae95cea5c57ba4433ba9fe9 Mon Sep 17 00:00:00 2001 From: Nicolas Bayet Date: Tue, 18 Jul 2023 12:03:17 +0200 Subject: [PATCH] [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 --- addons/web_editor/controllers/main.py | 27 +- .../js/editor/odoo-editor/src/OdooEditor.js | 2 +- .../odoo-editor/test/spec/collab.test.js | 26 +- .../src/js/editor/odoo-editor/test/utils.js | 31 +- .../static/src/js/wysiwyg/PeerToPeer.js | 31 +- .../static/src/js/wysiwyg/wysiwyg.js | 560 +++++++++--- .../tests/test_wysiwyg_collaboration.js | 813 ++++++++++++++++++ 7 files changed, 1324 insertions(+), 166 deletions(-) create mode 100644 addons/web_editor/static/tests/test_wysiwyg_collaboration.js diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 6092c1da083..23126a231c3 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -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): #------------------------------------------------------ diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js index 5f6e46736c5..1ae2ae8f8b7 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js @@ -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; } diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js index 415e3a1db02..d3f3b55d9fc 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/collab.test.js @@ -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); diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js index 50d71a9a9b4..388fd9035d8 100644 --- a/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js +++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js @@ -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 {} diff --git a/addons/web_editor/static/src/js/wysiwyg/PeerToPeer.js b/addons/web_editor/static/src/js/wysiwyg/PeerToPeer.js index 36072e7a054..0ddcd15b81e 100644 --- a/addons/web_editor/static/src/js/wysiwyg/PeerToPeer.js +++ b/addons/web_editor/static/src/js/wysiwyg/PeerToPeer.js @@ -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"; + } +} diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 8eeffd9cc0e..188cfa83d29 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -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|-1} missingSteps + * @return {Promise} 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; } diff --git a/addons/web_editor/static/tests/test_wysiwyg_collaboration.js b/addons/web_editor/static/tests/test_wysiwyg_collaboration.js new file mode 100644 index 00000000000..e9fb8e88f8f --- /dev/null +++ b/addons/web_editor/static/tests/test_wysiwyg_collaboration.js @@ -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 = '

a[]

'; + +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 = `
`; + 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 + */ + 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(), `

ab[]

`, 'p1 should have the document changed'); + assert.equal(peers.p2.getValue(), `

a[]

`, 'p2 should not have the document changed'); + assert.equal(peers.p3.getValue(), `

a[]

`, '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(), `

ab[]

`, 'p1 should have the same document as p2'); + assert.equal(peers.p2.getValue(), `[]

ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

a[]

`, '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(), `

ab[]

`, 'p1 should have the same document as p2'); + assert.equal(peers.p2.getValue(), `[]

ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

a[]

`, '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(), `

ab[]

`, '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(), `[]

ab

`, '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(), `

a[]

`, '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(), `

[]ab

`, 'p3 should have the same document as p1'); + + await peers.p1.makeStep(insert('c')); + assert.equal(peers.p1.getValue(), `

abc[]

`, 'p1 should have the same document as p3'); + assert.equal(peers.p3.getValue(), `

[]abc

`, '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(), `

a[]

`, 'p1 should have the same document as p2'); + assert.equal(peers.p2.getValue(), `[]

a

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `[]

a

`, 'p3 should have the same document as p1'); + + await peers.p3.setOffline(); + + await peers.p1.makeStep(insert('b')); + + assert.equal(peers.p1.getValue(), `

ab[]

`, 'p1 should have the same document as p2'); + assert.equal(peers.p2.getValue(), `

[]ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

[]a

`, '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(), `

ab[]

`, 'p1 should have the same document as p2'); + assert.equal(peers.p2.getValue(), `

[]ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

[]ab

`, '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(), `

ab[]

`, 'p1 have inserted char b'); + assert.equal(peers.p2.getValue(), `[]

a

`, 'p2 should not have the same document as p1'); + assert.equal(peers.p3.getValue(), `[]

a

`, '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(), `[]

ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

[]a

`, '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(), `[]

ab

`, '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(), `

ab[]

`, 'p1 have inserted char b'); + assert.equal(peers.p2.getValue(), `[]

a

`, 'p2 should not have the same document as p1'); + assert.equal(peers.p3.getValue(), `[]

a

`, '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(), `[]

ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

[]a

`, '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(), `[]

ab

`, '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(), `

ab[]

`, 'p1 have inserted char b'); + assert.equal(peers.p2.getValue(), `[]

a

`, 'p2 should not have the same document as p1'); + assert.equal(peers.p3.getValue(), `[]

a

`, '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(), `[]

ab

`, '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(), `

ab[]

`, 'p1 have inserted char b'); + assert.equal(peers.p2.getValue(), `[]

a

`, '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(), `[]

ab

`, '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(), `

ab[]

`, 'p1 have inserted char b'); + assert.equal(peers.p2.getValue(), `[]

a

`, 'p2 should not have the same document as p1'); + assert.equal(peers.p3.getValue(), `[]

a

`, '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(), `[]

ab

`, 'p2 should have the same document as p1'); + assert.equal(peers.p3.getValue(), `

[]a

`, '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); + }); + }); + }); + }); + }); +}); + +