js: Move Mopidy.js source code to a new Git repo

This commit is contained in:
Stein Magnus Jodal 2014-09-11 21:49:35 +02:00
parent 4eacc911c9
commit d6e0c8d7e6
11 changed files with 4 additions and 1630 deletions

View File

@ -66,9 +66,10 @@ After npm completes, you can import Mopidy.js using ``require()``:
Getting the library for development on the library
==================================================
If you want to work on the Mopidy.js library itself, you'll find a complete
development setup in the ``js/`` dir in our repo. The instructions in
``js/README.md`` will guide you on your way.
If you want to work on the Mopidy.js library itself, you'll find the source
code and a complete development setup in the `Mopidy.js Git repo
<https://github.com/mopidy/mopidy.js>`_. The instructions in ``README.md`` will
guide you on your way.
Creating an instance

View File

@ -1,101 +0,0 @@
/*global module:false*/
module.exports = function (grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
meta: {
banner: "/*! Mopidy.js v<%= pkg.version %> - built " +
"<%= grunt.template.today('yyyy-mm-dd') %>\n" +
" * http://www.mopidy.com/\n" +
" * Copyright (c) <%= grunt.template.today('yyyy') %> " +
"Stein Magnus Jodal and contributors\n" +
" * Licensed under the Apache License, Version 2.0 */\n",
files: {
own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"],
main: "src/mopidy.js",
concat: "../mopidy/http/data/mopidy.js",
minified: "../mopidy/http/data/mopidy.min.js"
}
},
buster: {
all: {}
},
browserify: {
test_mopidy: {
files: {
"test/lib/mopidy.js": "<%= meta.files.main %>"
},
options: {
postBundleCB: function (err, src, next) {
next(err, grunt.template.process("<%= meta.banner %>") + src);
},
standalone: "Mopidy"
}
},
test_when: {
files: {
"test/lib/when.js": "node_modules/when/when.js"
},
options: {
standalone: "when"
}
},
dist: {
files: {
"<%= meta.files.concat %>": "<%= meta.files.main %>"
},
options: {
postBundleCB: function (err, src, next) {
next(err, grunt.template.process("<%= meta.banner %>") + src);
},
standalone: "Mopidy"
}
}
},
jshint: {
options: {
curly: true,
eqeqeq: true,
immed: true,
indent: 4,
latedef: true,
newcap: true,
noarg: true,
sub: true,
quotmark: "double",
undef: true,
unused: true,
eqnull: true,
browser: true,
devel: true,
globals: {}
},
files: "<%= meta.files.own %>"
},
uglify: {
options: {
banner: "<%= meta.banner %>"
},
all: {
files: {
"<%= meta.files.minified %>": ["<%= meta.files.concat %>"]
}
}
},
watch: {
files: "<%= meta.files.own %>",
tasks: ["default"]
}
});
grunt.registerTask("test_build", ["browserify:test_when", "browserify:test_mopidy"]);
grunt.registerTask("test", ["jshint", "test_build", "buster"]);
grunt.registerTask("build", ["test", "browserify:dist", "uglify"]);
grunt.registerTask("default", ["build"]);
grunt.loadNpmTasks("grunt-buster");
grunt.loadNpmTasks("grunt-browserify");
grunt.loadNpmTasks("grunt-contrib-jshint");
grunt.loadNpmTasks("grunt-contrib-uglify");
grunt.loadNpmTasks("grunt-contrib-watch");
};

View File

