diff --git a/lib/client.js b/lib/client.js index fb1999e97..95acd0f4b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -151,7 +151,8 @@ function MatrixClient(opts) { setupCallEventHandler(this); this._supportsVoip = true; } - + this._syncState = null; + this._syncingRetry = null; } utils.inherits(MatrixClient, EventEmitter); @@ -179,6 +180,32 @@ MatrixClient.prototype.supportsVoip = function() { return this._supportsVoip; }; +/** + * Get the current sync state. + * @return {?string} the sync state, which may be null. + * @see module:client~MatrixClient#event:"sync" + */ +MatrixClient.prototype.getSyncState = function() { + return this._syncState; +}; + +/** + * Retry a backed off syncing request immediately. This should only be used when + * the user explicitly attempts to retry their lost connection. + * @return {boolean} True if this resulted in a request being retried. + */ +MatrixClient.prototype.retryImmediately = function() { + if (!this._syncingRetry) { + return false; + } + // stop waiting + clearTimeout(this._syncingRetry.timeoutId); + // invoke immediately + this._syncingRetry.fn(); + this._syncingRetry = null; + return true; +}; + /** * Is end-to-end crypto enabled for this client. * @return {boolean} True if end-to-end is enabled. @@ -991,6 +1018,9 @@ function _sendEvent(client, room, event, callback) { // the fake event we made above. If we don't find it, we're still // waiting on the real event and so should assign the fake event // with the real event_id for matching later. + + // FIXME: This manipulation of the room should probably be done + // inside the room class, not by the client. var matchingEvent = utils.findElement(room.timeline, function(ev) { return ev.getId() === eventId; }, true); @@ -1003,13 +1033,13 @@ function _sendEvent(client, room, event, callback) { matchingEvent.event.content = event.event.content; matchingEvent.event.type = event.event.type; } - utils.removeElement(room.timeline, function(ev) { - return ev.getId() === event.getId(); - }, true); + room.removeEvents([event.getId()]); } else { + room.removeEvents([event.getId()]); event.event.event_id = res.event_id; event.status = null; + room.addEventsToTimeline([event]); } } @@ -1675,7 +1705,7 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) { var matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self)); room.addEventsToTimeline(matrixEvents, true); room.oldState.paginationToken = res.end; - if (res.chunk.length < limit) { + if (res.chunk.length === 0) { room.oldState.paginationToken = null; } self.store.storeEvents(room, matrixEvents, res.end, true); @@ -1756,27 +1786,25 @@ MatrixClient.prototype.loginWithSAML2 = function(relayState, callback) { }; /** - * @param {module:client.callback} callback Optional. - * @return {module:client.Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. + * @param {string} redirectUrl The URL to redirect to after the HS + * authenticates with CAS. + * @return {string} The HS URL to hit to begin the CAS login process. */ -MatrixClient.prototype.getCasServer = function(callback) { - return this._http.request( - callback, "GET", "/login/cas", undefined, undefined - ); +MatrixClient.prototype.getCasLoginUrl = function(redirectUrl) { + return this._http.getUrl("/login/cas/redirect", { + "redirectUrl": redirectUrl + }, httpApi.PREFIX_V1); }; /** - * @param {string} ticket (Received from CAS) - * @param {string} service Service to which the token was granted + * @param {string} token Login token previously received from homeserver * @param {module:client.callback} callback Optional. * @return {module:client.Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ -MatrixClient.prototype.loginWithCas = function(ticket, service, callback) { - return this.login("m.login.cas", { - ticket: ticket, - service: service +MatrixClient.prototype.loginWithToken = function(token, callback) { + return this.login("m.login.token", { + token: token }, callback); }; @@ -1915,8 +1943,10 @@ MatrixClient.prototype.isLoggedIn = function() { * @param {MatrixClient} client * @param {integer} historyLen * @param {integer} includeArchived + * @param {integer} attempt */ -function doInitialSync(client, historyLen, includeArchived) { +function doInitialSync(client, historyLen, includeArchived, attempt) { + attempt = attempt || 1; var qps = { limit: historyLen }; if (includeArchived) { qps.archived = true; @@ -2024,12 +2054,17 @@ function doInitialSync(client, historyLen, includeArchived) { } client.clientRunning = true; - client.emit("syncComplete"); + updateSyncState(client, "PREPARED"); + // assume success until we fail which may be 30+ secs + updateSyncState(client, "SYNCING"); _pollForEvents(client); }, function(err) { - console.error("/initialSync error: %s", err); - client.emit("syncError", err); - // TODO: Retries. + console.error("/initialSync error (%s attempts): %s", attempt, err); + attempt += 1; + startSyncingRetryTimer(client, attempt, function() { + doInitialSync(client, historyLen, includeArchived, attempt); + }); + updateSyncState(client, "ERROR", { error: err }); }); } @@ -2046,6 +2081,8 @@ function doInitialSync(client, historyLen, includeArchived) { * @param {Boolean} opts.resolveInvitesToProfiles True to do /profile requests * on every invite event if the displayname/avatar_url is not known for this user ID. * Default: false. + * @param {Number} opts.pollTimeout The number of milliseconds to wait on /events. + * Default: 30000 (30 seconds). */ MatrixClient.prototype.startClient = function(opts) { if (this.clientRunning) { @@ -2063,6 +2100,7 @@ MatrixClient.prototype.startClient = function(opts) { opts.initialSyncLimit = opts.initialSyncLimit || 8; opts.includeArchivedRooms = opts.includeArchivedRooms || false; opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false; + opts.pollTimeout = opts.pollTimeout || (30 * 1000); this._config = opts; if (CRYPTO_ENABLED && this.sessionStore !== null) { @@ -2078,20 +2116,34 @@ MatrixClient.prototype.startClient = function(opts) { // periodically poll for turn servers if we support voip checkTurnServers(this); - var self = this; - this.pushRules().done(function(result) { - self.pushRules = result; - doInitialSync(self, opts.initialSyncLimit, opts.includeArchivedRooms); - }, function(err) { - self.emit("syncError", err); - }); + prepareForSync(this); }; +function prepareForSync(client, attempt) { + attempt = attempt || 1; + client.pushRules().done(function(result) { + client.pushRules = result; + doInitialSync( + client, + client._config.initialSyncLimit, + client._config.includeArchivedRooms + ); + }, function(err) { + attempt += 1; + startSyncingRetryTimer(client, attempt, function() { + prepareForSync(client, attempt); + }); + updateSyncState(client, "ERROR", { error: err }); + }); +} + /** * This is an internal method. * @param {MatrixClient} client + * @param {Number} attempt The attempt number */ -function _pollForEvents(client) { +function _pollForEvents(client, attempt) { + attempt = attempt || 1; var self = client; if (!client.clientRunning) { return; @@ -2101,11 +2153,11 @@ function _pollForEvents(client) { discardResult = true; console.error("/events request timed out."); _pollForEvents(client); - }, 40000); + }, client._config.pollTimeout + (20 * 1000)); // 20s buffer client._http.authedRequest(undefined, "GET", "/events", { from: client.store.getSyncToken(), - timeout: 30000 + timeout: client._config.pollTimeout }).done(function(data) { if (discardResult) { return; @@ -2113,6 +2165,11 @@ function _pollForEvents(client) { else { clearTimeout(timeoutObj); } + + if (self._syncState !== "SYNCING") { + updateSyncState(self, "SYNCING"); + } + try { var events = []; if (data) { @@ -2208,12 +2265,12 @@ function _pollForEvents(client) { else { clearTimeout(timeoutObj); } - self.emit("syncError", err); - // retry every few seconds - // FIXME: this should be exponential backoff with an option to nudge - setTimeout(function() { - _pollForEvents(self); - }, 2000); + + attempt += 1; + startSyncingRetryTimer(self, attempt, function() { + _pollForEvents(self, attempt); + }); + updateSyncState(self, "ERROR", { error: err }); }); } @@ -2223,7 +2280,8 @@ function _syncRoom(client, room) { } var defer = q.defer(); client._syncingRooms[room.roomId] = defer.promise; - client.roomInitialSync(room.roomId, 8).done(function(res) { + client.roomInitialSync(room.roomId, client._config.initialSyncLimit).done( + function(res) { room.timeline = []; // blow away any previous messages. _processRoomEvents(client, room, res.state, res.messages); room.recalculate(client.credentials.userId); @@ -2492,6 +2550,20 @@ function setupCallEventHandler(client) { }); } +function startSyncingRetryTimer(client, attempt, fn) { + client._syncingRetry = {}; + client._syncingRetry.fn = fn; + client._syncingRetry.timeoutId = setTimeout(function() { + fn(); + }, retryTimeMsForAttempt(attempt)); +} + +function updateSyncState(client, newState, data) { + var old = client._syncState; + client._syncState = newState; + client.emit("sync", client._syncState, old, data); +} + function checkTurnServers(client) { if (!client._supportsVoip) { return; @@ -2527,7 +2599,7 @@ function createNewUser(client, userId) { function createNewRoom(client, roomId) { var room = new Room(roomId); - reEmit(client, room, ["Room.name", "Room.tags", "Room.timeline"]); + reEmit(client, room, ["Room.name", "Room.timeline", "Room.receipt", "Room.tags"]); // we need to also re-emit room state and room member events, so hook it up // to the client now. We need to add a listener for RoomState.members in @@ -2548,6 +2620,12 @@ function createNewRoom(client, roomId) { return room; } +function retryTimeMsForAttempt(attempt) { + // 2,4,8,16,32,64,128,128,128,... seconds + // max 2^7 secs = 2.1 mins + return Math.pow(2, Math.min(attempt, 7)) * 1000; +} + function _reject(callback, defer, err) { if (callback) { callback(err); @@ -2635,23 +2713,69 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; */ /** - * Fires whenever the SDK has a problem syncing. This event is experimental - * and may change. - * @event module:client~MatrixClient#"syncError" - * @param {MatrixError} err The matrix error which caused this event to fire. + * Fires whenever the SDK's syncing state is updated. The state can be one of: + * + * State transition diagram: + *
+ *              +----->PREPARED -------> SYNCING <--+
+ *              |        ^                  |       |
+ *   null ------+        |  +---------------+       |
+ *              |        |  V                       |
+ *              +------->ERROR ---------------------+
+ *
+ * NB: 'null' will never be emitted by this event.
+ * 
+ * Transitions: + * + * + * @event module:client~MatrixClient#"sync" + * @param {string} state An enum representing the syncing state. One of "PREPARED", + * "SYNCING", "ERROR". + * @param {?string} prevState An enum representing the previous syncing state. + * One of "PREPARED", "SYNCING", "ERROR" or null. + * @param {?Object} data Data about this transition. + * @param {MatrixError} data.err The matrix error if state=ERROR. * @example - * matrixClient.on("syncError", function(err){ - * // update UI to say "Connection Lost" - * }); - */ - -/** - * Fires when the SDK has finished catching up and is now listening for live - * events. This event is experimental and may change. - * @event module:client~MatrixClient#"syncComplete" - * @example - * matrixClient.on("syncComplete", function(){ - * var rooms = matrixClient.getRooms(); + * matrixClient.on("sync", function(state, prevState, data) { + * switch (state) { + * case "ERROR": + * // update UI to say "Connection Lost" + * break; + * case "SYNCING": + * // update UI to remove any "Connection Lost" message + * break; + * case "PREPARED": + * // the client instance is ready to be queried. + * var rooms = matrixClient.getRooms(); + * break; + * } * }); */ diff --git a/lib/http-api.js b/lib/http-api.js index 09b3d4d38..797daa426 100644 --- a/lib/http-api.js +++ b/lib/http-api.js @@ -296,6 +296,25 @@ module.exports.MatrixHttpApi.prototype = { return this._request(callback, method, fullUri, queryParams, data); }, + /** + * Form and return a homeserver request URL based on the given path + * params and prefix. + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * @param {Object} queryParams A dict of query params (these will NOT be + * urlencoded). + * @param {string} prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". + * @return {string} URL + */ + getUrl: function(path, queryParams, prefix) { + var queryString = ""; + if (queryParams) { + queryString = "?" + utils.encodeParams(queryParams); + } + return this.opts.baseUrl + prefix + path + queryString; + }, + _request: function(callback, method, uri, queryParams, data) { if (callback !== undefined && !utils.isFunction(callback)) { throw Error( diff --git a/lib/models/room.js b/lib/models/room.js index 540bf8118..2fe28afb9 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -200,7 +200,26 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline) { else { this.timeline.push(events[i]); } - this.emit("Room.timeline", events[i], this, Boolean(toStartOfTimeline)); + + // synthesize and inject implicit read receipts + // Done after adding the event because otherwise the app would get a read receipt + // pointing to an event that wasn't yet in the timeline + + // This is really ugly because JS has no way to express an object literal + // where the name of a key comes from an expression + if (events[i].sender) { + var fakeReceipt = {content: {}}; + fakeReceipt.content[events[i].getId()] = { + 'm.read': { + } + }; + fakeReceipt.content[events[i].getId()]['m.read'][events[i].sender.userId] = { + ts: events[i].getTs() + }; + this.addReceipt(new MatrixEvent(fakeReceipt)); + } + + this.emit("Room.timeline", events[i], this, Boolean(toStartOfTimeline), false); } }; @@ -269,6 +288,29 @@ Room.prototype.addEvents = function(events, duplicateStrategy) { } }; +/** + * Removes events from this room. + * @param {String} event_ids A list of event_ids to remove. + */ +Room.prototype.removeEvents = function(event_ids) { + // avoids defining a function in the loop, which is a lint error + function removeEventWithId(timeline, id) { + // NB. we supply reverse to search from the end, + // on the assumption that recents events are much + // more likley to be removed than older ones. + return utils.removeElement(timeline, function(e) { + return e.getId() == id; + }, true); + } + + for (var i = 0; i < event_ids.length; ++i) { + var removed = removeEventWithId(this.timeline, event_ids[i]); + if (removed) { + this.emit("Room.timeline", removed, this, undefined, true); + } + } +}; + /** * Recalculate various aspects of the room, including the room name and * room summary. Call this any time the room's current state is modified. @@ -335,7 +377,6 @@ Room.prototype.getUsersReadUpTo = function(event) { * have received no read receipts from them. * @param {String} userId The user ID to get read receipt event ID for * @return {String} ID of the latest event that the given user has read, or null. - * an empty list. */ Room.prototype.getEventReadUpTo = function(userId) { if ( @@ -408,6 +449,10 @@ Room.prototype.addReceipt = function(event) { }); }); }); + + // send events after we've regenerated the cache, otherwise things that + // listened for the event would read from a stale cache + this.emit("Room.receipt", event, this); }; /** @@ -464,7 +509,7 @@ function calculateRoomName(room, userId) { // check for an alias, if any. for now, assume first alias is the // official one. var mRoomName = room.currentState.getStateEvents("m.room.name", ""); - if (mRoomName) { + if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { return mRoomName.getContent().name; } @@ -539,6 +584,7 @@ module.exports = Room; * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {Room} room The room whose Room.timeline was updated. * @param {boolean} toStartOfTimeline True if this event was added to the start + * @param {boolean} removed True if this event has just been removed from the timeline * (beginning; oldest) of the timeline e.g. due to pagination. * @example * matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline){ @@ -558,6 +604,17 @@ module.exports = Room; * }); */ +/** + * Fires whenever a receipt is received for a room + * @event module:client~MatrixClient#"Room.receipt" + * @param {event} event The receipt event + * @param {Room} room The room whose receipts was updated. + * @example + * matrixClient.on("Room.receipt", function(event, room){ + * var receiptContent = event.getContent(); + * }); + */ + /** * Fires whenever a room's tags are updated. * @event module:client~MatrixClient#"Room.tags" diff --git a/lib/scheduler.js b/lib/scheduler.js index e78e5dfe3..1f6da08b6 100644 --- a/lib/scheduler.js +++ b/lib/scheduler.js @@ -133,6 +133,12 @@ MatrixScheduler.RETRY_BACKOFF_RATELIMIT = function(event, attempts, err) { // client error; no amount of retrying with save you now. return -1; } + // we ship with browser-request which returns { cors: rejected } when trying + // with no connection, so if we match that, give up since they have no conn. + if (err.cors === "rejected") { + return -1; + } + if (err.name === "M_LIMIT_EXCEEDED") { var waitTime = err.data.retry_after_ms; if (waitTime) { diff --git a/lib/utils.js b/lib/utils.js index a18a1dc21..955903fff 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -152,19 +152,22 @@ module.exports.findElement = function(array, fn, reverse) { */ module.exports.removeElement = function(array, fn, reverse) { var i; + var removed; if (reverse) { for (i = array.length - 1; i >= 0; i--) { if (fn(array[i], i, array)) { + removed = array[i]; array.splice(i, 1); - return true; + return removed; } } } else { for (i = 0; i < array.length; i++) { if (fn(array[i], i, array)) { + removed = array[i]; array.splice(i, 1); - return true; + return removed; } } } diff --git a/spec/integ/matrix-client-room-timeline.spec.js b/spec/integ/matrix-client-room-timeline.spec.js index d56658f95..89bdd04ac 100644 --- a/spec/integ/matrix-client-room-timeline.spec.js +++ b/spec/integ/matrix-client-room-timeline.spec.js @@ -82,7 +82,8 @@ describe("MatrixClient room timelines", function() { it("should be added immediately after calling MatrixClient.sendEvent " + "with EventStatus.SENDING and the right event.sender", function(done) { - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); expect(room.timeline.length).toEqual(1); @@ -116,7 +117,8 @@ describe("MatrixClient room timelines", function() { ]; eventData.chunk[0].event_id = eventId; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); client.sendTextMessage(roomId, "I am a fish", "txn1").done( function() { @@ -144,7 +146,8 @@ describe("MatrixClient room timelines", function() { ]; eventData.chunk[0].event_id = eventId; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); var promise = client.sendTextMessage(roomId, "I am a fish", "txn1"); httpBackend.flush("/events", 1).done(function() { @@ -180,7 +183,8 @@ describe("MatrixClient room timelines", function() { it("should set Room.oldState.paginationToken to null at the start" + " of the timeline.", function(done) { - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); expect(room.timeline.length).toEqual(1); @@ -219,7 +223,8 @@ describe("MatrixClient room timelines", function() { }) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); expect(room.timeline.length).toEqual(1); @@ -249,7 +254,8 @@ describe("MatrixClient room timelines", function() { }) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); expect(room.timeline.length).toEqual(1); @@ -274,7 +280,8 @@ describe("MatrixClient room timelines", function() { }) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); expect(room.oldState.paginationToken).toBeDefined(); @@ -297,7 +304,8 @@ describe("MatrixClient room timelines", function() { utils.mkMessage({user: userId, room: roomId}), utils.mkMessage({user: userId, room: roomId}) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); var index = 0; @@ -331,7 +339,8 @@ describe("MatrixClient room timelines", function() { }), utils.mkMessage({user: userId, room: roomId}) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); httpBackend.flush("/events", 1).done(function() { var preNameEvent = room.timeline[room.timeline.length - 3]; @@ -352,7 +361,8 @@ describe("MatrixClient room timelines", function() { } }) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); var nameEmitCount = 0; client.on("Room.name", function(rm) { @@ -392,7 +402,8 @@ describe("MatrixClient room timelines", function() { user: userC, room: roomId, mship: "invite", skey: userD }) ]; - client.on("syncComplete", function() { + client.on("sync", function(state) { + if (state !== "PREPARED") { return; } var room = client.getRoom(roomId); httpBackend.flush("/events", 1).done(function() { expect(room.currentState.getMembers().length).toEqual(4); diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js new file mode 100644 index 000000000..d21d1ea06 --- /dev/null +++ b/spec/unit/matrix-client.spec.js @@ -0,0 +1,332 @@ +"use strict"; +var q = require("q"); +var sdk = require("../.."); +var MatrixClient = sdk.MatrixClient; +var utils = require("../test-utils"); + +describe("MatrixClient", function() { + var userId = "@alice:bar"; + var client, store, scheduler; + + var initialSyncData = { + end: "s_5_3", + presence: [], + rooms: [] + }; + + var eventData = { + start: "s_START", + end: "s_END", + chunk: [] + }; + + var PUSH_RULES_RESPONSE = { + method: "GET", path: "/pushrules/", data: {} + }; + + var httpLookups = [ + // items are objects which look like: + // { + // method: "GET", + // path: "/initialSync", + // data: {}, + // error: { errcode: M_FORBIDDEN } // if present will reject promise + // } + // items are popped off when processed and block if no items left. + ]; + var pendingLookup = null; + function httpReq(cb, method, path, qp, data, prefix) { + var next = httpLookups.shift(); + var logLine = ( + "MatrixClient[UT] RECV " + method + " " + path + " " + + "EXPECT " + (next ? next.method : next) + " " + (next ? next.path : next) + ); + console.log(logLine); + + if (!next) { // no more things to return + if (pendingLookup) { + if (pendingLookup.method === method && pendingLookup.path === path) { + return pendingLookup.promise; + } + // >1 pending thing, and they are different, whine. + expect(false).toBe( + true, ">1 pending request. You should probably handle them. " + + "PENDING: " + JSON.stringify(pendingLookup) + " JUST GOT: " + + method + " " + path + ); + } + pendingLookup = { + promise: q.defer().promise, + method: method, + path: path + }; + return pendingLookup.promise; + } + if (next.path === path && next.method === method) { + console.log( + "MatrixClient[UT] Matched. Returning " + + (next.error ? "BAD" : "GOOD") + " response" + ); + if (next.error) { + return q.reject({ + errcode: next.error.errcode, + name: next.error.errcode, + message: "Expected testing error", + data: next.error + }); + } + return q(next.data); + } + expect(true).toBe(false, "Expected different request. " + logLine); + return q.defer().promise; + } + + beforeEach(function() { + utils.beforeEach(this); + jasmine.Clock.useMock(); + scheduler = jasmine.createSpyObj("scheduler", [ + "getQueueForEvent", "queueEvent", "removeEventFromQueue", + "setProcessFunction" + ]); + store = jasmine.createSpyObj("store", [ + "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", + "setSyncToken", "storeEvents", "storeRoom", "storeUser" + ]); + client = new MatrixClient({ + baseUrl: "https://my.home.server", + accessToken: "my.access.token", + request: function() {}, // NOP + store: store, + scheduler: scheduler, + userId: userId + }); + // FIXME: We shouldn't be yanking _http like this. + client._http = jasmine.createSpyObj("httpApi", [ + "authedRequest", "authedRequestWithPrefix", "getContentUri", + "request", "requestWithPrefix", "uploadContent" + ]); + client._http.authedRequest.andCallFake(httpReq); + client._http.authedRequestWithPrefix.andCallFake(httpReq); + + // set reasonable working defaults + pendingLookup = null; + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push({ + method: "GET", path: "/initialSync", data: initialSyncData + }); + }); + + afterEach(function() { + // need to re-stub the requests with NOPs because there are no guarantees + // clients from previous tests will be GC'd before the next test. This + // means they may call /events and then fail an expect() which will fail + // a DIFFERENT test (pollution between tests!) - we return unresolved + // promises to stop the client from continuing to run. + client._http.authedRequest.andCallFake(function() { + return q.defer().promise; + }); + client._http.authedRequestWithPrefix.andCallFake(function() { + return q.defer().promise; + }); + }); + + describe("getSyncState", function() { + + it("should return null if the client isn't started", function() { + expect(client.getSyncState()).toBeNull(); + }); + + it("should return the same sync state as emitted sync events", function(done) { + client.on("sync", function(state) { + expect(state).toEqual(client.getSyncState()); + if (state === "SYNCING") { + done(); + } + }); + client.startClient(); + }); + }); + + describe("retryImmediately", function() { + it("should return false if there is no request waiting", function() { + client.startClient(); + expect(client.retryImmediately()).toBe(false); + }); + + it("should work on /initialSync", function(done) { + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push({ + method: "GET", path: "/initialSync", error: { errcode: "NOPE_NOPE_NOPE" } + }); + httpLookups.push({ + method: "GET", path: "/initialSync", error: { errcode: "NOPE_NOPE_NOPE" } + }); + + client.on("sync", function(state) { + if (state === "ERROR" && httpLookups.length > 0) { + expect(httpLookups.length).toEqual(1); + expect(client.retryImmediately()).toBe(true); + expect(httpLookups.length).toEqual(0); + done(); + } + }); + client.startClient(); + }); + + it("should work on /events", function(done) { + httpLookups.push({ + method: "GET", path: "/events", error: { errcode: "NOPE_NOPE_NOPE" } + }); + httpLookups.push({ + method: "GET", path: "/events", data: eventData + }); + + client.on("sync", function(state) { + if (state === "ERROR" && httpLookups.length > 0) { + expect(httpLookups.length).toEqual(1); + expect(client.retryImmediately()).toBe(true); + expect(httpLookups.length).toEqual(0); + done(); + } + }); + client.startClient(); + }); + + it("should work on /pushrules", function(done) { + httpLookups = []; + httpLookups.push({ + method: "GET", path: "/pushrules/", error: { errcode: "NOPE_NOPE_NOPE" } + }); + httpLookups.push({ + method: "GET", path: "/pushrules/", error: { errcode: "NOPE_NOPE_NOPE" } + }); + + client.on("sync", function(state) { + if (state === "ERROR" && httpLookups.length > 0) { + expect(httpLookups.length).toEqual(1); + expect(client.retryImmediately()).toBe(true); + expect(httpLookups.length).toEqual(0); + done(); + } + }); + client.startClient(); + }); + }); + + describe("emitted sync events", function() { + var expectedStates; + + function syncChecker(done) { + return function(state, old) { + var expected = expectedStates.shift(); + console.log( + "'sync' curr=%s old=%s EXPECT=%s", state, old, expected + ); + if (!expected) { + done(); + return; + } + expect(state).toEqual(expected[0]); + expect(old).toEqual(expected[1]); + if (expectedStates.length === 0) { + done(); + } + // standard retry time is 4s + jasmine.Clock.tick(4001); + }; + } + + beforeEach(function() { + expectedStates = [ + // [current, old] + ]; + }); + + it("should transition null -> PREPARED after /initialSync", function(done) { + expectedStates.push(["PREPARED", null]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + + it("should transition null -> ERROR after a failed /initialSync", function(done) { + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push({ + method: "GET", path: "/initialSync", error: { errcode: "NOPE_NOPE_NOPE" } + }); + expectedStates.push(["ERROR", null]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + + it("should transition ERROR -> PREPARED after /initialSync if prev failed", + function(done) { + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push({ + method: "GET", path: "/initialSync", error: { errcode: "NOPE_NOPE_NOPE" } + }); + httpLookups.push({ + method: "GET", path: "/initialSync", data: initialSyncData + }); + + expectedStates.push(["ERROR", null]); + expectedStates.push(["PREPARED", "ERROR"]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + + it("should transition PREPARED -> SYNCING after /initialSync", function(done) { + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + + it("should transition SYNCING -> ERROR after a failed /events", function(done) { + httpLookups.push({ + method: "GET", path: "/events", error: { errcode: "NONONONONO" } + }); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["ERROR", "SYNCING"]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + + it("should transition ERROR -> SYNCING after /events if prev failed", + function(done) { + httpLookups.push({ + method: "GET", path: "/events", error: { errcode: "NONONONONO" } + }); + httpLookups.push({ + method: "GET", path: "/events", data: eventData + }); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["ERROR", "SYNCING"]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + + it("should transition ERROR -> ERROR if multiple /events fails", function(done) { + httpLookups.push({ + method: "GET", path: "/events", error: { errcode: "NONONONONO" } + }); + httpLookups.push({ + method: "GET", path: "/events", error: { errcode: "NONONONONO" } + }); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["ERROR", "SYNCING"]); + expectedStates.push(["ERROR", "ERROR"]); + client.on("sync", syncChecker(done)); + client.startClient(); + }); + }); +}); diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index c18b51c8b..b92d89421 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -684,6 +684,21 @@ describe("Room", function() { }]); }); + it("should emit an event when a receipt is added", + function() { + var listener = jasmine.createSpy('spy'); + room.on("Room.receipt", listener); + + var ts = 13787898424; + + var receiptEvent = mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts) + ]); + + room.addReceipt(receiptEvent); + expect(listener).toHaveBeenCalledWith(receiptEvent, room); + }); + it("should clobber receipts based on type and user ID", function() { var nextEventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "I AM HERE YOU KNOW",