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: + *
+ * +----->PREPARED -------> SYNCING <--+ + * | ^ | | + * null ------+ | +---------------+ | + * | | V | + * +------->ERROR ---------------------+ + * + * NB: 'null' will never be emitted by this event. + *+ * Transitions: + *
null -> PREPARED : Occurs when the initial sync is completed
+ * first time.
+ * null -> ERROR : Occurs when the initial sync failed first time.
+ * ERROR -> PREPARED : Occurs when the initial sync succeeds
+ * after previously failing.
+ * PREPARED -> SYNCING : Occurs immediately after transitioning
+ * to PREPARED. Starts listening for live updates rather than catching up.
+ * SYNCING -> ERROR : Occurs the first time a client cannot perform a
+ * live update.
+ * ERROR -> SYNCING : Occurs when the client has performed a
+ * live update after having previously failed.
+ * ERROR -> ERROR : Occurs when the client has failed to sync
+ * for a second time or more.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",