Bug 1320389 - Implement dispatch of key actions in content context; r?ato draft
authorMaja Frydrychowicz <mjzffr@gmail.com>
Tue, 13 Dec 2016 18:29:48 -0500
changeset 451113 8278e8e84d29c679f85cb38afd7e21eb25419d89
parent 451112 ca6da45033020acfe7f07d3e116ead2978d2bf30
child 539921 c9f57e0ada8012629287dffd6d2b681b500c463a
push id39048
push userbmo:mjzffr@gmail.com
push dateMon, 19 Dec 2016 16:32:21 +0000
reviewersato
bugs1320389
milestone53.0a1
Bug 1320389 - Implement dispatch of key actions in content context; r?ato MozReview-Commit-ID: AxHTFdDtXJN
testing/marionette/action.js
testing/marionette/assert.js
testing/marionette/driver.js
testing/marionette/event.js
testing/marionette/listener.js
testing/marionette/test_action.js
--- a/testing/marionette/action.js
+++ b/testing/marionette/action.js
@@ -1,26 +1,29 @@
 /* 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/Task.jsm");
+
 Cu.import("chrome://marionette/content/assert.js");
 Cu.import("chrome://marionette/content/element.js");
 Cu.import("chrome://marionette/content/error.js");
+Cu.import("chrome://marionette/content/event.js");
 
 this.EXPORTED_SYMBOLS = ["action"];
 
 // TODO? With ES 2016 and Symbol you can make a safer approximation
 // to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689
 /**
- * Implements WebDriver Actions API: a low-level interfac for providing
+ * Implements WebDriver Actions API: a low-level interface for providing
  * virtualised device input to the web browser.
  */
 this.action = {
   Pause: "pause",
   KeyDown: "keyDown",
   KeyUp: "keyUp",
   PointerDown: "pointerDown",
   PointerUp: "pointerUp",
@@ -35,48 +38,343 @@ const ACTIONS = {
     action.Pause,
     action.PointerDown,
     action.PointerUp,
     action.PointerMove,
     action.PointerCancel,
   ]),
 };
 
