Bug 1320389 - Implement dispatch of key actions in content context; r=ato
authorMaja Frydrychowicz <mjzffr@gmail.com>
Tue, 13 Dec 2016 18:29:48 -0500
changeset 326658 14c79da883448c56bccbe2f5559de5ef53052866
parent 326657 1f6800191787b2d77acaa0b4f11ee351e14e5ceb
child 326659 6b0e836a33a9647097c8392bb7f6ceacbda4256b
push id85010
push userphilringnalda@gmail.com
push dateWed, 21 Dec 2016 04:21:25 +0000
treeherdermozilla-inbound@009bb9bc85e4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersato
bugs1320389
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
@@ -1622,16 +1622,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.
@@ -2834,16 +2860,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");
@@ -234,16 +235,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);
@@ -253,16 +256,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);
@@ -357,16 +362,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);
@@ -404,16 +411,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
@@ -472,16 +485,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);
 }
@@ -661,16 +676,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}`;