API Docs for: 0.0.3
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 os = require('os');
var cp = require('child_process');
var appium = require('appium/lib/server/main');
var portscanner = require('portscanner');

/**
 * This module is a browser plugin for [DalekJS](//github.com/dalekjs/dalek).
 * It provides all a WebDriverServer & browser launcher for Safari on iOS.
 * 
 * At the moment this only works with the IPhone 
 *
 * The browser plugin can be installed with the following command:
 *
 * ```bash
 * $ npm install dalek-browser-ios --save-dev
 * ```
 *
 * You can use the browser plugin by adding a config option to the your Dalekfile
 *
 * ```javascript
 * "browsers": ["ios"]
 * ```
 *
 * Or you can tell Dalek that it should test in this browser via the command line:
 *
 * ```bash
 * $ dalek mytest.js -b ios
 * ```
 *
 * The Webdriver Server tries to open Port 9003 by default,
 * if this port is blocked, it tries to use a port between 9004 & 9093
 * You can specifiy a different port from within your [Dalekfile](/pages/config.html) like so:
 *
 * ```javascript
 * "browsers": {
 *   "ios": {
 *     "port": 5555 
 *   }
 * }
 * ```
 *
 * It is also possible to specify a range of ports:
 *
 * ```javascript
 * "browsers": {
 *   "ios": {
 *     "portRange": [6100, 6120] 
 *   }
 * }
 * ```
 *
 * If you would like to test on the IPad (IPhone) emulator, you can simply apply a snd. argument,
 * which defines the browser type:
 *
 * ```bash
 * $ dalek mytest.js -b ios:ipad
 * ```
 * 
 * @module DalekJS
 * @class IosDriver
 * @namespace Browser
 * @part iOS
 * @api
 */