+/** Map from normalized key value to UI Events modifier key name */
+const MODIFIER_NAME_LOOKUP = {
+  "Alt": "alt",
+  "Shift": "shift",
+  "Control": "ctrl",
+  "Meta": "meta",
+};
+
+/** Map from raw key (codepoint) to normalized key value */
+const NORMALIZED_KEY_LOOKUP = {
+  "\uE000": "Unidentified",
+  "\uE001": "Cancel",
+  "\uE002": "Help",
+  "\uE003": "Backspace",
+  "\uE004": "Tab",
+  "\uE005": "Clear",
+  "\uE006": "Return",
+  "\uE007": "Enter",
+  "\uE008": "Shift",
+  "\uE009": "Control",
+  "\uE00A": "Alt",
+  "\uE00B": "Pause",
+  "\uE00C": "Escape",
+  "\uE00D": " ",
+  "\uE00E": "PageUp",
+  "\uE00F": "PageDown",
+  "\uE010": "End",
+  "\uE011": "Home",
+  "\uE012": "ArrowLeft",
+  "\uE013": "ArrowUp",
+  "\uE014": "ArrowRight",
+  "\uE015": "ArrowDown",
+  "\uE016": "Insert",
+  "\uE017": "Delete",
+  "\uE018": ";",
+  "\uE019": "=",
+  "\uE01A": "0",
+  "\uE01B": "1",
+  "\uE01C": "2",
+  "\uE01D": "3",
+  "\uE01E": "4",
+  "\uE01F": "5",
+  "\uE020": "6",
+  "\uE021": "7",
+  "\uE022": "8",
+  "\uE023": "9",
+  "\uE024": "*",
+  "\uE025": "+",
+  "\uE026": ",",
+  "\uE027": "-",
+  "\uE028": ".",
+  "\uE029": "/",
+  "\uE031": "F1",
+  "\uE032": "F2",
+  "\uE033": "F3",
+  "\uE034": "F4",
+  "\uE035": "F5",
+  "\uE036": "F6",
+  "\uE037": "F7",
+  "\uE038": "F8",
+  "\uE039": "F9",
+  "\uE03A": "F10",
+  "\uE03B": "F11",
+  "\uE03C": "F12",
+  "\uE03D": "Meta",
+  "\uE040": "ZenkakuHankaku",
+  "\uE050": "Shift",
+  "\uE051": "Control",
+  "\uE052": "Alt",
+  "\uE053": "Meta",
+  "\uE054": "PageUp",
+  "\uE055": "PageDown",
+  "\uE056": "End",
+  "\uE057": "Home",
+  "\uE058": "ArrowLeft",
+  "\uE059": "ArrowUp",
+  "\uE05A": "ArrowRight",
+  "\uE05B": "ArrowDown",
+  "\uE05C": "Insert",
+  "\uE05D": "Delete",
+};
+
+/** Map from raw key (codepoint) to key location */
+const KEY_LOCATION_LOOKUP = {
+  "\uE007": 1,
+  "\uE008": 1,
+  "\uE009": 1,
+  "\uE00A": 1,
+  "\uE01A": 3,
+  "\uE01B": 3,
+  "\uE01C": 3,
+  "\uE01D": 3,
+  "\uE01E": 3,
+  "\uE01F": 3,
+  "\uE020": 3,
+  "\uE021": 3,
+  "\uE022": 3,
+  "\uE023": 3,
+  "\uE024": 3,
+  "\uE025": 3,
+  "\uE026": 3,
+  "\uE027": 3,
+  "\uE028": 3,
+  "\uE029": 3,
+  "\uE03D": 1,
+  "\uE050": 2,
+  "\uE051": 2,
+  "\uE052": 2,
+  "\uE053": 2,
+  "\uE054": 3,
+  "\uE055": 3,
+  "\uE056": 3,
+  "\uE057": 3,
+  "\uE058": 3,
+  "\uE059": 3,
+  "\uE05A": 3,
+  "\uE05B": 3,
+  "\uE05C": 3,
+  "\uE05D": 3,
+};
+
+const KEY_CODE_LOOKUP = {
+  "\uE00A": "AltLeft",
+  "\uE052": "AltRight",
+  "\uE015": "ArrowDown",
+  "\uE012": "ArrowLeft",
+  "\uE014": "ArrowRight",
+  "\uE013": "ArrowUp",
+  "`": "Backquote",
+  "~": "Backquote",
+  "\\": "Backslash",
+  "|": "Backslash",
+  "\uE003": "Backspace",
+  "[": "BracketLeft",
+  "{": "BracketLeft",
+  "]": "BracketRight",
+  "}": "BracketRight",
+  ",": "Comma",
+  "<": "Comma",
+  "\uE009": "ControlLeft",
+  "\uE051": "ControlRight",
+  "\uE017": "Delete",
+  ")": "Digit0",
+  "0": "Digit0",
+  "!": "Digit1",
+  "1": "Digit1",
+  "2": "Digit2",
+  "@": "Digit2",
+  "#": "Digit3",
+  "3": "Digit3",
+  "$": "Digit4",
+  "4": "Digit4",
+  "%": "Digit5",
+  "5": "Digit5",
+  "6": "Digit6",
+  "^": "Digit6",
+  "&": "Digit7",
+  "7": "Digit7",
+  "*": "Digit8",
+  "8": "Digit8",
+  "(": "Digit9",
+  "9": "Digit9",
+  "\uE010": "End",
+  "\uE006": "Enter",
+  "+": "Equal",
+  "=": "Equal",
+  "\uE00C": "Escape",
+  "\uE031": "F1",
+  "\uE03A": "F10",
+  "\uE03B": "F11",
+  "\uE03C": "F12",
+  "\uE032": "F2",
+  "\uE033": "F3",
+  "\uE034": "F4",
+  "\uE035": "F5",
+  "\uE036": "F6",
+  "\uE037": "F7",
+  "\uE038": "F8",
+  "\uE039": "F9",
+  "\uE002": "Help",
+  "\uE011": "Home",
+  "\uE016": "Insert",
+  "<": "IntlBackslash",
+  ">": "IntlBackslash",
+  "A": "KeyA",
+  "a": "KeyA",
+  "B": "KeyB",
+  "b": "KeyB",
+  "C": "KeyC",
+  "c": "KeyC",
+  "D": "KeyD",
+  "d": "KeyD",
+  "E": "KeyE",
+  "e": "KeyE",
+  "F": "KeyF",
+  "f": "KeyF",
+  "G": "KeyG",
+  "g": "KeyG",
+  "H": "KeyH",
+  "h": "KeyH",
+  "I": "KeyI",
+  "i": "KeyI",
+  "J": "KeyJ",
+  "j": "KeyJ",
+  "K": "KeyK",
+  "k": "KeyK",
+  "L": "KeyL",
+  "l": "KeyL",
+  "M": "KeyM",
+  "m": "KeyM",
+  "N": "KeyN",
+  "n": "KeyN",
+  "O": "KeyO",
+  "o": "KeyO",
+  "P": "KeyP",
+  "p": "KeyP",
+  "Q": "KeyQ",
+  "q": "KeyQ",
+  "R": "KeyR",
+  "r": "KeyR",
+  "S": "KeyS",
+  "s": "KeyS",
+  "T": "KeyT",
+  "t": "KeyT",
+  "U": "KeyU",
+  "u": "KeyU",
+  "V": "KeyV",
+  "v": "KeyV",
+  "W": "KeyW",
+  "w": "KeyW",
+  "X": "KeyX",
+  "x": "KeyX",
+  "Y": "KeyY",
+  "y": "KeyY",
+  "Z": "KeyZ",
+  "z": "KeyZ",
+  "-": "Minus",
+  "_": "Minus",
+  "\uE01A": "Numpad0",
+  "\uE05C": "Numpad0",
+  "\uE01B": "Numpad1",
+  "\uE056": "Numpad1",
+  "\uE01C": "Numpad2",
+  "\uE05B": "Numpad2",
+  "\uE01D": "Numpad3",
+  "\uE055": "Numpad3",
+  "\uE01E": "Numpad4",
+  "\uE058": "Numpad4",
+  "\uE01F": "Numpad5",
+  "\uE020": "Numpad6",
+  "\uE05A": "Numpad6",
+  "\uE021": "Numpad7",
+  "\uE057": "Numpad7",
+  "\uE022": "Numpad8",
+  "\uE059": "Numpad8",
+  "\uE023": "Numpad9",
+  "\uE054": "Numpad9",
+  "\uE024": "NumpadAdd",
+  "\uE026": "NumpadComma",
+  "\uE028": "NumpadDecimal",
+  "\uE05D": "NumpadDecimal",
+  "\uE029": "NumpadDivide",
+  "\uE007": "NumpadEnter",
+  "\uE024": "NumpadMultiply",
+  "\uE026": "NumpadSubtract",
+  "\uE03D": "OSLeft",
+  "\uE053": "OSRight",
+  "\uE01E": "PageDown",
+  "\uE01F": "PageUp",
+  ".": "Period",
+  ">": "Period",
+  "\"": "Quote",
+  "'": "Quote",
+  ":": "Semicolon",
+  ";": "Semicolon",
+  "\uE008": "ShiftLeft",
+  "\uE050": "ShiftRight",
+  "/": "Slash",
+  "?": "Slash",
+  "\uE00D": "Space",
+  "  ": "Space",
+  "\uE004": "Tab",
+};
+
 /** Represents possible subtypes for a pointer input source. */
 action.PointerType = {
   Mouse: "mouse",
   Pen: "pen",
   Touch: "touch",
 };
 
 /**
  * Look up a PointerType.
  *
  * @param {string} str
  *     Name of pointer type.
  *
  * @return {string}
  *     A pointer type for processing pointer parameters.
  *
- * @throws InvalidArgumentError
+ * @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}`);
   }
   return this[name];
 };
 
 /**
  * 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.
+ *
+ * Initialized in listener.js
  */
