Bug 1303234 - Implement extracting action chain from a request; r?jgraham,ato draft
authorMaja Frydrychowicz <mjzffr@gmail.com>
Wed, 26 Oct 2016 13:04:48 -0400
changeset 431013 e0567f8484c42c15361f9c3c7094ae5daf45278a
parent 431012 1561c917ee27c3ea04bd69467e5b8c7c08102f2a
child 535339 635c3fc2b589f1842a001a185cb1733308dbe360
push id33975
push usermjzffr@gmail.com
push dateFri, 28 Oct 2016 15:30:08 +0000
reviewersjgraham, ato
bugs1303234
milestone52.0a1
Bug 1303234 - Implement extracting action chain from a request; r?jgraham,ato MozReview-Commit-ID: JxNoGZog1om
testing/marionette/action.js
testing/marionette/driver.js
testing/marionette/element.js
testing/marionette/jar.mn
testing/marionette/legacyaction.js
testing/marionette/listener.js
testing/marionette/test_action.js
testing/marionette/test_element.js
testing/marionette/unit.ini
--- a/testing/marionette/action.js
+++ b/testing/marionette/action.js
@@ -1,477 +1,463 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
+"use strict";
+
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Log.jsm");
-Cu.import("resource://gre/modules/Preferences.jsm");
 
 Cu.import("chrome://marionette/content/element.js");
-Cu.import("chrome://marionette/content/event.js");
+Cu.import("chrome://marionette/content/error.js");
 
-const CONTEXT_MENU_DELAY_PREF = "ui.click_hold_context_menus.delay";
-const DEFAULT_CONTEXT_MENU_DELAY = 750;  // ms
 
 this.EXPORTED_SYMBOLS = ["action"];
 
 const logger = Log.repository.getLogger("Marionette");
 
-this.action = {};
-
+// TODO? With ES 2016 and Symbol you can make a safer approximation
+// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689
 /**
- * Functionality for (single finger) action chains.
+ * Implements WebDriver Actions API: a low-level interfac for providing
+ * virtualised device input to the web browser.
  */
