summaryrefslogtreecommitdiffstats
path: root/testing/marionette/action.js
diff options
context:
space:
mode:
Diffstat (limited to 'testing/marionette/action.js')
-rw-r--r--testing/marionette/action.js1506
1 files changed, 1506 insertions, 0 deletions
diff --git a/testing/marionette/action.js b/testing/marionette/action.js
new file mode 100644
index 0000000000..1c47803256
--- /dev/null
+++ b/testing/marionette/action.js
@@ -0,0 +1,1506 @@
+/* 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/. */
+
+/* eslint no-dupe-keys:off */
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["action"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ assert: "chrome://marionette/content/assert.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ event: "chrome://marionette/content/event.js",
+ pprint: "chrome://marionette/content/format.js",
+ Sleep: "chrome://marionette/content/sync.js",
+});
+
+// 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 interface for providing
+ * virtualised device input to the web browser.
+ *
+ * @namespace
+ */
+this.action = {
+ Pause: "pause",
+ KeyDown: "keyDown",
+ KeyUp: "keyUp",
+ PointerDown: "pointerDown",
+ PointerUp: "pointerUp",
+ PointerMove: "pointerMove",
+ PointerCancel: "pointerCancel",
+};
+
+const ACTIONS = {
+ none: new Set([action.Pause]),
+ key: new Set([action.Pause, action.KeyDown, action.KeyUp]),
+ pointer: new Set([
+ action.Pause,
+ action.PointerDown,
+ action.PointerUp,
+ action.PointerMove,
+ action.PointerCancel,
+ ]),
+};
+
+/** 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": "Enter",
+ "\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 values for a pointer-move origin. */
+action.PointerOrigin = {
+ Viewport: "viewport",
+ Pointer: "pointer",
+};
+
+/** Flag for WebDriver spec conforming pointer origin calculation. */
+action.specCompatPointerOrigin = true;
+
+/**
+ * Look up a PointerOrigin.
+ *
+ * @param {(string|Element)=} obj
+ * Origin for a <code>pointerMove</code> action. Must be one of
+ * "viewport" (default), "pointer", or a DOM element.
+ *
+ * @return {action.PointerOrigin}
+ * Pointer origin.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a valid origin.
+ */
+action.PointerOrigin.get = function(obj) {
+ let origin = obj;
+ if (typeof obj == "undefined") {
+ origin = this.Viewport;
+ } else if (typeof obj == "string") {
+ let name = capitalize(obj);
+ assert.in(name, this, pprint`Unknown pointer-move origin: ${obj}`);
+ origin = this[name];
+ } else if (!element.isElement(obj)) {
+ throw new error.InvalidArgumentError(
+ "Expected 'origin' to be undefined, " +
+ '"viewport", "pointer", ' +
+ pprint`or an element, got: ${obj}`
+ );
+ }
+ return origin;
+};
+
+/** Represents possible subtypes for a pointer input source. */
+action.PointerType = {
+ Mouse: "mouse",
+ // TODO For now, only mouse is supported
+ // 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}
+ * If <code>str</code> is not a valid pointer type.
+ */
+action.PointerType.get = function(str) {
+ let name = capitalize(str);
+ assert.in(name, this, pprint`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.
+ *
+ * Re-initialized in listener.js.
+ */
+action.inputStateMap = new Map();
+
+/**
+ * List of {@link 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.
+ *
+ * Re-initialized in listener.js
+ */
+action.inputsToCancel = [];
+
+/**
+ * Represents device state for an input source.
+ */
+class InputState {
+ constructor() {
+ this.type = this.constructor.name.toLowerCase();
+ }
+
+ /**
+ * Check equality of this InputState object with another.
+ *
+ * @param {InputState} other
+ * Object representing an input state.
+ *
+ * @return {boolean}
+ * True if <code>this</code> has the same <code>type</code>
+ * as <code>other</code>.
+ */
+ is(other) {
+ if (typeof other == "undefined") {
+ return false;
+ }
+ return this.type === other.type;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name}InputState]`;
+ }
+
+ /**
+ * @param {Object.<string, ?>} obj
+ * Object with property <code>type</code> and optionally
+ * <code>parameters</code> or <code>pointerType</code>,
+ * representing an action sequence or an action item.
+ *
+ * @return {action.InputState}
+ * An {@link InputState} object for the type of the
+ * {@link actionSequence}.
+ *
+ * @throws {InvalidArgumentError}
+ * If {@link actionSequence.type} is not valid.
+ */
+ static fromJSON(obj) {
+ let type = obj.type;
+ assert.in(type, ACTIONS, pprint`Unknown action type: ${type}`);
+ let name = type == "none" ? "Null" : capitalize(type);
+ if (name == "Pointer") {
+ if (
+ !obj.pointerType &&
+ (!obj.parameters || !obj.parameters.pointerType)
+ ) {
+ throw new error.InvalidArgumentError(
+ pprint`Expected obj to have pointerType, got ${obj}`
+ );
+ }
+ let pointerType = obj.pointerType || obj.parameters.pointerType;
+ return new action.InputState[name](pointerType);
+ }
+ return new action.InputState[name]();
+ }
+}
+
+/** Possible kinds of |InputState| for supported input sources. */
+action.InputState = {};
+
+/**
+ * Input state associated with a keyboard-type device.
+ */
+action.InputState.Key = class Key extends InputState {
+ 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 error.InvalidArgumentError(
+ "Expected 'key' to be one of " +
+ Object.keys(MODIFIER_NAME_LOOKUP) +
+ pprint`, 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| was present before removal, false otherwise.
+ */
+ release(key) {
+ return this.pressed.delete(key);
+ }
+};
+
+/**
+ * Input state not associated with a specific physical device.
+ */
+action.InputState.Null = class Null extends InputState {
+ constructor() {
+ super();
+ this.type = "none";
+ }
+};
+
+/**
+ * Input state associated with a pointer-type input device.
+ *
+ * @param {string} subtype
+ * Kind of pointing device: mouse, pen, touch.
+ *
+ * @throws {InvalidArgumentError}
+ * If subtype is undefined or an invalid pointer type.
+ */
+action.InputState.Pointer = class Pointer extends InputState {
+ constructor(subtype) {
+ super();
+ this.pressed = new Set();
+ assert.defined(
+ subtype,
+ pprint`Expected subtype to be defined, got ${subtype}`
+ );
+ this.subtype = action.PointerType.get(subtype);
+ this.x = 0;
+ this.y = 0;
+ }
+
+ /**
+ * Check whether |button| is pressed.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @return {boolean}
+ * True if |button| is in set of pressed buttons.
+ */
+ isPressed(button) {
+ assert.positiveInteger(button);
+ return this.pressed.has(button);
+ }
+
+ /**
+ * Add |button| to the set of pressed keys.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @return {Set}
+ * Set of pressed buttons.
+ */
+ press(button) {
+ assert.positiveInteger(button);
+ return this.pressed.add(button);
+ }
+
+ /**
+ * Remove |button| from the set of pressed buttons.
+ *
+ * @param {number} button
+ * A positive integer that refers to a mouse button.
+ *
+ * @return {boolean}
+ * True if |button| was present before removals, false otherwise.
+ */
+ release(button) {
+ assert.positiveInteger(button);
+ return this.pressed.delete(button);
+ }
+};
+
+/**
+ * Repesents an action for dispatch. Used in |action.Chain| and
+ * |action.Sequence|.
+ *
+ * @param {string} id
+ * Input source ID.
+ * @param {string} type
+ * Action type: none, key, pointer.
+ * @param {string} subtype
+ * Action subtype: {@link action.Pause}, {@link action.KeyUp},
+ * {@link action.KeyDown}, {@link action.PointerUp},
+ * {@link action.PointerDown}, {@link action.PointerMove}, or
+ * {@link action.PointerCancel}.
+ *
+ * @throws {InvalidArgumentError}
+ * If any parameters are undefined.
+ */
+action.Action = class {
+ constructor(id, type, subtype) {
+ if ([id, type, subtype].includes(undefined)) {
+ throw new error.InvalidArgumentError("Missing id, type or subtype");
+ }
+ for (let attr of [id, type, subtype]) {
+ assert.string(attr, pprint`Expected string, got ${attr}`);
+ }
+ this.id = id;
+ this.type = type;
+ this.subtype = subtype;
+ }
+
+ toString() {
+ return `[action ${this.type}]`;
+ }
+
+ /**
+ * @param {action.Sequence} actionSequence
+ * Object representing sequence of actions from one input source.
+ * @param {action.Action} actionItem
+ * Object representing a single action from |actionSequence|.
+ *
+ * @return {action.Action}
+ * An action that can be dispatched; corresponds to |actionItem|.
+ *
+ * @throws {InvalidArgumentError}
+ * If any <code>actionSequence</code> or <code>actionItem</code>
+ * attributes are invalid.
+ * @throws {UnsupportedOperationError}
+ * If <code>actionItem.type</code> is {@link action.PointerCancel}.
+ */
+ static fromJSON(actionSequence, actionItem) {
+ let type = actionSequence.type;
+ let id = actionSequence.id;
+ let subtypes = ACTIONS[type];
+ if (!subtypes) {
+ throw new error.InvalidArgumentError("Unknown type: " + type);
+ }
+ let subtype = actionItem.type;
+ if (!subtypes.has(subtype)) {
+ throw new error.InvalidArgumentError(
+ `Unknown subtype for ${type} action: ${subtype}`
+ );
+ }
+
+ let item = new action.Action(id, type, subtype);
+ if (type === "pointer") {
+ action.processPointerAction(
+ id,
+ action.PointerParameters.fromJSON(actionSequence.parameters),
+ item
+ );
+ }
+
+ switch (item.subtype) {
+ case action.KeyUp:
+ case action.KeyDown:
+ let key = actionItem.value;
+ // TODO countGraphemes
+ // TODO key.value could be a single code point like "\uE012"
+ // (see rawKey) or "grapheme cluster"
+ assert.string(
+ key,
+ "Expected 'value' to be a string that represents single code point " +
+ pprint`or grapheme cluster, got ${key}`
+ );
+ item.value = key;
+ break;
+
+ case action.PointerDown:
+ case action.PointerUp:
+ assert.positiveInteger(
+ actionItem.button,
+ pprint`Expected 'button' (${actionItem.button}) to be >= 0`
+ );
+ item.button = actionItem.button;
+ break;
+
+ case action.PointerMove:
+ item.duration = actionItem.duration;
+ if (typeof item.duration != "undefined") {
+ assert.positiveInteger(
+ item.duration,
+ pprint`Expected 'duration' (${item.duration}) to be >= 0`
+ );
+ }
+ item.origin = action.PointerOrigin.get(actionItem.origin);
+ item.x = actionItem.x;
+ if (typeof item.x != "undefined") {
+ assert.integer(
+ item.x,
+ pprint`Expected 'x' (${item.x}) to be an Integer`
+ );
+ }
+ item.y = actionItem.y;
+ if (typeof item.y != "undefined") {
+ assert.integer(
+ item.y,
+ pprint`Expected 'y' (${item.y}) to be an Integer`
+ );
+ }
+ break;
+
+ case action.PointerCancel:
+ throw new error.UnsupportedOperationError();
+
+ case action.Pause:
+ item.duration = actionItem.duration;
+ if (typeof item.duration != "undefined") {
+ // eslint-disable-next-line
+ assert.positiveInteger(item.duration,
+ pprint`Expected 'duration' (${item.duration}) to be >= 0`
+ );
+ }
+ break;
+ }
+
+ return item;
+ }
+};
+
+/**
+ * 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
+ * Array of objects that each represent an action sequence.
+ *
+ * @return {action.Chain}
+ * Transpose of <var>actions</var> such that actions to be performed
+ * in a single tick are grouped together.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>actions</var> is not an Array.
+ */
+ static fromJSON(actions) {
+ assert.array(
+ actions,
+ pprint`Expected 'actions' to be an array, got ${actions}`
+ );
+
+ let actionsByTick = new action.Chain();
+ for (let actionSequence of actions) {
+ // TODO(maja_zf): Check that each actionSequence in actions refers
+ // to a different input ID.
+ let inputSourceActions = action.Sequence.fromJSON(actionSequence);
+ for (let i = 0; i < inputSourceActions.length; i++) {
+ // new tick
+ if (actionsByTick.length < i + 1) {
+ actionsByTick.push([]);
+ }
+ actionsByTick[i].push(inputSourceActions[i]);
+ }
+ }
+ return actionsByTick;
+ }
+};
+
+/**
+ * Represents one input source action sequence; this is essentially an
+ * |Array.<action.Action>|.
+ */
+action.Sequence = class extends Array {
+ toString() {
+ return `[sequence ${super.toString()}]`;
+ }
+
+ /**
+ * @param {Object.<string, ?>} actionSequence
+ * Object that represents a sequence action items for one input source.
+ *
+ * @return {action.Sequence}
+ * Sequence of actions that can be dispatched.
+ *
+ * @throws {InvalidArgumentError}
+ * If <code>actionSequence.id</code> is not a
+ * string or it's aleady mapped to an |action.InputState}
+ * incompatible with <code>actionSequence.type</code>, or if
+ * <code>actionSequence.actions</code> is not an <code>Array</code>.
+ */
+ static fromJSON(actionSequence) {
+ // used here to validate 'type' in addition to InputState type below
+ let inputSourceState = InputState.fromJSON(actionSequence);
+ let id = actionSequence.id;
+ assert.defined(id, "Expected 'id' to be defined");
+ assert.string(id, pprint`Expected 'id' to be a string, got ${id}`);
+ let actionItems = actionSequence.actions;
+ assert.array(
+ actionItems,
+ "Expected 'actionSequence.actions' to be an array, " +
+ pprint`got ${actionSequence.actions}`
+ );
+
+ if (!action.inputStateMap.has(id)) {
+ action.inputStateMap.set(id, inputSourceState);
+ } else if (!action.inputStateMap.get(id).is(inputSourceState)) {
+ throw new error.InvalidArgumentError(
+ `Expected ${id} to be mapped to ${inputSourceState}, ` +
+ `got ${action.inputStateMap.get(id)}`
+ );
+ }
+
+ let actions = new action.Sequence();
+ for (let actionItem of actionItems) {
+ actions.push(action.Action.fromJSON(actionSequence, actionItem));
+ }
+
+ return actions;
+ }
+};
+
+/**
+ * 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.
+ */
+action.PointerParameters = class {
+ constructor(pointerType = "mouse") {
+ this.pointerType = action.PointerType.get(pointerType);
+ }
+
+ toString() {
+ return `[pointerParameters ${this.pointerType}]`;
+ }
+
+ /**
+ * @param {Object.<string, ?>} parametersData
+ * Object that represents pointer parameters.
+ *
+ * @return {action.PointerParameters}
+ * Validated pointer paramters.
+ */
+ static fromJSON(parametersData) {
+ if (typeof parametersData == "undefined") {
+ return new action.PointerParameters();
+ }
+ return new action.PointerParameters(parametersData.pointerType);
+ }
+};
+
+/**
+ * Adds <var>pointerType</var> attribute to Action <var>act</var>.
+ *
+ * Helper function for {@link action.Action.fromJSON}.
+ *
+ * @param {string} id
+ * Input source ID.
+ * @param {action.PointerParams} pointerParams
+ * Input source pointer parameters.
+ * @param {action.Action} act
+ * Action to be updated.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is already mapped to an
+ * {@link action.InputState} that is not compatible with
+ * <code>act.type</code> or <code>pointerParams.pointerType</code>.
+ */
+action.processPointerAction = function(id, pointerParams, act) {
+ if (
+ action.inputStateMap.has(id) &&
+ action.inputStateMap.get(id).type !== act.type
+ ) {
+ throw new error.InvalidArgumentError(
+ `Expected 'id' ${id} to be mapped to InputState whose type is ` +
+ action.inputStateMap.get(id).type +
+ pprint` , got ${act.type}`
+ );
+ }
+ let pointerType = pointerParams.pointerType;
+ if (
+ action.inputStateMap.has(id) &&
+ action.inputStateMap.get(id).subtype !== pointerType
+ ) {
+ throw new error.InvalidArgumentError(
+ `Expected 'id' ${id} to be mapped to InputState whose subtype is ` +
+ action.inputStateMap.get(id).subtype +
+ pprint` , got ${pointerType}`
+ );
+ }
+ act.pointerType = pointerParams.pointerType;
+};
+
+/** 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;
+ // keyCode will be computed by event.sendKeyDown
+ }
+
+ update(inputState) {
+ this.altKey = inputState.alt;
+ this.shiftKey = inputState.shift;
+ this.ctrlKey = inputState.ctrl;
+ this.metaKey = inputState.meta;
+ }
+};
+
+/** Collect properties associated with MouseEvent */
+action.Mouse = class {
+ constructor(type, button = 0) {
+ this.type = type;
+ assert.positiveInteger(button);
+ this.button = button;
+ this.buttons = 0;
+ this.altKey = false;
+ this.shiftKey = false;
+ this.metaKey = false;
+ this.ctrlKey = false;
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (let inputState of action.inputStateMap.values()) {
+ if (inputState.type == "key") {
+ this.altKey = inputState.alt || this.altKey;
+ this.ctrlKey = inputState.ctrl || this.ctrlKey;
+ this.metaKey = inputState.meta || this.metaKey;
+ this.shiftKey = inputState.shift || this.shiftKey;
+ }
+ }
+ }
+
+ update(inputState) {
+ let allButtons = Array.from(inputState.pressed);
+ this.buttons = allButtons.reduce((a, i) => a + Math.pow(2, i), 0);
+ }
+};
+
+/**
+ * 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 {WindowProxy} win
+ * Current window global.
+ * @param {boolean=} [specCompatPointerOrigin=true] specCompatPointerOrigin
+ * Flag to turn off the WebDriver spec conforming pointer origin
+ * calculation. It has to be kept until all Selenium bindings can
+ * successfully handle the WebDriver spec conforming Pointer Origin
+ * calculation. See https://bugzilla.mozilla.org/show_bug.cgi?id=1429338.
+ *
+ * @return {Promise}
+ * Promise for dispatching all actions in |chain|.
+ */
+action.dispatch = function(chain, win, specCompatPointerOrigin = true) {
+ action.specCompatPointerOrigin = specCompatPointerOrigin;
+
+ let chainEvents = (async () => {
+ for (let tickActions of chain) {
+ await action.dispatchTickActions(
+ tickActions,
+ action.computeTickDuration(tickActions),
+ win
+ );
+ }
+ })();
+ 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 {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise for dispatching all tick-actions and pending DOM events.
+ */
+action.dispatchTickActions = function(tickActions, tickDuration, win) {
+ let pendingEvents = tickActions.map(toEvents(tickDuration, win));
+ return Promise.all(pendingEvents);
+};
+
+/**
+ * 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;
+};
+
+/**
+ * Compute viewport coordinates of pointer target based on given origin.
+ *
+ * @param {action.Action} a
+ * Action that specifies pointer origin and x and y coordinates of target.
+ * @param {action.InputState} inputState
+ * Input state that specifies current x and y coordinates of pointer.
+ * @param {Map.<string, number>=} center
+ * Object representing x and y coordinates of an element center-point.
+ * This is only used if |a.origin| is a web element reference.
+ *
+ * @return {Map.<string, number>}
+ * x and y coordinates of pointer destination.
+ */
+action.computePointerDestination = function(a, inputState, center = undefined) {
+ let { x, y } = a;
+ switch (a.origin) {
+ case action.PointerOrigin.Viewport:
+ break;
+ case action.PointerOrigin.Pointer:
+ x += inputState.x;
+ y += inputState.y;
+ break;
+ default:
+ // origin represents web element
+ assert.defined(center);
+ assert.in("x", center);
+ assert.in("y", center);
+ x += center.x;
+ y += center.y;
+ }
+ return { x, y };
+};
+
+/**
+ * Create a closure to use as a map from action definitions to Promise events.
+ *
+ * @param {number} tickDuration
+ * Duration in milliseconds of this tick.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @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, win) {
+ return a => {
+ let inputState = action.inputStateMap.get(a.id);
+
+ switch (a.subtype) {
+ case action.KeyUp:
+ return dispatchKeyUp(a, inputState, win);
+
+ case action.KeyDown:
+ return dispatchKeyDown(a, inputState, win);
+
+ case action.PointerDown:
+ return dispatchPointerDown(a, inputState, win);
+
+ case action.PointerUp:
+ return dispatchPointerUp(a, inputState, win);
+
+ case action.PointerMove:
+ return dispatchPointerMove(a, inputState, tickDuration, win);
+
+ case action.PointerCancel:
+ throw new error.UnsupportedOperationError();
+
+ case action.Pause:
+ return dispatchPause(a, tickDuration);
+ }
+
+ return undefined;
+ };
+}
+
+/**
+ * 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 {WindowProxy} win
+ * Current window global.
+ *
+ * @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(a.value, 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 {WindowProxy} win
+ * Current window global.
+ *
+ * @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(a.value, keyEvent, win);
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a pointerDown action equivalent to pressing a pointer-device
+ * button.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least a pointerdown event.
+ */
+function dispatchPointerDown(a, inputState, win) {
+ return new Promise(resolve => {
+ if (inputState.isPressed(a.button)) {
+ resolve();
+ return;
+ }
+
+ inputState.press(a.button);
+ // Append a copy of |a| with pointerUp subtype
+ let copy = Object.assign({}, a, { subtype: action.PointerUp });
+ action.inputsToCancel.push(copy);
+
+ switch (inputState.subtype) {
+ case action.PointerType.Mouse:
+ let mouseEvent = new action.Mouse("mousedown", a.button);
+ mouseEvent.update(inputState);
+ if (mouseEvent.ctrlKey) {
+ if (Services.appinfo.OS === "Darwin") {
+ mouseEvent.button = 2;
+ event.DoubleClickTracker.resetClick();
+ }
+ } else if (event.DoubleClickTracker.isClicked()) {
+ mouseEvent = Object.assign({}, mouseEvent, { clickCount: 2 });
+ }
+ event.synthesizeMouseAtPoint(
+ inputState.x,
+ inputState.y,
+ mouseEvent,
+ win
+ );
+ if (
+ event.MouseButton.isSecondary(a.button) ||
+ (mouseEvent.ctrlKey && Services.appinfo.OS === "Darwin")
+ ) {
+ let contextMenuEvent = Object.assign({}, mouseEvent, {
+ type: "contextmenu",
+ });
+ event.synthesizeMouseAtPoint(
+ inputState.x,
+ inputState.y,
+ contextMenuEvent,
+ win
+ );
+ }
+ break;
+
+ case action.PointerType.Pen:
+ case action.PointerType.Touch:
+ throw new error.UnsupportedOperationError(
+ "Only 'mouse' pointer type is supported"
+ );
+
+ default:
+ throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+ }
+
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a pointerUp action equivalent to releasing a pointer-device
+ * button.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least a pointerup event.
+ */
+function dispatchPointerUp(a, inputState, win) {
+ return new Promise(resolve => {
+ if (!inputState.isPressed(a.button)) {
+ resolve();
+ return;
+ }
+
+ inputState.release(a.button);
+
+ switch (inputState.subtype) {
+ case action.PointerType.Mouse:
+ let mouseEvent = new action.Mouse("mouseup", a.button);
+ mouseEvent.update(inputState);
+ if (event.DoubleClickTracker.isClicked()) {
+ mouseEvent = Object.assign({}, mouseEvent, { clickCount: 2 });
+ }
+ event.synthesizeMouseAtPoint(
+ inputState.x,
+ inputState.y,
+ mouseEvent,
+ win
+ );
+ break;
+
+ case action.PointerType.Pen:
+ case action.PointerType.Touch:
+ throw new error.UnsupportedOperationError(
+ "Only 'mouse' pointer type is supported"
+ );
+
+ default:
+ throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+ }
+
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a pointerMove action equivalent to moving pointer device
+ * in a line.
+ *
+ * If the action duration is 0, the pointer jumps immediately to the
+ * target coordinates. Otherwise, events are synthesized to mimic a
+ * pointer travelling in a discontinuous, approximately straight line,
+ * with the pointer coordinates being updated around 60 times per second.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least one pointermove event, as well as
+ * mousemove events as appropriate.
+ */
+function dispatchPointerMove(a, inputState, tickDuration, win) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ // interval between pointermove increments in ms, based on common vsync
+ const fps60 = 17;
+
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const [startX, startY] = [inputState.x, inputState.y];
+
+ let coords = getElementCenter(a.origin, win);
+ let target = action.computePointerDestination(a, inputState, coords);
+ const [targetX, targetY] = [target.x, target.y];
+
+ if (!inViewPort(targetX, targetY, win)) {
+ throw new error.MoveTargetOutOfBoundsError(
+ `(${targetX}, ${targetY}) is out of bounds of viewport ` +
+ `width (${win.innerWidth}) ` +
+ `and height (${win.innerHeight})`
+ );
+ }
+
+ const duration =
+ typeof a.duration == "undefined" ? tickDuration : a.duration;
+ if (duration === 0) {
+ // move pointer to destination in one step
+ performOnePointerMove(inputState, targetX, targetY, win);
+ resolve();
+ return;
+ }
+
+ const distanceX = targetX - startX;
+ const distanceY = targetY - startY;
+ const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
+ let intermediatePointerEvents = (async () => {
+ // wait |fps60| ms before performing first incremental pointer move
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ let durationRatio = Math.floor(Date.now() - start) / duration;
+ const epsilon = fps60 / duration / 10;
+ while (1 - durationRatio > epsilon) {
+ let x = Math.floor(durationRatio * distanceX + startX);
+ let y = Math.floor(durationRatio * distanceY + startY);
+ performOnePointerMove(inputState, x, y, win);
+ // wait |fps60| ms before performing next pointer move
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ durationRatio = Math.floor(Date.now() - start) / duration;
+ }
+ })();
+
+ // perform last pointer move after all incremental moves are resolved and
+ // durationRatio is close enough to 1
+ intermediatePointerEvents
+ .then(() => {
+ performOnePointerMove(inputState, targetX, targetY, win);
+ resolve();
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+}
+
+function performOnePointerMove(inputState, targetX, targetY, win) {
+ if (targetX == inputState.x && targetY == inputState.y) {
+ return;
+ }
+
+ switch (inputState.subtype) {
+ case action.PointerType.Mouse:
+ let mouseEvent = new action.Mouse("mousemove");
+ mouseEvent.update(inputState);
+ // TODO both pointermove (if available) and mousemove
+ event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win);
+ break;
+
+ case action.PointerType.Pen:
+ case action.PointerType.Touch:
+ throw new error.UnsupportedOperationError(
+ "Only 'mouse' pointer type is supported"
+ );
+
+ default:
+ throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+ }
+
+ inputState.x = targetX;
+ inputState.y = targetY;
+}
+
+/**
+ * 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) {
+ let ms = typeof a.duration == "undefined" ? tickDuration : a.duration;
+ return Sleep(ms);
+}
+
+// helpers
+
+function capitalize(str) {
+ assert.string(str);
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+function inViewPort(x, y, win) {
+ assert.number(x, `Expected x to be finite number`);
+ assert.number(y, `Expected y to be finite number`);
+ // Viewport includes scrollbars if rendered.
+ return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight);
+}
+
+function getElementCenter(el, win) {
+ if (element.isElement(el)) {
+ if (action.specCompatPointerOrigin) {
+ return element.getInViewCentrePoint(el.getClientRects()[0], win);
+ }
+ return element.coordinates(el);
+ }
+ return {};
+}