API Docs for: 0.0.6
Show:

File: index.js

/*!
 *
 * Copyright (c) 2013 Sebastian Golasch
 *
 * 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.
 *
 */

'use strict';

// ext. libs
var Q = require('q');
var fs = require('fs');
var path = require('path');
var rimraf = require('rimraf');
var which = require('which').sync;
var portscanner = require('portscanner');
var spawn = require('child_process').spawn;
var Events = require('events').EventEmitter;

// int. libs
var Marionette = require('./lib/marionette');
var WebDriverServer = require('./lib/webdriver');

/**
 * This module is a browser plugin for [DalekJS](//github.com/dalekjs/dalek).
 * It provides all a WebDriverServer & browser launcher for Mozilla Firefox & Firefox OS.
 *
 * The browser plugin can be installed with the following command:
 *
 * ```bash
 * $ npm install dalek-browser-firefox --save-dev
 * ```
 *
 * You can use the browser plugin by adding a config option to the your Dalekfile
 *
 * ```javascript
 * "browser": ["firefox"]
 * ```
 *
 * Or you can tell Dalek that it should test in this browser via the command line:
 *
 * ```bash
 * $ dalek mytest.js -b firefox
 * ```
 * 
 * Dalek looks for the browser in the std. installation directory, if you installed the
 * browser in a different place, you can add the location of the browser executable to you Dalekfile,
 * because Dalek isnʼt capable of finding the executable yet on its own.
 *
 * ```javascript
 * "browsers": [{
 *   "firefox": {
 *     "path": "~/Apps/FirefoxNightlyDebug.app/Contents/MacOS/firefox-bin"
 *   }
 * }]
 * ```
 *
 * The Firefox plugin only implements a subset of Dalekʼs assertions & actions,
 * so if you run into any bugs, the issue is most probably related to missing commands.
 * Please report any issues you find, Thank you :)
 *
 * The Webdriver Server tries to open Port 9006 by default,
 * if this port is blocked, it tries to use a port between 9007 & 9096
 * You can specifiy a different port from within your [Dalekfile](/pages/config.html) like so:
 *
 * ```javascript
 * "browsers": [{
 *   "firefox": {
 *     "port": 5555 
 *   }
 * }]
 * ```
 *
 * It is also possible to specify a range of ports:
 *
 * ```javascript
 * "browsers": [{
 *   "firefox": {
 *     "portRange": [6100, 6120] 
 *   }
 * }]
 * ```
 *
 * If you would like to test Nightly, Aurora oder Firefox OS releases, you can simply apply a snd. argument,
 * which defines the browser type:
 *
 * ```bash
 * $ dalek mytest.js -b firefox:aurora
 * ```
 *
 * for Firefox Aurora, and if you would like to use the Firefox OS, just append `:os`:
 *
 * ```bash
 * $ dalek mytest.js -b firefox:os
 * ```
 *
 * This will only work if you installed your browser in the default locations,
 * if the browsers binary is located in a non default location, you are able to specify
 * its location in your [Dalekfile](/pages/config.html):
 *
 * ```javascript
 * "browsers": [{
 *   "firefox": {
 *     "binary": "/Applications/Custom Located Firefox.app/MacOS/Contents/firefox-bin" 
 *   }
 * }]
 * ```
 *
 * This also works for the aurora & Firefox OS builds
 *
 * ```javascript
 * "browsers": [{
 *   "firefox:aurora": {
 *     "binary": "/Applications/Custom Located Firefox Aurora.app/MacOS/Contents/firefox-bin" 
 *   }
 * }]
 * ```
 *
 * @module DalekJS
 * @class FirefoxDriver
 * @namespace Browser
 * @part Firefox
 * @api
 */

