Automating and Testing Drupal with
Zmbie.js

From bite to believing

Infected by Travis Tidwell (travist)

http://github.com/travist | http://travistidwell.com | @softwaregnome | AllPlayers.com

Follow along @ http://travistidwell.com/drupal-zombie

Code @ https://github.com/travist/drupal-zombie

The Bite

What is Zombie.js?

  • Insanely fast, headless browser.
  • Written completely in JavaScript.
  • Powered by Node.js
  • Works on any website... not just Drupal.

The Bite

Why do I care?

  • Automate anything in the Drupal UI.
  • Automate Drupal sites you do not own.
  • Both an automation AND testing tool.
  • Headless == Continuous Integration friendly.
  • Full CLI support... Allows for user input.
  • Take advantage of any additional node.js library.
  • And let's face it... you're lazy.

And the lazy developers are bitten first...

You have been bitten.

dribble by Arran McKenna

Infection

Installation of Zombie.js is 'kind of' easy...

Infection

Step 1: Go to http://nodejs.org/ and download and install.

But... Zombie.js doesn't jive with v0.10 of node.js.

Infection

Step 2: Get the Node Version Manager (nvm).


   https://github.com/creationix/nvm
   curl https://raw.github.com/creationix/nvm/master/install.sh | sh
   mac:~ travist$ nvm install 0.8
            

Infection

Step 3: Install a C++ compiler.


   https://github.com/kennethreitz/osx-gcc-installer
            

Infection

Step 4: Setup your project.


   mac:~ travist$ mkdir drupal-zombie
   mac:drupal-zombie travist$ cd drupal-zombie
   mac:drupal-zombie travist$ nano package.json
   
      {
        "name": "drupal-zombie",
        "dependencies": {
          "zombie": "< 2"
        }
      }
   
            

Infection

Step 5: Use Node.js v0.8 and install dependencies.


   mac:drupal-zombie travist$ nvm use 0.8
   mac:drupal-zombie travist$ npm install
            

Walking

Create a new app.js file.


   mac:drupal-zombie travist$ nano app.js
   
    var Browser = require("zombie");
    var browser = new Browser();
    browser.visit("http://drupal.org/search", function () {
      browser.fill('#edit-keys', 'zombie', function() {
        browser.pressButton('#edit-submit', function() {
          console.log(browser.text('dt.title:eq(0)'));
        });
      });
    });
   
            

Asynchronous vs. Serial

Serial Execution

Waits for one operation to end before starting the next.


    function doSomething() {
      console.log('something');
    }

    function doSomethingElse() {
      console.log('something else');
    }

    doSomething();
    doSomethingElse();
            

    'something'
    'something else'
            

Serial Execution

It gets interesting when they depend on one another.


    function getSomething() {
      return 'something';
    }

    function saySomething(what) {
      console.log(what);
    }

    var something = getSomething();
    saySomething(something);
            

    'something'
            

Serial Execution

And more interesting when getSomething depends on an asynchronous source.


    function getSomething() {
      http.get('http://google.com', function(response) {
        return response; // ??????
      });
      // returns nothing... NULL
    }

    function saySomething(what) {
      console.log(what);
    }

    var something = getSomething(); // something = NULL
    saySomething(something);
            

    NULL
            

Asynchronous Execution


    function getSomething(callback) {
      http.get('http://google.com', function(response) {
        callback(response);
      });
    }

    function saySomething(what) {
      console.log(what);
    }

    
    getSomething(function(what) {
      saySomething(what);
    });
    
            

    'something'
            

Another look...


    var Browser = require("zombie");
    var browser = new Browser();
    browser.visit("http://drupal.org/search", function () {
      browser.fill('#edit-keys', 'zombie', function() {
        browser.pressButton('#edit-submit', function() {
          console.log(browser.text('dt.title:eq(0)'));
        });
      });
    });
            

