actions/Actions.js

const Pointer = require('./Pointer')
const Keyboard = require('./Keyboard')
const KEY = require('../KEY')

/**
 * The class responsible of creating action objects, which are a list
 * of UI actions to be carried out.
 *
 * Once the action object is created, you can add "ticks" to it using the
 * property `tick` (which is actually a getter). The way you use `tick` depends on the devices you created.
 *
 * If you call the constructor lile this:
 *
 *     var actions = new Actions()
 *
 * It's the same as typing:
 *
 *     var actions = new Actions(
 *       new Actions.Keyboard('keyboard'),
 *       new Actions.Pointer('mouse', Pointer.Type.MOUSE)
 *     )
 *
 * This will make two devices, `mouse` and `keyboard`, available.
 *
 * Such a scenario will allow you to call:
 *
 *     actions.tick.keyboardDown('r').mouseDown()
 *     actions.tick.keyboardUp('r').mouseUp()
 *
 * Here, `keyboardUp` was available as a combination of the keyboard ID `keyboard`
 * and the keyboard action `Up`.
 *
 * In short:
 *
 *   * * Keyboard devices will have the methods `Up`, `Down`
 *   * * Pointer devices will have the methors `Move`, `Up`, `Down`, `Cancel`
 *   * * Both of them have the method `pause`
 *
 * If you create an actions object like this:
 *
 *      var actions = new Actions(new Actions.Keyboard('cucumber'))
 *
 * You are then able to run:
 *
 *      actions.tick.cucumberDown('r')
 *      actions.tick.cucumberUp('r')
 *
 * However running:
 *
 *      actions.tick.cucumberMove('r')
 *
 * Will result in an error, since `cucumber` is a keyboard device, and it doesn't
 * implement `move` (only pointers do)
 *
 * If you have two devices set (like the default `keyboard` and `mouse`, which
 * is the most common use-case), you can set one action per tick:
 *
 *      var actions = new Actions() // By default, mouse and keyboard
 *      // Only a keyboard action in this tick. Mouse will pause
 *      actions.tick.keyboardDown('r')
 *      // Only a mouse action in this tick. Keyboard will pause
 *      actions.tick.mouseDown()
 *      // Both a mouse and a keyboard action this tick
 *      actions.tick.keyboardUp('r').mouseUp()
 *
 * You can only add one action per device in each tick. This will give an error,
 * because the `mouse` device is trying to define two different actions in the same
 * tick:
 *
 *      actions.tick.mouseDown().mouseUp()
 *
 * You are able to chain tick calls if you want to:
 *
 *      actions
 *      .tick.keyboardDown('r').mouseDown()
 *      .tick.keyboardUp('r').mouseUp()
 *
 * Once you have decided your actions, you can submit them:
 *
 *     await driver.performActions(actions)
 *
 * @param {...InputDevice} inputDevice Input devices that will be used to carry out
 *                         actions. By default, two devices are created:
 *                         `keyboard` and `mouse`
 * @example
 * // Creating an actions object. This...
 * var actions = new Actions()
 * // Is the same as:
 * var Pointer = Actions.Pointer
 * var Keyboard = Actions.Keyboard
 * var actions = new Actions( new Actions.Keyboard('keyboard'), new Actions.Pointer('mouse', Pointer.Type.MOUSE))
 *
 * // You can also be creative with names
 * var actions = new Actions.Pointer('da_mouse', Pointer.Type.MOUSE)
 *
 *
 */