var FirefoxDriver = {

  /**
   * Verbose version of the browser name
   *
   * @property longName
   * @type string
   * @default Mozilla Firefox
   */

  longName: 'Mozilla Firefox',

  /**
   * Default port of the FirefoxWebDriverServer
   * The port may change, cause the port conflict resolution
   * tool might pick another one, if the default one is blocked
   *
   * @property port
   * @type integer
   * @default 9006
   */

  port: 9006,

  /**
   * Default maximum port of the WebDriverServer
   * The port is the highest port in the range that can be allocated
   * by the WebDriverServer
   *
   * @property maxPort
   * @type integer
   * @default 9096
   */

  maxPort: 9096,

  /**
   * Default port of the Marionette TCP service
   * The port may change, cause the port conflict resolution
   * tool might pick another one, if the default one is blocked
   *
   * @property marionettePort
   * @type integer
   * @default 2828
   */

  marionettePort: 2828,

  /**
   * Default host of the FirefoxWebDriverServer
   * The host may be overridden with
   * a user configured value
   *
   * @property host
   * @type string
   * @default localhost
   */

  host: 'localhost',

  /**
   * Default desired capabilities that should be
   * transferred when the browser session gets requested
   *
   * @property desiredCapabilities
   * @type object
   */

  desiredCapabilities: {
    browserName: 'firefox'
  },

  /**
   * Driver defaults, what should the driver be able to access.
   *
   * @property driverDefaults
   * @type object
   */

  driverDefaults: {
    viewport: true,
    status: true,
    sessionInfo: true
  },

  /**
   * Root path of the FirefoxWebDriverServer
   *
   * @property path
   * @type string
   * @default ''
   */

  path: '',

  /**
   * Path to the Firefox binary file
   *
   * @property binary
   * @type string
   * @default null
   */

  binary: null,

  /**
   * Paths to the default Firefox binary files
   * 
   * @property defaultBinaries
   * @type object
   */

  defaultBinaries: {
    default: 'firefox',
    darwin: '/Applications/Firefox.app/Contents/MacOS/firefox-bin',
    win32: process.env.ProgramFiles + '\\Mozilla Firefox\\firefox.exe',
    win64: process.env['ProgramFiles(x86)'] + '\\Mozilla Firefox\\firefox.exe'
  },

  /**
   * Different browser types (Aurora / firefox OS) that can be controlled
   * via the Firefox driver
   *
   * @property browserTypes
   * @type object
   */

  browserTypes: {

    /**
     * Nightly Firefox
     *
     * @property nightly
     * @type object
     */

    nightly: {
      name: 'Firefox Nightly',
      linux: 'firefox',
      darwin: '/Applications/FirefoxNightlyDebug.app/Contents/MacOS/firefox-bin',
      win32: process.env.ProgramFiles + '\\Nightly\\firefox.exe',
      win64: process.env['ProgramFiles(x86)'] + '\\Mozilla Firefox Nightly\\firefox.exe'
    },

    /**
     * Firefox Aurora
     *
     * @property aurora
     * @type object
     */

    aurora: {
      name: 'Firefox Aurora',
      linux: 'firefox',
      darwin: '/Applications/FirefoxAuroraDebug.app/Contents/MacOS/firefox-bin',
      win32: process.env.ProgramFiles + '\\AuroraDebug\\firefox.exe',
      win64: process.env['ProgramFiles(x86)'] + '\\Mozilla AuroraDebug\\firefox.exe'
    },

    /**
     * Firefox OS
     *
     * @property os
     * @type object
     */

    os: {
      name: 'Firefox OS',
      linux: 'b2g',
      darwin: '/Applications/B2G.app/Contents/MacOS/b2g',
      win32: process.env.ProgramFiles + '\\B2G\\b2g.exe',
      win64: process.env.ProgramFiles + '\\B2G\\b2g.exe'
    }
  },

  /**
   * Child process instance of the Firefox browser
   *
   * @property spawned
   * @type null|Object
   * @default null
   */

  spawned: null,

  /**
   * Collected data about the created profile,
   * path, name, etc.
   *
   * @property profile
   * @type null|object
   * @default null
   */

  profile: null,

  /**
   * Resolves the driver port
   *
   * @method getPort
   * @return {integer} port WebDriver server port
   */

  getPort: function () {
    return this.port;
  },

  /**
   * Resolves the maximum range for the driver port
   *
   * @method getMaxPort
   * @return {integer} port Max WebDriver server port range
   */

  getMaxPort: function () {
    return this.maxPort;
  },

  /**
   * Resolves the marionette port
   *
   * @method getMarionettePort
   * @return {integer} port Marionette server port
   */

  getMarionettePort: function () {
    return this.marionettePort;
  },

  /**
   * Returns the driver host
   *
   * @method getHost
   * @return {string} host WebDriver server hostname
   */

  getHost: function () {
    return this.host;
  },

  /**
   * Launches the FirefoxWebDriverServer, 
   * the marionette client, & the browser.
   * Creates a user profile for the browser 
   *
   * @method launch
   * @param {object} configuration Browser configuration
   * @param {EventEmitter2} events EventEmitter (Reporter Emitter instance)
   * @param {Dalek.Internal.Config} config Dalek configuration class
   * @return {object} promise Browser promise
   */

  launch: function (configuration, events, config) {
    var deferred = Q.defer();

    // init the webdriver server, 
    // marionette client, event glue
    // and configuration settings
    configuration = this._initialize({
      userconfig: configuration,
      reporterEvents: events,
      config: config,
      events: new Events()
    });
    
    // check for a user set port
    var browsers = this.config.get('browsers');
    if (browsers && Array.isArray(browsers)) {
      browsers.forEach(this._checkUserDefinedPorts.bind(this));
    }

    // check the path of the browser binary
    Q.when(this.findBrowserBinary(configuration))
     .then(this._afterBinaryFound.bind(this, configuration, deferred));

    return deferred.promise;
  },

  /**
   * Shuts down the TCP (Marionette) & HTTP connection (Webdriver),
   * then kills the browser process & cleans up the profiles
   *
   * @method kill
   * @chainable
   */

  kill: function () {
    Q.all([
      // shutdown Marionette client
      this.marionette.kill(),
      // shutdown webdriver server
      this.webDriverServer.kill()
    ]).then(this._killProcess.bind(this));

    return this;
  },

  /**
   * Locates the browser binary
   * 
   * @method findBrowserBinary
   * @param {object} options Config options
   * @return {object} Promise 
   */

  findBrowserBinary: function (options) {
    var deferred = Q.defer();

    // check if the user has set a custom binary
    if (options && options.binary) {
      this._checkUserSetBinary(options.binary, deferred);
      return deferred.promise;
    }

    // get the defaukt binary for this OS
    var defaultBinary = this._getDefaultBinary();

    // check if the binary exists
    if (fs.existsSync(defaultBinary)) {
      this.binary = defaultBinary;
      deferred.resolve(defaultBinary);
    } else {
      var msg = 'dalek-driver-firefox: Binary not found: ' + defaultBinary;
      this.reporterEvents.emit('error', msg);
      deferred.reject({error: true, msg: msg});
    }

    return deferred.promise;
  },

  /**
   * Makes all needed modifications after we are sure if the
   * browser binary has been found
   *
   * @method _afterBinaryFound
   * @param {object} configuration User configuration
   * @param {object} deferred Promise
   * @chainable
   * @private
   */

  _afterBinaryFound: function (configuration, deferred) {
    // generate the verbose browser name
    this.longName = this._modifyVerboseBrowserName(configuration);

    // check if we are driving a desktop browser
    // when, create a profile, else, not
    if (configuration.type !== 'os') {
      Q.when(this._createProfile())
       .then(this._afterDesktopBinaryFound.bind(this, deferred, configuration));
    } else {
      this._startBrowser(null, 'os', deferred);
    }

    return this;
  },

  /**
   * Modifies the verbose browser name
   *
   * @method _modifyVerboseBrowserName
   * @param {object} configuration User configuration
   * @return {string} Verbose browser name
   * @private
   */

  _modifyVerboseBrowserName: function (configuration) {
    if (configuration.type && this.browserTypes[configuration.type]) {
      return this.browserTypes[configuration.type].name + ' (' + this.longName + ')';
    } else {
      return this.longName;
    }
  },

  /**
   * Initiates the browser startup after the desktop binary has been found
   *
   * @method _afterDesktopBinaryFound
   * @param {object} deferred Promise
   * @param {object} profile Profile data
   * @chainable
   * @private
   */

  _afterDesktopBinaryFound: function (deferred, configuration) {
    this._startBrowser(this.profile.path, this.profile.name, deferred, configuration);
    return this;
  },

  /**
   * Kills the currently running browser process
   * 
   * @method _killProcess
   * @chainable
   * @priavte
   */

  _killProcess: function () {
    // kill the browser process itself
    this.spawned.kill('SIGTERM');

    // check if we need to clean up some created profiles
    // should always be the case except for Firefox OS
    if (this.profile && this.profile.path) {
      rimraf.sync(this.profile.path);
    }

    return this;
  },

  /**
   * Initializes config & properties
   * 
   * @method  _initiaize
   * @param {object} opts Config options
   * @return {object} Processed userr config
   * @private
   */

  _initialize: function (opts) {
    // store config class
    this.config = opts.config;

    // glue events
    this.events = opts.events;
    this.events.setMaxListeners(0);
    this.reporterEvents = opts.reporterEvents;

    // setup Marionette client & Webdriver server
    this.marionette = new Marionette(this.events);
    this.webDriverServer = new WebDriverServer(this.events);

    return opts.userconfig === null ? {} : opts.userconfig;
  },

  /**
   * Get the default binary location
   *
   * @method _getDefaultBinary
   * @return {string} Path to binary
   * @private
   */

  _getDefaultBinary: function () {
    var platform = process.platform;

    // check default binary for linuy
    if (platform !== 'darwin' && platform !== 'win32' && this.defaultBinaries[platform]) {
      return which(this.defaultBinaries.linux);
    }

    // check to see if we are on Windows x64
    if (platform === 'win32' && process.arch === 'x64') {
      platform = 'win64';
    }
	
    return this.defaultBinaries[platform] ?
      this.defaultBinaries[platform] :
      which(this.defaultBinaries.default);
  },

  /**
   * Launches the browser
   * 
   * @method _startBrowser
   * @param {string} profilePath Directory that contains the profile
   * @param {string} profileName Name of the profile to use
   * @param {object} deferred Promise
   * @private
   * @chainable
   */

  _startBrowser: function (profilePath, profileName, deferred, configuration) {
    var df = Q.defer();
    var args = [];

    // set args based on environment
    if (profileName !== 'os') {
      args = ['-marionette', '-turbo', '-profile', profilePath, '-no-remote', '-url', 'about:blank'];
    }

    // start the browser
    this.spawned = spawn(this.binary, args);

    // kind of an ugly hack, but I have no other idea to
    // than to wait for 2 secs to ensure Firefox runs on windows
    if (process.platform === 'win32' || (configuration && !configuration.type)) {
      this.interval = setInterval(this._scanMarionettePort.bind(this, df), 50);
    } else {
      this.spawned.stdout.on('data', this._onBrowserStartup.bind(this, df));
    }

    // connect to the marionette socket server
    // and the webdriver server & resolve the promise when done
    df.promise.then(this._resolvePort.bind(this, deferred, profileName));
    return this;
  },

  /**
   * Repeatadly checks the status of the marionette port
   * 
   * @method _scanMarionettePort
   * @param {object} df Promise
   * @private
   * @chainable
   */

  _scanMarionettePort: function (df) {
    portscanner.checkPortStatus(this.getMarionettePort(), this.getHost(), this._startByInterval.bind(this, df));
    return this;
  },

  /**
   * Checks if the browser is available by listening on the marionette socket
   * 
   * @method _startByInterval
   * @param {object} df Promise
   * @param {object} interval Reference to the portchecker interval
   * @param {object|null} err Error or null
   * @param {string} status Status of the marionette port
   * @private
   * @chainable
   */

  _startByInterval: function (df, err, status) {
    if (status === 'open') {
      clearInterval(this.interval);
      df.resolve();
    }

    return this;
  },

  /**
   * Process user defined ports
   *
   * @method _checkUserDefinedPorts
   * @param {object} browser Browser configuration
   * @chainable
   * @private
   */

  _checkUserDefinedPorts: function (browser) {
    // check for a single defined port
    if (browser.firefox && browser.firefox.port) {
      this.port = parseInt(browser.firefox.port, 10);
      this.maxPort = this.port + 90;
      this.reporterEvents.emit('report:log:system', 'dalek-browser-firefox: Switching to user defined port: ' + this.port);
    }

    // check for a port range
    if (browser.firefox && browser.firefox.portRange && browser.firefox.portRange.length === 2) {
      this.port = parseInt(browser.firefox.portRange[0], 10);
      this.maxPort = parseInt(browser.firefox.portRange[1], 10);
      this.reporterEvents.emit('report:log:system', 'dalek-browser-firefox: Switching to user defined port(s): ' + this.port + ' -> ' + this.maxPort);
    }

    return this;
  },

  /**
   * Resolve the WebDriverServer port
   *
   * @method _resolvePort
   * @param {object} deferred Promise
   * @param {string} profileName Name of the profile
   * @chainable
   * @private
   */

  _resolvePort: function (deferred, profileName) {
    // check if the current port is in use, if so, scan for free ports
    portscanner.findAPortNotInUse(
      this.getPort(),
      this.getMaxPort(),
      this.getHost(),
      this._afterPortResolved.bind(this, deferred, profileName)
    );

    return this;
  },

  /**
   * Resolve the WebDriverServer port
   *
   * @method _resolvePort
   * @param {object} deferred Promise
   * @param {string} profileName Name of the profile
   * @param {object|null} error Error object if there is one
   * @param {integer} port Resolved port
   * @chainable
   * @private
   */

  _afterPortResolved: function (deferred, profileName, error, port) {
    // check for errors
    if (error !== null && error.code !== 'ECONNREFUSED') {
      this.reporterEvents.emit('error', 'dalek-browser-firefox: Error starting WebDriverServer, port ' + port + ' in use');
      deferred.reject(error);
      process.exit();
    }

    // check if the port was blocked & if we need to switch to another port
    if (this.port !== port) {
      this.reporterEvents.emit('report:log:system', 'dalek-browser-firefox: Switching to port: ' + port);
      this.port = port;
    }

    // kickstart marionette client & webdriver server
    Q.all([
      this.webDriverServer.connect(this.getPort(), this.getHost()),
      this.marionette.connect(this.getMarionettePort())
    ]).then(this._afterConnectionHasBeenEstablished.bind(this, profileName, deferred));
    return this;
  },

  /**
   * Callback that will be invoked after the marionette client has 
   * established a connection to the browser & after the webdriver server
   * has been launched correctly
   *
   * @method _afterConnectionHasBeenEstablished
   * @param {string} profileName Name of the user profile
   * @param {object} deferred Promise
   * @chainable
   * @private
   */

  _afterConnectionHasBeenEstablished: function (profileName, deferred) {
    // Due to the lack of firefox os readiness events, 
    // we need to wait an additional second before reporting
    // test readiness
    if (profileName === 'os') {
      setTimeout(deferred.resolve, 1000);
    } else {
      deferred.resolve();
    }

    return this;
  },

  /**
   * Consumes the console output when the browser is started
   *
   * @method _onBrowserStartup
   * @param {object} deferred Promise
   * @param {string} data Output from the spawned binary
   * @private
   * @chainable
   */

  _onBrowserStartup: function (deferred, data) {
    if (this._browserIsReady(data)) {
      deferred.resolve();
    }
    return this;
  },

  /**
   * Checks if the browser is ready for communication
   *
   * @method _browserIsReady
   * @param {string} data Output from the spawned binary
   * @return {bool} true when ready, false when not
   * @private
   */

  _browserIsReady: function (data) {
    // convert buffer to string
    data = data+'';
    // check for the ready signal on desktop firefox
    var desktopReady = data.search('DOMWINDOW == 12') !== -1;
    // check for the ready signal of the firefoxos emulator
    var b2gReady = data.search('BrowserElementChildPreload.js loaded') !== -1;
    return desktopReady || b2gReady;

  },

  /**
   * Creates a new Firefox profile
   *
   * @method _createProfile
   * @return {Q.promise}
   * @private
   */

  _createProfile: function () {
    var deferred = Q.defer();
    var profileName = 'dalekjs-' + Math.random().toString(16).slice(2);
    this._createUserPreferences(deferred, profileName);

    return deferred.promise;
  },

  /**
   * Creates user preferences for the profile
   * Saves them in `user.js` in the newly created profile
   *
   * @method _createProfile
   * @param {string} profilePath User profile directory
   * @return {Q.promise}
   * @private
   */

  _createUserPreferences: function (deferred, profileName) {
    // create marionette specific user preferences
    var prefs = 'user_pref("browser.shell.checkDefaultBrowser", false);\n';
    prefs += 'user_pref("marionette.contentListener", false);\n';
    prefs += 'user_pref("marionette.defaultPrefs.enabled", true);\n';
    prefs += 'user_pref("browser.shell.checkDefaultBrowser", false);\n';
    prefs += 'user_pref("browser.sessionstore.resume_from_crash", false);\n';
    prefs += 'user_pref("browser.bookmarks.restore_default_bookmarks", false);\n';
    prefs += 'user_pref("marionette.defaultPrefs.port", "' + this.getMarionettePort() + '");\n';

    // store the user preferences
    this.profile = {};
    this.profile.path = path.join(process.cwd(), 'temp');
    this.profile.name = profileName;

    // check if the temp dir exists, else create
    if (!fs.existsSync(this.profile.path)) {
      fs.mkdirSync(this.profile.path);
    }

    // create the preference file
    fs.writeFile(path.join(this.profile.path, 'prefs.js'), prefs, this._afterCreatingUserPreferences.bind(this, deferred));
    return deferred.promise;
  },

  /**
   * Callback that will be executed after the user preferences
   * have been created
   * 
   * @method _afterCreatingUserPreferences
   * @param {object} deferred Promise
   * @param {object|null} err Error or null
   * @private
   * @chainable
   */

  _afterCreatingUserPreferences: function (deferred, err) {
    // reject the deferred when an error occurrs
    if (err !== null) {
      this.reporterEvents.emit('error', 'dalek-browser-firefox: Error creating profile');
      deferred.reject(err);
      process.exit();
    }

    deferred.resolve();
    return this;
  },

  /**
   * Checks if the binary exists,
   * when set manually by the user
   *
   * @method _checkUserSetBinary
   * @param {string} userPath Path to the browser binary
   * @param {object} deferred Promise
   * @private
   * @chainable
   */

  _checkUserSetBinary: function (userPath, deferred) {
    // check if we need to replace the users home directory
    if (process.platform === 'darwin' && userPath.trim()[0] === '~') {
      userPath = userPath.replace('~', process.env.HOME);
    }

    // check if the binary exists
    if (fs.existsSync(userPath)) {
      this.binary = userPath;
      deferred.resolve(userPath);
    } else {
      var msg = 'dalek-driver-firefox: Binary not found: ' + userPath;
      this.reporterEvents.emit('error', msg);
      process.exit(); // MAYBE switch to Daleks 'killAll' Event 
      deferred.reject({error: true, msg: msg});
    }

    return this;
  }
};

module.exports = FirefoxDriver;