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