diff --git a/src/static/js/ace.js b/src/static/js/ace.js
index 059bac76e92..0772648af4a 100644
--- a/src/static/js/ace.js
+++ b/src/static/js/ace.js
@@ -141,7 +141,7 @@ const Ace2Editor = function () {
this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop);
this.getInInternationalComposition =
- () => loaded ? info.ace_getInInternationalComposition() : false;
+ () => loaded ? info.ace_getInInternationalComposition() : null;
// prepareUserChangeset:
// Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes
diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js
index d577731961e..a8cc3cb984c 100644
--- a/src/static/js/ace2_inner.js
+++ b/src/static/js/ace2_inner.js
@@ -3504,16 +3504,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const teardown = () => _teardownActions.forEach((a) => a());
- let inInternationalComposition = false;
- const handleCompositionEvent = (evt) => {
- // international input events, fired in FF3, at least; allow e.g. Japanese input
- if (evt.type === 'compositionstart') {
- inInternationalComposition = true;
- } else if (evt.type === 'compositionend') {
- inInternationalComposition = false;
- }
- };
-
+ let inInternationalComposition = null;
editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;
const bindTheEventHandlers = () => {
@@ -3602,8 +3593,15 @@ function Ace2Inner(editorInfo, cssManagers) {
});
});
- $(document.documentElement).on('compositionstart', handleCompositionEvent);
- $(document.documentElement).on('compositionend', handleCompositionEvent);
+ $(document.documentElement).on('compositionstart', () => {
+ if (inInternationalComposition) return;
+ inInternationalComposition = new Promise((resolve) => {
+ $(document.documentElement).one('compositionend', () => {
+ inInternationalComposition = null;
+ resolve();
+ });
+ });
+ });
};
const topLevel = (n) => {
diff --git a/src/static/js/collab_client.js b/src/static/js/collab_client.js
index a4cbafd9220..4b6c3ffcf0d 100644
--- a/src/static/js/collab_client.js
+++ b/src/static/js/collab_client.js
@@ -39,7 +39,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
pad = _pad; // Inject pad to avoid a circular dependency.
let rev = serverVars.rev;
- let state = 'IDLE';
+ let committing = false;
let stateMessage;
let channelState = 'CONNECTING';
let lastCommitTime = 0;
@@ -50,11 +50,6 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
const userSet = {}; // userId -> userInfo
userSet[userId] = initialUserInfo;
- const caughtErrors = [];
- const caughtErrorCatchers = [];
- const caughtErrorTimes = [];
- const msgQueue = [];
-
let isPendingRevision = false;
const callbacks = {
@@ -78,77 +73,49 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
}
const handleUserChanges = () => {
- if (editor.getInInternationalComposition()) return;
+ if (editor.getInInternationalComposition()) {
+ // handleUserChanges() will be called again once composition ends so there's no need to set up
+ // a future call before returning.
+ return;
+ }
+ const now = Date.now();
if ((!getSocket()) || channelState === 'CONNECTING') {
- if (channelState === 'CONNECTING' && (((+new Date()) - initialStartConnectTime) > 20000)) {
+ if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
setChannelState('DISCONNECTED', 'initsocketfail');
} else {
// check again in a bit
- setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 1000);
+ setTimeout(handleUserChanges, 1000);
}
return;
}
- const t = (+new Date());
-
- if (state !== 'IDLE') {
- if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 20000) {
+ if (committing) {
+ if (now - lastCommitTime > 20000) {
// a commit is taking too long
setChannelState('DISCONNECTED', 'slowcommit');
- } else if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 5000) {
+ } else if (now - lastCommitTime > 5000) {
callbacks.onConnectionTrouble('SLOW');
} else {
// run again in a few seconds, to detect a disconnect
- setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
+ setTimeout(handleUserChanges, 3000);
}
return;
}
const earliestCommit = lastCommitTime + 500;
- if (t < earliestCommit) {
- setTimeout(
- wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges),
- earliestCommit - t);
+ if (now < earliestCommit) {
+ setTimeout(handleUserChanges, earliestCommit - now);
return;
}
- // apply msgQueue changeset.
- if (msgQueue.length !== 0) {
- let msg;
- while ((msg = msgQueue.shift())) {
- const newRev = msg.newRev;
- rev = newRev;
- if (msg.type === 'ACCEPT_COMMIT') {
- editor.applyPreparedChangesetToBase();
- setStateIdle();
- callCatchingErrors('onInternalAction', () => {
- callbacks.onInternalAction('commitAcceptedByServer');
- });
- callCatchingErrors('onConnectionTrouble', () => {
- callbacks.onConnectionTrouble('OK');
- });
- handleUserChanges();
- } else if (msg.type === 'NEW_CHANGES') {
- const changeset = msg.changeset;
- const author = (msg.author || '');
- const apool = msg.apool;
-
- editor.applyChangesToBase(changeset, author, apool);
- }
- }
- if (isPendingRevision) {
- setIsPendingRevision(false);
- }
- }
-
let sentMessage = false;
// Check if there are any pending revisions to be received from server.
// Allow only if there are no pending revisions to be received from server
if (!isPendingRevision) {
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
- lastCommitTime = t;
- state = 'COMMITTING';
+ lastCommitTime = now;
+ committing = true;
stateMessage = {
type: 'USER_CHANGES',
baseRev: rev,
@@ -161,20 +128,30 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
}
} else {
// run again in a few seconds, to check if there was a reconnection attempt
- setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
+ setTimeout(handleUserChanges, 3000);
}
if (sentMessage) {
// run again in a few seconds, to detect a disconnect
- setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
+ setTimeout(handleUserChanges, 3000);
}
};
+ const acceptCommit = () => {
+ editor.applyPreparedChangesetToBase();
+ setStateIdle();
+ try {
+ callbacks.onInternalAction('commitAcceptedByServer');
+ callbacks.onConnectionTrouble('OK');
+ } catch (err) { /* intentionally ignored */ }
+ handleUserChanges();
+ };
+
const setUpSocket = () => {
setChannelState('CONNECTED');
doDeferredActions();
- initialStartConnectTime = +new Date();
+ initialStartConnectTime = Date.now();
};
const sendMessage = (msg) => {
@@ -186,24 +163,20 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
});
};
- const wrapRecordingErrors = (catcher, func) => function (...args) {
- try {
- return func.call(this, ...args);
- } catch (e) {
- caughtErrors.push(e);
- caughtErrorCatchers.push(catcher);
- caughtErrorTimes.push(+new Date());
- // console.dir({catcher: catcher, e: e});
- throw e;
+ const serverMessageTaskQueue = new class {
+ constructor() {
+ this._promiseChain = Promise.resolve();
}
- };
- const callCatchingErrors = (catcher, func) => {
- try {
- wrapRecordingErrors(catcher, func)();
- } catch (e) { /* absorb*/
+ async enqueue(fn) {
+ const taskPromise = this._promiseChain.then(fn);
+ // Use .catch() to prevent rejections from halting the queue.
+ this._promiseChain = taskPromise.catch(() => {});
+ // Do NOT do `return await this._promiseChain;` because the caller would not see an error if
+ // fn() throws/rejects (due to the .catch() added above).
+ return await taskPromise;
}
- };
+ }();
const handleMessageFromServer = (evt) => {
if (!getSocket()) return;
@@ -213,117 +186,61 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
const msg = wrapper.data;
if (msg.type === 'NEW_CHANGES') {
- const newRev = msg.newRev;
- const changeset = msg.changeset;
- const author = (msg.author || '');
- const apool = msg.apool;
-
- // When inInternationalComposition, msg pushed msgQueue.
- if (msgQueue.length > 0 || editor.getInInternationalComposition()) {
- const oldRev = msgQueue.length > 0 ? msgQueue[msgQueue.length - 1].newRev : rev;
- if (newRev !== (oldRev + 1)) {
- window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${oldRev + 1}`);
+ serverMessageTaskQueue.enqueue(async () => {
+ // Avoid updating the DOM while the user is composing a character. Notes about this `await`:
+ // * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
+ // currently composing a character then execution will continue without error.
+ // * We assume that it is not possible for a new 'compositionstart' event to fire after
+ // the `await` but before the next line of code after the `await` (or, if it is
+ // possible, that the chances are so small or the consequences so minor that it's not
+ // worth addressing).
+ await editor.getInInternationalComposition();
+ const {newRev, changeset, author = '', apool} = msg;
+ if (newRev !== (rev + 1)) {
+ window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_newchanges");
return;
}
- msgQueue.push(msg);
- return;
- }
-
- if (newRev !== (rev + 1)) {
- window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
- // setChannelState("DISCONNECTED", "badmessage_newchanges");
- return;
- }
- rev = newRev;
-
- editor.applyChangesToBase(changeset, author, apool);
+ rev = newRev;
+ editor.applyChangesToBase(changeset, author, apool);
+ });
} else if (msg.type === 'ACCEPT_COMMIT') {
- const newRev = msg.newRev;
- if (msgQueue.length > 0) {
- if (newRev !== (msgQueue[msgQueue.length - 1].newRev + 1)) {
- window.console.warn('bad message revision on ACCEPT_COMMIT: ' +
- `${newRev} not ${msgQueue[msgQueue.length - 1][0] + 1}`);
+ serverMessageTaskQueue.enqueue(() => {
+ const newRev = msg.newRev;
+ if (newRev !== (rev + 1)) {
+ window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
- msgQueue.push(msg);
- return;
- }
-
- if (newRev !== (rev + 1)) {
- window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
- // setChannelState("DISCONNECTED", "badmessage_acceptcommit");
- return;
- }
- rev = newRev;
- editor.applyPreparedChangesetToBase();
- setStateIdle();
- callCatchingErrors('onInternalAction', () => {
- callbacks.onInternalAction('commitAcceptedByServer');
- });
- callCatchingErrors('onConnectionTrouble', () => {
- callbacks.onConnectionTrouble('OK');
+ rev = newRev;
+ acceptCommit();
});
- handleUserChanges();
} else if (msg.type === 'CLIENT_RECONNECT') {
// Server sends a CLIENT_RECONNECT message when there is a client reconnect.
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
- if (msg.noChanges) {
- // If no revisions are pending, just make everything normal
- setIsPendingRevision(false);
- return;
- }
-
- const headRev = msg.headRev;
- const newRev = msg.newRev;
- const changeset = msg.changeset;
- const author = (msg.author || '');
- const apool = msg.apool;
-
- if (msgQueue.length > 0) {
- if (newRev !== (msgQueue[msgQueue.length - 1].newRev + 1)) {
- window.console.warn('bad message revision on CLIENT_RECONNECT: ' +
- `${newRev} not ${msgQueue[msgQueue.length - 1][0] + 1}`);
+ serverMessageTaskQueue.enqueue(() => {
+ if (msg.noChanges) {
+ // If no revisions are pending, just make everything normal
+ setIsPendingRevision(false);
+ return;
+ }
+ const {headRev, newRev, changeset, author = '', apool} = msg;
+ if (newRev !== (rev + 1)) {
+ window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
- msg.type = 'NEW_CHANGES';
- msgQueue.push(msg);
- return;
- }
-
- if (newRev !== (rev + 1)) {
- window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
- // setChannelState("DISCONNECTED", "badmessage_acceptcommit");
- return;
- }
-
- rev = newRev;
- if (author === pad.getUserId()) {
- editor.applyPreparedChangesetToBase();
- setStateIdle();
- callCatchingErrors('onInternalAction', () => {
- callbacks.onInternalAction('commitAcceptedByServer');
- });
- callCatchingErrors('onConnectionTrouble', () => {
- callbacks.onConnectionTrouble('OK');
- });
- handleUserChanges();
- } else {
- editor.applyChangesToBase(changeset, author, apool);
- }
-
- if (newRev === headRev) {
- // Once we have applied all pending revisions, make everything normal
- setIsPendingRevision(false);
- }
- } else if (msg.type === 'NO_COMMIT_PENDING') {
- if (state === 'COMMITTING') {
- // server missed our commit message; abort that commit
- setStateIdle();
- handleUserChanges();
- }
+ rev = newRev;
+ if (author === pad.getUserId()) {
+ acceptCommit();
+ } else {
+ editor.applyChangesToBase(changeset, author, apool);
+ }
+ if (newRev === headRev) {
+ // Once we have applied all pending revisions, make everything normal
+ setIsPendingRevision(false);
+ }
+ });
} else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
@@ -496,7 +413,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
const obj = {};
obj.userInfo = userSet[userId];
obj.baseRev = rev;
- if (state === 'COMMITTING' && stateMessage) {
+ if (committing && stateMessage) {
obj.committedChangeset = stateMessage.changeset;
obj.committedChangesetAPool = stateMessage.apool;
editor.applyPreparedChangesetToBase();
@@ -510,7 +427,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
};
const setStateIdle = () => {
- state = 'IDLE';
+ committing = false;
callbacks.onInternalAction('newlyIdle');
schedulePerhapsCallIdleFuncs();
};
@@ -528,7 +445,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
const schedulePerhapsCallIdleFuncs = () => {
setTimeout(() => {
- if (state === 'IDLE') {
+ if (!committing) {
while (idleFuncs.length > 0) {
const f = idleFuncs.shift();
f();
@@ -578,8 +495,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
editor.setProperty('userAuthor', userId);
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
- editor.setUserChangeNotificationCallback(
- wrapRecordingErrors('handleUserChanges', handleUserChanges));
+ editor.setUserChangeNotificationCallback(handleUserChanges);
setUpSocket();
return self;
diff --git a/src/tests/frontend/helper/multipleUsers.js b/src/tests/frontend/helper/multipleUsers.js
new file mode 100644
index 00000000000..08ed502172e
--- /dev/null
+++ b/src/tests/frontend/helper/multipleUsers.js
@@ -0,0 +1,147 @@
+'use strict';
+
+helper.multipleUsers = {
+ thisUser: null,
+ otherUser: null,
+
+ // open the same pad on different frames (allows concurrent editions to pad)
+ async init() {
+ // do some cleanup, in case any of the tests failed on the previous run
+ const currentToken = _createTokenForCurrentUser();
+ const otherToken = _createTokenForAnotherUser();
+ _removeExistingTokensFromCookie();
+
+ this.thisUser = {
+ $frame: $('#iframe-container iframe'),
+ token: currentToken,
+ // we'll switch between pads, need to store current values of helper.pad*
+ // to be able to restore those values later
+ padChrome$: helper.padChrome$,
+ padOuter$: helper.padOuter$,
+ padInner$: helper.padInner$,
+ };
+
+ this.otherUser = {
+ token: otherToken,
+ };
+
+ // need to perform as the other user, otherwise we'll get the userdup error message
+ await this.performAsOtherUser(this._createFrameForOtherUser.bind(this));
+ },
+
+ async performAsOtherUser(action) {
+ _startActingLike(this.otherUser);
+ await action();
+ // go back to initial state when we're done
+ _startActingLike(this.thisUser);
+ },
+
+ close() {
+ this.thisUser.$frame.attr('style', ''); // make the default ocopy the full height
+ this.otherUser.$frame.remove();
+ },
+
+ async _loadJQueryCodeForOtherFrame() {
+ const code = await $.get('/static/js/jquery.js');
+
+ // make sure we don't override existing jquery
+ const jQueryCode = `if(typeof $ === "undefined") {\n${code}\n}`;
+ const sendkeysCode = await $.get('/tests/frontend/lib/sendkeys.js');
+ const codesToLoad = [jQueryCode, sendkeysCode];
+
+ this.otherUser.padChrome$ = _getFrameJQuery(codesToLoad, this.otherUser.$frame);
+ this.otherUser.padOuter$ =
+ _getFrameJQuery(codesToLoad, this.otherUser.padChrome$('iframe[name="ace_outer"]'));
+ this.otherUser.padInner$ =
+ _getFrameJQuery(codesToLoad, this.otherUser.padOuter$('iframe[name="ace_inner"]'));
+
+ // update helper vars now that they are available
+ helper.padChrome$ = this.otherUser.padChrome$;
+ helper.padOuter$ = this.otherUser.padOuter$;
+ helper.padInner$ = this.otherUser.padInner$;
+ },
+
+ async _createFrameForOtherUser() {
+ // create the iframe
+ const padUrl = this.thisUser.$frame.attr('src');
+ this.otherUser.$frame = $(``);
+
+ // place one iframe (visually) below the other
+ this.thisUser.$frame.attr('style', 'height: 50%');
+ this.otherUser.$frame.attr('style', 'height: 50%; top: 50%');
+ this.otherUser.$frame.insertAfter(this.thisUser.$frame);
+
+ // wait for other pad to load
+ await new Promise((resolve) => this.otherUser.$frame.one('load', resolve));
+
+ const $editorLoadingMessage = this.otherUser.$frame.contents().find('#editorloadingbox');
+ const $errorMessageModal = this.thisUser.$frame.contents().find('#connectivity .userdup');
+
+ await helper.waitForPromise(() => {
+ const finishedLoadingOtherFrame = !$editorLoadingMessage.is(':visible');
+ // make sure we don't get the userdup by mistake
+ const didNotDetectUserDup = !$errorMessageModal.is(':visible');
+
+ return finishedLoadingOtherFrame && didNotDetectUserDup;
+ }, 50000);
+
+ // need to get values for this.otherUser.pad* vars
+ await this._loadJQueryCodeForOtherFrame();
+ },
+};
+
+// adapted form helper.js on Etherpad code
+const _getFrameJQuery = (codesToLoad, $iframe) => {
+ const win = $iframe[0].contentWindow;
+ const doc = win.document;
+
+ for (let i = 0; i < codesToLoad.length; i++) {
+ win.eval(codesToLoad[i]);
+ }
+
+ win.$.window = win;
+ win.$.document = doc;
+
+ return win.$;
+};
+
+const _getDocumentWithCookie = () => (
+ helper.padChrome$
+ ? helper.padChrome$.document
+ : helper.multipleUsers.thisUser.$frame.get(0).contentDocument
+);
+
+const _setTokenOnCookie = (token) => {
+ _getDocumentWithCookie().cookie = `token=${token};secure`;
+};
+
+const _getTokenFromCookie = () => {
+ const fullCookie = _getDocumentWithCookie().cookie;
+ return fullCookie.replace(/.*token=([^;]*).*/, '$1').trim();
+};
+
+const _createTokenForCurrentUser = () => (
+ _getTokenFromCookie().replace(/-other_user.*/g, '')
+);
+
+const _createTokenForAnotherUser = () => {
+ const currentToken = _createTokenForCurrentUser();
+ return `${currentToken}-other_user${helper.randomString(4)}`;
+};
+
+const _startActingLike = (user) => {
+ // update helper references, so other methods will act as if the main frame
+ // was the one we're using from now on
+ helper.padChrome$ = user.padChrome$;
+ helper.padOuter$ = user.padOuter$;
+ helper.padInner$ = user.padInner$;
+
+ _setTokenOnCookie(user.token);
+};
+
+const _removeExistingTokensFromCookie = () => {
+ // Expire cookie, to make sure it is removed by the browser.
+ // See https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#Example_4_Reset_the_previous_cookie
+ _getDocumentWithCookie().cookie = 'token=foo;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/p';
+ _getDocumentWithCookie().cookie = 'token=foo;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
+};
diff --git a/src/tests/frontend/index.html b/src/tests/frontend/index.html
index 3d806f626ae..e8a8012fb39 100644
--- a/src/tests/frontend/index.html
+++ b/src/tests/frontend/index.html
@@ -20,6 +20,7 @@
+
diff --git a/src/tests/frontend/specs/collab_client.js b/src/tests/frontend/specs/collab_client.js
new file mode 100644
index 00000000000..307e379082d
--- /dev/null
+++ b/src/tests/frontend/specs/collab_client.js
@@ -0,0 +1,102 @@
+'use strict';
+
+describe('Messages in the COLLABROOM', function () {
+ const user1Text = 'text created by user 1';
+ const user2Text = 'text created by user 2';
+
+ const triggerEvent = (eventName) => {
+ const event = new helper.padInner$.Event(eventName);
+ helper.padInner$('#innerdocbody').trigger(event);
+ };
+
+ const replaceLineText = async (lineNumber, newText) => {
+ const inner$ = helper.padInner$;
+
+ // get the line element
+ const $line = inner$('div').eq(lineNumber);
+
+ // simulate key presses to delete content
+ $line.sendkeys('{selectall}'); // select all
+ $line.sendkeys('{del}'); // clear the first line
+ $line.sendkeys(newText); // insert the string
+
+ await helper.waitForPromise(() => inner$('div').eq(lineNumber).text() === newText);
+ };
+
+ before(async function () {
+ this.timeout(10000);
+ await helper.aNewPad();
+ await helper.multipleUsers.init();
+ });
+
+ it('bug #4978 regression test', async function () {
+ // The bug was triggered by receiving a change from another user while simultaneously composing
+ // a character and waiting for an acknowledgement of a previously sent change.
+
+ // User 1 starts sending a change to the server.
+ let sendStarted;
+ const finishSend = (() => {
+ const socketJsonObj = helper.padChrome$.window.pad.socket.json;
+ const sendBackup = socketJsonObj.send;
+ let startSend;
+ sendStarted = new Promise((resolve) => { startSend = resolve; });
+ let finishSend;
+ const sendP = new Promise((resolve) => { finishSend = resolve; });
+ socketJsonObj.send = (...args) => {
+ startSend();
+ sendP.then(() => {
+ socketJsonObj.send = sendBackup;
+ socketJsonObj.send(...args);
+ });
+ };
+ return finishSend;
+ })();
+ await replaceLineText(0, user1Text);
+ await sendStarted;
+
+ // User 1 starts a character composition.
+ triggerEvent('compositionstart');
+
+ // User 1 receives a change from user 2. (User 1 will not incorporate the change until the
+ // composition is completed.)
+ const user2ChangeArrivedAtUser1 = new Promise((resolve) => {
+ const cc = helper.padChrome$.window.pad.collabClient;
+ const origHM = cc.handleMessageFromServer;
+ cc.handleMessageFromServer = (evt) => {
+ if (evt.type === 'COLLABROOM' && evt.data.type === 'NEW_CHANGES') {
+ cc.handleMessageFromServer = origHM;
+ resolve();
+ }
+ return origHM.call(cc, evt);
+ };
+ });
+ await helper.multipleUsers.performAsOtherUser(async () => await replaceLineText(1, user2Text));
+ await user2ChangeArrivedAtUser1;
+
+ // User 1 finishes sending the change to the server. User 2 should see the changes right away.
+ finishSend();
+ await helper.multipleUsers.performAsOtherUser(async () => await helper.waitForPromise(
+ () => helper.padInner$('div').eq(0).text() === user1Text));
+
+ // User 1 finishes the character composition. User 2's change should then become visible.
+ triggerEvent('compositionend');
+ await helper.waitForPromise(() => helper.padInner$('div').eq(1).text() === user2Text);
+
+ // Users 1 and 2 make some more changes.
+ await helper.multipleUsers.performAsOtherUser(async () => await replaceLineText(3, user2Text));
+ await replaceLineText(2, user1Text);
+
+ // All changes should appear in both views.
+ const assertContent = async () => await helper.waitForPromise(() => {
+ const expectedLines = [
+ user1Text,
+ user2Text,
+ user1Text,
+ user2Text,
+ ];
+ return expectedLines.every((txt, i) => helper.padInner$('div').eq(i).text() === txt);
+ });
+ await assertContent();
+ await helper.multipleUsers.performAsOtherUser(assertContent);
+ });
+});