@ -1,121 +0,0 @@
Mopidy.js
=========
Mopidy.js is a JavaScript library that is installed as a part of Mopidy's HTTP
frontend or from npm. The library makes Mopidy's core API available from the
browser or a Node.js environment, using JSON-RPC messages over a WebSocket to
communicate with Mopidy.
Getting it for browser use
--------------------------
Regular and minified versions of Mopidy.js, ready for use, is installed
together with Mopidy. When the HTTP frontend is running, the files are
available at:
- http://localhost:6680/mopidy/mopidy.js
- http://localhost:6680/mopidy/mopidy.min.js
You may need to adjust hostname and port for your local setup.
In the source repo, you can find the files at:
- `mopidy/http/data/mopidy.js`
- `mopidy/http/data/mopidy.min.js`
Getting it for Node.js use
--------------------------
If you want to use Mopidy.js from Node.js instead of a browser, you can install
Mopidy.js using npm:
npm install mopidy
After npm completes, you can import Mopidy.js using ``require()``:
var Mopidy = require("mopidy");
Using the library
-----------------
See the [Mopidy.js documentation](http://docs.mopidy.com/en/latest/api/js/).
Building from source
--------------------
1. Install [Node.js](http://nodejs.org/) and npm. If you're running Ubuntu:
sudo apt-get install nodejs-legacy npm
2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies:
cd js/
npm install
That's it.
You can now run the tests:
npm test
To run tests automatically when you save a file:
npm start
To run tests, concatenate, minify the source, and update the JavaScript files
in `mopidy/http/data/`:
npm run-script build
To run other [grunt](http://gruntjs.com/) targets which isn't predefined in
`package.json` and thus isn't available through `npm run-script`:
PATH=./node_modules/.bin:$PATH grunt foo
Changelog
---------
### 0.4.0 (2014-06-24)
- Add support for method calls with by-name arguments. The old calling
convention, "by-position-only", is still the default, but this will change in
the future. A warning is printed to the console if you don't explicitly
select a calling convention. See the docs for details.
### 0.3.0 (2014-06-16)
- Upgrade to when.js 3, which brings great performance improvements and better
debugging facilities. If you maintain a Mopidy client, you should review the
[differences between when.js 2 and 3](https://github.com/cujojs/when/blob/master/docs/api.md#upgrading-to-30-from-2x)
and the
[when.js debugging guide](https://github.com/cujojs/when/blob/master/docs/api.md#debugging-promises).
- All promise rejection values are now of the Error type. This ensures that all
JavaScript VMs will show a useful stack trace if a rejected promise's value
is used to throw an exception. To allow catch clauses to handle different
errors differently, server side errors are of the type `Mopidy.ServerError`,
and connection related errors are of the type `Mopidy.ConnectionError`.
### 0.2.0 (2014-01-04)
- **Backwards incompatible change for Node.js users:**
`var Mopidy = require('mopidy').Mopidy;` must be changed to
`var Mopidy = require('mopidy');`
- Add support for [Browserify](http://browserify.org/).
- Upgrade dependencies.
### 0.1.1 (2013-09-17)
- Upgrade dependencies.
### 0.1.0 (2013-03-31)
- Initial release as a Node.js module to the
[npm registry](https://npmjs.org/).

View File

@ -1,15 +0,0 @@
var config = module.exports;
config.browser_tests = {
environment: "browser",
libs: ["test/lib/*.js"],
testHelpers: ["test/**/*-helper.js"],
tests: ["test/**/*-test.js"]
};
config.node_tests = {
environment: "node",
sources: ["src/**/*.js"],
testHelpers: ["test/**/*-helper.js"],
tests: ["test/**/*-test.js"]
};

View File

@ -1 +0,0 @@
module.exports = { Client: window.WebSocket };

View File

@ -1,4 +0,0 @@
{
"browser": "browser.js",
"main": "server.js"
}

View File

@ -1 +0,0 @@
module.exports = require('faye-websocket');

View File

@ -1,60 +0,0 @@
{
"name": "mopidy",
"version": "0.4.0",
"description": "Client lib for controlling a Mopidy music server over a WebSocket",
"keywords": [
"mopidy",
"music",
"client",
"websocket",
"json-rpc"
],
"homepage": "http://www.mopidy.com/",
"bugs": "https://github.com/mopidy/mopidy/issues",
"license": "Apache-2.0",
"author": {
"name": "Stein Magnus Jodal",
"email": "stein.magnus@jodal.no",
"url": "http://www.jodal.no"
},
"contributors": [
{
"name": "Stein Magnus Jodal",
"email": "stein.magnus@jodal.no",
"url": "http://www.jodal.no"
},
{
"name": "Paul Connolley",
"email": "paul.connolley@gmail.com"
}
],
"main": "src/mopidy.js",
"repository": {
"type": "git",
"url": "git://github.com/mopidy/mopidy.git"
},
"scripts": {
"test": "grunt test",
"build": "grunt build",
"start": "grunt watch"
},
"dependencies": {
"bane": "~1.1.0",
"faye-websocket": "~0.7.2",
"when": "~3.2.3"
},
"devDependencies": {
"buster": "~0.7.13",
"browserify": "~3",
"grunt": "~0.4.5",
"grunt-buster": "~0.3.1",
"grunt-browserify": "~1.3.2",
"grunt-contrib-jshint": "~0.10.0",
"grunt-contrib-uglify": "~0.5.0",
"grunt-contrib-watch": "~0.6.1",
"phantomjs": "~1.9.7-8"
},
"engines": {
"node": "*"
}
}

View File

@ -1,331 +0,0 @@
/*global module:true, require:false*/
var bane = require("bane");
var websocket = require("../lib/websocket/");
var when = require("when");
function Mopidy(settings) {
if (!(this instanceof Mopidy)) {
return new Mopidy(settings);
}
this._console = this._getConsole(settings || {});
this._settings = this._configure(settings || {});
this._backoffDelay = this._settings.backoffDelayMin;
this._pendingRequests = {};
this._webSocket = null;
bane.createEventEmitter(this);
this._delegateEvents();
if (this._settings.autoConnect) {
this.connect();
}
}
Mopidy.ConnectionError = function (message) {
this.name = "ConnectionError";
this.message = message;
};
Mopidy.ConnectionError.prototype = new Error();
Mopidy.ConnectionError.prototype.constructor = Mopidy.ConnectionError;
Mopidy.ServerError = function (message) {
this.name = "ServerError";
this.message = message;
};
Mopidy.ServerError.prototype = new Error();
Mopidy.ServerError.prototype.constructor = Mopidy.ServerError;
Mopidy.WebSocket = websocket.Client;
Mopidy.prototype._getConsole = function (settings) {
if (typeof settings.console !== "undefined") {
return settings.console;
}
var con = typeof console !== "undefined" && console || {};
con.log = con.log || function () {};
con.warn = con.warn || function () {};
con.error = con.error || function () {};
return con;
};
Mopidy.prototype._configure = function (settings) {
var currentHost = (typeof document !== "undefined" &&
document.location.host) || "localhost";
settings.webSocketUrl = settings.webSocketUrl ||
"ws://" + currentHost + "/mopidy/ws";
if (settings.autoConnect !== false) {
settings.autoConnect = true;
}
settings.backoffDelayMin = settings.backoffDelayMin || 1000;
settings.backoffDelayMax = settings.backoffDelayMax || 64000;
if (typeof settings.callingConvention === "undefined") {
this._console.warn(
"Mopidy.js is using the default calling convention. The " +
"default will change in the future. You should explicitly " +
"specify which calling convention you use.");
}
settings.callingConvention = (
settings.callingConvention || "by-position-only");
return settings;
};
Mopidy.prototype._delegateEvents = function () {
// Remove existing event handlers
this.off("websocket:close");
this.off("websocket:error");
this.off("websocket:incomingMessage");
this.off("websocket:open");
this.off("state:offline");
// Register basic set of event handlers
this.on("websocket:close", this._cleanup);
this.on("websocket:error", this._handleWebSocketError);
this.on("websocket:incomingMessage", this._handleMessage);
this.on("websocket:open", this._resetBackoffDelay);
this.on("websocket:open", this._getApiSpec);
this.on("state:offline", this._reconnect);
};
Mopidy.prototype.connect = function () {
if (this._webSocket) {
if (this._webSocket.readyState === Mopidy.WebSocket.OPEN) {
return;
} else {
this._webSocket.close();
}
}
this._webSocket = this._settings.webSocket ||
new Mopidy.WebSocket(this._settings.webSocketUrl);
this._webSocket.onclose = function (close) {
this.emit("websocket:close", close);
}.bind(this);
this._webSocket.onerror = function (error) {
this.emit("websocket:error", error);
}.bind(this);
this._webSocket.onopen = function () {
this.emit("websocket:open");
}.bind(this);
this._webSocket.onmessage = function (message) {
this.emit("websocket:incomingMessage", message);
}.bind(this);
};
Mopidy.prototype._cleanup = function (closeEvent) {
Object.keys(this._pendingRequests).forEach(function (requestId) {
var resolver = this._pendingRequests[requestId];
delete this._pendingRequests[requestId];
var error = new Mopidy.ConnectionError("WebSocket closed");
error.closeEvent = closeEvent;
resolver.reject(error);
}.bind(this));
this.emit("state:offline");
};
Mopidy.prototype._reconnect = function () {
this.emit("reconnectionPending", {
timeToAttempt: this._backoffDelay
});
setTimeout(function () {
this.emit("reconnecting");
this.connect();
}.bind(this), this._backoffDelay);
this._backoffDelay = this._backoffDelay * 2;
if (this._backoffDelay > this._settings.backoffDelayMax) {
this._backoffDelay = this._settings.backoffDelayMax;
}
};
Mopidy.prototype._resetBackoffDelay = function () {
this._backoffDelay = this._settings.backoffDelayMin;
};
Mopidy.prototype.close = function () {
this.off("state:offline", this._reconnect);
this._webSocket.close();
};
Mopidy.prototype._handleWebSocketError = function (error) {
this._console.warn("WebSocket error:", error.stack || error);
};
Mopidy.prototype._send = function (message) {
switch (this._webSocket.readyState) {
case Mopidy.WebSocket.CONNECTING:
return when.reject(
new Mopidy.ConnectionError("WebSocket is still connecting"));
case Mopidy.WebSocket.CLOSING:
return when.reject(
new Mopidy.ConnectionError("WebSocket is closing"));
case Mopidy.WebSocket.CLOSED:
return when.reject(
new Mopidy.ConnectionError("WebSocket is closed"));
default:
var deferred = when.defer();
message.jsonrpc = "2.0";
message.id = this._nextRequestId();
this._pendingRequests[message.id] = deferred.resolver;
this._webSocket.send(JSON.stringify(message));
this.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) {
try {
var data = JSON.parse(message.data);
if (data.hasOwnProperty("id")) {
this._handleResponse(data);
} else if (data.hasOwnProperty("event")) {
this._handleEvent(data);
} else {
this._console.warn(
"Unknown message type received. Message was: " +
message.data);
}
} catch (error) {
if (error instanceof SyntaxError) {
this._console.warn(
"WebSocket message parsing failed. Message was: " +
message.data);
} else {
throw error;
}
}
};
Mopidy.prototype._handleResponse = function (responseMessage) {
if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) {
this._console.warn(
"Unexpected response received. Message was:", responseMessage);
return;
}
var error;
var resolver = this._pendingRequests[responseMessage.id];
delete this._pendingRequests[responseMessage.id];
if (responseMessage.hasOwnProperty("result")) {
resolver.resolve(responseMessage.result);
} else if (responseMessage.hasOwnProperty("error")) {
error = new Mopidy.ServerError(responseMessage.error.message);
error.code = responseMessage.error.code;
error.data = responseMessage.error.data;
resolver.reject(error);
this._console.warn("Server returned error:", responseMessage.error);
} else {
error = new Error("Response without 'result' or 'error' received");
error.data = {response: responseMessage};
resolver.reject(error);
this._console.warn(
"Response without 'result' or 'error' received. Message was:",
responseMessage);
}
};
Mopidy.prototype._handleEvent = function (eventMessage) {
var type = eventMessage.event;
var data = eventMessage;
delete data.event;
this.emit("event:" + this._snakeToCamel(type), data);
};
Mopidy.prototype._getApiSpec = function () {
return this._send({method: "core.describe"})
.then(this._createApi.bind(this))
.catch(this._handleWebSocketError);
};
Mopidy.prototype._createApi = function (methods) {
var byPositionOrByName = (
this._settings.callingConvention === "by-position-or-by-name");
var caller = function (method) {
return function () {
var message = {method: method};
if (arguments.length === 0) {
return this._send(message);
}
if (!byPositionOrByName) {
message.params = Array.prototype.slice.call(arguments);
return this._send(message);
}
if (arguments.length > 1) {
return when.reject(new Error(
"Expected zero arguments, a single array, " +
"or a single object."));
}
if (!Array.isArray(arguments[0]) &&
arguments[0] !== Object(arguments[0])) {
return when.reject(new TypeError(
"Expected an array or an object."));
}
message.params = arguments[0];
return this._send(message);
}.bind(this);
}.bind(this);
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 = this;
objPath.forEach(function (objName) {
objName = this._snakeToCamel(objName);
parentObj[objName] = parentObj[objName] || {};
parentObj = parentObj[objName];
}.bind(this));
return parentObj;
}.bind(this);
var createMethod = function (fullMethodName) {
var methodPath = getPath(fullMethodName);
var methodName = this._snakeToCamel(methodPath.slice(-1)[0]);
var object = createObjects(methodPath.slice(0, -1));
object[methodName] = caller(fullMethodName);
object[methodName].description = methods[fullMethodName].description;
object[methodName].params = methods[fullMethodName].params;
}.bind(this);
Object.keys(methods).forEach(createMethod);
this.emit("state:online");
};
Mopidy.prototype._snakeToCamel = function (name) {
return name.replace(/(_[a-z])/g, function (match) {
return match.toUpperCase().replace("_", "");
});
};
module.exports = Mopidy;

View File

@ -1,29 +0,0 @@
/*
* PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it.
*
* Implementation from:
* https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
*/
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== "function") {
// closest thing possible to the ECMAScript 5 internal IsCallable function
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function () {},
fBound = function () {
return fToBind.apply(this instanceof fNOP && oThis
? this
: oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}

View File

@ -1,964 +0,0 @@
/*global require:false */
if (typeof module === "object" && typeof require === "function") {
var buster = require("buster");
var Mopidy = require("../src/mopidy");
var when = require("when");
}
var assert = buster.assert;
var refute = buster.refute;
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 = Mopidy.WebSocket;
Mopidy.WebSocket = fakeWebSocket;
this.webSocketConstructorStub = this.stub(Mopidy, "WebSocket");
this.webSocket = {
close: this.stub(),
send: this.stub()
};
this.mopidy = new Mopidy({
callingConvention: "by-position-or-by-name",
webSocket: this.webSocket
});
},
tearDown: function () {
Mopidy.WebSocket = this.realWebSocket;
},
"constructor": {
"connects when autoConnect is true": function () {
new Mopidy({
autoConnect: true,
callingConvention: "by-position-or-by-name"
});
var currentHost = typeof document !== "undefined" &&
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + currentHost + "/mopidy/ws");
},
"does not connect when autoConnect is false": function () {
new Mopidy({
autoConnect: false,
callingConvention: "by-position-or-by-name"
});
refute.called(this.webSocketConstructorStub);
},
"does not connect when passed a WebSocket": function () {
new Mopidy({
callingConvention: "by-position-or-by-name",
webSocket: {}
});
refute.called(this.webSocketConstructorStub);
},
"defaults to by-position-only calling convention": function () {
var console = {
warn: function () {}
};
var mopidy = new Mopidy({
console: console,
webSocket: this.webSocket,
});
assert.equals(
mopidy._settings.callingConvention,
"by-position-only");
},
"warns if no calling convention explicitly selected": function () {
var console = {
warn: function () {}
};
var stub = this.stub(console, "warn");
new Mopidy({console: console});
assert.calledOnceWith(
stub,
"Mopidy.js is using the default calling convention. The " +
"default will change in the future. You should explicitly " +
"specify which calling convention you use.");
},
"does not warn if calling convention chosen explicitly": function () {
var console = {
warn: function () {}
};
var stub = this.stub(console, "warn");
new Mopidy({
callingConvention: "by-position-or-by-name",
console: console
});
refute.called(stub);
},
"works without 'new' keyword": function () {
var mopidyConstructor = Mopidy; // To trick jshint into submission
var mopidy = mopidyConstructor({
callingConvention: "by-position-or-by-name",
webSocket: {}
});
assert.isObject(mopidy);
assert(mopidy instanceof Mopidy);
}
},
".connect": {
"connects when autoConnect is false": function () {
var mopidy = new Mopidy({
autoConnect: false,
callingConvention: "by-position-or-by-name"
});
refute.called(this.webSocketConstructorStub);
mopidy.connect();
var currentHost = typeof document !== "undefined" &&
document.location.host || "localhost";
assert.calledOnceWith(this.webSocketConstructorStub,
"ws://" + currentHost + "/mopidy/ws");
},
"does nothing when the WebSocket is open": function () {
this.webSocket.readyState = Mopidy.WebSocket.OPEN;
var mopidy = new Mopidy({
callingConvention: "by-position-or-by-name",
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.settle([promise1, promise2]).done(
done(function (descriptors) {
assert.equals(descriptors.length, 2);
descriptors.forEach(function (d) {
assert.equals(d.state, "rejected");
assert(d.reason instanceof Error);
assert(d.reason instanceof Mopidy.ConnectionError);
assert.equals(d.reason.message, "WebSocket closed");
assert.same(d.reason.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._settings.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._settings.backoffDelayMin);
}
},
"close": {
"unregisters reconnection hooks": function () {
this.stub(this.mopidy, "off");
this.mopidy.close();
assert.calledOnceWith(
this.mopidy.off, "state:offline", this.mopidy._reconnect);
},
"closes the WebSocket": function () {
this.mopidy.close();
assert.calledOnceWith(this.mopidy._webSocket.close);
}
},
"._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(this.mopidy._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(this.mopidy._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 = Mopidy.WebSocket.CONNECTING;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(
error.message, "WebSocket is still connecting");
})
);
},
"immediately rejects request if CLOSING": function (done) {
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSING;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
assert.equals(error.message, "WebSocket is closing");
})
);
},
"immediately rejects request if CLOSED": function (done) {
this.mopidy._webSocket.readyState = Mopidy.WebSocket.CLOSED;
var promise = this.mopidy._send({method: "foo"});
refute.called(this.mopidy._webSocket.send);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ConnectionError);
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(this.mopidy._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(this.mopidy._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(this.mopidy._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(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {
code: -32601,
message: "Method not found",
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.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(error.code, responseError.code);
assert.equals(error.message, responseError.message);
assert.equals(error.data, responseError.data);
})
);
},
"rejects and logs requests which get errors without data": function (done) {
var stub = this.stub(this.mopidy._console, "warn");
var promise = this.mopidy._send({method: "bar"});
var responseError = {
code: -32601,
message: "Method not found"
// 'data' key intentionally missing
};
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.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof Mopidy.ServerError);
assert.equals(error.code, responseError.code);
assert.equals(error.message, responseError.message);
refute.defined(error.data);
})
);
},
"rejects and logs responses without result or error": function (done) {
var stub = this.stub(this.mopidy._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.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof 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 (done) {
var methods = {};
var sendStub = this.stub(this.mopidy, "_send");
sendStub.returns(when.resolve(methods));
var _createApiStub = this.stub(this.mopidy, "_createApi");
this.mopidy._getApiSpec().then(done(function () {
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.equals(this.mopidy.hello.description, "Says hello");
assert.equals(this.mopidy.hello.params, []);
assert.isFunction(this.mopidy.hi);
assert.equals(this.mopidy.hi.description, "Says hi");
assert.equals(this.mopidy.hi.params, []);
},
"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);
},
"by-position-only calling convention": {
setUp: function () {
this.mopidy = new Mopidy({
webSocket: this.webSocket,
callingConvention: "by-position-only"
});
this.mopidy._createApi({
foo: {
params: ["bar", "baz"]
}
});
this.sendStub = this.stub(this.mopidy, "_send");
},
"sends no params if no arguments passed to function": function () {
this.mopidy.foo();
assert.calledOnceWith(this.sendStub, {method: "foo"});
},
"sends messages with function arguments unchanged": function () {
this.mopidy.foo(31, 97);
assert.calledOnceWith(this.sendStub, {
method: "foo",
params: [31, 97]
});
},
},
"by-position-or-by-name calling convention": {
setUp: function () {
this.mopidy = new Mopidy({
webSocket: this.webSocket,
callingConvention: "by-position-or-by-name"
});
this.mopidy._createApi({
foo: {
params: ["bar", "baz"]
}
});
this.sendStub = this.stub(this.mopidy, "_send");
},
"must be turned on manually": function () {
assert.equals(
this.mopidy._settings.callingConvention,
"by-position-or-by-name");
},
"sends no params if no arguments passed to function": function () {
this.mopidy.foo();
assert.calledOnceWith(this.sendStub, {method: "foo"});
},
"sends by-position if argument is a list": function () {
this.mopidy.foo([31, 97]);
assert.calledOnceWith(this.sendStub, {
method: "foo",
params: [31, 97]
});
},
"sends by-name if argument is an object": function () {
this.mopidy.foo({bar: 31, baz: 97});
assert.calledOnceWith(this.sendStub, {
method: "foo",
params: {bar: 31, baz: 97}
});
},
"rejects with error if more than one argument": function (done) {
var promise = this.mopidy.foo([1, 2], {c: 3, d: 4});
refute.called(this.sendStub);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert.equals(
error.message,
"Expected zero arguments, a single array, " +
"or a single object.");
})
);
},
"rejects with error if string": function (done) {
var promise = this.mopidy.foo("hello");
refute.called(this.sendStub);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof TypeError);
assert.equals(
error.message, "Expected an array or an object.");
})
);
},
"rejects with error if number": function (done) {
var promise = this.mopidy.foo(1337);
refute.called(this.sendStub);
promise.done(
done(function () {
assert(false);
}),
done(function (error) {
assert(error instanceof Error);
assert(error instanceof TypeError);
assert.equals(
error.message, "Expected an array or an object.");
})
);
}
}
}
});