Bug 1048850 - Part 1: add a client part that can be used to request data from the Loop API or subscribe to incoming push messages. r=Standard8

This commit is contained in:
Mike de Boer 2015-11-18 17:09:31 +01:00
parent 4745d19f23
commit b8d9a3acb4
5 changed files with 479 additions and 1 deletions

View File

@ -0,0 +1,197 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
var loop = loop || {};
(function() {
"use strict";
var kMessageName = "Loop:Message";
var kPushMessageName = "Loop:Message:Push";
var kBatchMessage = "Batch";
var kReplyTimeoutMs = 5000;
var gListeningForMessages = false;
var gListenersMap = {};
var gListeningForPushMessages = false;
var gSubscriptionsMap = {};
var gRootObj = window;
loop._lastMessageID = 0;
/**
* Builds a proper request payload that adheres to the following conditions:
* - The first element (index 0) should be a numeric, unique sequence identifier.
* - The second element (index 1) should be the request command name in CapitalCase.
* - The rest of the elements (indices 2 and higher) contain the request command
* arguments.
*
* @param {Array} args The raw request data
* @return {Array} The request payload in the correct format, as explained above
*/
function buildRequestArray(args) {
var command = args.shift();
// Normalize the command name to look like 'SomeCommand'.
command = command.charAt(0).toUpperCase() + command.substr(1);
var seq = ++loop._lastMessageID;
args.unshift(seq, command);
return args;
}
/**
* Send a request to chrome, running in the main process.
*
* Note: it's not necessary for a request to receive a reply; some commands are
* as simple as fire-and-forget. Therefore each request will be resolved
* after a maximum timeout of `kReplyTimeoutMs` milliseconds.
*
* @param {String} command Required name of the command to send.
* @return {Promise} Gets resolved with the result of the command, IF the chrome
* script sent a reply. It never gets rejected.
*/
loop.request = function request() {
var args = Array.slice(arguments);
return new Promise(function(resolve) {
var payload = buildRequestArray(args);
var seq = payload[0];
if (!gListeningForMessages) {
gListeningForMessages = function listener(message) {
var replySeq = message.data[0];
if (!gListenersMap[replySeq]) {
return;
}
gListenersMap[replySeq](message.data[1]);
delete gListenersMap[replySeq];
};
gRootObj.addMessageListener(kMessageName, gListeningForMessages);
}
gListenersMap[seq] = resolve;
gRootObj.sendAsyncMessage(kMessageName, payload);
gRootObj.setTimeout(function() {
// Check if the promise was already resolved before by the message handler.
if (!gListenersMap[seq]) {
return;
}
resolve();
delete gListenersMap[seq];
}, kReplyTimeoutMs);
});
};
// These functions should only be used in unit tests.
loop.request.getReplyTimeoutMs = function() { return kReplyTimeoutMs; };
loop.request.inspect = function() { return _.extend({}, gListenersMap); };
loop.request.reset = function() {
gListeningForMessages = false;
gListenersMap = {};
};
/**
* Send multiple requests at once as a batch.
*
* @param {...Array} command An unlimited amount of Arrays may be passed as
* individual arguments of this function. They are
* bundled together and sent across.
* @return {Promise} Gets resolved when all commands have finished with their
* accumulated results.
*/
loop.requestMulti = function requestMulti() {
if (!arguments.length) {
throw new Error("loop.requestMulti: please pass in a list of calls to process in parallel.");
}
var calls = Array.slice(arguments);
calls.forEach(function(call) {
if (!Array.isArray(call)) {
throw new Error("loop.requestMulti: each call must be an array of options, " +
"exactly the same as the argument signature of `loop.request()`");
}
buildRequestArray(call);
});
return new Promise(function(resolve) {
loop.request(kBatchMessage, calls).then(function(resultSet) {
if (!resultSet) {
resolve();
return;
}
// Collect result as a sequenced array and pass it back.
var result = Object.getOwnPropertyNames(resultSet).map(function(seq) {
return resultSet[seq];
});
resolve(result);
});
});
};
/**
* Subscribe to push messages coming from chrome scripts. Since these may arrive
* at any time, the subscriptions are permanently registered.
*
* @param {String} name Name of the push message
* @param {Function} callback Function invoked when the push message arrives
*/
loop.subscribe = function subscribe(name, callback) {
if (!gListeningForPushMessages) {
gRootObj.addMessageListener(kPushMessageName, gListeningForPushMessages = function(message) {
var eventName = message.data[0];
if (!gSubscriptionsMap[eventName]) {
return;
}
gSubscriptionsMap[eventName].forEach(function(cb) {
cb.apply(null, message.data[1]);
});
});
}
if (!gSubscriptionsMap[name]) {
gSubscriptionsMap[name] = [];
}
gSubscriptionsMap[name].push(callback);
};
// These functions should only be used in unit tests.
loop.subscribe.inspect = function() { return _.extend({}, gSubscriptionsMap); };
loop.subscribe.reset = function() {
gListeningForPushMessages = false;
gSubscriptionsMap = {};
};
/**
* Cancel a subscription to a specific push message.
*
* @param {String} name Name of the push message
* @param {Function} callback Handler function
*/
loop.unsubscribe = function unsubscribe(name, callback) {
if (!gSubscriptionsMap[name]) {
return;
}
var idx = gSubscriptionsMap[name].indexOf(callback);
if (idx === -1) {
return;
}
gSubscriptionsMap[name].splice(idx, 1);
};
/**
* Cancel all running subscriptions.
*/
loop.unsubscribeAll = function unsubscribeAll() {
gSubscriptionsMap = {};
if (gListeningForPushMessages) {
gRootObj.removeMessageListener(kPushMessageName, gListeningForPushMessages);
gListeningForPushMessages = false;
}
};
})();