-action.inputStateMap = new Map();
+action.inputStateMap = undefined;
+
+/**
+ * List of |action.Action| associated with current session. Used to manage dispatching
+ * events when resetting the state of the input sources. Reset operations are assumed
+ * to be idempotent.
+ *
+ * Initialized in listener.js
+ */
+action.inputsToCancel = undefined;
 
 /**
  * Represents device state for an input source.
  */
 class InputState {
   constructor() {
     this.type = this.constructor.name.toLowerCase();
   }
@@ -96,27 +394,28 @@ class InputState {
     return this.type === other.type;
   }
 
   toString() {
     return `[object ${this.constructor.name}InputState]`;
   }
 
   /**
-   * @param {?} actionSequence
-   *     Object representing an action sequence.
+   * @param {?} obj
+   *     Object with property |type|, representing an action sequence or an
+   *     action item.
    *
    * @return {action.InputState}
    *     An |action.InputState| object for the type of the |actionSequence|.
    *
-   * @throws InvalidArgumentError
+   * @throws {InvalidArgumentError}
    *     If |actionSequence.type| is not valid.
    */
-  static fromJson(actionSequence) {
-    let type = actionSequence.type;
+  static fromJson(obj) {
+    let type = obj.type;
     if (!(type in ACTIONS)) {
       throw new InvalidArgumentError(`Unknown action type: ${type}`);
     }
     let name = type == "none" ? "Null" : capitalize(type);
     return new action.InputState[name]();
   }
 }
 
@@ -130,16 +429,75 @@ action.InputState.Key = class extends In
   constructor() {
     super();
     this.pressed = new Set();
     this.alt = false;
     this.shift = false;
     this.ctrl = false;
     this.meta = false;
   }
+
+  /**
+   * Update modifier state according to |key|.
+   *
+   * @param {string} key
+   *     Normalized key value of a modifier key.
+   * @param {boolean} value
+   *     Value to set the modifier attribute to.
+   *
+   * @throws {InvalidArgumentError}
+   *     If |key| is not a modifier.
+   */
+  setModState(key, value) {
+    if (key in MODIFIER_NAME_LOOKUP) {
+      this[MODIFIER_NAME_LOOKUP[key]] = value;
+    } else {
+      throw new InvalidArgumentError("Expected 'key' to be one of " +
+          `${Object.keys(MODIFIER_NAME_LOOKUP)}; got: ${key}`);
+    }
+  }
+
+  /**
+   * Check whether |key| is pressed.
+   *
+   * @param {string} key
+   *     Normalized key value.
+   *
+   * @return {boolean}
+   *     True if |key| is in set of pressed keys.
+   */
+  isPressed(key) {
+    return this.pressed.has(key);
+  }
+
+  /**
+   * Add |key| to the set of pressed keys.
+   *
+   * @param {string} key
+   *     Normalized key value.
+   *
+   * @return {boolean}
+   *     True if |key| is in list of pressed keys.
+   */
+  press(key) {
+    return this.pressed.add(key);
+  }
+
+  /**
+   * Remove |key| from the set of pressed keys.
+   *
+   * @param {string} key
+   *     Normalized key value.
+   *
+   * @return {boolean}
+   *     True if |key| is removed successfully, false otherwise.
+   */
+  release(key) {
+    return this.pressed.delete(key);
+  }
 };
 
 /**
  * Input state not associated with a specific physical device.
  */
 action.InputState.Null = class extends InputState {
   constructor() {
     super();
@@ -171,17 +529,17 @@ action.InputState.Pointer = class extend
  *
  * @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.
  *
- * @throws InvalidArgumentError
+ * @throws {InvalidArgumentError}
  *      If any parameters are undefined.
  */
 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]) {
@@ -197,24 +555,24 @@ action.Action = class {
   toString() {
     return `[action ${this.type}]`;
   }
 
   /**
    * @param {?} actionSequence
    *     Object representing sequence of actions from one input source.
    * @param {?} actionItem
-   *     Object representing a single action from |actionSequence|
+   *     Object representing a single action from |actionSequence|.
    *
    * @return {action.Action}
    *     An action that can be dispatched; corresponds to |actionItem|.
    *
-   * @throws InvalidArgumentError
+   * @throws {InvalidArgumentError}
    *     If any |actionSequence| or |actionItem| attributes are invalid.
-   * @throws UnsupportedOperationError
+   * @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);
@@ -230,19 +588,22 @@ action.Action = class {
           action.PointerParameters.fromJson(actionSequence.parameters), item);
     }
 
     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);
+        // TODO key.value could be a single code point like "\uE012" (see rawKey)
+        // or "grapheme cluster"
+        if (typeof key != "string") {
+          throw new InvalidArgumentError(
+              "Expected 'value' to be a string that represents single code point " +
+              "or grapheme cluster, got: " + key);
         }
         item.value = key;
         break;
 
       case action.PointerDown:
       case action.PointerUp:
         assert.positiveInteger(actionItem.button,
             error.pprint`Expected 'button' (${actionItem.button}) to be >= 0`);
@@ -294,30 +655,29 @@ action.Action = class {
  * Represents a series of ticks, specifying which actions to perform at each tick.
  */
 action.Chain = class extends Array {
   toString() {
     return `[chain ${super.toString()}]`;
   }
 
   /**
-   * @param {Array<?>} actions
+   * @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
+   * @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}`);
-    }
+    assert.array(actions,
+              error.pprint`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([]);
@@ -325,31 +685,31 @@ action.Chain = class extends Array {
         actionsByTick[i].push(inputSourceActions[i]);
       }
     }
     return actionsByTick;
   }
 };
 
 /**
- * Represents one input source action sequence; this is essentially an |Array<action.Action>|.
+ * Represents one input source action sequence; this is essentially an |Array.<action.Action>|.
  */
 action.Sequence = class extends Array {
   toString() {
     return `[sequence ${super.toString()}]`;
   }
 
   /**
    * @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
+   * @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;
@@ -379,24 +739,24 @@ action.Sequence = class extends Array {
 
 /**
  * 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.
+ *     If the parameter is undefined, true is used.
  */
 action.PointerParameters = class {
   constructor(pointerType = "mouse", primary = true) {
     this.pointerType = action.PointerType.get(pointerType);
     assert.boolean(primary);
     this.primary = primary;
-  };
+  }
 
   toString() {
     return `[pointerParameters ${this.pointerType}, primary=${this.primary}]`;
   }
 
   /**
    * @param {?} parametersData
    *     Object that represents pointer parameters.
@@ -419,30 +779,263 @@ action.PointerParameters = class {
  *
  * @param {string} id
  *     Input source ID.
  * @param {action.PointerParams} pointerParams
  *     Input source pointer parameters.
  * @param {action.Action} act
  *     Action to be updated.
  *
- * @throws InvalidArgumentError
+ * @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;
 };
 
+/** Collect properties associated with KeyboardEvent */
+action.Key = class {
+  constructor(rawKey) {
+    this.key = NORMALIZED_KEY_LOOKUP[rawKey] || rawKey;
+    this.code =  KEY_CODE_LOOKUP[rawKey];
+    this.location = KEY_LOCATION_LOOKUP[rawKey] || 0;
+    this.altKey = false;
+    this.shiftKey = false;
+    this.ctrlKey = false;
+    this.metaKey = false;
+    this.repeat = false;
+    this.isComposing = false;
+    // Prevent keyCode from being guessed in event.js; we don't want to use it anyway.
+    this.keyCode = 0;
+  }
+
+  update(inputState) {
+    this.altKey = inputState.alt;
+    this.shiftKey = inputState.shift;
+    this.ctrlKey = inputState.ctrl;
+    this.metaKey = inputState.meta;
+  }
+};
+
+/**
+ * Dispatch a chain of actions over |chain.length| ticks.
+ *
+ * This is done by creating a Promise for each tick that resolves once all the
+ * Promises for individual tick-actions are resolved. The next tick's actions are
+ * not dispatched until the Promise for the current tick is resolved.
+ *
+ * @param {action.Chain} chain
+ *     Actions grouped by tick; each element in |chain| is a sequence of
+ *     actions for one tick.
+ * @param {element.Store} seenEls
+ *     Element store.
+ * @param {?} container
+ *     Object with |frame| attribute of type |nsIDOMWindow|.
+ *
+ * @return {Promise}
+ *     Promise for dispatching all actions in |chain|.
+ */
+action.dispatch = function(chain, seenEls, container) {
+  let chainEvents = Task.spawn(function*() {
+    for (let tickActions of chain) {
+      yield action.dispatchTickActions(
+        tickActions, action.computeTickDuration(tickActions), seenEls, container);
+    }
+  });
+  return chainEvents;
+};
+
+/**
+ * Dispatch sequence of actions for one tick.
+ *
+ * This creates a Promise for one tick that resolves once the Promise for each
+ * tick-action is resolved, which takes at least |tickDuration| milliseconds.
+ * The resolved set of events for each tick is followed by firing of pending DOM events.
+ *
+ * Note that the tick-actions are dispatched in order, but they may have different
+ * durations and therefore may not end in the same order.
+ *
+ * @param {Array.<action.Action>} tickActions
+ *     List of actions for one tick.
+ * @param {number} tickDuration
+ *     Duration in milliseconds of this tick.
+ * @param {element.Store} seenEls
+ *     Element store.
+ * @param {?} container
+ *     Object with |frame| attribute of type |nsIDOMWindow|.
+ *
+ * @return {Promise}
+ *     Promise for dispatching all tick-actions and pending DOM events.
+ */
+action.dispatchTickActions = function(tickActions, tickDuration, seenEls, container) {
+  let pendingEvents = tickActions.map(toEvents(tickDuration, seenEls, container));
+  return Promise.all(pendingEvents).then(() => flushEvents(container));
+};
+
+/**
+ * Compute tick duration in milliseconds for a collection of actions.
+ *
+ * @param {Array.<action.Action>} tickActions
+ *     List of actions for one tick.
+ *
+ * @return {number}
+ *     Longest action duration in |tickActions| if any, or 0.
+ */
+action.computeTickDuration = function(tickActions) {
+  let max = 0;
+  for (let a of tickActions) {
+    let affectsWallClockTime = a.subtype == action.Pause ||
+        (a.type == "pointer" && a.subtype == action.PointerMove);
+    if (affectsWallClockTime && a.duration) {
+      max = Math.max(a.duration, max);
+    }
+  }
+  return max;
+};
+
+/**
+ * Create a closure to use as a map from action definitions to Promise events.
+ *
+ * @param {number} tickDuration
+ *     Duration in milliseconds of this tick.
+ * @param {element.Store} seenEls
+ *     Element store.
+ * @param {?} container
+ *     Object with |frame| attribute of type |nsIDOMWindow|.
+ *
+ * @return {function(action.Action): Promise}
+ *     Function that takes an action and returns a Promise for dispatching
+ *     the event that corresponds to that action.
+ */
+function toEvents(tickDuration, seenEls, container) {
+  return function (a) {
+    if (!action.inputStateMap.has(a.id)) {
+      action.inputStateMap.set(a.id, InputState.fromJson(a));
+    }
+    let inputState = action.inputStateMap.get(a.id);
+    switch (a.subtype) {
+      case action.KeyUp:
+        return dispatchKeyUp(a, inputState, container.frame);
+
+      case action.KeyDown:
+        return dispatchKeyDown(a, inputState, container.frame);
+
+      case action.PointerDown:
+      case action.PointerUp:
+      case action.PointerMove:
+      case action.PointerCancel:
+        throw new UnsupportedOperationError();
+
+      case action.Pause:
+        return dispatchPause(a, tickDuration);
+    }
+  };
+}
+
+/**
+ * Dispatch a keyDown action equivalent to pressing a key on a keyboard.
+ *
+ * @param {action.Action} a
+ *     Action to dispatch.
+ * @param {action.InputState} inputState
+ *     Input state for this action's input source.
+ * @param {nsIDOMWindow} win
+ *     Current window.
+ *
+ * @return {Promise}
+ *     Promise to dispatch at least a keydown event, and keypress if appropriate.
+ */
+function dispatchKeyDown(a, inputState, win) {
+  return new Promise(resolve => {
+    let keyEvent = new action.Key(a.value);
+    keyEvent.repeat = inputState.isPressed(keyEvent.key);
+    inputState.press(keyEvent.key);
+    if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+      inputState.setModState(keyEvent.key, true);
+    }
+    // Append a copy of |a| with keyUp subtype
+    action.inputsToCancel.push(Object.assign({}, a, {subtype: action.KeyUp}));
+    keyEvent.update(inputState);
+    event.sendKeyDown(keyEvent.key, keyEvent, win);
+
+    resolve();
+  });
+}
+
+/**
+ * Dispatch a keyUp action equivalent to releasing a key on a keyboard.
+ *
+ * @param {action.Action} a
+ *     Action to dispatch.
+ * @param {action.InputState} inputState
+ *     Input state for this action's input source.
+ * @param {nsIDOMWindow} win
+ *     Current window.
+ *
+ * @return {Promise}
+ *     Promise to dispatch a keyup event.
+ */
+function dispatchKeyUp(a, inputState, win) {
+  return new Promise(resolve => {
+    let keyEvent = new action.Key(a.value);
+    if (!inputState.isPressed(keyEvent.key)) {
+      resolve();
+      return;
+    }
+    if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+      inputState.setModState(keyEvent.key, false);
+    }
+    inputState.release(keyEvent.key);
+    keyEvent.update(inputState);
+    event.sendKeyUp(keyEvent.key, keyEvent, win);
+
+    resolve();
+  });
+}
+
+/**
+ * Dispatch a pause action equivalent waiting for |a.duration| milliseconds, or a
+ * default time interval of |tickDuration|.
+ *
+ * @param {action.Action} a
+ *     Action to dispatch.
+ * @param {number} tickDuration
+ *     Duration in milliseconds of this tick.
+ *
+ * @return {Promise}
+ *     Promise that is resolved after the specified time interval.
+ */
+function dispatchPause(a, tickDuration) {
+  const TIMER = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+  let duration = typeof a.duration == "undefined" ? tickDuration : a.duration;
+  return new Promise(resolve =>
+      TIMER.initWithCallback(resolve, duration, Ci.nsITimer.TYPE_ONE_SHOT)
+  );
+}
+
 // helpers
+/**
+ * Force any pending DOM events to fire.
+ *
+ * @param {?} container
+ *     Object with |frame| attribute of type |nsIDOMWindow|.
+ *
+ * @return {Promise}
+ *     Promise to flush DOM events.
+ */
+function flushEvents(container) {
+  return new Promise(resolve => container.frame.requestAnimationFrame(resolve));
+}
+
 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/assert.js
+++ b/testing/marionette/assert.js
@@ -188,16 +188,35 @@ assert.string = function (obj, msg = "")
  *     If |obj| is not an object.
  */
 assert.object = function (obj, msg = "") {
   msg = msg || error.pprint`Expected ${obj} to be an object`;
   return assert.that(o => typeof o == "object", msg)(obj);
 };
 
 /**
+ * Asserts that |obj| is an Array.
+ *
+ * @param {?} obj
+ *     Value to test.
+ * @param {string=} msg
+ *     Custom error message.
+ *
+ * @return {Object}
+ *     |obj| is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ *     If |obj| is not an Array.
+ */
+assert.array = function (obj, msg = "") {
+  msg = msg || error.pprint`Expected ${obj} to be an Array`;
+  return assert.that(o => Array.isArray(o), msg)(obj);
+};
+
+/**
  * Returns a function that is used to assert the |predicate|.
  *
  * @param {function(?): boolean} predicate
  *     Evaluated on calling the return value of this function.  If its
  *     return value of the inner function is false, |error| is thrown
  *     with |message|.
  * @param {string=} message
  *     Custom error message.
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -1640,16 +1640,42 @@ GeckoDriver.prototype.singleTap = functi
     case Context.CONTENT:
       this.addFrameCloseListener("tap");
       yield this.listener.singleTap(id, x, y);
       break;
   }
 };
 
 /**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {Array.<?>} actions
+ *     Array of objects that each represent an action sequence.
+ *
+ * @throws {UnsupportedOperationError}
+ *     If the command is made in chrome context.
+ */
+GeckoDriver.prototype.performActions = function(cmd, resp) {
+  switch (this.context) {
+    case Context.CHROME:
+      throw new UnsupportedOperationError(
+          "Command 'performActions' is not available in chrome context");
+    case Context.CONTENT:
+      return this.listener.performActions({"actions": cmd.parameters.actions});
+  }
+};
+
+/**
+ * Release all the keys and pointer buttons that are currently depressed.
+ */
+GeckoDriver.prototype.releaseActions = function(cmd, resp) {
+  return this.listener.releaseActions();
+};
+
+/**
  * An action chain.
  *
  * @param {Object} value
  *     A nested array where the inner array represents each event,
  *     and the outer array represents a collection of events.
  *
  * @return {number}
  *     Last touch ID.
@@ -2853,16 +2879,18 @@ GeckoDriver.prototype.commands = {
   "getLogs": GeckoDriver.prototype.getLogs,
   "setContext": GeckoDriver.prototype.setContext,
   "getContext": GeckoDriver.prototype.getContext,
   "executeScript": GeckoDriver.prototype.executeScript,
   "getTimeouts": GeckoDriver.prototype.getTimeouts,
   "timeouts": GeckoDriver.prototype.setTimeouts,  // deprecated until Firefox 55
   "setTimeouts": GeckoDriver.prototype.setTimeouts,
   "singleTap": GeckoDriver.prototype.singleTap,
+  "performActions": GeckoDriver.prototype.performActions,
+  "releaseActions": GeckoDriver.prototype.releaseActions,
   "actionChain": GeckoDriver.prototype.actionChain, // deprecated
   "multiAction": GeckoDriver.prototype.multiAction, // deprecated
   "executeAsyncScript": GeckoDriver.prototype.executeAsyncScript,
   "executeJSScript": GeckoDriver.prototype.executeJSScript,
   "findElement": GeckoDriver.prototype.findElement,
   "findElements": GeckoDriver.prototype.findElements,
   "clickElement": GeckoDriver.prototype.clickElement,
   "getElementAttribute": GeckoDriver.prototype.getElementAttribute,
--- a/testing/marionette/event.js
+++ b/testing/marionette/event.js
@@ -479,22 +479,22 @@ event.isKeypressFiredKey = function (key
 /**
  * Synthesise a key event.
  *
  * It is targeted at whatever would be targeted by an actual keypress
  * by the user, typically the focused element.
  *
  * @param {string} key
  *     Key to synthesise.  Should either be a character or a key code
- *     starting with "VK_" such as VK_RETURN.
+ *     starting with "VK_" such as VK_RETURN, or a normalized key value.
  * @param {Object.<string, ?>} event
  *     Object which may contain the properties shiftKey, ctrlKey, altKey,
- *     metaKey, accessKey, type.  If the type is specified, a key event
- *    of that type is fired.  Otherwise, a keydown, a keypress, and then a
- *     keyup event are fired in sequence.
+ *     metaKey, accessKey, type.  If the type is specified (keydown or keyup),
+ *     a key event of that type is fired.  Otherwise, a keydown, a keypress,
+ *     and then a keyup event are fired in sequence.
  * @param {Window=} window
  *     Window object.  Defaults to the current window.
  *
  * @throws {TypeError}
  *     If unknown key.
  */
 event.synthesizeKey = function (key, event, win = undefined)
 {
@@ -589,17 +589,20 @@ function createKeyboardEventDictionary_(
   } else if (key != "") {
     keyName = key;
     if (!keyCodeIsDefined) {
       keyCode = computeKeyCodeFromChar_(key.charAt(0));
     }
     if (!keyCode) {
       result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO;
     }
-    result.flags |= Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY;
+    // keyName was already determined in keyEvent so no fall-back needed
+    if (!("key" in keyEvent && keyName == keyEvent.key)) {
+      result.flags |= Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY;
+    }
   }
   var locationIsDefined = "location" in keyEvent;
   if (locationIsDefined && keyEvent.location === 0) {
     result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD;
   }
   result.dictionary = {
     key: keyName,
     code: "code" in keyEvent ? keyEvent.code : "",
@@ -1204,35 +1207,52 @@ function getKeyCode(c) {
     return VIRTUAL_KEYCODE_LOOKUP[c];
   }
   return c;
 }
 
 event.sendKeyDown = function (keyToSend, modifiers, document) {
   modifiers.type = "keydown";
   event.sendSingleKey(keyToSend, modifiers, document);
+  // TODO This doesn't do anything since |synthesizeKeyEvent| ignores explicit
+  // keypress request, and instead figures out itself when to send keypress
   if (["VK_SHIFT", "VK_CONTROL", "VK_ALT", "VK_META"].indexOf(getKeyCode(keyToSend)) < 0) {
     modifiers.type = "keypress";
     event.sendSingleKey(keyToSend, modifiers, document);
   }
   delete modifiers.type;
 };
 
 event.sendKeyUp = function (keyToSend, modifiers, window = undefined) {
   modifiers.type = "keyup";
   event.sendSingleKey(keyToSend, modifiers, window);
   delete modifiers.type;
 };
 
+/**
+ * Synthesize a key event for a single key.
+ *
+ * @param {string} keyToSend
+ *     Code point or normalized key value
+ * @param {?} modifiers
+ *     Object with properties used in KeyboardEvent (shiftkey, repeat, ...)
+ *     as well as, the event |type| such as keydown. All properties are optional.
+ * @param {Window=} window
+ *     Window object.  If |window| is undefined, the event is synthesized in
+ *     current window.
+ */
 event.sendSingleKey = function (keyToSend, modifiers, window = undefined) {
   let keyCode = getKeyCode(keyToSend);
   if (keyCode in KEYCODES_LOOKUP) {
+    // We assume that if |keyToSend| is a raw code point (like "\uE009") then
+    // |modifiers| does not already have correct value for corresponding
+    // |modName| attribute (like ctrlKey), so that value needs to be flipped
     let modName = KEYCODES_LOOKUP[keyCode];
     modifiers[modName] = !modifiers[modName];
-  } else if (modifiers.shiftKey) {
+  } else if (modifiers.shiftKey && keyCode != "Shift") {
     keyCode = keyCode.toUpperCase();
   }
   event.synthesizeKey(keyCode, modifiers, window);
 };
 
 /**
  * Focus element and, if a textual input field and no previous selection
  * state exists, move the caret to the end of the input field.
--- a/testing/marionette/listener.js
+++ b/testing/marionette/listener.js
@@ -8,25 +8,26 @@ 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/legacyaction.js");
+Cu.import("chrome://marionette/content/action.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");
+Cu.import("chrome://marionette/content/legacyaction.js");
 Cu.import("chrome://marionette/content/logging.js");
 Cu.import("chrome://marionette/content/navigate.js");
 Cu.import("chrome://marionette/content/proxy.js");
 Cu.import("chrome://marionette/content/simpletest.js");
 
 Cu.import("resource://gre/modules/FileUtils.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
@@ -235,16 +236,18 @@ var isElementSelectedFn = dispatch(isEle
 var clearElementFn = dispatch(clearElement);
 var isElementDisplayedFn = dispatch(isElementDisplayed);
 var getElementValueOfCssPropertyFn = dispatch(getElementValueOfCssProperty);
 var switchToShadowRootFn = dispatch(switchToShadowRoot);
 var getCookiesFn = dispatch(getCookies);
 var singleTapFn = dispatch(singleTap);
 var takeScreenshotFn = dispatch(takeScreenshot);
 var getScreenshotHashFn = dispatch(getScreenshotHash);
+var performActionsFn = dispatch(performActions);
+var releaseActionsFn = dispatch(releaseActions);
 var actionChainFn = dispatch(actionChain);
 var multiActionFn = dispatch(multiAction);
 var addCookieFn = dispatch(addCookie);
 var deleteCookieFn = dispatch(deleteCookie);
 var deleteAllCookiesFn = dispatch(deleteAllCookies);
 var executeFn = dispatch(execute);
 var executeInSandboxFn = dispatch(executeInSandbox);
 var executeSimpleTestFn = dispatch(executeSimpleTest);
@@ -254,16 +257,18 @@ var sendKeysToElementFn = dispatch(sendK
  * Start all message listeners
  */
 function startListeners() {
   addMessageListenerId("Marionette:newSession", newSession);
   addMessageListenerId("Marionette:execute", executeFn);
   addMessageListenerId("Marionette:executeInSandbox", executeInSandboxFn);
   addMessageListenerId("Marionette:executeSimpleTest", executeSimpleTestFn);
   addMessageListenerId("Marionette:singleTap", singleTapFn);
+  addMessageListenerId("Marionette:performActions", performActionsFn);
+  addMessageListenerId("Marionette:releaseActions", releaseActionsFn);
   addMessageListenerId("Marionette:actionChain", actionChainFn);
   addMessageListenerId("Marionette:multiAction", multiActionFn);
   addMessageListenerId("Marionette:get", get);
   addMessageListenerId("Marionette:pollForReadyState", pollForReadyState);
   addMessageListenerId("Marionette:cancelRequest", cancelRequest);
   addMessageListenerId("Marionette:getCurrentUrl", getCurrentUrlFn);
   addMessageListenerId("Marionette:getTitle", getTitleFn);
   addMessageListenerId("Marionette:getPageSource", getPageSourceFn);
@@ -358,16 +363,18 @@ function restart(msg) {
  * Removes all listeners
  */
 function deleteSession(msg) {
   removeMessageListenerId("Marionette:newSession", newSession);
   removeMessageListenerId("Marionette:execute", executeFn);
   removeMessageListenerId("Marionette:executeInSandbox", executeInSandboxFn);
   removeMessageListenerId("Marionette:executeSimpleTest", executeSimpleTestFn);
   removeMessageListenerId("Marionette:singleTap", singleTapFn);
+  removeMessageListenerId("Marionette:performActions", performActionsFn);
+  removeMessageListenerId("Marionette:releaseActions", releaseActionsFn);
   removeMessageListenerId("Marionette:actionChain", actionChainFn);
   removeMessageListenerId("Marionette:multiAction", multiActionFn);
   removeMessageListenerId("Marionette:get", get);
   removeMessageListenerId("Marionette:pollForReadyState", pollForReadyState);
   removeMessageListenerId("Marionette:cancelRequest", cancelRequest);
   removeMessageListenerId("Marionette:getTitle", getTitleFn);
   removeMessageListenerId("Marionette:getPageSource", getPageSourceFn);
   removeMessageListenerId("Marionette:getCurrentUrl", getCurrentUrlFn);
@@ -405,16 +412,22 @@ function deleteSession(msg) {
   if (isB2G) {
     content.removeEventListener("mozbrowsershowmodalprompt", modalHandler, false);
   }
   seenEls.clear();
   // reset container frame to the top-most frame
   curContainer = { frame: content, shadowRoot: null };
   curContainer.frame.focus();
   legacyactions.touchIds = {};
+  if (action.inputStateMap !== undefined) {
+    action.inputStateMap.clear();
+  }
+  if (action.inputsToCancel !== undefined) {
+    action.inputsToCancel.length = 0;
+  }
 }
 
 /**
  * Send asynchronous reply to chrome.
  *
  * @param {UUID} uuid
  *     Unique identifier of the request.
  * @param {AsyncContentSender.ResponseType} type
@@ -473,16 +486,18 @@ function sendLog(msg) {
 
 /**
  * Clear test values after completion of test
  */
 function resetValues() {
   sandboxes.clear();
   curContainer = {frame: content, shadowRoot: null};
   legacyactions.mouseEventsOnly = false;
+  action.inputStateMap = new Map();
+  action.inputsToCancel = [];
 }
 
 /**
  * Dump a logline to stdout. Prepends logline with a timestamp.
  */
 function dumpLog(logline) {
   dump(Date.now() + " Marionette: " + logline);
 }
@@ -662,16 +677,40 @@ function createATouch(el, corx, cory, to
   let win = doc.defaultView;
   let [clientX, clientY, pageX, pageY, screenX, screenY] =
    legacyactions.getCoordinateInfo(el, corx, cory);
   let atouch = doc.createTouch(win, el, touchId, pageX, pageY, screenX, screenY, clientX, clientY);
   return atouch;
 }
 
 /**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {obj} msg
+ *      Object with an |actions| attribute that is an Array of objects
+ *      each of which represents an action sequence.
+ */
+function performActions(msg) {
+  let chain = action.Chain.fromJson(msg.actions);
+  action.dispatch(chain, seenEls, curContainer);
+}
+
+/**
+ * The Release Actions command is used to release all the keys and pointer
+ * buttons that are currently depressed. This causes events to be fired as if
+ * the state was released by an explicit series of actions. It also clears all
+ * the internal state of the virtual devices.
+ */
+function releaseActions() {
+  action.dispatchTickActions(action.inputsToCancel.reverse(), 0, seenEls, curContainer);
+  action.inputsToCancel.length = 0;
+  action.inputStateMap.clear();
+}
+
+/**
  * Start action chain on one finger.
  */
 function actionChain(chain, touchId) {
   let touchProvider = {};
   touchProvider.createATouch = createATouch;
   touchProvider.emitTouchEvent = emitTouchEvent;
 
   return legacyactions.dispatchActions(
--- a/testing/marionette/test_action.js
+++ b/testing/marionette/test_action.js
@@ -5,16 +5,18 @@
 "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");
 
+action.inputStateMap = new Map();
+
 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"));
@@ -27,17 +29,17 @@ add_test(function test_defaultPointerPar
   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 parametersData = {pointerType: "foo", primary: undefined};
   let message = `parametersData: [pointerType: ${parametersData.pointerType}, ` +
       `primary: ${parametersData.primary}]`;
   check(/Unknown pointerType/, message, parametersData);
   parametersData.pointerType = "pen";
   parametersData.primary = "a";
   check(/Expected \[object String\] "a" to be boolean/, message, parametersData);
   parametersData.primary = false;
   deepEqual(action.PointerParameters.fromJson(parametersData),
@@ -72,29 +74,29 @@ add_test(function test_validateActionDur
     checkErrors(/Expected '.*' \(.*\) to be >= 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;
+  actionItem.duration = 5000;
   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"};
+  let actionItem = {duration: 5000, 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"};
@@ -103,38 +105,38 @@ add_test(function test_processPointerMov
 
   run_next_test();
 });
 
 add_test(function test_processPointerMoveAction() {
   let actionSequence = {id: "some_id", type: "pointer"};
   let actionItems = [
     {
-      duration: 5,
+      duration: 5000,
       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,
+      duration: 5000,
       type: "pointerMove",
       x: 0,
       y: undefined,
       element: undefined,
     },
     {
-      duration: 5,
+      duration: 5000,
       type: "pointerMove",
       x: 1,
       y: 2,
       element: undefined,
     },
   ];
   for (let expected of actionItems) {
     let actual = action.Action.fromJson(actionSequence, expected);
@@ -153,22 +155,22 @@ add_test(function test_processPointerAct
     id: "some_id",
     parameters: {
       pointerType: "touch",
       primary: false,
     },
   }
   let actionItems = [
     {
-      duration: 2,
+      duration: 2000,
       type: "pause",
     },
     {
       type: "pointerMove",
-      duration: 2,
+      duration: 2000,
     },
     {
       type: "pointerUp",
       button: 1,
     }
   ];
   for (let expected of actionItems) {
     let actual = action.Action.fromJson(actionSequence, expected);
@@ -185,17 +187,17 @@ add_test(function test_processPointerAct
       equal(actual.pointerType, actionSequence.parameters.pointerType);
     }
   }
 
   run_next_test();
 });
 
 add_test(function test_processPauseAction() {
-  let actionItem = {type: "pause", duration: 5};
+  let actionItem = {type: "pause", duration: 5000};
   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);
@@ -221,26 +223,26 @@ add_test(function test_processActionSubt
   }
   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]) {
+  for (let v of [-1, 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);
+        /Expected 'value' to be a string that represents single code point/, message);
   }
 
-  actionItem.value = "a";
+  actionItem.value = "\uE004";
   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();
@@ -317,32 +319,32 @@ add_test(function test_processInputSourc
   deepEqual(actions[0], expectedAction);
   run_next_test();
 });
 
 add_test(function test_processInputSourceActionSequenceGenerateID() {
   let actionItems = [
     {
       type: "pause",
-      duration: 5,
+      duration: 5000,
     },
   ];
   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 actionItem = {type: "pause", duration: 5000};
   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],
@@ -388,17 +390,17 @@ add_test(function test_extractActionChai
 });
 
 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 actionItem = {type: "pause", duration: 5000};
   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]);
@@ -459,16 +461,52 @@ add_test(function test_extractActionChai
 
   // 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();
 });
 
+add_test(function test_computeTickDuration() {
+  let expected = 8000;
+  let tickActions = [
+    {type: "none", subtype: "pause", duration: 5000},
+    {type: "key", subtype: "pause", duration: 1000},
+    {type: "pointer", subtype: "pointerMove", duration: 6000},
+    // invalid because keyDown should not have duration, so duration should be ignored.
+    {type: "key", subtype: "keyDown", duration: 100000},
+    {type: "pointer", subtype: "pause", duration: expected},
+    {type: "pointer", subtype: "pointerUp"},
+  ];
+  equal(expected, action.computeTickDuration(tickActions));
+  run_next_test();
+});
+
+add_test(function test_computeTickDuration_empty() {
+  equal(0, action.computeTickDuration([]));
+  run_next_test();
+});
+
+add_test(function test_computeTickDuration_noDurations() {
+  let tickActions = [
+    // invalid because keyDown should not have duration, so duration should be ignored.
+    {type: "key", subtype: "keyDown", duration: 100000},
+    // undefined duration permitted
+    {type: "none", subtype: "pause"},
+    {type: "pointer", subtype: "pointerMove"},
+    {type: "pointer", subtype: "pointerDown"},
+    {type: "key", subtype: "keyUp"},
+  ];
+
+  equal(0, action.computeTickDuration(tickActions));
+  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}`;