But this indicates a different problem...


    var Browser = require("zombie");
    var browser = new Browser();
    browser.visit("http://drupal.org/search", function () {
      browser.fill('#edit-keys', 'zombie', function() {
        browser.pressButton('#edit-submit', function() {
          browser.visit("http://drupal.org/project/mediafront", function() {
            browser.clickLink('Read documentation', function() {
              browser.doSomething('something', function() {
                browser.doSomethingElse('somethingelse', function() {
                  browser.keepDoingStuff('stuff', function() {
                    browser.doMoreStuff('more stuff', function() {
                      browser.evenMoreStuff('oh man...', function() {
                        ...
                        ...
                      });
                    });
                  });
                });
              });
            });
          });
        });
      });
    });
            

It's like a zombie matryoshka nightmare.

somtimes called 'callback hell'.

Zombie.js 'tries' to fix this problem with 'promises'.


    // Fill in the form and submit.
    browser.
      fill("Your Name", "Arm Biter").
      fill("Profession", "Living dead").
      select("Born", "1968").
      uncheck("Send me the newsletter").
      pressButton("Sign me up", function() {

        // Make sure we got redirected to thank you page.
        assert.equal(browser.location.pathname, "/thankyou");

      });
            

But when you should and should NOT use the 'promise' varies...

And it breaks down when promises have dependencies on other promises.

And what about this use case?...

Try to iterate over a list of nodes (Views) and do something with them.


    var _ = require('underscore');
    browser.visit('http://drupal.org/forum', function() {
      var nodes = browser.queryAll('tbody tr');
      _.each(nodes, function(node) {
        var link = browser.query('div.name a', node);
        browser.fire("click", link, function() {
          console.log(browser.text('h1'));
        });
      });
    });
            

      Community
      Community
      Community
      Installing Drupal
      Installing Drupal
      Installing Drupal
      Installing Drupal
      ...
      ...
            

Each clickLink will interrupt the previous and only go to the last one...

We need a library to control the asynchronous process flow.

Using async.js


https://github.com/caolan/async

  • Uses asynchronous control flow instead of promises.
  • Isomorphic

Install Async.js

