mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-01-31 19:02:59 +01:00
[fix] Dequeue messages when committing
This commit is contained in:
parent
b80f5bdae8
commit
c8b0f8fed4
4 changed files with 358 additions and 37 deletions
|
@ -97,6 +97,10 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
setChannelState('DISCONNECTED', 'slowcommit');
|
||||
} else if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 5000) {
|
||||
callbacks.onConnectionTrouble('SLOW');
|
||||
} else if (msgQueue.length > 0) {
|
||||
// in slow or bad network conditions, there might be enqueued messages
|
||||
// while the state is still COMMITTING
|
||||
dequeueMessages();
|
||||
} else {
|
||||
// run again in a few seconds, to detect a disconnect
|
||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
||||
|
@ -114,31 +118,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
|
||||
// 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);
|
||||
}
|
||||
dequeueMessages();
|
||||
}
|
||||
|
||||
let sentMessage = false;
|
||||
|
@ -170,6 +150,42 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
}
|
||||
};
|
||||
|
||||
const acceptCommit = () => {
|
||||
editor.applyPreparedChangesetToBase();
|
||||
setStateIdle();
|
||||
callCatchingErrors('onInternalAction', () => {
|
||||
callbacks.onInternalAction('commitAcceptedByServer');
|
||||
});
|
||||
callCatchingErrors('onConnectionTrouble', () => {
|
||||
callbacks.onConnectionTrouble('OK');
|
||||
});
|
||||
handleUserChanges();
|
||||
};
|
||||
|
||||
const enqueueMessage = (msg) => {
|
||||
msgQueue.push(msg);
|
||||
};
|
||||
|
||||
const dequeueMessages = () => {
|
||||
let msg;
|
||||
while ((msg = msgQueue.shift())) {
|
||||
const newRev = msg.newRev;
|
||||
rev = newRev;
|
||||
if (msg.type === 'ACCEPT_COMMIT') {
|
||||
acceptCommit();
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
const setUpSocket = () => {
|
||||
setChannelState('CONNECTED');
|
||||
doDeferredActions();
|
||||
|
@ -226,7 +242,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
// setChannelState("DISCONNECTED", "badmessage_newchanges");
|
||||
return;
|
||||
}
|
||||
msgQueue.push(msg);
|
||||
enqueueMessage(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -247,7 +263,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||
return;
|
||||
}
|
||||
msgQueue.push(msg);
|
||||
enqueueMessage(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -257,15 +273,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
return;
|
||||
}
|
||||
rev = newRev;
|
||||
editor.applyPreparedChangesetToBase();
|
||||
setStateIdle();
|
||||
callCatchingErrors('onInternalAction', () => {
|
||||
callbacks.onInternalAction('commitAcceptedByServer');
|
||||
});
|
||||
callCatchingErrors('onConnectionTrouble', () => {
|
||||
callbacks.onConnectionTrouble('OK');
|
||||
});
|
||||
handleUserChanges();
|
||||
acceptCommit();
|
||||
} 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
|
||||
|
@ -289,7 +297,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
return;
|
||||
}
|
||||
msg.type = 'NEW_CHANGES';
|
||||
msgQueue.push(msg);
|
||||
enqueueMessage(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
182
src/tests/frontend/helper/multipleUsers.js
Normal file
182
src/tests/frontend/helper/multipleUsers.js
Normal file
|
@ -0,0 +1,182 @@
|
|||
'use strict';
|
||||
|
||||
helper.multipleUsers = {
|
||||
thisUser: null,
|
||||
otherUser: null,
|
||||
};
|
||||
|
||||
// open the same pad on the same frame (does not allow concurrent editions to pad)
|
||||
helper.multipleUsers.loadSamePadAsAnotherUser = (done, tokenForOtherUser) => {
|
||||
// change user
|
||||
const token = tokenForOtherUser || _createTokenForAnotherUser();
|
||||
_removeExistingTokensFromCookie();
|
||||
_setTokenOnCookie(token);
|
||||
|
||||
// reload pad
|
||||
const padId = helper.padChrome$.window.clientVars.padId;
|
||||
helper.newPad(done, padId);
|
||||
};
|
||||
|
||||
// open the same pad on different frames (allows concurrent editions to pad)
|
||||
helper.multipleUsers.openSamePadOnWithAnotherUser = (done) => {
|
||||
const self = helper.multipleUsers;
|
||||
|
||||
// do some cleanup, in case any of the tests failed on the previous run
|
||||
const currentToken = _createTokenForCurrentUser();
|
||||
const otherToken = _createTokenForAnotherUser();
|
||||
_removeExistingTokensFromCookie();
|
||||
|
||||
self.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$,
|
||||
};
|
||||
|
||||
self.otherUser = {
|
||||
token: otherToken,
|
||||
};
|
||||
|
||||
// need to perform as the other user, otherwise we'll get the userdup error message
|
||||
self.performAsOtherUser(_createFrameForOtherUser, done);
|
||||
};
|
||||
|
||||
helper.multipleUsers.performAsOtherUser = (action, done) => {
|
||||
const self = helper.multipleUsers;
|
||||
|
||||
self.startActingLikeOtherUser();
|
||||
action(() => {
|
||||
// go back to initial state when we're done
|
||||
self.startActingLikeThisUser();
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
helper.multipleUsers.closePadForOtherUser = () => {
|
||||
const self = helper.multipleUsers;
|
||||
|
||||
self.thisUser.$frame.attr('style', ''); // make the default ocopy the full height
|
||||
self.otherUser.$frame.remove();
|
||||
};
|
||||
|
||||
helper.multipleUsers.startActingLikeOtherUser = () => {
|
||||
const self = helper.multipleUsers;
|
||||
_startActingLike(self.otherUser);
|
||||
};
|
||||
|
||||
helper.multipleUsers.startActingLikeThisUser = () => {
|
||||
const self = helper.multipleUsers;
|
||||
_startActingLike(self.thisUser);
|
||||
};
|
||||
|
||||
// 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 _loadJQueryCodeForOtherFrame = (done) => {
|
||||
const self = helper.multipleUsers;
|
||||
|
||||
$.get('/static/js/jquery.js').done((code) => {
|
||||
// make sure we don't override existing jquery
|
||||
const jQueryCode = `if(typeof $ === "undefined") {\n${code}\n}`;
|
||||
$.get('/tests/frontend/lib/sendkeys.js').done((sendkeysCode) => {
|
||||
const codesToLoad = [jQueryCode, sendkeysCode];
|
||||
|
||||
self.otherUser.padChrome$ = _getFrameJQuery(codesToLoad, self.otherUser.$frame);
|
||||
self.otherUser.padOuter$ = _getFrameJQuery(codesToLoad, self.otherUser.padChrome$('iframe[name="ace_outer"]'));
|
||||
self.otherUser.padInner$ = _getFrameJQuery(codesToLoad, self.otherUser.padOuter$('iframe[name="ace_inner"]'));
|
||||
|
||||
// update helper vars now that they are available
|
||||
helper.padChrome$ = self.otherUser.padChrome$;
|
||||
helper.padOuter$ = self.otherUser.padOuter$;
|
||||
helper.padInner$ = self.otherUser.padInner$;
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const _createFrameForOtherUser = (done) => {
|
||||
const self = helper.multipleUsers;
|
||||
|
||||
// create the iframe
|
||||
const padUrl = self.thisUser.$frame.attr('src');
|
||||
self.otherUser.$frame = $(`<iframe id="other_pad" src="${padUrl}"></iframe>`);
|
||||
|
||||
// place one iframe (visually) below the other
|
||||
self.thisUser.$frame.attr('style', 'height: 50%');
|
||||
self.otherUser.$frame.attr('style', 'height: 50%; top: 50%');
|
||||
self.otherUser.$frame.insertAfter(self.thisUser.$frame);
|
||||
|
||||
// wait for other pad to load
|
||||
self.otherUser.$frame.one('load', () => {
|
||||
const $editorLoadingMessage = self.otherUser.$frame.contents().find('#editorloadingbox');
|
||||
const $errorMessageModal = self.thisUser.$frame.contents().find('#connectivity .userdup');
|
||||
|
||||
helper.waitFor(() => {
|
||||
const finishedLoadingOtherFrame = !$editorLoadingMessage.is(':visible');
|
||||
// make sure we don't get the userdup by mistake
|
||||
const didNotDetectUserDup = !$errorMessageModal.is(':visible');
|
||||
|
||||
return finishedLoadingOtherFrame && didNotDetectUserDup;
|
||||
}, 50000).done(() => {
|
||||
// need to get values for self.otherUser.pad* vars
|
||||
_loadJQueryCodeForOtherFrame(done);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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=/';
|
||||
};
|
|
@ -20,6 +20,7 @@
|
|||
<script src="helper.js"></script>
|
||||
<script src="helper/methods.js"></script>
|
||||
<script src="helper/ui.js"></script>
|
||||
<script src="helper/multipleUsers.js"></script>
|
||||
|
||||
<script src="specs_list.js"></script>
|
||||
<script src="runner.js"></script>
|
||||
|
|
130
src/tests/frontend/specs/collab_client.js
Normal file
130
src/tests/frontend/specs/collab_client.js
Normal file
|
@ -0,0 +1,130 @@
|
|||
'use strict';
|
||||
|
||||
describe('Messages in the COLLABROOM', function () {
|
||||
const newTextOfUser1 = 'text created by user 1';
|
||||
const newTextOfUser2 = 'text created by user 2';
|
||||
|
||||
before(function (done) {
|
||||
helper.newPad(() => {
|
||||
helper.multipleUsers.openSamePadOnWithAnotherUser(done);
|
||||
});
|
||||
this.timeout(60000);
|
||||
});
|
||||
|
||||
context('when user 1 is with slow or bad network conditions', function () {
|
||||
before(function (done) {
|
||||
helper.multipleUsers.startActingLikeThisUser();
|
||||
simulateSlowOrBadNetworkConditions();
|
||||
done();
|
||||
});
|
||||
|
||||
context('and user 1 is in international composition', function () {
|
||||
before(function (done) {
|
||||
replaceLineText(1, newTextOfUser1, () => {
|
||||
simulateInternationalCompositionStartEvent();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
context('and user 2 edits the text', function () {
|
||||
before(function (done) {
|
||||
this.timeout(4000);
|
||||
helper.multipleUsers.startActingLikeOtherUser();
|
||||
replaceLineText(2, newTextOfUser2, () => {
|
||||
helper.multipleUsers.startActingLikeThisUser();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
context('and user 1 ends the international composition', function () {
|
||||
before(function (done) {
|
||||
simulateInternationalCompositionEndEvent();
|
||||
done();
|
||||
});
|
||||
|
||||
context('and both users add more editions', function () {
|
||||
before(function (done) {
|
||||
this.timeout(10000);
|
||||
|
||||
helper.multipleUsers.startActingLikeOtherUser();
|
||||
replaceLineText(4, newTextOfUser2, () => {
|
||||
helper.multipleUsers.startActingLikeThisUser();
|
||||
replaceLineText(3, newTextOfUser1, done);
|
||||
});
|
||||
});
|
||||
|
||||
it('user 1 has all editions of user 2', function (done) {
|
||||
this.timeout(5000);
|
||||
helper.multipleUsers.startActingLikeThisUser();
|
||||
helper.waitFor(() => {
|
||||
const inner$ = helper.padInner$;
|
||||
const expectedLines = [2, 4];
|
||||
return expectedLines.every((line) => (
|
||||
inner$('div').eq(line).text() === newTextOfUser2)
|
||||
);
|
||||
}, 4000).done(done);
|
||||
});
|
||||
|
||||
it('user 2 has all editions of user 1', function (done) {
|
||||
this.timeout(5000);
|
||||
helper.multipleUsers.startActingLikeOtherUser();
|
||||
helper.waitFor(() => {
|
||||
const inner$ = helper.padInner$;
|
||||
const expectedLines = [1, 3];
|
||||
return expectedLines.every((line) => (
|
||||
inner$('div').eq(line).text() === newTextOfUser1)
|
||||
);
|
||||
}, 4000).done(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const triggerEvent = (eventName) => {
|
||||
const event = new helper.padInner$.Event(eventName);
|
||||
helper.padInner$('#innerdocbody').trigger(event);
|
||||
};
|
||||
|
||||
const simulateInternationalCompositionStartEvent = () => {
|
||||
triggerEvent('compositionstart');
|
||||
};
|
||||
|
||||
const simulateInternationalCompositionEndEvent = () => {
|
||||
triggerEvent('compositionend');
|
||||
};
|
||||
|
||||
const simulateSlowOrBadNetworkConditions = () => {
|
||||
// to simulate slow or bad network conditions (packet loss), we delay the
|
||||
// sending of messages through the socket.
|
||||
const originalFunction = helper.padChrome$.window.pad.socket.json.send;
|
||||
const mockedFunction = function (...args) {
|
||||
const context = this;
|
||||
setTimeout(() => {
|
||||
originalFunction.apply(context, args);
|
||||
}, 4000);
|
||||
};
|
||||
mockedFunction.bind(helper.padChrome$.window.pad.socket);
|
||||
helper.padChrome$.window.pad.socket.json.send = mockedFunction;
|
||||
};
|
||||
|
||||
const replaceLineText = (lineNumber, newText, done) => {
|
||||
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
|
||||
|
||||
helper.waitFor(() => (
|
||||
inner$('div').eq(lineNumber).text() === newText
|
||||
), 2000).done(() => {
|
||||
// give some time to receive NEW_CHANGES message
|
||||
setTimeout(done, 2000);
|
||||
});
|
||||
};
|
||||
});
|
Loading…
Reference in a new issue