[fix] Dequeue messages when committing
parent
b80f5bdae8
commit
c8b0f8fed4
|
@ -97,6 +97,10 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
setChannelState('DISCONNECTED', 'slowcommit');
|
setChannelState('DISCONNECTED', 'slowcommit');
|
||||||
} else if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 5000) {
|
} else if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 5000) {
|
||||||
callbacks.onConnectionTrouble('SLOW');
|
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 {
|
} else {
|
||||||
// run again in a few seconds, to detect a disconnect
|
// run again in a few seconds, to detect a disconnect
|
||||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
||||||
|
@ -114,31 +118,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
|
|
||||||
// apply msgQueue changeset.
|
// apply msgQueue changeset.
|
||||||
if (msgQueue.length !== 0) {
|
if (msgQueue.length !== 0) {
|
||||||
let msg;
|
dequeueMessages();
|
||||||
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;
|
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 = () => {
|
const setUpSocket = () => {
|
||||||
setChannelState('CONNECTED');
|
setChannelState('CONNECTED');
|
||||||
doDeferredActions();
|
doDeferredActions();
|
||||||
|
@ -226,7 +242,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
// setChannelState("DISCONNECTED", "badmessage_newchanges");
|
// setChannelState("DISCONNECTED", "badmessage_newchanges");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msgQueue.push(msg);
|
enqueueMessage(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -247,7 +263,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msgQueue.push(msg);
|
enqueueMessage(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -257,15 +273,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rev = newRev;
|
rev = newRev;
|
||||||
editor.applyPreparedChangesetToBase();
|
acceptCommit();
|
||||||
setStateIdle();
|
|
||||||
callCatchingErrors('onInternalAction', () => {
|
|
||||||
callbacks.onInternalAction('commitAcceptedByServer');
|
|
||||||
});
|
|
||||||
callCatchingErrors('onConnectionTrouble', () => {
|
|
||||||
callbacks.onConnectionTrouble('OK');
|
|
||||||
});
|
|
||||||
handleUserChanges();
|
|
||||||
} else if (msg.type === 'CLIENT_RECONNECT') {
|
} else if (msg.type === 'CLIENT_RECONNECT') {
|
||||||
// Server sends a CLIENT_RECONNECT message when there is a 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
|
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
|
||||||
|
@ -289,7 +297,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msg.type = 'NEW_CHANGES';
|
msg.type = 'NEW_CHANGES';
|
||||||
msgQueue.push(msg);
|
enqueueMessage(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.js"></script>
|
||||||
<script src="helper/methods.js"></script>
|
<script src="helper/methods.js"></script>
|
||||||
<script src="helper/ui.js"></script>
|
<script src="helper/ui.js"></script>
|
||||||
|
<script src="helper/multipleUsers.js"></script>
|
||||||
|
|
||||||
<script src="specs_list.js"></script>
|
<script src="specs_list.js"></script>
|
||||||
<script src="runner.js"></script>
|
<script src="runner.js"></script>
|
||||||
|
|
|
@ -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 New Issue