class Actions {
  /**
   * The constructor
   */
  constructor (...devices) {
    var self = this
    this.actions = []

    this._compiled = false
    this.compiledActions = []
    // Assign `devices`. If not there, assign a default 'mouse' and 'keyboard'
    if (Object.keys(devices).length) {
      this.devices = devices
    } else {
      this.devices = [
        new Pointer('mouse', Pointer.Type.MOUSE),
        new Keyboard('keyboard')
      ]
    }

    // Make up a _tickSetters object, which are the setters available
    // after `driver.tick`, so that you can do `driver.tick.mouseDown()`
    // The keys `tick` and `compile` are always available, as it's handy to
    // get them as "chained" methods (so that you can do
    // `actions.tick.mouseDown().tick.mouseUp()`
    // The other keys will depend on the devices passed to the
    // `Actions` constructor.
    // With `Actions(new Actions.Pointer('fancyMouse'))` will establish
    // `tick.fancyMouseUp()`, `tick.fancyMouseDown()` etc.
    // By default, `new Actions()` will create two devices, called
    // `mouse` and `keyboard`
    //
    this._tickSetters = {
      get tick () {
        // Return self
        return self.tick
      },
      compile: self.compile.bind(self)
    }
    this.devices.forEach((device) => {
      var deviceTickMethods = device._tickMethods()
      Object.keys(deviceTickMethods).forEach((k) => {
        this._tickSetters[device.id + k] = function (...args) {
          if (!self._currentAction[device.id].virgin) {
            throw new Error(`Action for device ${device.id} already defined (${device.id + k}) for this tick`)
          }
          var res = deviceTickMethods[k].apply(device, args)
          self._currentAction[device.id] = res
          return self._tickSetters
        }
        // this._tickSetters['pause'] = function (duration = 0) {
        //   self._currentAction[device.id] = { type: 'pause', duration }
        //   return self._tickSetters
        // }
      })
    })
  }

  /** Constant returning special KEY characters (enter, etc.)
   *  Constant are from the global variable {@link KEY}
   *
   * @example
   * var actions = new Actions()
   * actions.tick.keyboardDown(Actions.Key.ENTER).keyboardUp(Actions.Key.ENTER)
   */
  static get Key () { return KEY }

  /**
   * The {@link Keyboard} class constructor
   */
  static get Keyboard () {
    return Keyboard
  }

  /**
   * The {@link Keyboard} class constructor
   */
  static get Pointer () {
    return Pointer
  }

  _takeVirginOutOfCurrentAction () {
    if (this._currentAction) {
      Object.keys(this._currentAction).forEach((k) => {
        if (this._currentAction[k].virgin) delete this._currentAction[k].virgin
      })
    }
  }

  /**
   * Compiles the stored actions into a `compiledActions` object, which is
   * an object compatible with the webdriver protocol for actions
   *
   * There is no need to call this method directly, since the driver always tries
   * to `compile()` actions before sending them over.
   */
  compile () {
    if (this._compiled) return
    this.compiledActions = []

    this._takeVirginOutOfCurrentAction()

    this.devices.forEach((device) => {
      var deviceActions = { actions: [] }
      deviceActions.type = device.type
      deviceActions.id = device.id
      if (device.type === 'pointer') {
        deviceActions.parameters = { pointerType: device.pointerType }
      }

      this.actions.forEach((action) => {
        deviceActions.actions.push(action[ device.id ])
      })
      this.compiledActions.push(deviceActions)
    })

    // console.log('COMPILED ACTIONS:', require('util').inspect(this.compiledActions, {depth: 10}))
  }

  _setAction (deviceId, action) {
    this._currentAction[ deviceId ] = action
  }

  /**
   * As a getter, it will return an object with the right methods depending on
   * what devices were passed when constructing the object.
   * For example running:
   *
   *      var actions = new Actions(new Actions.Keyboard('cucumber'))
   *
   * Will ensure that `tick` will return an object with the properties
   * `cucumberUp`, `cucumberDown` and `pause`
   * More conventionally, creating the action object like this:
   *
   *      var actions = new Actions()
   *
   * Will default to two devices, `mouse` and `keyboard`. So, the object returned
   * by the `tick` getter will return an object with the methods `keyboardUp()`, `keyboardDown()`,
   * `keyboardPause()`, `mouseUp()`, `mouseDown()`, `mouseCancel()`, `mousePause()`
   */
  get tick () {
    // The tick you add a tick, this is no longer compiled
    this._compiled = false

    // The current action (about not to be current anymore) still has
    // that "virgin" on for every device. Take it out.
    this._takeVirginOutOfCurrentAction()

    // Make up the action object. It will be an object where
    // each key is a device ID.
    // By default, ALL actions are set as 'pause'
    var action = {}
    this.devices.forEach((device) => {
      action[ device.id ] = { type: 'pause', duration: 0, virgin: true }
    })
    this.actions.push(action)

    // Set _currentAction, which is what the tickSetters will
    // change
    this._currentAction = action

    // Return the _tickSetters, so that actions.tick.mouse() works
    return this._tickSetters
  }
}

exports = module.exports = Actions