package.json

    "dependencies": {
        "async": ">=0",
        ...
            
mac:drupal-zombie travist$ npm install

async.series usage


    var async = require('async');

    async.series([
      function(done){
        // do some stuff ...
        done();
      },
      function(done){
        // do some more stuff ...
        done();
      }
    ], function(err, results){
      // results is now equal to ['one', 'two']
    });
            

async.series example

Before:


    browser.visit("http://drupal.org/search", function () {
      browser.fill('#edit-keys', 'zombie', function() {
        browser.pressButton('#edit-submit', function() {
          console.log(browser.text('dt.title:eq(0)'));
        });
      });
    });
            

After:


    async.series([
      function(done) {
        browser.visit('http://drupal.org/search', done);
      },
      function(done) {
        browser.fill('#edit-keys', 'zombie', done);
      },
      function(done) {
        browser.pressButton('#edit-submit', done);
      }
    ], function() {
      console.log(browser.text('dt.title:eq(0)'));
    });
            

Create a wrapper function...


    var go = function() {
      var args = _.values(arguments);
      var method = args.shift();
      return function(done) {
        args.push(done);
        browser[method].apply(browser, args);
      };
    };

    async.series([
      go('visit', 'http://drupal.org/search'),
      go('fill', '#edit-keys', 'zombie'),
      go('pressButton', '#edit-submit')
    ], function() {
      console.log(browser.text('dt.title:eq(0)'));
    });
            

Remember this nightmare?


    browser.visit('http://drupal.org/forum', function() {
      var nodes = browser.queryAll('tbody tr');
      _.each(nodes, function(node) {
        var link = browser.query('div.name a', node);
        browser.fire("click", link, function() {
          console.log(browser.text('h1'));
        });
      });
    });
            
examples/viewslist.js

    async.series([
      go('visit', 'http://drupal.org/forum'),
      function(done) {
        var nodes = browser.queryAll('tbody tr');
        async.eachSeries(nodes, function(node, nodeDone) {
          var link = browser.query('div.name a', node);
          browser.fire("click", link, function() {
            console.log(browser.text('h1'));
            nodeDone();
          });
        }, done);
      }
    ]);
            

async.js: Methods to care about

Drupal and Zombie.js

Feeding

Benefits of Drupal + Zombie.js.

  • Standard menu system = code reuse.
  • Don't need to be an admin of the site/server to automate.
  • Command line arguments and prompts.

Example: Login to Drupal


    async.series([
      go('visit', '/user'),
      go('fill', '#edit-name', 'admin'),
      go('fill', '#edit-pass', '123password'),
      go('pressButton', '#edit-submit')
    ], function() {
      console.log('Logged in as admin.');
    });
            

Login to Drupal: Code Reuse


    var login = function(user, pass, done) {
      async.series([
        go('visit', '/user'),
        go('fill', '#edit-name', user),
        go('fill', '#edit-pass', pass),
        go('pressButton', '#edit-submit')
      ], done);
    };

    async.series([
      go('login', 'admin', '123password')
    ], function() {
      console.log('Logged in as admin.');
    });
            

Using Configurations

Let's change this code to use a configuration file.

Using a library called nconf.

package.json

    "dependencies": {
        "nconf": ">=0",
        ...
            
mac:drupal-zombie travist$ npm install
config.json

    {
        "host": "http://drupal.local",
        "user": "admin",
        "pass": "123password"
    }
            

Using Configurations

config.json

    {
        "host": "http://drupal.local",
        "user": "admin",
        "pass": "123password"
    }
            

    var Browser =   require('zombie');
    var config =    require('nconf');

    config.argv().env().file({file:'config.json'});

    ...
    ...

    async.series([
      go('login', config.get('user'), config.get('pass'))
    ], function() {
      console.log('Logged in as admin.');
    });
            

Using Prompt

Change the password so it prompts you for it.

Using a library called prompt.

package.json

    "dependencies": {
        "prompt": ">=0",
        ...
            
mac:drupal-zombie travist$ npm install

Using Prompt

Change the password so it prompts you for it.

Using a library called prompt.


    var prompt = require('prompt');
    prompt.start();
    ...
    ...

    async.series([
      function(done) {
        prompt.get({name: 'pass', hidden: true}, function(pass) {
          config.set('pass', pass);
          done();
        });
      },
      go('login', config.get('user'), config.get('pass'))
    ], function() {
      console.log('Logged in as admin.');
    });
            

Man... all this should be in a library!

Believing

drupal.go.js

A node.js package to automate and test Drupal using Zombie.js.


https://github.com/travist/drupal.go.js
            

    mac:drupal-zombie travist$ npm install drupalgo
          

Setup

package.json

    {
      "name": "drupal-automate",
      "dependencies": {
        "drupalgo": ">=0",
        "async": ">=0"
      }
    }
            

    mac:drupal-zombie travist$ nvm use 0.8
    mac:drupal-zombie travist$ npm install
            
config.json

    {
      "host": "http://drupal.org",
      "user": "travist"
    }
            

Building your application.

app.js

    var drupal = require('drupalgo');
    var async = require('async');

    // Load the configuration file.
    drupal.load('config.json');

    async.series([
      drupal.go('login')
    ], function() {
      console.log('Done');
    });
            

The API (for now...)

  • drupal.go - Wrapper function to return async promises.
  • drupal.login(user, [pass], done) - Login to Drupal.
  • drupal.get(param, [value], done) - Get or prompt a configuration.
  • drupal.set(param, value, done) - Sets a configuration variable.
  • drupal.createContent(node, done) - Create a piece of content.
  • drupal.createMultipleContent(nodes, done) - Create multiple content.
  • drupal.eachViewItem(context, item, callback, done) - Iterates over every item within a view (including pagination).

Example 1: Creating Content.

Note: Every API can be passed to drupal.go


    var drupal = require('drupalgo');
    var async = require('async');
    drupal.load('example1.json');

    async.series([
      drupal.go('login'),
      drupal.go('get', 'title'),
      drupal.go('createContent', function() {
        return {
          type: 'article',
          title: drupal.config.get('title')
        };
      })
    ]);
            
examples/example1.json

    {
      "host": "http://drupal.local",
      "user": "admin",
      "title": "My node"
    }
            

Example 2: Create content on the fly.

examples/example2.js

    var drupal = require('drupalgo');
    var async = require('async');
    drupal.load('example2.json');

    async.series([
      drupal.go('login'),
      function(done) {
        async.whilst(
          function() { return drupal.config.get('title') !== ''; },
          function(nodeDone) {
            async.series([
              drupal.go('set', 'title', ''),
              drupal.go('get', 'title'),
              drupal.go('createContent', function() {
                return {
                  type: 'article',
                  title: drupal.config.get('title')
                };
              })
            ], nodeDone);
          },
          done
        );
      }
    ]);
            

Example 3: Automated Content Creation.

examples/example3.json

    {
        "host": "http://drupal.local",
        "user": "admin",
        "nodes": [
          {
            "type": "article",
            "title": "Hello There",
            "body": "This is very cool!"
          },
          {
            "type": "article",
            "title": "This is another node",
            "body": "Nice!"
          }
        ]
    }
            
examples/example3.js

    var drupal = require('drupalgo');
    var async = require('async');
    drupal.load('example3.json');

    async.series([
      drupal.go('login'),
      drupal.go('createMultipleContent', drupal.config.get('nodes'))
    ]);
            

Example 4: Node Fields.

examples/example4.json

    {
        "host": "http://drupal.local",
        "user": "admin",
        "nodes": [
          {
            "type": "article",
            "title": "Hello There",
            "body": "Body still works...",
            "fields": {
              "select[name='field_shirt_size[und]']": {
                "action": "select",
                "value": "m"
              },
              "input[name='field_favorite_color[und]'][value='orange']": {
                "action": "choose"
              },
              "input[name='field_interests[und][math]']": {
                "action": "check"
              },
              "input[name='field_interests[und][english]']": {
                "action": "check"
              },
              "input[name='field_approved[und]']": {
                "action": "check"
              }
            }
          },
          {
            "type": "article",
            "title": "This is another node",
            "body": "Nice!",
            "fields": {
              ...
              ...
            }
          }
        ]
    }
            

Example 5: Auto module maintainer.

examples/example5.json

    var drupal = require('drupalgo');
    var async = require('async');
    var browser = drupal.load('example5.json');

    async.series([
      drupal.go('login'),
      drupal.go('visit', '/project/issues/mediafront'),
      function(done) {
        drupal.eachViewItem('div.view-id-project_issue_project', 'td.views-field-title', function(node, done) {
          var version = browser.text('td.views-field-version', browser.xpath('./..', node).value[0]);
          console.log(browser.text('a', node) + ' : ' + version);
          done();
        }, done);
      }
    ]);
            

Extending drupal.go.js


    var drupal =    require('drupalgo');
    drupal.load('config.json');

    drupal.editNode = function(nid, title, done) {
      async.series([
        this.do('visit', 'node/' + nid + '/edit'),
        this.do('fill', '#edit-title', title),
        this.do('pressButton', '#edit-submit')
      ], done);
    };

    async.series([
      drupal.go('login'),
      drupal.go('createMultipleContent', drupal.config.get('nodes')),
      drupal.go('editNode', 12345, 'Testing!')
    ]);
            

It's open source... so fork it!

https://github.com/travist/drupal.go.js

Testing

Testing using assert.


    var assert = require('assert');
    var drupal = require('drupalgo');
    drupal.load('config.json');

    drupal.editNode = function(nid, title, done) {
      async.series([
        this.do('visit', 'node/' + nid + '/edit'),
        this.do('fill', '#edit-title', title),
        this.do('pressButton', '#edit-submit'),
        function(done) {
          assert.ok(browser.success);
          assert.equal(browser.text("title"), title);
        }
      ], done);
    };

    async.series([
      drupal.go('login'),
      drupal.go('createMultipleContent', drupal.config.get('nodes')),
      drupal.go('editNode', 12345, 'Testing!')
    ]);
            

Thanks

References