View File

@ -95,11 +95,12 @@ browser.jar:
content/browser/loop/shared/js/store.js (content/shared/js/store.js)
content/browser/loop/shared/js/activeRoomStore.js (content/shared/js/activeRoomStore.js)
content/browser/loop/shared/js/dispatcher.js (content/shared/js/dispatcher.js)
content/browser/loop/shared/js/linkifiedTextView.js (content/shared/js/linkifiedTextView.js)
content/browser/loop/shared/js/loopapi-client.js (content/shared/js/loopapi-client.js)
content/browser/loop/shared/js/models.js (content/shared/js/models.js)
content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js)
content/browser/loop/shared/js/otSdkDriver.js (content/shared/js/otSdkDriver.js)
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
content/browser/loop/shared/js/linkifiedTextView.js (content/shared/js/linkifiedTextView.js)
content/browser/loop/shared/js/textChatStore.js (content/shared/js/textChatStore.js)
content/browser/loop/shared/js/textChatView.js (content/shared/js/textChatView.js)
content/browser/loop/shared/js/urlRegExps.js (content/shared/js/urlRegExps.js)

View File

@ -18,6 +18,7 @@ module.exports = function(config) {
"content/shared/libs/sdk.js",
"test/shared/vendor/*.js",
"test/karma/head.js", // Add test fixture container
"content/shared/js/loopapi-client.js",
"content/shared/js/utils.js",
"content/shared/js/store.js",
"content/shared/js/models.js",

View File

@ -47,6 +47,7 @@
</script>
<!-- App scripts -->
<script src="../../content/shared/js/loopapi-client.js"></script>
<script src="../../content/shared/js/utils.js"></script>
<script src="../../content/shared/js/models.js"></script>
<script src="../../content/shared/js/mixins.js"></script>
@ -78,6 +79,7 @@
<script src="textChatStore_test.js"></script>
<script src="textChatView_test.js"></script>
<script src="linkifiedTextView_test.js"></script>
<script src="loopapi-client_test.js"></script>
<script>
describe("Uncaught Error Check", function() {

View File

@ -0,0 +1,277 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
describe("loopapi-client", function() {
"use strict";
var expect = chai.expect;
var sandbox, clock, replyTimeoutMs;
var sharedMixins = loop.shared.mixins;
var TestUtils = React.addons.TestUtils;
beforeEach(function() {
sandbox = sinon.sandbox.create();
window.addMessageListener = sinon.stub();
window.removeMessageListener = sinon.stub();
window.sendAsyncMessage = sinon.stub();
clock = sandbox.useFakeTimers();
replyTimeoutMs = loop.request.getReplyTimeoutMs();
});
afterEach(function() {
loop.request.reset();
loop.subscribe.reset();
sandbox.restore();
});
describe("loop.request", function() {
it("should send a message", function() {
var promise = loop.request("GetLoopPref", "enabled");
expect(promise).to.be.an.instanceof(Promise);
sinon.assert.calledOnce(window.sendAsyncMessage);
sinon.assert.calledWithExactly(window.sendAsyncMessage, "Loop:Message",
[loop._lastMessageID, "GetLoopPref", "enabled"]);
sinon.assert.calledOnce(window.addMessageListener);
clock.tick(replyTimeoutMs);
return promise;
});
it("should correct the command name", function() {
var promise = loop.request("getLoopPref", "enabled");
sinon.assert.calledWithExactly(window.sendAsyncMessage, "Loop:Message",
[loop._lastMessageID, "GetLoopPref", "enabled"]);
clock.tick(replyTimeoutMs);
return promise;
});
it("should pass all arguments in-order", function() {
var promise = loop.request("SetLoopPref", "enabled", false, 1, 2, 3);
sinon.assert.calledWithExactly(window.sendAsyncMessage, "Loop:Message",
[loop._lastMessageID, "SetLoopPref", "enabled", false, 1, 2, 3]);
clock.tick(replyTimeoutMs);
return promise;
});
it("should resolve the promise when a response is received", function() {
var listener;
window.addMessageListener = function(name, callback) {
listener = callback;
};
var promise = loop.request("GetLoopPref", "enabled").then(function(result) {
expect(result).to.eql("result");
});
listener({
data: [loop._lastMessageID, "result"]
});
return promise;
});
it("should cancel the message listener when no reply is received in time", function() {
var promise = loop.request("GetLoopPref", "enabled");
promise.then(function(result) {
expect(result).to.eql(undefined);
});
clock.tick(replyTimeoutMs);
return promise;
});
it("should not start listening for messages more than once", function() {
return new Promise(function(resolve) {
loop.request("GetLoopPref", "enabled").then(function() {
sinon.assert.calledOnce(window.addMessageListener);
loop.request("GetLoopPref", "enabled").then(function() {
sinon.assert.calledOnce(window.addMessageListener);
resolve();
});
clock.tick(replyTimeoutMs);
});
clock.tick(replyTimeoutMs);
});
});
});
describe("loop.requestMulti", function() {
it("should send a batch of messages", function() {
var promise = loop.requestMulti(
["GetLoopPref", "enabled"],
["GetLoopPref", "e10s.enabled"]
);
expect(promise).to.be.an.instanceof(Promise);
sinon.assert.calledOnce(window.sendAsyncMessage);
sinon.assert.calledWithExactly(window.sendAsyncMessage, "Loop:Message",
[loop._lastMessageID, "Batch", [
[loop._lastMessageID - 2, "GetLoopPref", "enabled"],
[loop._lastMessageID - 1, "GetLoopPref", "e10s.enabled"]]
]);
clock.tick(replyTimeoutMs);
return promise;
});
it("should correct command names", function() {
var promise = loop.requestMulti(
["GetLoopPref", "enabled"],
// Use lowercase 'g' on purpose, it should get corrected:
["getLoopPref", "e10s.enabled"]
);
sinon.assert.calledWithExactly(window.sendAsyncMessage, "Loop:Message",
[loop._lastMessageID, "Batch", [
[loop._lastMessageID - 2, "GetLoopPref", "enabled"],
[loop._lastMessageID - 1, "GetLoopPref", "e10s.enabled"]]
]);
clock.tick(replyTimeoutMs);
return promise;
});
it("should resolve the promise when a response is received", function() {
var listener;
window.addMessageListener = function(name, callback) {
listener = callback;
};
var promise = loop.requestMulti(
["GetLoopPref", "enabled"],
["GetLoopPref", "e10s.enabled"]
).then(function(result) {
expect(result).to.eql(["result1", "result2"]);
});
listener({
data: [
loop._lastMessageID, {
"1": "result1",
"2": "result2"
}
]
});
return promise;
});
it("should throw an error when no requests are passed in", function() {
expect(loop.requestMulti).to.throw(Error, /please pass in a list of calls/);
});
it("should throw when invalid request is passed in", function() {
expect(loop.requestMulti.bind(null, ["command"], null)).to
.throw(Error, /each call must be an array of options/);
expect(loop.requestMulti.bind(null, null, ["command"])).to
.throw(Error, /each call must be an array of options/);
});
});
describe("loop.subscribe", function() {
var sendMessage = null;
var callCount = 0;
beforeEach(function() {
callCount = 0;
window.addMessageListener = function(name, callback) {
sendMessage = callback;
++callCount;
};
});
afterEach(function() {
sendMessage = null;
});
it("subscribe to a push message", function() {
loop.subscribe("LoopStatusChanged", function() {});
var subscriptions = loop.subscribe.inspect();
expect(callCount).to.eql(1);
expect(Object.getOwnPropertyNames(subscriptions).length).to.eql(1);
expect(subscriptions.LoopStatusChanged.length).to.eql(1);
});
it("should start listening for push messages when a subscriber registers", function() {
loop.subscribe("LoopStatusChanged", function() {});
expect(callCount).to.eql(1);
loop.subscribe("LoopStatusChanged", function() {});
expect(callCount).to.eql(1);
loop.subscribe("Test", function() {});
expect(callCount).to.eql(1);
});
it("incoming push messages should invoke subscriptions", function() {
var stub1 = sinon.stub();
var stub2 = sinon.stub();
var stub3 = sinon.stub();
loop.subscribe("LoopStatusChanged", stub1);
loop.subscribe("LoopStatusChanged", stub2);
loop.subscribe("LoopStatusChanged", stub3);
sendMessage({ data: ["Foo", ["bar"]] });
sinon.assert.notCalled(stub1);
sinon.assert.notCalled(stub2);
sinon.assert.notCalled(stub3);
sendMessage({ data: ["LoopStatusChanged", ["Foo", "Bar"]] });
sinon.assert.calledOnce(stub1);
sinon.assert.calledWithExactly(stub1, "Foo", "Bar");
sinon.assert.calledOnce(stub2);
sinon.assert.calledWithExactly(stub2, "Foo", "Bar");
sinon.assert.calledOnce(stub3);
sinon.assert.calledWithExactly(stub3, "Foo", "Bar");
});
});
describe("unsubscribe", function() {
it("should remove subscriptions from the map", function() {
var handler = function() {};
loop.subscribe("LoopStatusChanged", handler);
loop.unsubscribe("LoopStatusChanged", handler);
expect(loop.subscribe.inspect().LoopStatusChanged.length).to.eql(0);
});
it("should not remove a subscription when a different handler is passed in", function() {
var handler = function() {};
loop.subscribe("LoopStatusChanged", handler);
loop.unsubscribe("LoopStatusChanged", function() {});
expect(loop.subscribe.inspect().LoopStatusChanged.length).to.eql(1);
});
it("should not throw when unsubscribing from an unknown subscription", function() {
loop.unsubscribe("foobar");
});
});
describe("unsubscribeAll", function() {
it("should clear all present subscriptions", function() {
loop.subscribe("LoopStatusChanged", function() {});
expect(Object.getOwnPropertyNames(loop.subscribe.inspect()).length).to.eql(1);
loop.unsubscribeAll();
expect(Object.getOwnPropertyNames(loop.subscribe.inspect()).length).to.eql(0);
});
});
});