var IosDriver = {

  /**
   * Verbose version of the browser name
   *
   * @property longName
   * @type string
   * @default Mobile Safari iOS
   */

  longName: 'Mobile Safari iOS (iPhone)',

  /**
   * Default port of the Appium WebDriverServer
   * 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 4723
   */

  port: 4723,

  /**
   * WebHook port
   *
   * @property webhookPort
   * @type integer
   * @default 9003
   */

  webhookPort: 9003,

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

  host: 'localhost',

  /**
   * Root path of the appium webdriver server
   *
   * @property path
   * @type string
   * @default /wd/hub
   */

  path: '/wd/hub',

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

  desiredCapabilities: {
    device: 'iPhone Emulator',
    name: 'Safari remote via WD',
    app: 'safari',
    version: '6.1',
    browserName: ''
  },

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

  driverDefaults: {
    viewport: true,
    status: {
      os: {
        arch: os.arch(),
        version: os.release(),
        name: 'Mac OSX'
      }
    },
    sessionInfo: true
  },

  /**
   * Special arguments that are needed to invoke
   * appium. These are the defaults, they need to be modified later on 
   *
   * @property appiumArgs
   * @type object
   */

  appiumArgs: {
    app: null,
    ipa: null,
    quiet: true,
    udid: null,
    keepArtifacts: false,
    noSessionOverride: false,
    fullReset: false,
    noReset: false,
    launch: false,
    log: false,
    nativeInstrumentsLib: false,
    safari: false,
    forceIphone: false,
    forceIpad: false,
    orientation: null,
    useKeystore: false,
    address: '0.0.0.0',
    nodeconfig: null,
    port: null,
    webhook: null
  },

  /**
   * Different browser types (iPhone / iPad)
   *
   * @property browserTypes
   * @type object
   */

  browserTypes: {

    /**
     * IPad emulator
     *
     * @property ipad
     * @type object
     */

    ipad: {
      name: 'iPad'
    }

  },

  /**
   * 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 webhook port
   *
   * @method getWebhookPort
   * @return {integer} WebHook server port
   */

  getWebhookPort: function () {
    return this.webhookPort;
  },

  /**
   * Resolves the maximum range for the webhook port
   *
   * @method getWebhookPort
   * @return {integer} WebHook Max WebHook port
   */

  getMaxWebhookPort: function () {
    return this.maxWebhookPort;
  },

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

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

  /**
   * Launches appium & corresponding emulator or device,
   * kicks off the portscanner
   *
   * @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();

    // store injected configuration/log event handlers
    this.reporterEvents = events;
    this.configuration = configuration;
    this.config = config;

    // check if the user wants to run the iPad emulator
    if (configuration && configuration.type === 'ipad') {
      this.longName = this.longName.replace('iPhone', 'iPad');
      this.appiumArgs.forceIpad = true;
    }

    // check for a user set port
    var browsers = this.config.get('browsers');
    if (browsers && Array.isArray(browsers)) {
      browsers.forEach(this._checkUserDefinedPorts.bind(this));
    }

    // check if the current port is in use, if so, scan for free ports
    portscanner.findAPortNotInUse(this.getPort(), this.getMaxPort(), this.getHost(), this._checkPorts.bind(this, deferred));
    return deferred.promise;
  },

  /**
   * Kills the Appium Server process,
   * kills simulator processses
   * with a slight timeout to prevent 
   * appium from throwing errors
   * 
   * @method kill
   * @chainable
   */

  kill: function () {
    // kill appium servers
    this.appiumServer.webSocket.server.close();
    this.appiumServer.rest.listen().close();
    // slight timeout for process killing
    setTimeout(this._processes.bind(this, this._kill.bind(this)), 1000);
    return this;
  },

  /**
   * Kills the non blacklisted simulator processes & restores
   * the stderr handler
   *
   * @method _kill
   * @param {object|null} err Error or null
   * @param {array} result List of currently running simulator processes
   * @chainable
   * @private
   */

  _kill: function (err, result) {
    // kill simulator processes
    result.forEach(this._killProcess.bind(this));
    // (re)establish stderr/stdout stream
    this._reinstantiateLog();
    return this;
  },

  /**
   * Checks a blacklist & kills the process when
   * not found
   *
   * @method _killProcess
   * @param {integer} processID Process ID
   * @chainable
   * @private
   */

  _killProcess: function (processID) {
    var kill = true;

    // walk through the list of processes that are
    // open before the driver started
    this.openProcesses.forEach(function (pid) {
      if (pid === processID) {
        kill = false;
      }
    });

    if (kill === true) {
      cp.spawn('kill', [processID]);
    }

    return this;
  },

  /**
   * Checks & switches the appium server port,
   * scans the range for the webhook port
   *
   * @method _listProcesses
   * @param {object} deferred Promise
   * @param {object|null} err Error or null
   * @param {integer} port Appium server port to use
   * @chainable
   * @private
   */

  _checkPorts: function (deferred, error, port) {
    // 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-ios: Switching to port: ' + port);
      this.port = port;
    }

    // check if the current webhook port is in use, if so, scan for free ports
    portscanner.findAPortNotInUse(this.getWebhookPort(), this.getMaxWebhookPort(), this.getHost(), this._launch.bind(this, deferred));
    return this;
  },

  /**
   * Checks & switches the webhook port,
   * loads a list of running simulator processes
   *
   * @method _listProcesses
   * @param {object} deferred Promise
   * @param {object|null} err Error or null
   * @param {integer} port Webhook port to use
   * @chainable
   * @private
   */

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

    // launch appium & the emulator
    this._processes(this._listProcesses.bind(this, deferred));
    return this;
  },

  /**
   * Stores open processes,
   * suppresses stdout logs,
   * starts appium
   *
   * @method _listProcesses
   * @param {object} deferred Promise
   * @param {object|null} err Error or null
   * @param {array} result List of currently running simulator processes
   * @chainable
   * @private
   */

  _listProcesses: function (deferred, err, result) {
    // save list of open emulator processes, before we launched it
    this.openProcesses = result;
    // nasty hack to surpress socket.io debug reports from appium
    this._suppressAppiumLogs();
    // run appium
    appium.run(this._loadAppiumArgs(this.appiumArgs), this._afterAppiumStarted.bind(this, deferred));
    return this;
  },

  /**
   * Stores the appium server reference,
   * restores the stdout logs
   *
   * @method _afterAppiumStarted
   * @param {object} deferred Promise
   * @param {object} appiumServer Appium server instance
   * @chainable
   * @private
   */

  _afterAppiumStarted: function (deferred, appiumServer) {
    this.appiumServer = appiumServer;
    deferred.resolve();
    return this;
  },

  /**
   * Configures appium
   *
   * @method _loadAppiumArgs
   * @param {object} appiumArgs Appium specific configuration
   * @return {object} Modified appium configuration
   * @private
   */

  _loadAppiumArgs: function (appiumArgs) {
    appiumArgs.port = this.getPort();
    appiumArgs.webhook = this.getHost() + ':' + this.getWebhookPort();
    return appiumArgs;
  },

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

  _checkUserDefinedPorts: function (browser) {
    this._checkAppiumPorts(browser);
    this._checkWebhookPorts(browser);
    return this;
  },

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

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

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

    return this;
  },

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

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

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

    return this;
  },

  /**
   * Tracks running simulator processes
   *
   * @method _processes
   * @param {function} fn Callback
   * @chainable
   * @private
   */

  _processes: function (fn) {
    var cmd = ['ps -ax', '|', 'grep "iPhone Simulator.app"'];
    cp.exec(cmd.join(' '), this._transformProcesses.bind(this, fn));
    return this;
  },

  /**
   * Transforms the process list output into
   * a json structure
   * 
   * @method _transformProcesses
   * @param {function} fn Callback
   * @param {null|object} err Error if error, null if not
   * @param {string} stdout Terminal output
   * @chainable
   * @private
   */

  _transformProcesses: function(fn, err, stdout){
    var result = [];
    stdout.split('\n').forEach(this._scanProcess.bind(this, result));
    fn(err, result);
    return this;
  },

  /**
   * Scans and transforms the process list
   *
   * @method _scanProcess
   * @param {array} result Transformed result
   * @param {string} line Process list entry
   * @chainable
   * @private
   */

  _scanProcess: function (result, line){
    var data = line.split(' ');
    data = data.filter(this._filterProcessItem);

    if (data[1] === '??') {
      result.push(data[0]);
    }

    return this;
  },

  /**
   * Filters process list items
   *
   * @method _filterProcessItem
   * @param {string} item Process list entry
   * @return {bool|string} Process item or false
   * @private
   */

  _filterProcessItem: function (item) {
    if (item !== '') {
      return item;
    }

    return false;
  },

  /**
   * Overwrite default stdout & stderr handler
   * to suppress some appium logs 
   *
   * @method _suppressAppiumLogs
   * @chainable
   * @private
   */

  _suppressAppiumLogs: function () {
    // TODO: Check if the log level of appium can be set to 0
    var _supLogs = function (data) {
      if (data.search('6minfo') === -1 && data.search('33mwarn') === -1 && data.search('90mdebug') === -1) {
        this.oldWrite.bind(process.stdout)(data);
      }
    }.bind(this);

    // store old std. handler
    this.oldWrite = process.stdout.write;
    this.oldWriteErr = process.stderr.write;

    // overwrite with ugliness
    process.stdout.write = _supLogs;
    process.stderr.write = _supLogs;
    return this;
  },

  /**
   * Reinstantiate stdout handler after appium has
   * been started
   *
   * @method _reinstantiateLog
   * @chainable
   * @private
   */

  _reinstantiateLog: function () {
    setTimeout(function () {
      process.stdout.write = this.oldWrite;
      process.stderr.write = this.oldWriteErr;
    }.bind(this), 8000);
    return this;
  }

};

// expose the module
module.exports = IosDriver;