js: Add fully working core API in JavaScript

This commit is contained in:
Stein Magnus Jodal 2012-11-30 02:05:40 +01:00
parent 6face51e52
commit 12f60f3a52
2 changed files with 919 additions and 1 deletions

View File

@ -1,2 +1,276 @@
/*global bane:false, when:false*/
function Mopidy(settings) {
var mopidy = this;
mopidy._webSocket = null;
mopidy._pendingRequests = {};
mopidy._backoffDelayMin = 1000;
mopidy._backoffDelayMax = 64000;
mopidy._backoffDelay = mopidy._backoffDelayMin;
mopidy._settings = settings || {};
mopidy._settings.webSocketUrl =
mopidy._settings.webSocketUrl ||
"ws://" + document.location.host + "/mopidy/ws/";
if (mopidy._settings.autoConnect !== false) {
mopidy._settings.autoConnect = true;
}
bane.createEventEmitter(mopidy);
mopidy._delegateEvents();
if (mopidy._settings.autoConnect) {
mopidy._connect();
}
}
Mopidy.prototype._delegateEvents = function () {
var mopidy = this;
// Remove existing event handlers
mopidy.off("websocket:close");
mopidy.off("websocket:error");
mopidy.off("websocket:incomingMessage");
mopidy.off("websocket:open");
mopidy.off("state:offline");
// Register basic set of event handlers
mopidy.on("websocket:close", mopidy._cleanup);
mopidy.on("websocket:error", mopidy._handleWebSocketError);
mopidy.on("websocket:incomingMessage", mopidy._handleMessage);
mopidy.on("websocket:open", mopidy._resetBackoffDelay);
mopidy.on("websocket:open", mopidy._getApiSpec);
mopidy.on("state:offline", mopidy._reconnect);
};
Mopidy.prototype._connect = function () {
var mopidy = this;
if (mopidy._webSocket) {
if (mopidy._webSocket.readyState === WebSocket.OPEN) {
return;
} else {
mopidy._webSocket.close();
}
}
mopidy._webSocket = mopidy._settings.webSocket ||
new WebSocket(mopidy._settings.webSocketUrl);
mopidy._webSocket.onclose = function (close) {
mopidy.emit("websocket:close", close);
};
mopidy._webSocket.onerror = function (error) {
mopidy.emit("websocket:error", error);
};
mopidy._webSocket.onopen = function () {
mopidy.emit("websocket:open");
};
mopidy._webSocket.onmessage = function (message) {
mopidy.emit("websocket:incomingMessage", message);
};
};
Mopidy.prototype._cleanup = function (closeEvent) {
var mopidy = this;
Object.keys(mopidy._pendingRequests).forEach(function (requestId) {
var resolver = mopidy._pendingRequests[requestId];
delete mopidy._pendingRequests[requestId];
resolver.reject({
message: "WebSocket closed",
closeEvent: closeEvent
});
});
mopidy.emit("state:offline");
};
Mopidy.prototype._reconnect = function () {
var mopidy = this;
mopidy.emit("reconnectionPending", {
timeToAttempt: mopidy._backoffDelay
});
setTimeout(function () {
mopidy.emit("reconnecting");
mopidy._connect();
}, mopidy._backoffDelay);
mopidy._backoffDelay = mopidy._backoffDelay * 2;
if (mopidy._backoffDelay > mopidy._backoffDelayMax) {
mopidy._backoffDelay = mopidy._backoffDelayMax;
}
};
Mopidy.prototype._resetBackoffDelay = function () {
var mopidy = this;
mopidy._backoffDelay = mopidy._backoffDelayMin;
};
Mopidy.prototype._handleWebSocketError = function (error) {
console.warn("WebSocket error:", error.stack || error);
};
Mopidy.prototype._send = function (message) {
var mopidy = this;
var deferred = when.defer();
switch (mopidy._webSocket.readyState) {
case WebSocket.CONNECTING:
deferred.resolver.reject({
message: "WebSocket is still connecting"
});
break;
case WebSocket.CLOSING:
deferred.resolver.reject({
message: "WebSocket is closing"
});
break;
case WebSocket.CLOSED:
deferred.resolver.reject({
message: "WebSocket is closed"
});
break;
default:
message.jsonrpc = "2.0";
message.id = mopidy._nextRequestId();
this._pendingRequests[message.id] = deferred.resolver;
this._webSocket.send(JSON.stringify(message));
mopidy.emit("websocket:outgoingMessage", message);
}
return deferred.promise;
};
Mopidy.prototype._nextRequestId = (function () {
var lastUsed = -1;
return function () {
lastUsed += 1;
return lastUsed;
};
}());
Mopidy.prototype._handleMessage = function (message) {
var mopidy = this;
try {
var data = JSON.parse(message.data);
if (data.hasOwnProperty("id")) {
mopidy._handleResponse(data);
} else if (data.hasOwnProperty("event")) {
mopidy._handleEvent(data);
} else {
console.warn(
"Unknown message type received. Message was: " +
message.data);
}
} catch (error) {
if (error instanceof SyntaxError) {
console.warn(
"WebSocket message parsing failed. Message was: " +
message.data);
} else {
throw error;
}
}
};
Mopidy.prototype._handleResponse = function (responseMessage) {
var mopidy = this;
if (!mopidy._pendingRequests.hasOwnProperty(responseMessage.id)) {
console.warn(
"Unexpected response received. Message was:", responseMessage);
return;
}
var resolver = mopidy._pendingRequests[responseMessage.id];
delete mopidy._pendingRequests[responseMessage.id];
if (responseMessage.hasOwnProperty("result")) {
resolver.resolve(responseMessage.result);
} else if (responseMessage.hasOwnProperty("error")) {
resolver.reject(responseMessage.error);
console.warn("Server returned error:", responseMessage.error);
} else {
resolver.reject({
message: "Response without 'result' or 'error' received",
data: {response: responseMessage}
});
console.warn(
"Response without 'result' or 'error' received. Message was:",
responseMessage);
}
};
Mopidy.prototype._handleEvent = function (eventMessage) {
var mopidy = this;
var type = eventMessage.event;
var data = eventMessage;
delete data.event;
mopidy.emit("event:" + mopidy._snakeToCamel(type), data);
};
Mopidy.prototype._getApiSpec = function () {
var mopidy = this;
mopidy._send({method: "core.describe"})
.then(mopidy._createApi.bind(mopidy), mopidy._handleWebSocketError)
.then(null, mopidy._handleWebSocketError);
};
Mopidy.prototype._createApi = function (methods) {
var mopidy = this;
var caller = function (method) {
return function () {
var params = Array.prototype.slice.call(arguments);
return mopidy._send({
method: method,
params: params
});
};
};
var getPath = function (fullName) {
var path = fullName.split(".");
if (path.length >= 1 && path[0] === "core") {
path = path.slice(1);
}
return path;
};
var createObjects = function (objPath) {
var parentObj = mopidy;
objPath.forEach(function (objName) {
objName = mopidy._snakeToCamel(objName);
parentObj[objName] = parentObj[objName] || {};
parentObj = parentObj[objName];
});
return parentObj;
};
var createMethod = function (fullMethodName) {
var methodPath = getPath(fullMethodName);
var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]);
var object = createObjects(methodPath.slice(0, -1));
object[methodName] = caller(fullMethodName);
};
Object.keys(methods).forEach(createMethod);
mopidy.emit("state:online");
};
Mopidy.prototype._snakeToCamel = function (name) {
return name.replace(/(_[a-z])/g, function (match) {
return match.toUpperCase().replace("_", "");
});
};

