TL;DR:
A lightweight tool for choreographing browser and Node.js catastrophes.
Panic is designed to test the fault-tolerance of distributed systems. It allows you to dynamically group clients and concurrently control them with Javascript, and is compatible with the test frameworks you already use. Think of it as Selenium WebDriver on steroids.
For example:
// import the runner
var panic = require('panic-server')
// Start the server.
// This is what you'll connect
// to from a client.
panic.server().listen(3000)
// The list of all connected clients,
// updating in real-time.
var clients = panic.clients
clients.run(function () {
console.log('This code runs on every client');
}).then(function () {
// this runs when all clients have finished.
}).catch(function () {
// this runs if a client throws an error.
})
The .run command sends a function to be evaluated on every connected client at that point in time. To add a client, you'll need to import the code and point it to the server.
<!--
The port number and hostname are
configured using `panic.server()`
-->
<script src="http://localhost:3000/panic.js"></script>
<!--
As soon as it finishes loading,
`panic` will be a global variable.
-->
<script>
// this attempts to connect to your panic server
panic.server('http://localhost:3000')
</script>
Server
The panic-client code can be downloaded through npm. To install it, run this in your terminal:
npm install panic-client
Now you can use it in Node.js:
// imports the client code
var panic = require('panic-client')
// connects to your panic server
panic.server('http://localhost:3000')
Now that the clients are connected, you can run any code you want on them from the panic-server. Obviously you won't want to do this when, no, hang on, lemme make this bigger...
WARNING:
eval()is used. Including this library in user-facing code may open serious XSS vulnerabilities. This library is targeted towards testing frameworks, and should not be used in production code unless you really really know what you're doing.
Think of it as a control center for your code. You can pick out a group of clients, and run arbitrary code on them. Now that all that is out of the way, let's get to the API...
API
Panic-server consists of two parts:
- the server
- the clients
Most of what panic's functionality comes from it's client interface, leaving the server as the simpler of the two. We'll start there.
panic.server([http.Server])
If an http.Server is passed, panic will use it to configure socket.io and the /panic.js route will be added that servers up the panic-client browser code.
If no server is passed, a new one will be created.
If you're not familiar with Node.js' http module, that's okay. The quickest way to get up and running is to call .listen(8080) which listens for requests on port 8080. In a browser, the url will look something like this: http://localhost:8080/panic.js.
Create a new server
var panic = require('panic-server')
// create a new http server instance
var server = panic.server()
// listen for requests on port 8080
server.listen(8080)
Reuse an existing one
var panic = require('panic-server')
// create a new http server
var server = require('http').createServer()
server.on('request', doThings)
server.on('request', doOtherThings)
// pass it to panic
panic.server(server)
// start listening on a port
server.listen(8080)
If you want to listen on port 80 (the default for browsers), you may need to run node as
sudo.
Once you have a server listening, point browsers/servers to your address (here's how).
Note: if you're using PhantomJS, you'll need to serve the html page over http/s for socket.io to work.
panic.clients
Every group is a ClientList instance, and inherits from EventEmitter. They update in real-time as clients are added and disconnected, and have RxJS-style methods for manipulating and filtering. panic.clients is the root level list, and contains every client currently connected.
panic.client
Returns the panic-client bundle code. This is useful for injection into a WebDriver instance (using driver.executeScript) without needing to do file system calls. The property is immutable and
Events
As the list changes, it will emit one of two mutation events:
add: a new client is added to the listremove: a client is removed from the list
Both events pass the client and it's id.
Examples
// listen for new clients
panic.clients.on('add', function (client, id) {
// a new client is added
})
// listen for removed clients
panic.clients.on('remove', function (client, id) {
// a client has been removed
})
ClientList
The list constructor is exposed as panic.ClientList, and is useful when composing large groups from smaller ones. For example, you might have a list of both Internet Explorer and Opera Mini clients that you want to join into a new list. To create a group containing both, you'd either write a complex filter, or you can make a new list that simply combines them. Here's what that looks like:
// Grab the List constructor
var List = panic.ClientList;
var clients = panic.clients;
// Get the list of IE browsers
var IE = clients.filter('Internet Explorer');
// Get the list of Opera browsers
var opera = clients.filter('Opera Mini');
// create a new list that represents both
var pickyBrowsers = new List([ IE, opera ]);
If no array is given, an empty list is returned.
Methods
Every client inside a list is an object with two properties,
platformandsocket. The platform (via platform.js) is sent as part of the client handshake, while the socket is a websocket interface provided bysocket.io.
// each client has this structure
var client = {
// the websocket is a socket.io interface
socket: WebSocket,
platform: { /* platform.js */ }
}
Table of Contents
.filter(query)
Returns a filtered list containing everything that matches a platform query.
Platform data is generated by platform.js.
When passed a String or RegExp, it'll be used to match against the platform.name. For example, clients.filter('Firefox') will return a dynamic list of all firefox clients, as will clients.filter(/Firefox/). A more complex query can be formed by passing an object containing more platform descriptors.
var list = clients.filter({
layout: /(Gecko|Blink)/,
os: {
architecture: 64,
family: 'OS X'
}
})
Every setting above is optional, and you can create as loose or specific a query as you need. If you need a more complex query than that, you can also pass a filtering callback, which functions much like Array.prototype.filter.
var firefox = clients.filter(function (client, id, list) {
// `id`: The unique client id
// `list`: The parent list object, in this case `clients`
var platform = client.platform;
/*
This query only adds versions of
Firefox later than version 36.
*/
if (platform.name === 'Firefox' && platform.version > 36) {
// add this client to the new list
return true;
} else {
// leave the client out of the new list
return false;
}
});
To make things cooler, you can chain filters off one another. For example, the above query only allows versions of firefox after 36. You could write that as two separate queries...
// the list of all firefox clients
var firefox = clients.filter('Firefox')
// the list of firefox newer after version 36
var firefoxAfter36 = firefox.filter(function (client) {
return client.platform.version > 36
});
As new clients are added, they'll be run through the firefox filters, and if added, will be run through the version filter. The dynamic filtering process allows for some cool RxJS style code.
.excluding(ClientList)
You can also create lists that exclude other lists, like a list of browsers might be anything that isn't a server, or perhaps you want to exclude all Chrome browsers from a list. You can do that with .excluding.
// create a dynamic list of all node.js clients
var servers = clients.filter('Node.js')
// the list of all clients,
// except anything that belongs to `servers`.
var browsers = clients.excluding(servers)
Like filter, you can chain queries off each other to create really powerful queries.
// using `browsers` from above
var chrome = browsers.filter('Chrome')
var notChrome = browsers.excluding(chrome)
.pluck(Number)
.pluck restricts the list length to a number, reactively listening for changes to ensure it's as close to the maximum as it can be. An excellent use case for .pluck is singling out clients of the same platform. This becomes especially powerful when paired with .excluding and the ClientList constructor. For example, if you want to control 3 clients individually, it might look like this:
var clients = panic.clients
var List = panic.ClientList
// grab one client from the list
var alice = clients.pluck(1)
// grab another, so long as it isn't alice
var bob = clients
.excluding(alice)
.pluck(1)
// and another, so long as it isn't alice or bob
var carl = clients
.excluding(
new List([ alice, bob ])
)
.pluck(1)
.pluckis highly reactive, and will readjust itself to hold as many clients as possible.
.atLeast(Number)
Oftentimes, you need a certain number of clients before running any tests. .atLeast takes that minimum number, and returns a promise.
That promise resolves when the minimum has been reached.
Here's an example:
var clients = panic.clients
// Waits for 2 clients before resolving.
var minimum = clients.atLeast(2)
minimum.then(function () {
// 2 clients are connected now.
return clients.run(/* ... */)
})
It can also be used on derived lists, like so:
var node = clients.filter('Node.js')
node.atLeast(3).then(/* ... */)
Pro tip:
.atLeastgoes great with mocha'sbeforefunction.
.run(Function[, Object])
.run is where the magic happens. This method allows you to evaluate a function on all platforms belonging to this list, and reject or resolve a promise when either everyone finishes or one fails. Asynchronous code is supported.
.run takes one argument: the function to evaluate. It can be weird to think about, and may trip you up a couple times. The function will not be run on panic-server. It is run on the client, it does not have your local scope, and may not have your platform tools (like CommonJS, window variables, npm modules, ES2015 compatibility, etc). You are potentially evaluating on an entirely different machine. Code responsibly 😉
That said, here's an example:
clients.run(function () {
// this code is evaluated on every platform
})
clients.filter('Node.js').run(function () {
var http = require('http')
// evaluating live on every server
});
The function passed is first stringified, then sent to the clients for evaluation. When it's invoked on the client, it's given a special this context and some control parameters to work with async data.
The function is passed one parameter: a done callback. If takes a parameter, it's assumed that the code is asynchronous and won't report a done event until the callback is invoked or an error is thrown.
After sending off your function, .run returns a promise. When every client has finished running the code, the promise fulfills. If you're not familiar with promises, I recommend reading the MDN page. They're an invaluable tool and native support is well on it's way. They're also incredibly useful when paired with .run.
For example, you could have a list of 100 browsers, and perhaps you want each of them to load a script before running any other code. First, you'd send the code to load the script, call done when you're finished. Once all the browsers report done, the promise resolves and you can run more code. Here's what that looks like:
var servers = clients.filter('Node.js')
var browsers = clients.excluding(servers)
function loadExpectJS(done) {
// create a script element
var script = document.createElement('script')
// set the source to expect.js
script.src = 'https://cdn.rawgit.com/Automattic/expect.js/master/index.js'
script.onload = done;
script.onerror = this.fail;
}
browsers.run(loadExpectJS)
.then(function () {
// all browsers successfully loaded expect.js
})
.catch(function (error) {
// one or more browsers failed to load expect.js
})
Fun fact: by returning a new promise in a
.thencallback, everything chained after refers to that new promise. This means you can create really cool chains, like "load expect.js, then run a function, then refresh all browsers", all executed synchronously. Here's an example:
// using the function defined above,
// load expect.js
browsers.run(loadExpectJS)
.then(function () {
// once they've all loaded the file...
return browsers.run(function () {
// run this assertion code
expect(true).to.eq(true)
})
})
.then(function () {
// once everyone's ran the assertion code,
return browsers.run(function () {
// refresh every browser.
location.reload()
})
})
async/await
If you're using Babel.js, promises become much more succinct using the ES7/2016 async/await controls, or alternatively asyncawait on npm, or the co module. I recommend checking them out, as it greatly improves the code readability.
Babel.js/ES2016
// define an async function
async function runCodeStuff () {
// pause for all browsers to finish 1st chunk
await browsers.run(function () {
// 1st code chunk
})
// pause for 2nd chunk
await browsers.run(function () {
// 1st chunk finished,
// 2nd code chunk running
})
console.log('Both chunks finished!')
}
co js
co(function * () {
yield browsers.run(function () {
// first code chunk
})
yield browsers.run(function () {
// second code chunk
})
console.log('Both code chunks finished!')
})
However, that none of them are necessary to use panic-server.
.run scope controls
This section is gonna be a little tricky, hold on...
.run allows you to send local variables to the clients and continue using them as locals. If you're familiar with Javascript's with statement, it's basically a fancy with, but don't worry, it's opt in. Let's show a simpler case first...
By passing an object as the second parameter, you'll be able to use them on the client by accessing this.data.
clients.run(function (client) {
// using `this.data`
console.log(this.data.localVariable) // 'visible'
// the `client` param is the same as `this`
console.log(client.data.numbers) // array
}, {
localVariable: 'visible',
numbers: [1, 2, 3, 5, 8]
})
If you set '@scope' to true on your object, it'll export those variables into the local scope of your callback.
clients.run(function () {
console.log(localVariable) // 'visible'
console.log(numbers) // array
}, {
localVariable: 'visible',
numbers: [1, 2, 3, 5, 8],
'@scope': true
})
In this way, you can share variables on the server with the clients without duplicating code. This is also useful for injecting variables into a mixin, like loadScript:
function loadScript(done) {
var script = document.createElement('script');
// use the exported `src` variable
script.src = src;
script.onload = done;
document.body.appendChild(script);
}
// expose the `src` variable
clients.run(loadScript, {
src: 'http://...',
'@scope': true
})
Some Javascript linters may complain about using undefined variables, in which case you can either turn off the linter rule, or use this.data. If you're planning on maintaining the code long-term, I'd recommend using this.data.
clients.run(function () {
typeof src; // 'undefined'
this.data.src; // 'http://...'
}, {
src: 'http://...'
})
.length
A getter property which returns the number of clients in a list.
Low-level API
.get(id)
Returns the client corresponding to the id. Presently, socket.io's socket.id is used to uniquely key clients.
.add(client)
Manually adds a client to the list. This is low-level enough that you should never need it. Clients have two properties, the platform and their socket, and are further explained here.
.remove(client)
Removes a client from the list, emitting a remove event with the client object. This API is low-level enough that you shouldn't need to use it.
.each(Function)
Iterate over a collection of clients. This method accepts a callback to be invoked for each item in the collection, and is passed three arguments:
- client
- id
- list
The client is defined here, the id is the unique name that identifies the client, and the list is the ClientList that .each was called on.
Example
clients.each(function (client, id, list) {
client; // { platform: Object, socket: Object }
typeof id; // 'string'
list === clients; // true
})
.chain([...lists])
This is an abstraction method that just calls this.constructor to create a new instance. Mainly used to allow subclassing, it makes sure the right class context is kept even when chaining off methods that create new lists, like .filter and .pluck.
var list = new ClientList()
list.chain() instanceof ClientList // true
class SubClass extends ClientList {
coolNewMethod() { /* bacon */ }
}
var sub = new SubClass()
sub.chain() instanceof SubClass // true
sub.chain() instanceof ClientList // true
sub.chain().coolNewMethod() // properly inherits
If you're making an extension that creates a new list instance, use this method to play nice with other extensions.
Roadmap
The goal is to keep panic light-weight and modular. Future releases will likely be aimed at improving the plugin system and fixing any egregious bugs or compatibility issues. That said, there are some features we really want first...
-
Allow clients to send back non-error data (through either the
donecallback or a continuous data stream) to enable ssh-style apps. -
Catch and report asynchronously thrown errors on...
-
Node.js: feasible by listening for UncaughtException on
global.process. -
Browsers: sounds easy in practice, but the browser is a place filled with pain and misery that makes that a really hard thing. Please tell me if you've got a better idea than
window.onerror🙏
-
-
Implement an underlying
Clientinterface that the ClientList builds on. The idea is to separate concerns and abstract the transport layer, UIDs, and job runners.
Support
If you have questions or ideas, we'd love to hear them! Just swing by our gitter channel and ask for @PsychoLlama or @amark. We're usually around 😉
Built with ❤️ by the team at gunDB.
