/*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."); }) ); } } } });