-action.Chain = function(checkForInterrupted) {
-  // for assigning unique ids to all touches
-  this.nextTouchId = 1000;
-  // keep track of active Touches
-  this.touchIds = {};
-  // last touch for each fingerId
-  this.lastCoordinates = null;
-  this.isTap = false;
-  this.scrolling = false;
-  // whether to send mouse event
-  this.mouseEventsOnly = false;
-  this.checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
-
-  if (typeof checkForInterrupted == "function") {
-    this.checkForInterrupted = checkForInterrupted;
-  } else {
-    this.checkForInterrupted = () => {};
-  }
-
-  // determines if we create touch events
-  this.inputSource = null;
+this.action = {
+  Pause: "pause",
+  KeyDown: "keyDown",
+  KeyUp: "keyUp",
+  PointerDown: "pointerDown",
+  PointerUp: "pointerUp",
+  PointerMove: "pointerMove",
+  PointerCancel: "pointerCancel",
 };
 
-action.Chain.prototype.dispatchActions = function(
-    args,
-    touchId,
-    container,
-    seenEls,
-    touchProvider) {
-  // Some touch events code in the listener needs to do ipc, so we can't
-  // share this code across chrome/content.
-  if (touchProvider) {
-    this.touchProvider = touchProvider;
-  }
+const ACTIONS = {
+  none: new Set([action.Pause]),
+  key: new Set([action.Pause, action.KeyDown, action.KeyUp]),
+  pointer: new Set([
+    action.Pause,
+    action.PointerDown,
+    action.PointerUp,
+    action.PointerMove,
+    action.PointerCancel,
+  ]),
+};
 
-  this.seenEls = seenEls;
-  this.container = container;
-  let commandArray = element.fromJson(
-      args, seenEls, container.frame, container.shadowRoot);
+/** Represents possible subtypes for a pointer input source. */
+action.PointerType = {
+  Mouse: "mouse",
+  Pen: "pen",
+  Touch: "touch",
+};
 
-  if (touchId == null) {
-    touchId = this.nextTouchId++;
-  }
-
-  if (!container.frame.document.createTouch) {
-    this.mouseEventsOnly = true;
+/**
+ * Look up a PointerType.
+ *
+ * @param {string} str
+ *     Name of pointer type.
+ *
+ * @return {string}
+ *     A pointer type for processing pointer parameters.
+ *
+ * @throws InvalidArgumentError
+ *     If |str| is not a valid pointer type.
+ */
+action.PointerType.get = function(str) {
+  let name = capitalize(str);
+  if (!(name in this)) {
+    throw new InvalidArgumentError(`Unknown pointerType: ${str}`);
   }
-
-  let keyModifiers = {
-    shiftKey: false,
-    ctrlKey: false,
-    altKey: false,
-    metaKey: false,
-  };
-
-  return new Promise(resolve => {
-    this.actions(commandArray, touchId, 0, keyModifiers, resolve);
-  }).catch(this.resetValues);
+  return this[name];
 };
 
 /**
- * This function emit mouse event.
- *
- * @param {Document} doc
- *     Current document.
- * @param {string} type
- *     Type of event to dispatch.
- * @param {number} clickCount
- *     Number of clicks, button notes the mouse button.
- * @param {number} elClientX
- *     X coordinate of the mouse relative to the viewport.
- * @param {number} elClientY
- *     Y coordinate of the mouse relative to the viewport.
- * @param {Object} modifiers
- *     An object of modifier keys present.
+ * Input state associated with current session. This is a map between input ID and
+ * the device state for that input source, with one entry for each active input source.
+ */
+action.inputStateMap = new Map();
+
+/**
+ * Represents device state for an input source.
  */
-action.Chain.prototype.emitMouseEvent = function(
-    doc,
-    type,
-    elClientX,
-    elClientY,
-    button,
-    clickCount,
-    modifiers) {
-  if (!this.checkForInterrupted()) {
-    logger.debug(`Emitting ${type} mouse event ` +
-        `at coordinates (${elClientX}, ${elClientY}) ` +
-        `relative to the viewport, ` +
-        `button: ${button}, ` +
-        `clickCount: ${clickCount}`);
+class InputState {
+  constructor() {
+    this.type = this.constructor.name.toLowerCase();
+  }
+
+  /**
+   * Check equality of this InputState object with another.
+   *
+   * @para{?} other
+   *     Object representing an input state.
+   * @return {boolean}
+   *     True if |this| has the same |type| as |other|.
+   */
+  is(other) {
+    if (typeof other == "undefined") {
+      return false;
+    }
+    return this.type === other.type;
+  }
+
+  toString() {
+    return `[object ${this.constructor.name}InputState]`;
+  }
+
+  /**
+   * @param {?} actionSequence
+   *     Object representing an action sequence.
+   *
+   * @return {action.InputState}
+   *     An |action.InputState| object for the type of the |actionSequence|.
+   *
+   * @throws InvalidArgumentError
+   *     If |actionSequence.type| is not valid.
+   */
+  static fromJson(actionSequence) {
+    let type = actionSequence.type;
+    if (!(type in ACTIONS)) {
+      throw new InvalidArgumentError(`Unknown action type: ${type}`);
+    }
+    let name = type == "none" ? "Null" : capitalize(type);
+    return new action.InputState[name]();
+  }
+}
 
-    let win = doc.defaultView;
-    let domUtils = win.QueryInterface(Ci.nsIInterfaceRequestor)
-        .getInterface(Ci.nsIDOMWindowUtils);
+/** Possible kinds of |InputState| for supported input sources. */
+action.InputState = {};
 
-    let mods;
-    if (typeof modifiers != "undefined") {
-      mods = event.parseModifiers_(modifiers);
-    } else {
-      mods = 0;
-    }
+/**
+ * Input state associated with a keyboard-type device.
+ */
+action.InputState.Key = class extends InputState {
+  constructor() {
+    super();
+    this.pressed = new Set();
+    this.alt = false;
+    this.shift = false;
+    this.ctrl = false;
+    this.meta = false;
+  }
+};
 
-    domUtils.sendMouseEvent(
-        type,
-        elClientX,
-        elClientY,
-        button || 0,
-        clickCount || 1,
-        mods,
-        false,
-        0,
-        this.inputSource);
+/**
+ * Input state not associated with a specific physical device.
+ */
+action.InputState.Null = class extends InputState {
+  constructor() {
+    super();
+    this.type = "none";
+  }
+};
+
+/**
+ * Input state associated with a pointer-type input device.
+ *
+ * @param {string} subtype
+ *     Kind of pointing device: mouse, pen, touch.
+ * @param {boolean} primary
+ *     Whether the pointing device is primary.
+ */
+action.InputState.Pointer = class extends InputState {
+  constructor(subtype, primary) {
+    super();
+    this.pressed = new Set();
+    this.subtype = subtype;
+    this.primary = primary;
+    this.x = 0;
+    this.y = 0;
   }
 };
 
 /**
- * Reset any persisted values after a command completes.
- */
-action.Chain.prototype.resetValues = function() {
-  this.container = null;
-  this.seenEls = null;
-  this.touchProvider = null;
-  this.mouseEventsOnly = false;
-};
-
-/**
- * Emit events for each action in the provided chain.
+ * Repesents an action for dispatch. Used in |action.Chain| and |action.Sequence|.
  *
- * To emit touch events for each finger, one might send a [["press", id],
- * ["wait", 5], ["release"]] chain.
+ * @param {string} id
+ *     Input source ID.
+ * @param {string} type
+ *     Action type: none, key, pointer.
+ * @param {string} subtype
+ *     Action subtype: pause, keyUp, keyDown, pointerUp, pointerDown, pointerMove, pointerCancel.
  *
- * @param {Array.<Array<?>>} chain
- *     A multi-dimensional array of actions.
- * @param {Object.<string, number>} touchId
- *     Represents the finger ID.
- * @param {number} i
- *     Keeps track of the current action of the chain.
- * @param {Object.<string, boolean>} keyModifiers
- *     Keeps track of keyDown/keyUp pairs through an action chain.
- * @param {function(?)} cb
- *     Called on success.
- *
- * @return {Object.<string, number>}
- *     Last finger ID, or an empty object.
+ * @throws InvalidArgumentError
+ *      If any parameters are undefined.
  */
-action.Chain.prototype.actions = function(chain, touchId, i, keyModifiers, cb) {
-  if (i == chain.length) {
-    cb(touchId || null);
-    this.resetValues();
-    return;
-  }
+action.Action = class {
+  constructor(id, type, subtype) {
+    if ([id, type, subtype].includes(undefined)) {
+      throw new InvalidArgumentError("Missing id, type or subtype");
+    }
+    for (let attr of [id, type, subtype]) {
+      if (typeof attr != "string") {
+        throw new InvalidArgumentError(`Expected string, got: ${attr}`);
+      }
+    }
+    this.id = id;
+    this.type = type;
+    this.subtype = subtype;
+  };
 
-  let pack = chain[i];
-  let command = pack[0];
-  let el;
-  let c;
-  i++;
-
-  if (["press", "wait", "keyDown", "keyUp", "click"].indexOf(command) == -1) {
-    // if mouseEventsOnly, then touchIds isn't used
-    if (!(touchId in this.touchIds) && !this.mouseEventsOnly) {
-      this.resetValues();
-      throw new WebDriverError("Element has not been pressed");
-    }
+  toString() {
+    return `[action ${this.type}]`;
   }
 
-  switch (command) {
-    case "keyDown":
-      event.sendKeyDown(pack[1], keyModifiers, this.container.frame);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
-
-    case "keyUp":
-      event.sendKeyUp(pack[1], keyModifiers, this.container.frame);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
-
-    case "click":
-      el = this.seenEls.get(pack[1], this.container);
-      let button = pack[2];
-      let clickCount = pack[3];
-      c = element.coordinates(el);
-      this.mouseTap(el.ownerDocument, c.x, c.y, button, clickCount, keyModifiers);
-      if (button == 2) {
-        this.emitMouseEvent(el.ownerDocument, "contextmenu", c.x, c.y,
-            button, clickCount, keyModifiers);
-      }
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
+  /**
+   * @param {?} actionSequence
+   *     Object representing sequence of actions from one input source.
+   * @param {?} actionItem
+   *     Object representing a single action from |actionSequence|
+   *
+   * @return {action.Action}
+   *     An action that can be dispatched; corresponds to |actionItem|.
+   *
+   * @throws InvalidArgumentError
+   *     If any |actionSequence| or |actionItem| attributes are invalid.
+   * @throws UnsupportedOperationError
+   *     If |actionItem.type| is |pointerCancel|.
+   */
+  static fromJson(actionSequence, actionItem) {
+    let type = actionSequence.type;
+    let id = actionSequence.id;
+    let subtypes = ACTIONS[type];
+    if (!subtypes) {
+      throw new InvalidArgumentError("Unknown type: " + type);
+    }
+    let subtype = actionItem.type;
+    if (!subtypes.has(subtype)) {
+      throw new InvalidArgumentError(`Unknown subtype for ${type} action: ${subtype}`);
+    }
 
-    case "press":
-      if (this.lastCoordinates) {
-        this.generateEvents(
-            "cancel",
-            this.lastCoordinates[0],
-            this.lastCoordinates[1],
-            touchId,
-            null,
-            keyModifiers);
-        this.resetValues();
-        throw new WebDriverError(
-            "Invalid Command: press cannot follow an active touch event");
-      }
-
-      // look ahead to check if we're scrolling,
-      // needed for APZ touch dispatching
-      if ((i != chain.length) && (chain[i][0].indexOf('move') !== -1)) {
-        this.scrolling = true;
-      }
-      el = this.seenEls.get(pack[1], this.container);
-      c = element.coordinates(el, pack[2], pack[3]);
-      touchId = this.generateEvents("press", c.x, c.y, null, el, keyModifiers);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
-
-    case "release":
-      this.generateEvents(
-          "release",
-          this.lastCoordinates[0],
-          this.lastCoordinates[1],
-          touchId,
-          null,
-          keyModifiers);
-      this.actions(chain, null, i, keyModifiers, cb);
-      this.scrolling =  false;
-      break;
+    let item = new action.Action(id, type, subtype);
+    if (type === "pointer") {
+      action.processPointerAction(id,
+          action.PointerParameters.fromJson(actionSequence.parameters), item);
+    }
 
-    case "move":
-      el = this.seenEls.get(pack[1], this.container);
-      c = element.coordinates(el);
-      this.generateEvents("move", c.x, c.y, touchId, null, keyModifiers);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
+    switch (item.subtype) {
+      case action.KeyUp:
+      case action.KeyDown:
+        let key = actionItem.value;
+        // TODO countGraphemes
+        if (typeof key != "string" || (typeof key == "string" && key.length != 1)) {
+          throw new InvalidArgumentError("Expected 'key' to be a single-character string, " +
+                                         "got: " + key);
+        }
+        item.value = key;
+        break;
 
-    case "moveByOffset":
-      this.generateEvents(
-          "move",
-          this.lastCoordinates[0] + pack[1],
-          this.lastCoordinates[1] + pack[2],
-          touchId,
-          null,
-          keyModifiers);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
+      case action.PointerDown:
+      case action.PointerUp:
+        assertPositiveInteger(actionItem.button, "button");
+        item.button = actionItem.button;
+        break;
 
-    case "wait":
-      if (pack[1] != null) {
-        let time = pack[1] * 1000;
-
-        // standard waiting time to fire contextmenu
-        let standard = Preferences.get(
-            CONTEXT_MENU_DELAY_PREF,
-            DEFAULT_CONTEXT_MENU_DELAY);
-
-        if (time >= standard && this.isTap) {
-          chain.splice(i, 0, ["longPress"], ["wait", (time - standard) / 1000]);
-          time = standard;
+      case action.PointerMove:
+        item.duration = actionItem.duration;
+        if (typeof item.duration != "undefined"){
+          assertPositiveInteger(item.duration, "duration");
         }
-        this.checkTimer.initWithCallback(
-            () => this.actions(chain, touchId, i, keyModifiers, cb),
-            time, Ci.nsITimer.TYPE_ONE_SHOT);
-      } else {
-        this.actions(chain, touchId, i, keyModifiers, cb);
-      }
-      break;
+        if (typeof actionItem.element != "undefined" &&
+            !element.isWebElementReference(actionItem.element)) {
+          throw new InvalidArgumentError(
+              "Expected 'actionItem.element' to be a web element reference, " +
+              `got: ${actionItem.element}`);
+        }
+        item.element = actionItem.element;
 
-    case "cancel":
-      this.generateEvents(
-          "cancel",
-          this.lastCoordinates[0],
-          this.lastCoordinates[1],
-          touchId,
-          null,
-          keyModifiers);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      this.scrolling = false;
-      break;
+        item.x = actionItem.x;
+        if (typeof item.x != "undefined") {
+          assertPositiveInteger(item.x, "x");
+        }
+        item.y = actionItem.y;
+        if (typeof item.y != "undefined") {
+          assertPositiveInteger(item.y, "y");
+        }
+        break;
 
-    case "longPress":
-      this.generateEvents(
-          "contextmenu",
-          this.lastCoordinates[0],
-          this.lastCoordinates[1],
-          touchId,
-          null,
-          keyModifiers);
-      this.actions(chain, touchId, i, keyModifiers, cb);
-      break;
+      case action.PointerCancel:
+        throw new UnsupportedOperationError();
+        break;
+
+      case action.Pause:
+        item.duration = actionItem.duration;
+        if (typeof item.duration != "undefined") {
+          assertPositiveInteger(item.duration, "duration");
+        }
+        break;
+    }
+
+    return item;
   }
 };
 
 /**
- * Given an element and a pair of coordinates, returns an array of the
- * form [clientX, clientY, pageX, pageY, screenX, screenY].
+ * Represents a series of ticks, specifying which actions to perform at each tick.
  */
-action.Chain.prototype.getCoordinateInfo = function(el, corx, cory) {
-  let win = el.ownerDocument.defaultView;
-  return [
-    corx, // clientX
-    cory, // clientY
-    corx + win.pageXOffset, // pageX
-    cory + win.pageYOffset, // pageY
-    corx + win.mozInnerScreenX, // screenX
-    cory + win.mozInnerScreenY // screenY
-  ];
+action.Chain = class extends Array {
+  toString() {
+    return `[chain ${super.toString()}]`;
+  }
+
+  /**
+   * @param {Array<?>} actions
+   *     Array of objects that each represent an action sequence.
+   *
+   * @return {action.Chain}
+   *     Transpose of |actions| such that actions to be performed in a single tick
+   *     are grouped together.
+   *
+   * @throws InvalidArgumentError
+   *     If |actions| is not an Array.
+   */
+  static fromJson(actions) {
+    if (!Array.isArray(actions)) {
+      throw new InvalidArgumentError(`Expected 'actions' to be an Array, got: ${actions}`);
+    }
+    let actionsByTick = new action.Chain();
+    //  TODO check that each actionSequence in actions refers to a different input ID
+    for (let actionSequence of actions) {
+      let inputSourceActions = action.Sequence.fromJson(actionSequence);
+      for (let i = 0; i < inputSourceActions.length; i++) {
+        // new tick
+        if (actionsByTick.length < (i + 1)) {
+          actionsByTick.push([]);
+        }
+        actionsByTick[i].push(inputSourceActions[i]);
+      }
+    }
+    return actionsByTick;
+  }
 };
 
 /**
- * @param {number} x
- *     X coordinate of the location to generate the event that is relative
- *     to the viewport.
- * @param {number} y
- *     Y coordinate of the location to generate the event that is relative
- *     to the viewport.
+ * Represents one input source action sequence; this is essentially an |Array<action.Action>|.
  */
-action.Chain.prototype.generateEvents = function(
-    type, x, y, touchId, target, keyModifiers) {
-  this.lastCoordinates = [x, y];
-  let doc = this.container.frame.document;
-
-  switch (type) {
-    case "tap":
-      if (this.mouseEventsOnly) {
-        this.mouseTap(
-            touch.target.ownerDocument,
-            touch.clientX,
-            touch.clientY,
-            null,
-            null,
-            keyModifiers);
-      } else {
-        touchId = this.nextTouchId++;
-        let touch = this.touchProvider.createATouch(target, x, y, touchId);
-        this.touchProvider.emitTouchEvent("touchstart", touch);
-        this.touchProvider.emitTouchEvent("touchend", touch);
-        this.mouseTap(
-            touch.target.ownerDocument,
-            touch.clientX,
-            touch.clientY,
-            null,
-            null,
-            keyModifiers);
-      }
-      this.lastCoordinates = null;
-      break;
-
-    case "press":
-      this.isTap = true;
-      if (this.mouseEventsOnly) {
-        this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers);
-        this.emitMouseEvent(doc, "mousedown", x, y, null, null, keyModifiers);
-      } else {
-        touchId = this.nextTouchId++;
-        let touch = this.touchProvider.createATouch(target, x, y, touchId);
-        this.touchProvider.emitTouchEvent("touchstart", touch);
-        this.touchIds[touchId] = touch;
-        return touchId;
-      }
-      break;
-
-    case "release":
-      if (this.mouseEventsOnly) {
-        let [x, y] = this.lastCoordinates;
-        this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers);
-      } else {
-        let touch = this.touchIds[touchId];
-        let [x, y] = this.lastCoordinates;
-
-        touch = this.touchProvider.createATouch(touch.target, x, y, touchId);
-        this.touchProvider.emitTouchEvent("touchend", touch);
+action.Sequence = class extends Array {
+  toString() {
+    return `[sequence ${super.toString()}]`;
+  }
 
-        if (this.isTap) {
-          this.mouseTap(
-              touch.target.ownerDocument,
-              touch.clientX,
-              touch.clientY,
-              null,
-              null,
-              keyModifiers);
-        }
-        delete this.touchIds[touchId];
-      }
-
-      this.isTap = false;
-      this.lastCoordinates = null;
-      break;
-
-    case "cancel":
-      this.isTap = false;
-      if (this.mouseEventsOnly) {
-        let [x, y] = this.lastCoordinates;
-        this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers);
-      } else {
-        this.touchProvider.emitTouchEvent("touchcancel", this.touchIds[touchId]);
-        delete this.touchIds[touchId];
-      }
-      this.lastCoordinates = null;
-      break;
+  /**
+   * @param {?} actionSequence
+   *     Object that represents a sequence action items for one input source.
+   *
+   * @return {action.Sequence}
+   *     Sequence of actions that can be dispatched.
+   *
+   * @throws InvalidArgumentError
+   *     If |actionSequence.id| is not a string or it's aleady mapped
+   *     to an |action.InputState} incompatible with |actionSequence.type|.
+   *     If |actionSequence.actions| is not an Array.
+   */
+  static fromJson(actionSequence) {
+    // used here only to validate 'type' and InputState type
+    let inputSourceState = InputState.fromJson(actionSequence);
+    let id = actionSequence.id;
+    if (typeof id == "undefined") {
+      actionSequence.id = id = element.generateUUID();
+    } else if (typeof id != "string") {
+      throw new InvalidArgumentError(`Expected 'id' to be a string, got: ${id}`);
+    }
+    let actionItems = actionSequence.actions;
+    if (!Array.isArray(actionItems)) {
+      throw new InvalidArgumentError(
+          `Expected 'actionSequence.actions' to be an Array, got: ${actionSequence.actions}`);
+    }
 
-    case "move":
-      this.isTap = false;
-      if (this.mouseEventsOnly) {
-        this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers);
-      } else {
-        let touch = this.touchProvider.createATouch(
-            this.touchIds[touchId].target, x, y, touchId);
-        this.touchIds[touchId] = touch;
-        this.touchProvider.emitTouchEvent("touchmove", touch);
-      }
-      break;
-
-    case "contextmenu":
-      this.isTap = false;
-      let event = this.container.frame.document.createEvent("MouseEvents");
-      if (this.mouseEventsOnly) {
-        target = doc.elementFromPoint(this.lastCoordinates[0], this.lastCoordinates[1]);
-      } else {
-        target = this.touchIds[touchId].target;
-      }
-
-      let [clientX, clientY, pageX, pageY, screenX, screenY] =
-          this.getCoordinateInfo(target, x, y);
-
-      event.initMouseEvent(
-          "contextmenu",
-          true,
-          true,
-          target.ownerDocument.defaultView,
-          1,
-          screenX,
-          screenY,
-          clientX,
-          clientY,
-          false,
-          false,
-          false,
-          false,
-          0,
-          null);
-      target.dispatchEvent(event);
-      break;
-
-    default:
-      throw new WebDriverError("Unknown event type: " + type);
+    if (action.inputStateMap.has(id) && !action.inputStateMap.get(id).is(inputSourceState)) {
+      throw new InvalidArgumentError(
+          `Expected ${id} to be mapped to ${inputSourceState}, ` +
+          `got: ${action.inputStateMap.get(id)}`);
+    }
+    let actions = new action.Sequence();
+    for (let actionItem of actionItems) {
+      actions.push(action.Action.fromJson(actionSequence, actionItem));
+    }
+    return actions;
   }
-  this.checkForInterrupted();
 };
 
-action.Chain.prototype.mouseTap = function(doc, x, y, button, count, mod) {
-  this.emitMouseEvent(doc, "mousemove", x, y, button, count, mod);
-  this.emitMouseEvent(doc, "mousedown", x, y, button, count, mod);
-  this.emitMouseEvent(doc, "mouseup", x, y, button, count, mod);
+/**
+ * Represents parameters in an action for a pointer input source.
+ *
+ * @param {string=} pointerType
+ *     Type of pointing device. If the parameter is undefined, "mouse" is used.
+ * @param {boolean=} primary
+ *     Whether the input source is the primary pointing device.
+ *     If the parameter is underfined, true is used.
+ */
+action.PointerParameters = class {
+  constructor(pointerType = "mouse", primary = true) {
+    this.pointerType = action.PointerType.get(pointerType);
+    assertBoolean(primary, "primary");
+    this.primary = primary;
+  };
+
+  toString() {
+    return `[pointerParameters ${this.pointerType}, primary=${this.primary}]`;
+  }
+
+  /**
+   * @param {?} parametersData
+   *     Object that represents pointer parameters.
+   *
+   * @return {action.PointerParameters}
+   *     Validated pointer paramters.
+   */
+  static fromJson(parametersData) {
+    if (typeof parametersData == "undefined") {
+      return new action.PointerParameters();
+    } else {
+      return new action.PointerParameters(parametersData.pointerType, parametersData.primary);
+    }
+  }
 };
+
+/**
+ * Adds |pointerType| and |primary| attributes to Action |act|. Helper function
+ * for |action.Action.fromJson|.
+ *
+ * @param {string} id
+ *     Input source ID.
+ * @param {action.PointerParams} pointerParams
+ *     Input source pointer parameters.
+ * @param {action.Action} act
+ *     Action to be updated.
+ *
+ * @throws InvalidArgumentError
+ *     If |id| is already mapped to an |action.InputState| that is
+ *     not compatible with |act.subtype|.
+ */
+action.processPointerAction = function processPointerAction(id, pointerParams, act) {
+  let subtype = act.subtype;
+  if (action.inputStateMap.has(id) && action.inputStateMap.get(id).subtype !== subtype) {
+    throw new InvalidArgumentError(
+        `Expected 'id' ${id} to be mapped to InputState whose subtype is ` +
+        `${action.inputStateMap.get(id).subtype}, got: ${subtype}`);
+  }
+  act.pointerType = pointerParams.pointerType;
+  act.primary = pointerParams.primary;
+};
+
+// helpers
+function assertPositiveInteger(value, name = undefined) {
+  let suffix = name ? ` (${name})` : '';
+  if (!Number.isInteger(value) || value < 0) {
+    throw new InvalidArgumentError(`Expected integer >= 0${suffix}, got: ${value}`);
+  }
+}
+
+function assertBoolean(value, name = undefined) {
+  let suffix = name ? ` (${name})` : '';
+  if (typeof(value) != "boolean") {
+    throw new InvalidArgumentError(`Expected boolean${suffix}, got: ${value}`);
+  }
+}
+
+function capitalize(str) {
+  if (typeof str != "string") {
+    throw new InvalidArgumentError(`Expected string, got: ${str}`);
+  }
+  return str.charAt(0).toUpperCase() + str.slice(1);
+}
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -12,24 +12,24 @@ var loader = Cc["@mozilla.org/moz/jssubs
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(
     this, "cookieManager", "@mozilla.org/cookiemanager;1", "nsICookieManager2");
 
-Cu.import("chrome://marionette/content/action.js");
 Cu.import("chrome://marionette/content/atom.js");
 Cu.import("chrome://marionette/content/browser.js");
 Cu.import("chrome://marionette/content/element.js");
 Cu.import("chrome://marionette/content/error.js");
 Cu.import("chrome://marionette/content/evaluate.js");
 Cu.import("chrome://marionette/content/event.js");
 Cu.import("chrome://marionette/content/interaction.js");
+Cu.import("chrome://marionette/content/legacyaction.js");
 Cu.import("chrome://marionette/content/logging.js");
 Cu.import("chrome://marionette/content/modal.js");
 Cu.import("chrome://marionette/content/proxy.js");
 Cu.import("chrome://marionette/content/simpletest.js");
 
 this.EXPORTED_SYMBOLS = ["GeckoDriver", "Context"];
 
 var FRAME_SCRIPT = "chrome://marionette/content/listener.js";
--- a/testing/marionette/element.js
+++ b/testing/marionette/element.js
@@ -634,16 +634,29 @@ element.isCollection = function(seq) {
 
 element.makeWebElement = function(uuid) {
   return {
     [element.Key]: uuid,
     [element.LegacyKey]: uuid,
   };
 };
 
+/**
+ * Checks if |ref| has either |element.Key| or |element.LegacyKey| as properties.
+ *
+ * @param {?} ref
+ *     Object that represents a web element reference.
+ * @return {boolean}
+ *     True if |ref| has either expected property.
+ */
+element.isWebElementReference = function(ref) {
+  let properties = Object.getOwnPropertyNames(ref);
+  return properties.includes(element.Key) || properties.includes(element.LegacyKey);
+};
+
 element.generateUUID = function() {
   let uuid = uuidGen.generateUUID().toString();
   return uuid.substring(1, uuid.length - 1);
 };
 
 /**
  * Convert any web elements in arbitrary objects to DOM elements by
  * looking them up in the seen element store.
--- a/testing/marionette/jar.mn
+++ b/testing/marionette/jar.mn
@@ -2,16 +2,17 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 marionette.jar:
 % content marionette %content/
   content/server.js (server.js)
   content/driver.js (driver.js)
   content/action.js (action.js)
+  content/legacyaction.js (legacyaction.js)
   content/browser.js (browser.js)
   content/interaction.js (interaction.js)
   content/accessibility.js (accessibility.js)
   content/listener.js (listener.js)
   content/element.js (element.js)
   content/simpletest.js (simpletest.js)
   content/frame.js (frame.js)
   content/event.js  (event.js)
copy from testing/marionette/action.js
copy to testing/marionette/legacyaction.js
--- a/testing/marionette/listener.js
+++ b/testing/marionette/listener.js
@@ -8,17 +8,17 @@ var {classes: Cc, interfaces: Ci, utils:
 
 var uuidGen = Cc["@mozilla.org/uuid-generator;1"]
     .getService(Ci.nsIUUIDGenerator);
 
 var loader = Cc["@mozilla.org/moz/jssubscript-loader;1"]
     .getService(Ci.mozIJSSubScriptLoader);
 
 Cu.import("chrome://marionette/content/accessibility.js");
-Cu.import("chrome://marionette/content/action.js");
+Cu.import("chrome://marionette/content/legacyaction.js");
 Cu.import("chrome://marionette/content/atom.js");
 Cu.import("chrome://marionette/content/capture.js");
 Cu.import("chrome://marionette/content/cookies.js");
 Cu.import("chrome://marionette/content/element.js");
 Cu.import("chrome://marionette/content/error.js");
 Cu.import("chrome://marionette/content/evaluate.js");
 Cu.import("chrome://marionette/content/event.js");
 Cu.import("chrome://marionette/content/interaction.js");
new file mode 100644
--- /dev/null
+++ b/testing/marionette/test_action.js
@@ -0,0 +1,478 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("chrome://marionette/content/action.js");
+Cu.import("chrome://marionette/content/element.js");
+Cu.import("chrome://marionette/content/error.js");
+
+add_test(function test_createAction() {
+  Assert.throws(() => new action.Action(), InvalidArgumentError,
+      "Missing Action constructor args");
+  Assert.throws(() => new action.Action(1,2), InvalidArgumentError,
+      "Missing Action constructor args");
+  Assert.throws(
+      () => new action.Action(1, 2, "sometype"), /Expected string/, "Non-string arguments.");
+  ok(new action.Action("id", "sometype", "sometype"));
+  run_next_test();
+});
+
+add_test(function test_defaultPointerParameters() {
+  let defaultParameters = {pointerType: action.PointerType.Mouse, primary: true};
+  deepEqual(action.PointerParameters.fromJson(), defaultParameters);
+
+  run_next_test();
+});
+
+add_test(function test_processPointerParameters() {
+  let check = (regex, message, arg) => checkErrors(
+      regex, action.PointerParameters.fromJson, [arg], message);
+  let parametersData = {pointerType: "foo"};
+  let message = `parametersData: [pointerType: ${parametersData.pointerType}, ` +
+      `primary: ${parametersData.primary}]`;
+  check(/Unknown pointerType/, message, parametersData);
+  parametersData.pointerType = "pen";
+  parametersData.primary = "a";
+  check(/Expected boolean \(primary\)/, message, parametersData);
+
+  parametersData.primary = false;
+  deepEqual(action.PointerParameters.fromJson(parametersData),
+      {pointerType: action.PointerType.Pen, primary: false});
+
+  run_next_test();
+});
+
+add_test(function test_processPointerUpDownAction() {
+  let actionItem = {type: "pointerDown"};
+  let actionSequence = {type: "pointer", id: "some_id"};
+  for (let d of [-1, "a"]) {
+    actionItem.button = d;
+    checkErrors(
+        /integer >= 0/, action.Action.fromJson, [actionSequence, actionItem],
+        `button: ${actionItem.button}`);
+  }
+  actionItem.button = 5;
+  let act = action.Action.fromJson(actionSequence, actionItem);
+  equal(act.button, actionItem.button);
+
+  run_next_test();
+});
+
+add_test(function test_validateActionDurationAndCoordinates() {
+  let actionItem = {};
+  let actionSequence = {id: "some_id"};
+  let check = function(type, subtype, message = undefined) {
+    message = message || `duration: ${actionItem.duration}, subtype: ${subtype}`;
+    actionItem.type = subtype;
+    actionSequence.type = type;
+    checkErrors(/integer >= 0/,
+        action.Action.fromJson, [actionSequence, actionItem], message);
+  };
+  for (let d of [-1, "a"]) {
+    actionItem.duration = d;
+    check("none", "pause");
+    check("pointer", "pointerMove");
+  }
+  actionItem.duration = 5;
+  for (let d of [-1, "a"]) {
+    for (let name of ["x", "y"]) {
+      actionItem[name] = d;
+      check("pointer", "pointerMove", `${name}: ${actionItem[name]}`);
+    }
+  }
+  run_next_test();
+});
+
+add_test(function test_processPointerMoveActionElementValidation() {
+  let actionSequence = {type: "pointer", id: "some_id"};
+  let actionItem = {duration: 5, type: "pointerMove"};
+  for (let d of [-1, "a", {a: "blah"}]) {
+    actionItem.element = d;
+    checkErrors(/Expected 'actionItem.element' to be a web element reference/,
+        action.Action.fromJson,
+        [actionSequence, actionItem],
+        `actionItem.element: (${getTypeString(d)})`);
+  }
+  actionItem.element = {[element.Key]:"something"};
+  let a = action.Action.fromJson(actionSequence, actionItem);
+  deepEqual(a.element, actionItem.element);
+
+  run_next_test();
+});
+
+add_test(function test_processPointerMoveAction() {
+  let actionSequence = {id: "some_id", type: "pointer"};
+  let actionItems = [
+    {
+      duration: 5,
+      type: "pointerMove",
+      element: undefined,
+      x: undefined,
+      y: undefined,
+    },
+    {
+      duration: undefined,
+      type: "pointerMove",
+      element: {[element.Key]: "id", [element.LegacyKey]: "id"},
+      x: undefined,
+      y: undefined,
+    },
+    {
+      duration: 5,
+      type: "pointerMove",
+      x: 0,
+      y: undefined,
+      element: undefined,
+    },
+    {
+      duration: 5,
+      type: "pointerMove",
+      x: 1,
+      y: 2,
+      element: undefined,
+    },
+  ];
+  for (let expected of actionItems) {
+    let actual = action.Action.fromJson(actionSequence, expected);
+    ok(actual instanceof action.Action);
+    equal(actual.duration, expected.duration);
+    equal(actual.element, expected.element);
+    equal(actual.x, expected.x);
+    equal(actual.y, expected.y);
+  }
+  run_next_test();
+});
+
+add_test(function test_processPointerAction() {
+  let actionSequence = {
+    type: "pointer",
+    id: "some_id",
+    parameters: {
+      pointerType: "touch",
+      primary: false,
+    },
+  }
+  let actionItems = [
+    {
+      duration: 2,
+      type: "pause",
+    },
+    {
+      type: "pointerMove",
+      duration: 2,
+    },
+    {
+      type: "pointerUp",
+      button: 1,
+    }
+  ];
+  for (let expected of actionItems) {
+    let actual = action.Action.fromJson(actionSequence, expected);
+    equal(actual.type, actionSequence.type);
+    equal(actual.subtype, expected.type);
+    equal(actual.id, actionSequence.id);
+    if (expected.type === "pointerUp") {
+      equal(actual.button, expected.button);
+    } else {
+      equal(actual.duration, expected.duration);
+    }
+    if (expected.type !== "pause") {
+      equal(actual.primary, actionSequence.parameters.primary);
+      equal(actual.pointerType, actionSequence.parameters.pointerType);
+    }
+  }
+
+  run_next_test();
+});
+
+add_test(function test_processPauseAction() {
+  let actionItem = {type: "pause", duration: 5};
+  let actionSequence = {id: "some_id"};
+  for (let type of ["none", "key", "pointer"]) {
+    actionSequence.type = type;
+    let act = action.Action.fromJson(actionSequence, actionItem);
+    ok(act instanceof action.Action);
+    equal(act.type, type);
+    equal(act.subtype, actionItem.type);
+    equal(act.id, actionSequence.id);
+    equal(act.duration, actionItem.duration);
+  }
+  actionItem.duration = undefined;
+  let act = action.Action.fromJson(actionSequence, actionItem);
+  equal(act.duration, actionItem.duration);
+
+  run_next_test();
+});
+
+add_test(function test_processActionSubtypeValidation() {
+  let actionItem = {type: "dancing"};
+  let actionSequence = {id: "some_id"};
+  let check = function(regex) {
+    let message = `type: ${actionSequence.type}, subtype: ${actionItem.type}`;
+    checkErrors(regex, action.Action.fromJson, [actionSequence, actionItem], message);
+  };
+  for (let type of ["none", "key", "pointer"]) {
+    actionSequence.type = type;
+    check(new RegExp(`Unknown subtype for ${type} action`));
+  }
+  run_next_test();
+});
+
+add_test(function test_processKeyActionUpDown() {
+  let actionSequence = {type: "key", id: "some_id"};
+  let actionItem = {type: "keyDown"};
+
+  for (let v of [-1, "bad", undefined, [], ["a"], {length: 1}, null]) {
+    actionItem.value = v;
+    let message = `actionItem.value: (${getTypeString(v)})`;
+    Assert.throws(() => action.Action.fromJson(actionSequence, actionItem),
+        InvalidArgumentError, message);
+    Assert.throws(() => action.Action.fromJson(actionSequence, actionItem),
+        /Expected 'key' to be a single-character string/, message);
+  }
+
+  actionItem.value = "a";
+  let act = action.Action.fromJson(actionSequence, actionItem);
+  ok(act instanceof action.Action);
+  equal(act.type, actionSequence.type);
+  equal(act.subtype, actionItem.type);
+  equal(act.id, actionSequence.id);
+  equal(act.value, actionItem.value);
+
+  run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceValidation() {
+  let actionSequence = {type: "swim", id: "some id"};
+  let check = (message, regex) => checkErrors(
+      regex, action.Sequence.fromJson, [actionSequence], message);
+  check(`actionSequence.type: ${actionSequence.type}`, /Unknown action type/);
+
+  actionSequence.type = "none";
+  actionSequence.id = -1;
+  check(`actionSequence.id: ${getTypeString(actionSequence.id)}`,
+      /Expected 'id' to be a string/);
+
+  actionSequence.id = "some_id";
+  actionSequence.actions = -1;
+  check(`actionSequence.actions: ${getTypeString(actionSequence.actions)}`,
+      /Expected 'actionSequence.actions' to be an Array/);
+
+  run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequence() {
+  let actionItem = { type: "pause", duration: 5};
+  let actionSequence = {
+    type: "none",
+    id: "some id",
+    actions: [actionItem],
+  };
+  let expectedAction = new action.Action(actionSequence.id, "none", actionItem.type);
+  expectedAction.duration = actionItem.duration;
+  let actions = action.Sequence.fromJson(actionSequence);
+  equal(actions.length, 1);
+  deepEqual(actions[0], expectedAction);
+  run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequencePointer() {
+  let actionItem = {type: "pointerDown", button: 1};
+  let actionSequence = {
+    type: "pointer",
+    id: "9",
+    actions: [actionItem],
+    parameters: {
+      pointerType: "pen",
+      primary: false,
+    },
+  };
+  let expectedAction = new action.Action(
+      actionSequence.id, actionSequence.type, actionItem.type);
+  expectedAction.pointerType = actionSequence.parameters.pointerType;
+  expectedAction.primary = actionSequence.parameters.primary;
+  expectedAction.button = actionItem.button;
+  let actions = action.Sequence.fromJson(actionSequence);
+  equal(actions.length, 1);
+  deepEqual(actions[0], expectedAction);
+  run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceKey() {
+  let actionItem = {type: "keyUp", value: "a"};
+  let actionSequence = {
+    type: "key",
+    id: "9",
+    actions: [actionItem],
+  };
+  let expectedAction = new action.Action(
+      actionSequence.id, actionSequence.type, actionItem.type);
+  expectedAction.value = actionItem.value;
+  let actions = action.Sequence.fromJson(actionSequence);
+  equal(actions.length, 1);
+  deepEqual(actions[0], expectedAction);
+  run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceGenerateID() {
+  let actionItems = [
+    {
+      type: "pause",
+      duration: 5,
+    },
+  ];
+  let actionSequence = {
+    type: "key",
+    actions: actionItems,
+  };
+  let actions = action.Sequence.fromJson(actionSequence);
+  equal(typeof actions[0].id, "string");
+  ok(actions[0].id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i));
+  run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceInputStateMap() {
+  let id = "1";
+  let actionItem = {type: "pause", duration: 5};
+  let actionSequence = {
+    type: "key",
+    id: id,
+    actions: [actionItem],
+  };
+  let wrongInputState = new action.InputState.Null();
+  action.inputStateMap.set(actionSequence.id, wrongInputState);
+  checkErrors(/to be mapped to/, action.Sequence.fromJson, [actionSequence],
+      `${actionSequence.type} using ${wrongInputState}`);
+  action.inputStateMap.clear();
+  let rightInputState = new action.InputState.Key();
+  action.inputStateMap.set(id, rightInputState);
+  let acts = action.Sequence.fromJson(actionSequence);
+  equal(acts.length, 1);
+  action.inputStateMap.clear();
+  run_next_test();
+});
+
+add_test(function test_processPointerActionInputStateMap() {
+  let actionItem = {type: "pointerDown"};
+  let id = "1";
+  let parameters = {pointerType: "mouse", primary: false};
+  let a = new action.Action(id, "pointer", actionItem.type);
+  let wrongInputState = new action.InputState.Pointer("pause", true);
+  action.inputStateMap.set(id, wrongInputState)
+  checkErrors(
+      /to be mapped to InputState whose subtype is/, action.processPointerAction,
+      [id, parameters, a],
+      `$subtype ${actionItem.type} with ${wrongInputState.subtype} in inputState`);
+  action.inputStateMap.clear();
+  let rightInputState = new action.InputState.Pointer("pointerDown", false);
+  action.inputStateMap.set(id, rightInputState);
+  action.processPointerAction(id, parameters, a);
+  equal(a.primary, parameters.primary);
+  action.inputStateMap.clear();
+  run_next_test();
+});
+
+add_test(function test_extractActionChainValidation() {
+  for (let actions of [-1, "a", undefined, null]) {
+    let message = `actions: ${getTypeString(actions)}`
+    Assert.throws(() => action.Chain.fromJson(actions),
+        InvalidArgumentError, message);
+    Assert.throws(() => action.Chain.fromJson(actions),
+        /Expected 'actions' to be an Array/, message);
+  }
+  run_next_test();
+});
+
+add_test(function test_extractActionChainEmpty() {
+  deepEqual(action.Chain.fromJson([]), []);
+  run_next_test();
+});
+
+add_test(function test_extractActionChain_oneTickOneInput() {
+  let actionItem = {type: "pause", duration: 5};
+  let actionSequence = {
+    type: "none",
+    id: "some id",
+    actions: [actionItem],
+  };
+  let expectedAction = new action.Action(actionSequence.id, "none", actionItem.type);
+  expectedAction.duration = actionItem.duration;
+  let actionsByTick = action.Chain.fromJson([actionSequence]);
+  equal(1, actionsByTick.length);
+  equal(1, actionsByTick[0].length);
+  deepEqual(actionsByTick, [[expectedAction]]);
+  run_next_test();
+});
+
+add_test(function test_extractActionChain_twoAndThreeTicks() {
+  let mouseActionItems = [
+    {
+      type: "pointerDown",
+      button: 2,
+    },
+    {
+      type: "pointerUp",
+      button: 2,
+    },
+  ];
+  let mouseActionSequence = {
+    type: "pointer",
+    id: "7",
+    actions: mouseActionItems,
+    parameters: {
+      pointerType: "touch",
+      primary: false,
+    },
+  };
+  let keyActionItems = [
+    {
+      type: "keyDown",
+      value: "a",
+    },
+    {
+      type: "pause",
+      duration: 4,
+    },
+    {
+      type: "keyUp",
+      value: "a",
+    },
+  ];
+  let keyActionSequence = {
+    type: "key",
+    id: "1",
+    actions: keyActionItems,
+  };
+  let actionsByTick = action.Chain.fromJson([keyActionSequence, mouseActionSequence]);
+  // number of ticks is same as longest action sequence
+  equal(keyActionItems.length, actionsByTick.length);
+  equal(2, actionsByTick[0].length);
+  equal(2, actionsByTick[1].length);
+  equal(1, actionsByTick[2].length);
+  let expectedAction = new action.Action(keyActionSequence.id, "key", keyActionItems[2].type);
+  expectedAction.value = keyActionItems[2].value;
+  deepEqual(actionsByTick[2][0], expectedAction);
+
+  // one empty action sequence
+  actionsByTick = action.Chain.fromJson([keyActionSequence, {type: "none", actions: []}]);
+  equal(keyActionItems.length, actionsByTick.length);
+  equal(1, actionsByTick[0].length);
+  run_next_test();
+});
+
+// helpers
+function getTypeString(obj) {
+  return Object.prototype.toString.call(obj);
+};
+
+function checkErrors(regex, func, args, message) {
+  if (typeof message == "undefined") {
+    message = `actionFunc: ${func.name}; args: ${args}`;
+  }
+  Assert.throws(() => func.apply(this, args), InvalidArgumentError, message);
+  Assert.throws(() => func.apply(this, args), regex, message);
+};
--- a/testing/marionette/test_element.js
+++ b/testing/marionette/test_element.js
@@ -37,8 +37,19 @@ add_test(function test_coordinates() {
   Assert.throws(() => element.coordinates(el, undefined, {}));
   Assert.throws(() => element.coordinates(el, {}, {}));
   Assert.throws(() => element.coordinates(el, [], undefined));
   Assert.throws(() => element.coordinates(el, undefined, []));
   Assert.throws(() => element.coordinates(el, [], []));
 
   run_next_test();
 });
+
+add_test(function test_isWebElementReference() {
+  strictEqual(element.isWebElementReference({[element.Key]: "some_id"}), true);
+  strictEqual(element.isWebElementReference({[element.LegacyKey]: "some_id"}), true);
+  strictEqual(element.isWebElementReference(
+      {[element.LegacyKey]: "some_id", [element.Key]: "2"}), true);
+  strictEqual(element.isWebElementReference({}), false);
+  strictEqual(element.isWebElementReference({"key": "blah"}), false);
+
+  run_next_test();
+});
--- a/testing/marionette/unit.ini
+++ b/testing/marionette/unit.ini
@@ -2,12 +2,13 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 # xpcshell unit tests for Marionette
 
 [DEFAULT]
 skip-if = appname == "thunderbird"
 
+[test_action.js]
 [test_element.js]
 [test_error.js]
 [test_message.js]
 [test_navigate.js]