diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..831ff34 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,221 @@ +--- + env: + browser: true + node: false + + globals: + global: false + module: false + require: false + define: false + exports: false + console: false + debugger: false + # 0 disabled, 1 warning, 2 error + rules: + # possible errors + no-comma-dangle: 2 + no-cond-assign: + - 2 + - "always" + no-console: 1 + no-constant-condition: 2 + no-control-regex: 2 + no-debugger: 1 + no-dupe-keys: 2 + no-empty: 2 + no-empty-class: 2 + no-ex-assign: 2 + no-extra-boolean-cast: 2 + no-extra-parens: 2 + no-extra-semi: 2 + no-func-assign: 2 + no-inner-declarations: 2 + no-invalid-regexp: 2 + no-irregular-whitespace: 2 + no-negated-in-lhs: 2 + no-obj-calls: 2 + no-regex-spaces: 2 + no-reserved-keys: 2 + no-sparse-arrays: 2 + no-unreachable: 2 + use-isnan: 2 + valid-jsdoc: 2 + valid-typeof: 2 + + # best practices + block-scoped-var: 2 + complexity: 0 + consistent-return: 2 + curly: + - 2 + - "all" + default-case: 2 + dot-notation: 2 + eqeqeq: + - 2 + - "allow-null" + guard-for-in: 2 + no-alert: 1 + no-caller: 2 + no-div-regex: 0 + no-else-return: 2 + no-empty-label: 2 + no-eq-null: 0 + no-eval: 2 + no-native-reassign: 2 + no-extra-bind: 2 + no-fallthrough: 2 + no-floating-decimal: 2 + no-implied-eval: 2 + no-iterator: 2 + no-labels: 2 + no-lone-blocks: 2 + no-loop-func: 2 + no-multi-spaces: 2 + no-multi-str: 2 + no-native-reassign: 2 + no-new: 2 + no-new-func: 2 + no-new-wrappers: 2 + no-octal: 2 + no-octal-escape: 2 + no-process-env: 0 + no-proto: 2 + no-redeclare: 2 + no-return-assign: 2 + no-script-url: 2 + no-self-compare: 2 + no-sequences: 2 + no-throw-literal: 2 + no-unused-expressions: 2 + no-void: 2 + no-warning-comments: + - 1 + - terms: + - "todo" + - "fixme" + location: "anywhere" + no-with: 2 + radix: 2 + vars-on-top: 2 + wrap-iife: + - 2 + - "inside" + yoda: + - 2 + - "never" + + # strict mode + global-strict: + - 2 + - "always" + no-extra-strict: 2 + strict: 2 + + # variables + no-catch-shadow: 2 + no-delete-var: 2 + no-label-var: 2 + no-shadow: 2 + no-shadow-restricted-names: 2 + no-undef: 2 + no-undef-init: 2 + no-undefined: 2 + no-unused-vars: 2 + no-use-before-define: + - 2 + - "nofunc" + + # node.js + + # stylistic stuff + indent: + - 2 + - 2 + brace-style: + - 2 + - "1tbs" + - allowSingleLine: true + camelcase: 2 + comma-spacing: + - 2 + - before: false + after: true + comma-style: + - 2 + - "last" + consistent-this: + - 2 + - "self" + eol-last: 2 + func-names: 0 + func-style: + - 2 + - "declaration" + key-spacing: + - 2 + - align: "value" + beforeColon: false + afterColon: true + max-nested-callbacks: 0 + new-cap: + - 2 + - newIsCap: true + capIsNew: true + new-parens: 2 + no-array-constructor: 2 + no-inline-comments: 0 + no-lonely-if: 2 + no-mixed-spaces-and-tabs: 2 + no-multiple-empty-lines: + - 2 + - max: 1 + no-nested-ternary: 2 + no-new-object: 2 + no-space-before-semi: 2 + no-spaced-func: 2 + no-ternary: 0 + no-trailing-spaces: 2 + no-underscore-dangle: 0 + no-wrap-func: 2 + one-var: 0 + operator-assignment: + - 2 + - "always" + padded-blocks: + - 2 + - "never" + quote-props: + - 2 + - "as-needed" + quotes: + - 2 + - "single" + - "avoid-escape" + semi: + - 2 + - "always" + sort-vars: 0 + space-after-keywords: + - 2 + - "always" + - checkFunctionKeyword: true + space-before-blocks: + - 2 + - "always" + space-in-brackets: 0 + space-in-parens: + - 2 + - "never" + space-infix-ops: 2 + space-return-throw-case: 2 + space-unary-ops: + - 2 + - words: true + nonwords: false + spaced-line-comment: + - 2 + - "always" + wrap-regex: 0 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d023ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +spec/functional/public/js/ +npm-debug.log +phantomjsdriver.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..87a1cf5 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v0.12.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3548510 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +How to contribute +================= + +We welcome pull requests, issue submissions, and feature requests. Before contributing, please read these guidelines. + +## Requirements + +This library strives to be compatible with __IE8+__, so please be aware of any ECMAScript5 usages that do not operate in all browsers. If you are contributing code features or changes, we will expect tests to be in your submission. If there are no tests but the submission is a small change we may allow it. + +## Contributing documentation + +Feel free to edit `README.md` and submit it as a pull request either through GitHub's interface or through submitting a PR from your own fork. + +## Contributing code + +### Getting started + +We use `nvm` for managing our node versions, but you do not have to. Replace any `nvm` references with the tool of your choice below. + +``` +nvm install +npm install +``` + +### Testing + +This library uses a combination of unit-style testing and functional/smoke style testing. The unit tests use [Mocha](http://mochajs.org) while the functional tests use a combination of [Selenium](http://docs.seleniumhq.org/), Mocha and [wd-sync](https://github.com/sebv/node-wd-sync). All testing dependencies will be installed upon `npm install` and the test suite executed with `npm test`. + +``` +npm test +``` + +### Servers for manual testing + +The test suite automatically starts the servers for functional testing, but if you like to have the servers stood up for your own manual testing, you can do so: + +``` +npm start +``` +The default ports are `3099` and `4567` and can be set via `PORT` and `PORT2` environment variables, respectively. + +``` +env PORT=8000 PORT2=8001 npm start +``` + +The app server and templates default the host to `localhost`. This can be changed with the `HOST` environment variable. + +``` +env HOST=example.com npm start +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..814c1b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2009-2015 Braintree, a division of PayPal, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f61bfbe --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +Framebus +======== + +Framebus allows you to easily send messages across frames (and iframes) with a simple bus. + +In one frame: + +```js +var bus = require('framebus'); + +bus.emit('message', { + from: 'Ron', + contents: 'they named it...San Diago' +}); +``` + +In another frame: + +```js +var bus = require('framebus'); + +bus.on('message', function (data) { + console.log(data.from + ' said: ' + data.contents); +}); +``` + +## API + +#### `publish('event', (data or callback) [, toOrigin]): boolean` +__aliases__: `pub`, `trigger`, `emit` + +__returns__: `true` if the event was successfully published, `false` otherwise + +| Argument | Type | Description | +| ---------------- | -------- | ---------------------------------------------------- | +| `event` | String | The name of the event | +| `data` | any | The data to give to subscribers | +| `callback(data)` | Function | Give subscribers a function for easy, direct replies | +| `toOrigin` | String | (default: `'*'`) only target frames with this origin | + +#### `subscribe('event', fn [, fromOrigin]): boolean` +__alises__: `sub`, `on` + +__returns__: `true` if the subscriber was successfully added, `false` otherwise + +| Argument | Type | Description | +| ------------------------------ | -------- | ----------------------------------------------------------- | +| `event` | String | The name of the event | +| `fn(data or callback, origin)` | Function | Event handler | +| `↳ data` | any | The data that was published with the event | +| `↳ callback(data)` | Function | A callback for sending data directly back to the emitter | +| `↳ origin` | String | The origin address that originated the event | +| `fromOrigin` | String | (default: `'*'`) only subscribe to events from this origin | + +#### `unsubscribe('event', fn [, fromOrigin]): boolean` +__aliases__: `unsub`, `off` + +__returns__: `true` if the subscriber was successfully removed, `false` otherwise + +| Argument | Type | Description | +| ------------ | -------- | ---------------------------------------------------- | +| `event` | String | The name of the event | +| `fn` | Function | The function that was subscribed | +| `fromOrigin` | String | The origin, if given during `subscribe` | + +## Pitfalls + +These are some things to keep in mind while using __framebus__ to handle your +event delegation + +### Cross-site scripting (XSS) + +__framebus__ allows convenient event delegation across iframe borders. By +default it will broadcast events to all iframes on the page, regardless of +origin. Use the optional `origin` parameter when you know the exact domain of +the iframes you are communicating with. This will protect your event data from +malicious domains. + +### Data is serialized as JSON + +__framebus__ operates over `postMessage` using `JSON.parse` and `JSON.stringify` +to facilitate message data passing. Keep in mind that not all JavaScript objects +serialize cleanly into and out of JSON, such as `undefined`. + +### Asynchronicity + +Even when the subscriber and publisher are within the same frame, events go +through `postMessage`. Keep in mind that `postMessage` is an asynchronous +protocol and that publication and subscription handling occur on separate +iterations of the [event +loop (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/EventLoop#Event_loop). + +### Published callback functions are an abstraction + +When you specify a `callback` while using `publish`, the function is not actually +given to the subscriber. The subscriber receives a one-time-use function that is +generated locally by the subscriber's __framebus__. This one-time-use callback function +is pre-configured to publish an event back to the event origin's domain using a +[UUID](http://tools.ietf.org/html/rfc4122) as the event name. The events occur +as follows: + +1. `http://emitter.example.com` publishes an event with a function as the event data + + ```javascript + var callback = function (data) { + console.log('Got back %s as a reply!', data) + } + + framebus.publish('Marco!', callback, 'http://listener.example.com'); + ``` + +1. The __framebus__ on `http://emitter.example.com` generates a UUID as an event name + and adds the `callback` as a subscriber to this event. +1. The __framebus__ on `http://listener.example.com` sees that a special callback + event is in the event payload. A one-time-use function is created locally and + given to subscribers of `'Marco!'` as the event data. +1. The subscriber on `http://listener.example.com` uses the local one-time-use + callback function to send data back to the emitter's origin + + ```javascript + framebus.on('Marco!', function (callback) { + callback('Polo!'); + }, 'http://emitter.example.com') + ``` + +1. The one-time-use function on `http://listener.example.com` publishes an event + as the UUID generated in __step 2__ to the origin that emitted the event. +1. Back on `http://emitter.example.com`, the `callback` is called and + unsubscribed from the special UUID event afterward. + +## Development and contributing + +See [__CONTRIBUTING.md__](CONTRIBUTING.md) diff --git a/bin/www b/bin/www new file mode 100755 index 0000000..4e5aaa3 --- /dev/null +++ b/bin/www @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +'use strict'; + +var server = require('../spec/functional/server'); +server.start(function () { console.log('') }, true); diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..e832dad --- /dev/null +++ b/bower.json @@ -0,0 +1,30 @@ +{ + "name": "framebus", + "description": "Framebus allows you to easily send messages across frames (and iframes) with a simple bus.", + "version": "1.0.0", + "homepage": "https://github.com/braintree/framebus", + "authors": [ + "braintree " + ], + "moduleType": [ + "amd", + "globals", + "node" + ], + "keywords": [ + "postMessage", + "payments", + "braintree", + "event", + "bus", + "iframes" + ], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ] +} diff --git a/dist/framebus.js b/dist/framebus.js new file mode 100644 index 0000000..a37cbf5 --- /dev/null +++ b/dist/framebus.js @@ -0,0 +1,189 @@ +'use strict'; +(function (root, factory) { + if (typeof exports === 'object' && typeof module !== 'undefined') { + module.exports = factory(); + } else if (typeof define === 'function' && define.amd) { + define([], factory); + } else { + root.framebus = factory(); + } +})(this, function () { + var win; + var subscribers = {}; + + function publish(event, data, origin) { + var payload; + origin = origin || '*'; + if (typeof event !== 'string') { return false; } + if (typeof origin !== 'string') { return false; } + + payload = _packagePayload(event, data, origin); + if (payload === false) { return false; } + + _broadcast(win.top, payload, origin); + + return true; + } + + function subscribe(event, fn, origin) { + origin = origin || '*'; + if (_subscriptionArgsInvalid(event, fn, origin)) { return false; } + + subscribers[origin] = subscribers[origin] || {}; + subscribers[origin][event] = subscribers[origin][event] || []; + subscribers[origin][event].push(fn); + + return true; + } + + function unsubscribe(event, fn, origin) { + var i, subscriberList; + origin = origin || '*'; + + if (_subscriptionArgsInvalid(event, fn, origin)) { return false; } + + subscriberList = subscribers[origin] && subscribers[origin][event]; + if (!subscriberList) { return false; } + + for (i = 0; i < subscriberList.length; i++) { + if (subscriberList[i] === fn) { + subscriberList.splice(i, 1); + return true; + } + } + + return false; + } + + function _packagePayload(event, data, origin) { + var packaged = false; + var payload = { event: event }; + + if (typeof data === 'function') { + payload.reply = _subscribeReplier(data, origin); + } else { + payload.data = data; + } + + try { + packaged = JSON.stringify(payload); + } catch (e) { + throw new Error('Could not stringify event: ' + e.message); + } + return packaged; + } + + function _unpackPayload(e) { + var payload, replyOrigin, replySource; + + try { + payload = JSON.parse(e.data); + } catch (err) { + return false; + } + + if (payload.event == null) { return false; } + + if (payload.reply != null) { + replyOrigin = e.origin; + replySource = e.source; + + payload.data = function reply(data) { + var replyPayload = _packagePayload(payload.reply, data, replyOrigin); + if (replyPayload === false) { return false; } + + replySource.postMessage(replyPayload, replyOrigin); + }; + } + + return payload; + } + + function _attach(w) { + if (win) { return; } + win = w; + + if (win.addEventListener) { + win.addEventListener('message', _onmessage, false); + } else if (win.attachEvent) { + win.attachEvent('onmessage', _onmessage); + } else if (win.onmessage === null) { + win.onmessage = _onmessage; + } else { + win = null; + } + } + + function _uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0; + var v = c === 'x' ? r : r & 0x3 | 0x8; + return v.toString(16); + }); + } + + function _onmessage(e) { + var payload; + if (typeof e.data !== 'string') { return; } + + payload = _unpackPayload(e); + if (!payload) { return; } + + _dispatch('*', payload.event, payload.data, e.origin); + _dispatch(e.origin, payload.event, payload.data, e.origin); + } + + function _dispatch(origin, event, data, eventOrigin) { + var i; + if (!subscribers[origin]) { return; } + if (!subscribers[origin][event]) { return; } + + for (i = 0; i < subscribers[origin][event].length; i++) { + subscribers[origin][event][i](data, eventOrigin); + } + } + + function _broadcast(frame, payload, origin) { + var i; + frame.postMessage(payload, origin); + + for (i = 0; i < frame.frames.length; i++) { + _broadcast(frame.frames[i], payload, origin); + } + } + + function _subscribeReplier(fn, origin) { + var uuid = _uuid(); + + function replier(d, o) { + fn(d, o); + unsubscribe(uuid, replier, origin); + } + + subscribe(uuid, replier, origin); + return uuid; + } + + function _subscriptionArgsInvalid(event, fn, origin) { + if (typeof event !== 'string') { return true; } + if (typeof fn !== 'function') { return true; } + if (typeof origin !== 'string') { return true; } + + return false; + } + + _attach(window); + + return { + publish: publish, + pub: publish, + trigger: publish, + emit: publish, + subscribe: subscribe, + sub: subscribe, + on: subscribe, + unsubscribe: unsubscribe, + unsub: unsubscribe, + off: unsubscribe + }; +}); diff --git a/dist/framebus.min.js b/dist/framebus.min.js new file mode 100644 index 0000000..f83c794 --- /dev/null +++ b/dist/framebus.min.js @@ -0,0 +1 @@ +"use strict";!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):n.framebus=t()}(this,function(){function n(n,t,e){var o;return e=e||"*","string"!=typeof n?!1:"string"!=typeof e?!1:(o=r(n,t,e),o===!1?!1:(s(g.top,o,e),!0))}function t(n,t,e){return e=e||"*",c(n,t,e)?!1:(p[e]=p[e]||{},p[e][n]=p[e][n]||[],p[e][n].push(t),!0)}function e(n,t,e){var r,o;if(e=e||"*",c(n,t,e))return!1;if(o=p[e]&&p[e][n],!o)return!1;for(r=0;r", + "devDependencies": { + "async": "^0.9.0", + "chai": "^1.9.1", + "del": "^1.1.0", + "ejs": "^1.0.0", + "express": "^4.10.4", + "gulp": "^3.8.0", + "gulp-concat": "^2.4.2", + "gulp-eslint": "^0.5.0", + "gulp-mocha": "^0.4.1", + "gulp-remove-code": "^1.0.1", + "gulp-rename": "^1.2.0", + "gulp-size": "^1.1.0", + "gulp-streamify": "0.0.5", + "gulp-uglify": "^0.3.0", + "morgan": "^1.5.0", + "node-static": "^0.7.6", + "phantomjs": "1.9.11", + "selenium-standalone": "2.43.1-5", + "sinon": "^1.10.2", + "sinon-chai": "^2.5.0", + "vinyl-source-stream": "^1.0.0", + "wd-sync": "^1.2.5" + } +} diff --git a/spec/functional/domain-restriction-spec.js b/spec/functional/domain-restriction-spec.js new file mode 100644 index 0000000..fa04820 --- /dev/null +++ b/spec/functional/domain-restriction-spec.js @@ -0,0 +1,57 @@ +'use strict'; + +var wdSync = require('wd-sync'); + +describe('Domain Restrictions', function () { + var browser; + var wrap = wdSync.wrap({ + with: function () { return browser }, + pre: function () { this.timeout(30000); } + }); + + before(function (done) { + var client = wdSync.remote(); + browser = client.browser; + done(); + }); + + it('should only publish to targeted domains', wrap(function () { + browser.init({ browserName: 'phantomjs' }); + browser.get('http://localhost:3099'); // pull out, variablize + + var rootWindowName = browser.windowName(); + + browser.frame('frame3'); + browser.elementById('polo-text').type('polo'); + browser.window(rootWindowName); + browser.frame('frame1'); + browser.frame('frame1-inner'); + browser.elementById('marco-button').click(); + + browser.waitForElementByTagName('p', function (el) { + return el.innerHTML === 'polo'; + }, 1000); + + browser.window(rootWindowName); + var indexReceived = browser.elementByTagNameIfExists('p'); + + browser.window(rootWindowName); + browser.frame('frame1'); + var frame1Received = browser.elementByTagNameIfExists('p'); + + browser.window(rootWindowName); + browser.frame('frame2'); + var frame2Received = browser.elementByTagNameIfExists('p'); + + browser.window(rootWindowName); + browser.frame('frame3'); + var frame3Received = browser.elementByTagNameIfExists('p'); + + browser.quit(); + + expect(indexReceived).to.be.undefined; + expect(frame1Received).to.be.undefined; + expect(frame2Received).to.be.undefined; + expect(frame3Received).to.be.undefined; + })); +}); diff --git a/spec/functional/global.js b/spec/functional/global.js new file mode 100644 index 0000000..3ba8c67 --- /dev/null +++ b/spec/functional/global.js @@ -0,0 +1,51 @@ +'use strict'; + +var seleniumServer, appServers; +var appServer = require('./server'); + +global.sinon = require('sinon'); +global.chai = require('chai'); +global.expect = global.chai.expect; +global.assert = global.chai.assert; +global.should = global.chai.should(); +global.chai.use(require('sinon-chai')); + +before(function () { + this.sandbox = sinon.sandbox.create(); +}); + +before(function (done) { + this.timeout(30000); + var selenium = require('selenium-standalone'); + var spawnOptions = { stdio: 'pipe' }; + var seleniumArgs = ['-debug']; + seleniumServer = selenium(spawnOptions, seleniumArgs); + + console.log('Waiting for selenium to start...'); + seleniumServer.stderr.on('data', function (data) { + if (data.toString().indexOf('Started org.openqa.jetty.jetty.Server') !== -1) { + console.log('Selenium started!'); + done(); + } + }); +}); + +before(function (done) { + appServer.start(function () { + done(); + }); +}); + +after(function () { + this.sandbox.restore(); +}); + +after(function () { + seleniumServer.kill('SIGKILL'); +}); + +after(function (done) { + appServer.stop(function () { + done(); + }); +}); diff --git a/spec/functional/public/html/frame1-inner.html b/spec/functional/public/html/frame1-inner.html new file mode 100644 index 0000000..dbcefd5 --- /dev/null +++ b/spec/functional/public/html/frame1-inner.html @@ -0,0 +1,22 @@ + + + + + + + + +frame1-inner.html + + + + diff --git a/spec/functional/public/html/frame1.html b/spec/functional/public/html/frame1.html new file mode 100644 index 0000000..bcf3ce8 --- /dev/null +++ b/spec/functional/public/html/frame1.html @@ -0,0 +1,17 @@ + + + + + + + + frame1.html + + + + + diff --git a/spec/functional/public/html/frame2.html b/spec/functional/public/html/frame2.html new file mode 100644 index 0000000..0833a2a --- /dev/null +++ b/spec/functional/public/html/frame2.html @@ -0,0 +1,14 @@ + + + + + + + +frame2.html + + + + diff --git a/spec/functional/public/html/frame3.html b/spec/functional/public/html/frame3.html new file mode 100644 index 0000000..e4c7aa2 --- /dev/null +++ b/spec/functional/public/html/frame3.html @@ -0,0 +1,19 @@ + + + + + + + +frame3.html + + + + + diff --git a/spec/functional/public/index.html b/spec/functional/public/index.html new file mode 100644 index 0000000..8aa1bd5 --- /dev/null +++ b/spec/functional/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + index.html + + + + + + + + diff --git a/spec/functional/reply-spec.js b/spec/functional/reply-spec.js new file mode 100644 index 0000000..b18b400 --- /dev/null +++ b/spec/functional/reply-spec.js @@ -0,0 +1,57 @@ +'use strict'; + +var wdSync = require('wd-sync'); + +describe('Reply Events', function () { + var browser; + var wrap = wdSync.wrap({ + with: function () { return browser }, + pre: function () { this.timeout(30000); } + }); + + before(function (done) { + var client = wdSync.remote(); + browser = client.browser; + done(); + }); + + it('should only publish to targeted domains', wrap(function () { + browser.init({ browserName: 'phantomjs' }); + browser.get('http://localhost:3099'); // pull out, variablize + + var rootWindowName = browser.windowName(); + + browser.frame('frame3'); + browser.elementById('polo-text').type('polo'); + browser.window(rootWindowName); + browser.frame('frame1'); + browser.frame('frame1-inner'); + browser.elementById('marco-button').click(); + + browser.waitForElementByTagName('p', function (el) { + return el.innerHTML === 'polo'; + }, 1000); + + browser.window(rootWindowName); + var indexReceived = browser.elementByTagNameIfExists('p'); + + browser.window(rootWindowName); + browser.frame('frame1'); + var frame1Received = browser.elementByTagNameIfExists('p'); + + browser.window(rootWindowName); + browser.frame('frame2'); + var frame2Received = browser.elementByTagNameIfExists('p'); + + browser.window(rootWindowName); + browser.frame('frame3'); + var frame3Received = browser.elementByTagNameIfExists('p'); + + browser.quit(); + + expect(indexReceived).to.be.undefined; + expect(frame1Received).to.be.undefined; + expect(frame2Received).to.be.undefined; + expect(frame3Received).to.be.undefined; + })); +}); diff --git a/spec/functional/server.js b/spec/functional/server.js new file mode 100644 index 0000000..aba0b80 --- /dev/null +++ b/spec/functional/server.js @@ -0,0 +1,81 @@ +'use strict'; + +var servers; +var path = require('path'); +var express = require('express'); +var morgan = require('morgan'); +var async = require('async'); + +var app = express(); +var ports = [ + process.env['PORT'] || 3099, + process.env['PORT2'] || 4567 +]; +var domain = process.env['HOST'] || 'localhost'; +var model = { + port1: ports[0], + port2: ports[1], + domain: domain +}; + +app.set('views', path.join(__dirname, '/public')); +app.set('view engine', 'html'); +app.engine('html', require('ejs').renderFile); + +function _routeHandler(req, res) { + var _path = req.path; + var template = _path === '/' ? 'index' : _path.replace(/^\/(.*)\.html$/, '$1'); + + res.render(template, model); +} + +app.get('/', _routeHandler); +app.get('/*.html', _routeHandler); + +app.use(express['static'](__dirname + '/public')); + +function _noop() {} + +function start(cb, logRequests) { + cb = cb || _noop; + + var asyncTasks; + + if (logRequests) { + app.use(morgan('combined')); + } + + asyncTasks = ports.map(function (port) { + return function (done) { + var srv = app.listen(port, function () { + console.log('app running on', port); + done(null, srv); + }); + } + }); + + async.parallel(asyncTasks, function (err, apps) { + servers = apps; + cb(); + }); +} + +function stop(cb) { + cb = cb || _noop; + + async.parallel(servers.map(function (server) { + return function (done) { + server.close(function () { + console.log('server killed'); + done(); + }); + } + }), function () { + cb(); + }); +} + +module.exports = { + start: start, + stop: stop +}; diff --git a/spec/functional/spec-helpers.js b/spec/functional/spec-helpers.js new file mode 100644 index 0000000..5155080 --- /dev/null +++ b/spec/functional/spec-helpers.js @@ -0,0 +1,9 @@ +global.toParentFrame = function(browser, cb) { + browser._jsonWireCall( + { + method: 'POST', + relPath: '/frame/parent', + data: {}, + cb: cb + }) +} diff --git a/spec/unit/_attach.spec.js b/spec/unit/_attach.spec.js new file mode 100644 index 0000000..91899ab --- /dev/null +++ b/spec/unit/_attach.spec.js @@ -0,0 +1,47 @@ +'use strict'; + +describe('_attach', function () { + beforeEach(function () { + this.bus._detach(); + }); + it('should add listener to scope', function () { + this.bus._attach(this.scope); + + expect(this.scope.addEventListener) + .to.have.been.calledWith('message', this.bus._onmessage, false); + }); + + it('should only add listener to scope once', function () { + this.bus._attach(this.scope); + this.bus._attach(this.scope); + + expect(this.scope.addEventListener.callCount).to.equal(1); + }); + + it('should use attachEvent if addEventListener not available', function () { + delete this.scope.addEventListener; + this.scope.attachEvent = this.sandbox.spy(); + + this.bus._attach(this.scope); + + expect(this.scope.attachEvent).to.have.been.calledWith('onmessage', this.bus._onmessage); + }); + + it('should assign to onmessage if event attachers are not available', function () { + delete this.scope.addEventListener; + this.scope.onmessage = null; + + this.bus._attach(this.scope); + + expect(this.scope.onmessage).to.equal(this.bus._onmessage); + }); + + it('should not attach if no event adders or onmessage present', function () { + delete this.scope.addEventListener; + + this.bus._attach(this.scope); + + expect(this.scope.onmessage).to.be.undefined; + expect(this.bus._win()).not.to.exist; + }); +}); diff --git a/spec/unit/_broadcast.spec.js b/spec/unit/_broadcast.spec.js new file mode 100644 index 0000000..b696235 --- /dev/null +++ b/spec/unit/_broadcast.spec.js @@ -0,0 +1,19 @@ +'use strict'; + +describe('_broadcast', function () { + it('should postMessage to current frame', function () { + var frame = mkFrame(this); + this.bus._broadcast(frame, 'payload', '*'); + + expect(frame.postMessage).to.have.been.called; + }); + + it("should postMessage to current frame's child frames", function () { + var frame = mkFrame(this); + frame.frames.push(mkFrame(this)); + + this.bus._broadcast(frame, 'payload', '*'); + + expect(frame.frames[0].postMessage).to.have.been.called; + }); +}); diff --git a/spec/unit/_dispatch.spec.js b/spec/unit/_dispatch.spec.js new file mode 100644 index 0000000..8550846 --- /dev/null +++ b/spec/unit/_dispatch.spec.js @@ -0,0 +1,33 @@ +'use strict'; + +describe('_dispatch', function () { + it('should execute subscribers for the given event and origin', function () { + var subscriber = this.sandbox.spy(); + var origin = 'https://example.com'; + this.bus.subscribe('test event', subscriber, origin); + + this.bus._dispatch(origin, 'test event', 'data', origin) + + expect(subscriber).to.have.been.called; + }); + + it('should not execute subscribers for a different event', function () { + var subscriber = this.sandbox.spy(); + var origin = 'https://example.com'; + this.bus.subscribe('test event', subscriber, origin); + + this.bus._dispatch(origin, 'different event', 'data', null) + + expect(subscriber).not.to.have.been.called; + }); + + it('should not execute subscribers for a different domain', function () { + var subscriber = this.sandbox.spy(); + var origin = 'https://example.com'; + this.bus.subscribe('test event', subscriber, origin); + + this.bus._dispatch('https://domain.com', 'test event', 'data', null) + + expect(subscriber).not.to.have.been.called; + }); +}); diff --git a/spec/unit/_packagePayload.spec.js b/spec/unit/_packagePayload.spec.js new file mode 100644 index 0000000..479a979 --- /dev/null +++ b/spec/unit/_packagePayload.spec.js @@ -0,0 +1,52 @@ +'use strict'; + +describe('_packagePayload', function () { + beforeEach(function () { + this.args = ['event', {}, '*']; + }); + + it('should add event to payload', function () { + var expected = 'event name'; + this.args[0] = expected; + + var result = this.bus._packagePayload.apply(this.bus, this.args); + var actual = JSON.parse(result).event; + + expect(actual).to.equal(expected); + }); + + it('should add data to payload', function () { + var expected = { some: 'data' }; + this.args[1] = expected; + + var result = this.bus._packagePayload.apply(this.bus, this.args); + var actual = JSON.parse(result).data; + + expect(actual).to.deep.equal(expected); + }); + + it('should add reply to payload if data is function', function () { + this.args[1] = function () {}; + + var result = this.bus._packagePayload.apply(this.bus, this.args); + var actual = JSON.parse(result); + + expect(actual.reply).to.be.a('string'); + expect(actual.data).not.to.exist; + }); + + it('should throw error with prefix text when element cannot be stringified', function () { + var payload = {}; + Object.defineProperty(payload, 'prop', { + get: function () { throw new Error('Cross-origin denied'); }, + enumerable: true + }); + this.args[1] = payload; + + var fn = function () { + this.bus._packagePayload.apply(this.bus, this.args); + }.bind(this); + + expect(fn).to.throw('Could not stringify event: '); + }); +}); diff --git a/spec/unit/_subscribeReplier.spec.js b/spec/unit/_subscribeReplier.spec.js new file mode 100644 index 0000000..a2f5785 --- /dev/null +++ b/spec/unit/_subscribeReplier.spec.js @@ -0,0 +1,27 @@ +'use strict'; + +describe('_subscribeReplier', function () { + it('should return UUID of reply event', function () { + var actual = this.bus._subscribeReplier(function () {}, '*'); + + expect(actual).to.match(/^\w{8}-\w{4}-4\w{3}-\w{4}-\w{12}$/); + }); + + it('should subscribe function to returned event', function () { + var origin = 'http://example.com'; + var event = this.bus._subscribeReplier(function () {}, origin); + + expect(this.bus._getSubscribers()[origin][event][0]).to.be.a('function'); + }); + + it('should unsubscribe function when reply invoked', function () { + var origin = 'http://example.com'; + var event = this.bus._subscribeReplier(function () {}, origin); + + expect(this.bus._getSubscribers()[origin][event][0]).to.be.a('function'); + + this.bus._getSubscribers()[origin][event][0](); + + expect(this.bus._getSubscribers()[origin][event][0]).not.to.exist; + }); +}); diff --git a/spec/unit/_subscriptionArgsInvalid.spec.js b/spec/unit/_subscriptionArgsInvalid.spec.js new file mode 100644 index 0000000..984abbe --- /dev/null +++ b/spec/unit/_subscriptionArgsInvalid.spec.js @@ -0,0 +1,34 @@ +'use strict'; + +describe('_subscriptionArgsInvalid', function () { + beforeEach(function () { + this.args = ['event', function () {}, '*']; + }); + + it('should return false for valid types', function () { + var actual = this.bus._subscriptionArgsInvalid.apply(this.bus, this.args); + + expect(actual).to.be.false; + }); + + it('should return true if event is not string', function () { + this.args[0] = {}; + var actual = this.bus._subscriptionArgsInvalid.apply(this.bus, this.args); + + expect(actual).to.be.true; + }); + + it('should return true if fn is not function', function () { + this.args[1] = 'function'; + var actual = this.bus._subscriptionArgsInvalid.apply(this.bus, this.args); + + expect(actual).to.be.true; + }); + + it('should return true if origin is not string', function () { + this.args[2] = { event: 'object' }; + var actual = this.bus._subscriptionArgsInvalid.apply(this.bus, this.args); + + expect(actual).to.be.true; + }); +}); diff --git a/spec/unit/_unpackPayload.spec.js b/spec/unit/_unpackPayload.spec.js new file mode 100644 index 0000000..8fe3ed6 --- /dev/null +++ b/spec/unit/_unpackPayload.spec.js @@ -0,0 +1,37 @@ +'use strict'; + +describe('_unpackPayload', function () { + it('should return false if unparsable', function () { + var actual = this.bus._unpackPayload({ data: {} }); + + expect(actual).to.be.false; + }); + + it('should return false if event not defined', function () { + var actual = this.bus._unpackPayload({ data: JSON.stringify({}) }); + + expect(actual).to.be.false; + }); + + it('should return event and data in payload', function () { + var event = 'event name'; + var data = 'my string'; + var actual = this.bus._unpackPayload({ + data: JSON.stringify({event: event, data: data }) + }); + + expect(actual.event).to.equal(event); + expect(actual.data).to.equal(data); + }); + + it('should return event and data in payload', function () { + var event = 'event name'; + var reply = '123129085-4234-1231-99887877'; + var actual = this.bus._unpackPayload({ + data: JSON.stringify({event: event, reply: reply }) + }); + + expect(actual.event).to.equal(event); + expect(actual.data).to.be.a('function'); + }); +}); diff --git a/spec/unit/global.js b/spec/unit/global.js new file mode 100644 index 0000000..4feaaa4 --- /dev/null +++ b/spec/unit/global.js @@ -0,0 +1,27 @@ +'use strict'; + +var chai = require('chai'); +chai.use(require('sinon-chai')); + +global.sinon = require('sinon'); +global.expect = chai.expect; + +before(function () { + this.sandbox = sinon.sandbox.create(); + this.bus = require('../../lib/framebus'); +}); + +beforeEach(function () { + this.scope = { + addEventListener: this.sandbox.spy(), + removeEventListener: this.sandbox.spy() + }; +}); + +afterEach(function () { + this.bus._detach(); +}); + +after(function () { + this.sandbox.restore(); +}); diff --git a/spec/unit/helpers.js b/spec/unit/helpers.js new file mode 100644 index 0000000..2e7d929 --- /dev/null +++ b/spec/unit/helpers.js @@ -0,0 +1,20 @@ +'use strict'; + +global.mkWindow = function () { + return { + top: { + postMessage: function () {}, + frames: [] + }, + onmessage: null + }; +}; + +global.mkFrame = function (scope) { + return { + postMessage: scope.sandbox.spy(), + frames: [] + }; +}; + +global.window = mkWindow(); diff --git a/spec/unit/publish.spec.js b/spec/unit/publish.spec.js new file mode 100644 index 0000000..300a859 --- /dev/null +++ b/spec/unit/publish.spec.js @@ -0,0 +1,25 @@ +'use strict'; + +describe('publish', function () { + beforeEach(function () { + this.bus._attach(mkWindow()); + }); + + it('should return false if event is not a string', function () { + var actual = this.bus.publish({}, ""); + + expect(actual).to.be.false; + }); + + it('should return false if origin is not a string', function () { + var actual = this.bus.publish("event", "", { origin: "object"}); + + expect(actual).to.be.false; + }); + + it('should return true if origin and event are strings', function () { + var actual = this.bus.publish("event", "", "https://example.com"); + + expect(actual).to.be.true; + }); +}); diff --git a/spec/unit/subscribe.spec.js b/spec/unit/subscribe.spec.js new file mode 100644 index 0000000..cbaaf49 --- /dev/null +++ b/spec/unit/subscribe.spec.js @@ -0,0 +1,22 @@ +'use strict'; + +describe('subscribe', function () { + it('should add subscriber to given event and origin', function () { + var event = 'event name'; + var origin = 'https://example.com'; + var fn = function () {}; + + this.bus.subscribe(event, fn, origin); + + expect(this.bus._getSubscribers()[origin][event]).to.contain(fn); + }); + + it('should add subscriber to given event and * origin if origin not given', function () { + var event = 'event name'; + var fn = function () {}; + + this.bus.subscribe(event, fn); + + expect(this.bus._getSubscribers()['*'][event]).to.contain(fn); + }); +}); diff --git a/spec/unit/unsubscribe.spec.js b/spec/unit/unsubscribe.spec.js new file mode 100644 index 0000000..70e859f --- /dev/null +++ b/spec/unit/unsubscribe.spec.js @@ -0,0 +1,69 @@ +'use strict'; + +describe('unsubscribe', function () { + it('should remove subscriber given event and origin', function () { + var event = 'the event'; + var origin = 'https://example.com'; + var fn = function () {}; + var s = this.bus._getSubscribers(); + s[origin] = {}; + s[origin][event] = [function () {}, fn]; + + this.bus.unsubscribe(event, fn, origin); + + expect(s[origin][event]).not.to.contain(fn); + expect(s[origin][event].length).to.equal(1); + }); + + it('should correctly update the array', function () { + var event = 'the event'; + var origin = 'https://example.com'; + var fn = function () {}; + var s = this.bus._getSubscribers(); + s[origin] = {}; + s[origin][event] = [function () {}, fn]; + + this.bus.unsubscribe(event, fn, origin); + + expect(s[origin][event].length).to.equal(1); + }); + + it('should return true if removed', function () { + var event = 'the event'; + var origin = 'https://example.com'; + var fn = function () {}; + var s = this.bus._getSubscribers(); + s[origin] = {}; + s[origin][event] = [function () {}, fn]; + + var actual = this.bus.unsubscribe(event, fn, origin); + + expect(actual).to.be.true; + }); + + it('should return false if not removed for unknown event', function () { + var event = 'the event'; + var origin = 'https://example.com'; + var fn = function () {}; + var s = this.bus._getSubscribers(); + s[origin] = {}; + s[origin][event] = [function () {}, fn]; + + var actual = this.bus.unsubscribe('another event', fn, origin); + + expect(actual).to.be.false; + }); + + it('should return false if not removed for unknown origin', function () { + var event = 'the event'; + var origin = 'https://example.com'; + var fn = function () {}; + var s = this.bus._getSubscribers(); + s[origin] = {}; + s[origin][event] = [function () {}, fn]; + + var actual = this.bus.unsubscribe(event, fn, 'https://another.domain'); + + expect(actual).to.be.false; + }); +});