View File

@ -1,4 +1,648 @@
/*global when:false, buster:false, assert:false, refute:false, Mopidy:false*/
/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/
buster.testCase("Mopidy", {
setUp: function () {
// Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation,
// so we replace it with a dummy temporarily.
var fakeWebSocket = function () {
return {
send: function () {},
close: function () {}
};
};
fakeWebSocket.CONNECTING = 0;
fakeWebSocket.OPEN = 1;
fakeWebSocket.CLOSING = 2;
fakeWebSocket.CLOSED = 3;
this.realWebSocket = WebSocket;
window.WebSocket = fakeWebSocket;
this.webSocketConstructorStub = this.stub(window, "WebSocket");
this.webSocket = {
close: this.stub(),
send: this.stub()
};
this.mopidy = new Mopidy({webSocket: this.webSocket});
},
tearDown: function () {
window.WebSocket = this.realWebSocket;
},
"constructor": {
"connects when autoConnect is true": function () {
new Mopidy({autoConnect: true});
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + document.location.host + "/mopidy/ws/");
},
"does not connect when autoConnect is false": function () {
new Mopidy({autoConnect: false});
refute.called(this.webSocketConstructorStub);
},
"does not connect when passed a WebSocket": function () {
new Mopidy({webSocket: {}});
refute.called(this.webSocketConstructorStub);
}
},
"._connect": {
"does nothing when the WebSocket is open": function () {
this.webSocket.readyState = WebSocket.OPEN;
var mopidy = new Mopidy({webSocket: this.webSocket});
mopidy._connect();
refute.called(this.webSocket.close);
refute.called(this.webSocketConstructorStub);
}
},
"WebSocket events": {
"emits 'websocket:close' when connection is closed": function () {
var spy = this.spy();
this.mopidy.off("websocket:close");
this.mopidy.on("websocket:close", spy);
var closeEvent = {};
this.webSocket.onclose(closeEvent);
assert.calledOnceWith(spy, closeEvent);
},
"emits 'websocket:error' when errors occurs": function () {
var spy = this.spy();
this.mopidy.off("websocket:error");
this.mopidy.on("websocket:error", spy);
var errorEvent = {};
this.webSocket.onerror(errorEvent);
assert.calledOnceWith(spy, errorEvent);
},
"emits 'websocket:incomingMessage' when a message arrives": function () {
var spy = this.spy();
this.mopidy.off("websocket:incomingMessage");
this.mopidy.on("websocket:incomingMessage", spy);
var messageEvent = {data: "this is a message"};
this.webSocket.onmessage(messageEvent);
assert.calledOnceWith(spy, messageEvent);
},
"emits 'websocket:open' when connection is opened": function () {
var spy = this.spy();
this.mopidy.off("websocket:open");
this.mopidy.on("websocket:open", spy);
this.webSocket.onopen();
assert.calledOnceWith(spy);
}
},
"._cleanup": {
setUp: function () {
this.mopidy.off("state:offline");
},
"is called on 'websocket:close' event": function () {
var closeEvent = {};
var stub = this.stub(this.mopidy, "_cleanup");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:close", closeEvent);
assert.calledOnceWith(stub, closeEvent);
},
"rejects all pending requests": function (done) {
var closeEvent = {};
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
var promise1 = this.mopidy._send({method: "foo"});
var promise2 = this.mopidy._send({method: "bar"});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2);
this.mopidy._cleanup(closeEvent);
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
when.join(promise1, promise2).then(done(function () {
assert(false, "Promises should be rejected");
}), done(function (error) {
assert.equals(error.message, "WebSocket closed");
assert.same(error.closeEvent, closeEvent);
}));
},
"emits 'state:offline' event when done": function () {
var spy = this.spy();
this.mopidy.on("state:offline", spy);
this.mopidy._cleanup({});
assert.calledOnceWith(spy);
}
},
"._reconnect": {
"is called when the state changes to offline": function () {
var stub = this.stub(this.mopidy, "_reconnect");
this.mopidy._delegateEvents();
this.mopidy.emit("state:offline");
assert.calledOnceWith(stub);
},
"tries to connect after an increasing backoff delay": function () {
var clock = this.useFakeTimers();
var connectStub = this.stub(this.mopidy, "_connect");
var pendingSpy = this.spy();
this.mopidy.on("reconnectionPending", pendingSpy);
var reconnectingSpy = this.spy();
this.mopidy.on("reconnecting", reconnectingSpy);
refute.called(connectStub);
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000});
clock.tick(0);
refute.called(connectStub);
clock.tick(1000);
assert.calledOnceWith(reconnectingSpy);
assert.calledOnce(connectStub);
pendingSpy.reset();
reconnectingSpy.reset();
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000});
assert.calledOnce(connectStub);
clock.tick(0);
assert.calledOnce(connectStub);
clock.tick(1000);
assert.calledOnce(connectStub);
clock.tick(1000);
assert.calledOnceWith(reconnectingSpy);
assert.calledTwice(connectStub);
pendingSpy.reset();
reconnectingSpy.reset();
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000});
assert.calledTwice(connectStub);
clock.tick(0);
assert.calledTwice(connectStub);
clock.tick(2000);
assert.calledTwice(connectStub);
clock.tick(2000);
assert.calledOnceWith(reconnectingSpy);
assert.calledThrice(connectStub);
},
"tries to connect at least about once per minute": function () {
var clock = this.useFakeTimers();
var connectStub = this.stub(this.mopidy, "_connect");
var pendingSpy = this.spy();
this.mopidy.on("reconnectionPending", pendingSpy);
this.mopidy._backoffDelay = this.mopidy._backoffDelayMax;
refute.called(connectStub);
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
clock.tick(0);
refute.called(connectStub);
clock.tick(64000);
assert.calledOnce(connectStub);
pendingSpy.reset();
this.mopidy._reconnect();
assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
assert.calledOnce(connectStub);
clock.tick(0);
assert.calledOnce(connectStub);
clock.tick(64000);
assert.calledTwice(connectStub);
}
},
"._resetBackoffDelay": {
"is called on 'websocket:open' event": function () {
var stub = this.stub(this.mopidy, "_resetBackoffDelay");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:open");
assert.calledOnceWith(stub);
},
"resets the backoff delay to the minimum value": function () {
this.mopidy._backoffDelay = this.mopidy._backoffDelayMax;
this.mopidy._resetBackoffDelay();
assert.equals(this.mopidy._backoffDelay,
this.mopidy._backoffDelayMin);
}
},
"._handleWebSocketError": {
"is called on 'websocket:error' event": function () {
var error = {};
var stub = this.stub(this.mopidy, "_handleWebSocketError");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:error", error);
assert.calledOnceWith(stub, error);
},
"without stack logs the error to the console": function () {
var stub = this.stub(console, "warn");
var error = {};
this.mopidy._handleWebSocketError(error);
assert.calledOnceWith(stub, "WebSocket error:", error);
},
"with stack logs the error to the console": function () {
var stub = this.stub(console, "warn");
var error = {stack: "foo"};
this.mopidy._handleWebSocketError(error);
assert.calledOnceWith(stub, "WebSocket error:", error.stack);
}
},
"._send": {
"adds JSON-RPC fields to the message": function () {
this.stub(this.mopidy, "_nextRequestId").returns(1);
var stub = this.stub(JSON, "stringify");
this.mopidy._send({method: "foo"});
assert.calledOnceWith(stub, {
jsonrpc: "2.0",
id: 1,
method: "foo"
});
},
"adds a resolver to the pending requests queue": function () {
this.stub(this.mopidy, "_nextRequestId").returns(1);
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
this.mopidy._send({method: "foo"});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
assert.isFunction(this.mopidy._pendingRequests[1].resolve);
},
"sends message on the WebSocket": function () {
refute.called(this.mopidy._webSocket.send);
this.mopidy._send({method: "foo"});
assert.calledOnce(this.mopidy._webSocket.send);
},
"emits a 'websocket:outgoingMessage' event": function () {
var spy = this.spy();
this.mopidy.on("websocket:outgoingMessage", spy);
this.stub(this.mopidy, "_nextRequestId").returns(1);
this.mopidy._send({method: "foo"});
assert.calledOnceWith(spy, {
jsonrpc: "2.0",
id: 1,
method: "foo"
});
},
"immediately rejects request if CONNECTING": function (done) {
this.mopidy._webSocket.readyState = WebSocket.CONNECTING;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message, "WebSocket is still connecting");
}));
},
"immediately rejects request if CLOSING": function (done) {
this.mopidy._webSocket.readyState = WebSocket.CLOSING;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message, "WebSocket is closing");
}));
},
"immediately rejects request if CLOSED": function (done) {
this.mopidy._webSocket.readyState = WebSocket.CLOSED;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message, "WebSocket is closed");
}));
}
},
"._nextRequestId": {
"returns an ever increasing ID": function () {
var base = this.mopidy._nextRequestId();
assert.equals(this.mopidy._nextRequestId(), base + 1);
assert.equals(this.mopidy._nextRequestId(), base + 2);
assert.equals(this.mopidy._nextRequestId(), base + 3);
}
},
"._handleMessage": {
"is called on 'websocket:incomingMessage' event": function () {
var messageEvent = {};
var stub = this.stub(this.mopidy, "_handleMessage");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:incomingMessage", messageEvent);
assert.calledOnceWith(stub, messageEvent);
},
"passes JSON-RPC responses on to _handleResponse": function () {
var stub = this.stub(this.mopidy, "_handleResponse");
var message = {
jsonrpc: "2.0",
id: 1,
result: null
};
var messageEvent = {data: JSON.stringify(message)};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub, message);
},
"passes events on to _handleEvent": function () {
var stub = this.stub(this.mopidy, "_handleEvent");
var message = {
event: "track_playback_started",
track: {}
};
var messageEvent = {data: JSON.stringify(message)};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub, message);
},
"logs unknown messages": function () {
var stub = this.stub(console, "warn");
var messageEvent = {data: JSON.stringify({foo: "bar"})};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub,
"Unknown message type received. Message was: " +
messageEvent.data);
},
"logs JSON parsing errors": function () {
var stub = this.stub(console, "warn");
var messageEvent = {data: "foobarbaz"};
this.mopidy._handleMessage(messageEvent);
assert.calledOnceWith(stub,
"WebSocket message parsing failed. Message was: " +
messageEvent.data);
}
},
"._handleResponse": {
"logs unexpected responses": function () {
var stub = this.stub(console, "warn");
var responseMessage = {
jsonrpc: "2.0",
id: 1337,
result: null
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Unexpected response received. Message was:", responseMessage);
},
"removes the matching request from the pending queue": function () {
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
this.mopidy._send({method: "bar"});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
this.mopidy._handleResponse({
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
result: "baz"
});
assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
},
"resolves requests which get results back": function (done) {
var promise = this.mopidy._send({method: "bar"});
var responseResult = {};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
result: responseResult
};
this.mopidy._handleResponse(responseMessage);
promise.then(done(function (result) {
assert.equals(result, responseResult);
}), done(function () {
assert(false);
}));
},
"rejects and logs requests which get errors back": function (done) {
var stub = this.stub(console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {message: "Error", data: {}};
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0],
error: responseError
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Server returned error:", responseError);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(error, responseError);
}));
},
"rejects and logs responses without result or error": function (done) {
var stub = this.stub(console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseMessage = {
jsonrpc: "2.0",
id: Object.keys(this.mopidy._pendingRequests)[0]
};
this.mopidy._handleResponse(responseMessage);
assert.calledOnceWith(stub,
"Response without 'result' or 'error' received. Message was:",
responseMessage);
promise.then(done(function () {
assert(false);
}), done(function (error) {
assert.equals(
error.message,
"Response without 'result' or 'error' received");
assert.equals(error.data.response, responseMessage);
}));
}
},
"._handleEvent": {
"emits server side even on Mopidy object": function () {
var spy = this.spy();
this.mopidy.on(spy);
var track = {};
var message = {
event: "track_playback_started",
track: track
};
this.mopidy._handleEvent(message);
assert.calledOnceWith(spy,
"event:trackPlaybackStarted", {track: track});
}
},
"._getApiSpec": {
"is called on 'websocket:open' event": function () {
var stub = this.stub(this.mopidy, "_getApiSpec");
this.mopidy._delegateEvents();
this.mopidy.emit("websocket:open");
assert.calledOnceWith(stub);
},
"gets Api description from server and calls _createApi": function () {
var methods = {};
var sendStub = this.stub(this.mopidy, "_send");
sendStub.returns(when.resolve(methods));
var _createApiStub = this.stub(this.mopidy, "_createApi");
this.mopidy._getApiSpec();
assert.calledOnceWith(sendStub, {method: "core.describe"});
assert.calledOnceWith(_createApiStub, methods);
}
},
"._createApi": {
"can create an API with methods on the root object": function () {
refute.defined(this.mopidy.hello);
refute.defined(this.mopidy.hi);
this.mopidy._createApi({
hello: {
description: "Says hello",
params: []
},
hi: {
description: "Says hi",
params: []
}
});
assert.isFunction(this.mopidy.hello);
assert.isFunction(this.mopidy.hi);
},
"can create an API with methods on a sub-object": function () {
refute.defined(this.mopidy.hello);
this.mopidy._createApi({
"hello.world": {
description: "Says hello to the world",
params: []
}
});
assert.defined(this.mopidy.hello);
assert.isFunction(this.mopidy.hello.world);
},
"strips off 'core' from method paths": function () {
refute.defined(this.mopidy.hello);
this.mopidy._createApi({
"core.hello.world": {
description: "Says hello to the world",
params: []
}
});
assert.defined(this.mopidy.hello);
assert.isFunction(this.mopidy.hello.world);
},
"converts snake_case to camelCase": function () {
refute.defined(this.mopidy.mightyGreetings);
this.mopidy._createApi({
"mighty_greetings.hello_world": {
description: "Says hello to the world",
params: []
}
});
assert.defined(this.mopidy.mightyGreetings);
assert.isFunction(this.mopidy.mightyGreetings.helloWorld);
},
"triggers 'state:online' event when API is ready for use": function () {
var spy = this.spy();
this.mopidy.on("state:online", spy);
this.mopidy._createApi({});
assert.calledOnceWith(spy);
}
}
});