UNPKG

zendesk_app_framework_sdk

Version:

The Zendesk App Framework (ZAF) SDK is a JavaScript library that simplifies cross-frame communication between iframed apps and ZAF.

844 lines (698 loc) 29.7 kB
describe('Client', function() { var Client = require('client'), Promise = window.Promise || require('../vendor/native-promise-only'), sandbox = sinon.sandbox.create(), origin = 'https://foo.zendesk.com', appGuid = 'ABC123', version = require('version'), subject, source, callback; beforeEach(function() { sandbox.stub(window, 'addEventListener'); sandbox.stub(window, 'postMessage'); source = { postMessage: sandbox.stub() }; subject = new Client({ origin: origin, appGuid: appGuid, source: source }); }); afterEach(function() { sandbox.restore(); }); function triggerEvent(client, name, data) { var evt = { origin: client._origin, source: client._source, data: { key: 'zaf.' + name, message: data, instanceGuid: client._instanceGuid } }; window.addEventListener.callArgWith(1, evt); } describe('initialisation', function() { it('can be instantiated', function() { expect(subject).to.exist; }); it('adds a listener for the message event', function() { expect(window.addEventListener).to.have.been.calledWith('message'); }); it('defaults to the window.top source', function (){ var client = new Client({ origin: origin, appGuid: appGuid }); expect(client).to.have.property('_source', window.top); }); it('posts an "iframe.handshake" message when initialised', function() { var data = { key: "iframe.handshake", message: { version: version }, appGuid: appGuid, instanceGuid: appGuid }; expect(source.postMessage).to.have.been.calledWithMatch(JSON.stringify(data)); }); it('listens for app.registered to mark the client as ready', function() { var data = { metadata: { appId: 1, installationId: 1 }, context: { product: 'support', location: 'ticket_sidebar' } }; expect(subject.ready).to.equal(false); triggerEvent(subject, 'app.registered', data); expect(subject.ready).to.equal(true); expect(subject._metadata).to.equal(data.metadata); expect(subject._context).to.equal(data.context); }); it('listens for context.updated to update the client context', function() { var context = { foo: 123 }; expect(subject._context).not.to.equal(context); triggerEvent(subject, 'context.updated', context); expect(subject._context).to.equal(context); }); describe('with a parent client', function() { var childClient; beforeEach(function() { subject.ready = true; source.postMessage.reset(); window.addEventListener.reset(); childClient = new Client({ parent: subject }); }); it('sets origin and appGuid from parent', function() { expect(childClient._origin).to.equal(origin); expect(childClient._appGuid).to.equal(appGuid); }); it('does not post a handshake', function() { expect(source.postMessage).not.to.have.been.called; }); it('does not add a listener for the message event', function() { expect(window.addEventListener).not.to.have.been.calledWith('message'); }); it('inherits the ready state of the parent', function() { expect(childClient.ready).to.equal(true); }); }); }); describe('events', function() { beforeEach(function() { callback = sandbox.spy(); }); describe('when a message is received', function() { var message, evt, handler; beforeEach(function() { handler = sandbox.stub(); message = { awesome: true }; evt = { data: { key: 'zaf.hello', message: message } }; subject.on('hello', handler); }); describe('when the event is valid', function() { beforeEach(function() { evt.origin = subject._origin; evt.source = subject._source; }); it("passes the message to the client", function() { window.addEventListener.callArgWith(1, evt); expect(handler).to.have.been.calledWithExactly(message); }); describe('when the message is a stringified JSON', function() { it("passes the parsed message to the client", function() { evt.data = JSON.stringify(evt.data); window.addEventListener.callArgWith(1, evt); expect(handler).to.have.been.calledWithExactly(message); }); }); describe('when the message is not from zaf', function() { it("does not pass the message to the client", function() { evt.data.key = 'hello'; window.addEventListener.callArgWith(1, evt); expect(handler).to.not.have.been.called; }); }); describe('when the message is for a hook event', function() { beforeEach(function() { evt.data.needsReply = true; subject.ready = true; }); it('calls the handler and sends back the response', function() { var retval = window.addEventListener.lastCall.args[1].call(subject, evt); return retval.then(function() { expect(handler).to.have.been.called; expect(source.postMessage).to.have.been.calledWith( { appGuid: "ABC123", key: "iframe.reply:hello" }, 'https://foo.zendesk.com' ); }); }); describe('when the handler throws an error', function() { beforeEach(function() { handler.throwsException(); }); it('calls the handler and sends back the error', function() { var retval = window.addEventListener.lastCall.args[1].call(subject, evt); return retval.then(function() { expect(handler).to.have.been.called; expect(source.postMessage).to.have.been.calledWith( { appGuid: "ABC123", error: { msg: "Error" }, key: "iframe.reply:hello" }, 'https://foo.zendesk.com' ); }); }); }); describe('when the handler returns false', function() { beforeEach(function() { handler.returns(false); }); it('calls the handler and sends back the error', function() { var retval = window.addEventListener.lastCall.args[1].call(subject, evt); return retval.then(function() { expect(handler).to.have.been.called; expect(source.postMessage).to.have.been.calledWith( { appGuid: "ABC123", error: { msg: false }, key: "iframe.reply:hello" }, 'https://foo.zendesk.com' ); }); }); }); describe('when the handler returns a string', function() { beforeEach(function() { handler.returns('Oh no! [object Object]'); }); it('calls the handler and sends back the string as an error', function() { var retval = window.addEventListener.lastCall.args[1].call(subject, evt); return retval.then(function() { expect(handler).to.have.been.called; expect(source.postMessage).to.have.been.calledWith( { appGuid: "ABC123", error: { msg: 'Oh no! [object Object]' }, key: "iframe.reply:hello" }, 'https://foo.zendesk.com' ); }); }); }); describe('when the handler rejects a promise', function() { beforeEach(function() { handler.returns(Promise.reject('The third party API is broken.')); }); it('calls the handler and sends back the rejection value as an error', function() { var retval = window.addEventListener.lastCall.args[1].call(subject, evt); return retval.then(function() { expect(handler).to.have.been.called; expect(source.postMessage).to.have.been.calledWith( { appGuid: "ABC123", error: { msg: 'The third party API is broken.' }, key: "iframe.reply:hello" }, 'https://foo.zendesk.com' ); }); }); }); }); }); describe('when the event is not valid', function() { it("does not pass the message to the client", function() { evt.origin = 'https://foo.com'; window.addEventListener.callArgWith(1, evt); expect(handler).to.not.have.been.called; }); }); }); describe('#postMessage', function() { var oldReady; beforeEach(function() { oldReady = subject.ready; subject.ready = false; sandbox.spy(subject, 'on'); }); afterEach(function() { subject.ready = oldReady; }); it('waits until the client is ready to post messages', function() { subject.postMessage('foo'); expect(source.postMessage).to.not.have.been.calledWithMatch('{"key":"foo","appGuid":"ABC123","instanceGuid":"ABC123"}'); expect(subject.on).to.have.been.calledWithMatch('app.registered'); }); it('posts a message when the client is ready', function() { subject.ready = true; subject.postMessage('foo'); expect(source.postMessage).to.have.been.calledWithMatch('{"key":"foo","appGuid":"ABC123","instanceGuid":"ABC123"}'); }); }); describe('#on', function() { it('registers a handler for a given event', function() { subject.on('foo', callback); expect(subject._messageHandlers.foo).to.exist; }); it('registers multiple handlers for the same event', function() { subject.on('foo', callback); subject.on('foo', callback); expect(subject._messageHandlers.foo.length).to.equal(2); }); it('only registers when handler is a function', function() { subject.on('foo', 2); expect(subject._messageHandlers.foo).to.not.exist; }); it('notifies the framework of the handler registration', function() { sandbox.spy(subject, 'postMessage'); subject.on('foo', callback); expect(subject.postMessage).to.have.been.calledWithMatch('iframe.on:foo', { subscriberCount: 1 }); }); }); describe('#off', function() { it('removes a previously registered handler', function() { subject.on('foo', callback); expect(subject._messageHandlers.foo.length).to.equal(1); subject.off('foo', callback); expect(subject._messageHandlers.foo.length).to.equal(0); }); it('returns the handler that was removed', function() { subject.on('foo', callback); expect(subject.off('foo', callback)).to.equal(callback); }); it('returns false if no handler was found', function() { expect(subject.off('foo', callback)).to.be.false; }); it('notifies the framework of the handler removal', function() { sandbox.spy(subject, 'postMessage'); subject.on('foo', callback); subject.off('foo', callback); expect(subject.postMessage).to.have.been.calledWithMatch('iframe.off:foo', { subscriberCount: 0 }); }); describe('when #off is called before #on', function() { beforeEach(function() { sandbox.spy(subject, 'postMessage'); subject.on('foo', function() {}); }); it('notifies the framework of the handler removal', function() { subject.off('foo', callback); expect(subject.postMessage).to.have.been.calledWithMatch('iframe.off:foo', { subscriberCount: 1 }); }); it('does not remove other handlers', function() { subject.off('foo', callback); expect(subject._messageHandlers.foo.length).to.equal(1); }); }); }); describe('#has', function() { it('returns true if the given handler is registered for the given event', function() { subject.on('foo', callback); expect(subject.has('foo', callback)).to.be.true; }); it('returns false if the given handler is not registered for the given event', function() { expect(subject.has('foo', callback)).to.be.false; }); it("returns false if the given event isn't registered", function() { expect(subject.has('bar')).to.be.false; }); }); describe('#trigger', function() { var data = { bar: 2 }; beforeEach(function() { sandbox.spy(subject, 'postMessage'); }); it('posts a message so the framework can trigger the event on all registered clients', function() { subject.trigger('foo', data); expect(subject.postMessage).to.have.been.calledWith('iframe.trigger:foo', data); }); }); describe('#request', function() { var promise, doneHandler, failHandler, requestsCount = 1; beforeEach(function() { sandbox.spy(subject, 'postMessage'); doneHandler = sandbox.spy(); failHandler = sandbox.spy(); promise = subject.request('/api/v2/tickets.json').then(doneHandler, failHandler); }); afterEach(function() { requestsCount++; }); it('asks ZAF to make a request', function() { expect(subject.postMessage).to.have.been.calledWithMatch(/request:\d+/, { url: '/api/v2/tickets.json' }); }); it('returns a promise', function() { expect(promise).to.respondTo('then'); }); describe('promise', function() { var response = { responseArgs: [ {} ] }; it('resolves when the request succeeds', function(done) { triggerEvent(subject, 'request:' + requestsCount + '.done', response); promise.then(function() { expect(doneHandler).to.have.been.calledWith(response.responseArgs[0]); done(); }); }); it('rejects when the request fails', function(done) { triggerEvent(subject, 'request:' + requestsCount + '.fail', response); promise.then(function() { expect(failHandler).to.have.been.calledWith(response.responseArgs[0]); done(); }); }); }); }); }); describe('v2 methods', function() { var promise; var requestsCount = 1; afterEach(function() { promise && promise.catch(function() {}); requestsCount++; }); describe('#get', function() { it('takes an argument and returns a promise with data', function(done) { promise = subject.get('ticket.subject'); expect(promise).to.eventually.become({ errors: {}, 'ticket.subject': 'test' }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: {}, 'ticket.subject': 'test' } } }); }); it('throws an error when the handler throws it', function(done) { promise = subject.get('ticket.err'); expect(promise).to.be.rejectedWith(Error, 'ticket.err unavailable').and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: { 'ticket.err': { message: 'ticket.err unavailable' } } } } }); }); it('accepts an array with multiple paths', function(done) { promise = subject.get(['ticket.subject', 'ticket.requester']); expect(promise).to.eventually.become({ 'ticket.subject': 'test', 'ticket.requester': 'test' }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { 'ticket.subject': 'test', 'ticket.requester': 'test' }} }); }); it('resolves with errors when bulk requesting', function(done) { var promise = subject.get(['ticket.subj']); expect(promise).to.become({ errors: { 'ticket.subj': { message: 'No such Api' } } }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: { 'ticket.subj': { message: 'No such Api' } } } } }); }); it("doesn't accepts multiple arguments", function() { requestsCount--; expect(function() { subject.get('ticket.subject', 'ticket.requester'); }).to.throw(Error); }); it('rejects the promise after 5 seconds', function(done) { var clock = sinon.useFakeTimers(); promise = subject.get('ticket.subject'); clock.tick(5000); clock.restore(); expect(promise).to.be.rejectedWith(Error, 'Invocation request timeout').and.notify(done); }); it('returns an error when not passing in strings', function() { requestsCount--; expect(function() { subject.get(123); }).to.throw(Error); expect(function() { subject.get({ 'ticket.subject': true }); }).to.throw(Error); }); }); describe('#set', function() { it('takes two arguments and returns a promise with data', function(done) { promise = subject.set('ticket.subject', 'value'); expect(promise).to.eventually.become({ errors: {}, 'ticket.subject': 'value' }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: {}, 'ticket.subject': 'value' } } }); }); it('throws an error when not including a value', function() { requestsCount--; expect(function() { subject.set('ticket.subject'); }).to.throw(Error); }); it('rejects the promise when single request and handler throws an error', function(done) { promise = subject.set('ticket.foo', 'bar'); expect(promise).to.be.rejectedWith(Error, 'ticket.foo unavailable').and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: { 'ticket.foo': { message: 'ticket.foo unavailable' } } } } }); }); it('accepts an object with multiple paths', function(done) { promise = subject.set({ 'ticket.subject': 'value', 'ticket.description': 'value' }); expect(promise).to.eventually.become({ 'ticket.subject': 'value', 'ticket.requester': 'value' }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { 'ticket.subject': 'value', 'ticket.requester': 'value' }} }); }); it('resolves with errors when bulk requesting', function(done) { var promise = subject.set({ 'ticket.foo': 'bar' }); expect(promise).to.become({ errors: { 'ticket.foo': { message: 'No such Api' } } }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: { 'ticket.foo': { message: 'No such Api' } } } } }); }); it('rejects the promise after 5 seconds', function(done) { var clock = sinon.useFakeTimers(); promise = subject.set('ticket.subject', 'test'); clock.tick(5000); clock.restore(); expect(promise).to.be.rejectedWith(Error, 'Invocation request timeout').and.notify(done); }); it('throws on invalid input', function() { requestsCount--; expect(function() { subject.set(123); }).to.throw(Error); expect(function() { subject.set('test'); }).to.throw(Error); expect(function() { subject.set(['foo', 'bar']); }).to.throw(Error); }); }); describe('#invoke', function() { it('takes multiple arguments and returns a promise with data', function(done) { // in reality appendText doesn't return anything promise = subject.invoke('ticket.appendText', 'foobar'); expect(promise).to.eventually.become({ errors: {}, 'ticket.appendText': true }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: {}, 'ticket.appendText': true } } }); }); it('rejects the promise when single request and handler throws an error', function(done) { promise = subject.invoke('ticket.foo', 'bar'); expect(promise).to.be.rejectedWith(Error, 'ticket.foo unavailable').and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: { 'ticket.foo': { message: 'ticket.foo unavailable' } } } } }); }); it('throws an error when invoked with an object', function() { requestsCount--; expect(function() { subject.invoke({ 'iframe.resize': [1] }); }).to.throw(Error, "Invoke supports string arguments or an object with array of strings."); }); it('rejects the promise after 5 seconds', function(done) { var clock = sinon.useFakeTimers(); promise = subject.invoke('ticket.subject', 'test'); clock.tick(5000); clock.restore(); expect(promise).to.be.rejectedWith(Error, 'Invocation request timeout').and.notify(done); }); it('doesnt reject whitelisted promises after 5 seconds', function(done) { var clock = sinon.useFakeTimers(); promise = subject.invoke('instances.create'); clock.tick(10000); clock.restore(); expect(promise).to.eventually.become({ errors: {}, 'instances.create': { url: 'http://a.b' } }).and.notify(done); window.addEventListener.callArgWith(1, { origin: subject._origin, source: subject._source, data: { id: requestsCount, result: { errors: {}, 'instances.create': { url: 'http://a.b' } } } }); }); }); describe('#context', function() { var context = { location: 'top_bar' }; it('resolves with the cached context if ready', function() { subject.ready = true; subject._context = context; promise = subject.context(); return expect(promise).to.eventually.eq(context); }); it('waits for the app to be registered before resolving', function() { promise = subject.context(); triggerEvent(subject, 'app.registered', { metadata: {}, context: context }); return expect(promise).to.eventually.eq(context); }); }); describe('#instance', function() { beforeEach(function() { subject.ready = true; }); it('throws an Error when an instanceGuid is not passed', function() { expect(function() { subject.instance(); }).to.throw(Error); }); it('throws an Error when instanceGuid is not a string', function() { expect(function() { subject.instance(1234); }).to.throw(Error); }); it('returns a client for the instance', function() { var instanceClient = subject.instance('def-321'); expect(instanceClient).to.be.an.instanceof(Client); expect(instanceClient).to.have.property('_instanceGuid').that.equals('def-321'); }); it('returns the same client when requested multiple times', function() { expect(subject.instance('def-321')).to.equal(subject.instance('def-321')); }); it('returns its own client if the instanceGuid is matches its own', function() { expect(subject.instance(subject._instanceGuid)).to.equal(subject); }); describe('with the returned client', function() { var childClient; beforeEach(function() { childClient = subject.instance('def-321'); source.postMessage.reset(); }); it('should be ready', function() { expect(childClient).to.have.property('ready', true); }); it('defaults to inheriting the source from the parent', function() { expect(childClient).to.have.property('_source', subject._source); }); describe('#context', function() { var context = { location: 'top_bar' }; it('delegates to instances api', function() { sandbox.stub(childClient, 'get').withArgs('instances.def-321').returns( Promise.resolve({ 'instances.def-321': context }) ); promise = childClient.context(); expect(childClient.get).to.have.been.calledWith('instances.def-321'); return expect(promise).to.eventually.equal(context); }); }); describe('#postMessage', function() { it('includes the instanceGuid in the message', function() { childClient.postMessage('foo.bar', { bar: 'foo' }); expect(source.postMessage).to.have.been.called; var lastCall = JSON.parse(source.postMessage.lastCall.args[0]); expect(lastCall.key).to.equal('foo.bar'); expect(lastCall.message).to.deep.equal({ bar: 'foo' }); expect(lastCall.appGuid).to.equal('ABC123'); expect(lastCall.instanceGuid).to.equal('def-321'); }); }); describe('#get', function() { it('makes a call with the instanceGuid set', function() { promise = childClient.get('foo.bar'); var lastCall = JSON.parse(source.postMessage.lastCall.args[0]); expect(lastCall.request).to.equal('get'); expect(lastCall.params).to.deep.equal(['foo.bar']); expect(lastCall.appGuid).to.equal('ABC123'); expect(lastCall.instanceGuid).to.equal('def-321'); }); }); describe('#set', function() { it('makes a call with the instanceGuid set', function() { promise = childClient.set('foo.bar', 'baz'); var lastCall = JSON.parse(source.postMessage.lastCall.args[0]); expect(lastCall.request).to.equal('set'); expect(lastCall.params).to.deep.equal({'foo.bar': 'baz'}); expect(lastCall.appGuid).to.equal('ABC123'); expect(lastCall.instanceGuid).to.equal('def-321'); }); }); describe('#invoke', function() { it('makes a call with the instanceGuid set', function() { promise = childClient.invoke('popover', 'hide'); var lastCall = JSON.parse(source.postMessage.lastCall.args[0]); expect(lastCall.request).to.equal('invoke'); expect(lastCall.params).to.deep.equal({popover: ['hide']}); expect(lastCall.appGuid).to.equal('ABC123'); expect(lastCall.instanceGuid).to.equal('def-321'); }); it('makes a call with an object', function() { promise = childClient.invoke({popover: ['hide']}); var lastCall = JSON.parse(source.postMessage.lastCall.args[0]); expect(lastCall.request).to.equal('invoke'); expect(lastCall.params).to.deep.equal({popover: ['hide']}); expect(lastCall.appGuid).to.equal('ABC123'); expect(lastCall.instanceGuid).to.equal('def-321'); }); it('returns an error with incorrect arguments', function() { expect(function() { promise = childClient.invoke({popover: 'hide'}); }).to.throw(Error, "Invoke supports string arguments or an object with array of strings."); }); it('returns an error with incorrect arguments', function() { expect(function() { promise = childClient.invoke({popover: [['hide']]}); }).to.throw(Error, "Invoke supports string arguments or an object with array of strings."); }); it('returns an error with incorrect arguments', function() { expect(function() { promise = childClient.invoke(['popover', ['hide']]); }).to.throw(Error, "Invoke supports string arguments or an object with array of strings."); }); }); describe('when a message is received for a child client', function() { var message, handler; beforeEach(function() { handler = sandbox.stub(); message = { awesome: true }; childClient.on('hello', handler); }); it("passes the message to the client", function() { triggerEvent(childClient, 'hello', message); expect(handler).to.have.been.calledWithExactly(message); }); }); }); }); }); });