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