1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /* eslint no-dupe-keys:off */
6 /* eslint-disable no-restricted-globals */
10 ChromeUtils.defineESModuleGetters(lazy, {
11 clearTimeout: "resource://gre/modules/Timer.sys.mjs",
12 setTimeout: "resource://gre/modules/Timer.sys.mjs",
14 AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
15 assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
16 AsyncQueue: "chrome://remote/content/shared/AsyncQueue.sys.mjs",
17 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
18 event: "chrome://remote/content/shared/webdriver/Event.sys.mjs",
19 keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs",
20 Log: "chrome://remote/content/shared/Log.sys.mjs",
21 pprint: "chrome://remote/content/shared/Format.sys.mjs",
22 Sleep: "chrome://remote/content/marionette/sync.sys.mjs",
25 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
26 lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
29 // TODO? With ES 2016 and Symbol you can make a safer approximation
30 // to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689
32 * Implements WebDriver Actions API: a low-level interface for providing
33 * virtualized device input to the web browser.
35 * Typical usage is to construct an action chain and then dispatch it:
36 * const state = new actions.State();
37 * const chain = await actions.Chain.fromJSON(state, protocolData);
38 * await chain.dispatch(state, window);
42 export const actions = {};
44 // Max interval between two clicks that should result in a dblclick or a tripleclick (in ms)
45 export const CLICK_INTERVAL = 640;
47 /** Map from normalized key value to UI Events modifier key name */
48 const MODIFIER_NAME_LOOKUP = {
56 * Object containing various callback functions to be used when deserializing
57 * action sequences and dispatching these.
59 * @typedef {object} ActionsOptions
60 * @property {Function} isElementOrigin
61 * Function to check if it's a valid origin element.
62 * @property {Function} getElementOrigin
63 * Function to retrieve the element reference for an element origin.
64 * @property {Function} assertInViewPort
65 * Function to check if the coordinates [x, y] are in the visible viewport.
66 * @property {Function} dispatchEvent
67 * Function to use for dispatching events.
68 * @property {Function} getClientRects
69 * Function that retrieves the client rects for an element.
70 * @property {Function} getInViewCentrePoint
71 * Function that calculates the in-view center point for the given
76 * State associated with actions.
78 * Typically each top-level navigable in a WebDriver session should have a
79 * single State object.
81 actions.State = class {
85 * Creates a new {@link State} instance.
88 // A queue that ensures that access to the input state is serialized.
89 this.#actionsQueue = new lazy.AsyncQueue();
91 // Tracker for mouse button clicks.
92 this.clickTracker = new ClickTracker();
95 * A map between input ID and the device state for that input
96 * source, with one entry for each active input source.
98 * Maps string => InputSource
100 this.inputStateMap = new Map();
103 * List of {@link Action} associated with current session. Used to
104 * manage dispatching events when resetting the state of the input sources.
105 * Reset operations are assumed to be idempotent.
107 this.inputsToCancel = new TickActions();
109 // Map between string input id and numeric pointer id.
110 this.pointerIdMap = new Map();
114 * Returns the list of inputs to cancel when releasing the actions.
116 * @returns {TickActions}
117 * The inputs to cancel.
119 get inputCancelList() {
120 return this.inputsToCancel;
124 return `[object ${this.constructor.name} ${JSON.stringify(this)}]`;
128 * Enqueue a new action task.
130 * @param {Function} task
134 * Promise that resolves when the task is completed, with the resolved
135 * value being the result of the task.
137 enqueueAction(task) {
138 return this.#actionsQueue.enqueue(task);
142 * Get the state for a given input source.
145 * Id of the input source.
147 * @returns {InputSource}
148 * State of the input source.
151 return this.inputStateMap.get(id);
155 * Find or add state for an input source.
157 * The caller should verify that the returned state is the expected type.
160 * Id of the input source.
161 * @param {InputSource} newInputSource
162 * State of the input source.
164 * @returns {InputSource}
167 getOrAddInputSource(id, newInputSource) {
168 let inputSource = this.getInputSource(id);
170 if (inputSource === undefined) {
171 this.inputStateMap.set(id, newInputSource);
172 inputSource = newInputSource;
179 * Iterate over all input states of a given type.
181 * @param {string} type
182 * Type name of the input source (e.g. "pointer").
184 * @returns {Iterator<string, InputSource>}
185 * Iterator over id and input source.
187 *inputSourcesByType(type) {
188 for (const [id, inputSource] of this.inputStateMap) {
189 if (inputSource.type === type) {
190 yield [id, inputSource];
196 * Get a numerical pointer id for a given pointer.
198 * Pointer ids are positive integers. Mouse pointers are typically
199 * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each
200 * pointer gets a unique id.
204 * @param {string} type
205 * Type of the pointer.
208 * The numerical pointer id.
210 getPointerId(id, type) {
211 let pointerId = this.pointerIdMap.get(id);
213 if (pointerId === undefined) {
214 // Reserve pointer ids 0 and 1 for mouse pointers
215 const idValues = Array.from(this.pointerIdMap.values());
217 if (type === "mouse") {
218 for (const mouseId of [0, 1]) {
219 if (!idValues.includes(mouseId)) {
226 if (pointerId === undefined) {
227 pointerId = Math.max(1, ...idValues) + 1;
229 this.pointerIdMap.set(id, pointerId);
237 * Tracker for mouse button clicks.
239 export class ClickTracker {
245 * Creates a new {@link ClickTracker} instance.
249 this.#lastButtonClicked = null;
257 lazy.clearTimeout(this.#timer);
261 this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL);
265 * Reset tracking mouse click counter.
270 this.#lastButtonClicked = null;
274 * Track |button| click to identify possible double or triple click.
276 * @param {number} button
277 * A positive integer that refers to a mouse button.
283 this.#lastButtonClicked === null ||
284 this.#lastButtonClicked === button
291 this.#lastButtonClicked = button;
297 * Device state for an input source.
304 * Creates a new {@link InputSource} instance.
307 * Id of {@link InputSource}.
311 this.type = this.constructor.type;
315 return `[object ${this.constructor.name} id: ${this.#id} type: ${
321 * Unmarshals a JSON Object to an {@link InputSource}.
323 * @see https://w3c.github.io/webdriver/#dfn-get-or-create-an-input-source
325 * @param {State} actionState
327 * @param {Sequence} actionSequence
328 * Actions for a specific input source.
330 * @returns {InputSource}
331 * An {@link InputSource} object for the type of the
332 * action {@link Sequence}.
334 * @throws {InvalidArgumentError}
335 * If the <code>actionSequence</code> is invalid.
337 static fromJSON(actionState, actionSequence) {
338 const { id, type } = actionSequence;
342 lazy.pprint`Expected "id" to be a string, got ${id}`
345 const cls = inputSourceTypes.get(type);
346 if (cls === undefined) {
347 throw new lazy.error.InvalidArgumentError(
348 lazy.pprint`Expected known action type, got ${type}`
352 const sequenceInputSource = cls.fromJSON(actionState, actionSequence);
353 const inputSource = actionState.getOrAddInputSource(
358 if (inputSource.type !== type) {
359 throw new lazy.error.InvalidArgumentError(
360 lazy.pprint`Expected input source ${id} to be ` +
361 `type ${inputSource.type}, got ${type}`
368 * Input state not associated with a specific physical device.
370 class NullInputSource extends InputSource {
371 static type = "none";
374 * Unmarshals a JSON Object to a {@link NullInputSource}.
376 * @param {State} actionState
378 * @param {Sequence} actionSequence
379 * Actions for a specific input source.
381 * @returns {NullInputSource}
382 * A {@link NullInputSource} object for the type of the
383 * action {@link Sequence}.
385 * @throws {InvalidArgumentError}
386 * If the <code>actionSequence</code> is invalid.
388 static fromJSON(actionState, actionSequence) {
389 const { id } = actionSequence;
396 * Input state associated with a keyboard-type device.
398 class KeyInputSource extends InputSource {
402 * Creates a new {@link KeyInputSource} instance.
405 * Id of {@link InputSource}.
410 this.pressed = new Set();
418 * Unmarshals a JSON Object to a {@link KeyInputSource}.
420 * @param {State} actionState
422 * @param {Sequence} actionSequence
423 * Actions for a specific input source.
425 * @returns {KeyInputSource}
426 * A {@link KeyInputSource} object for the type of the
427 * action {@link Sequence}.
429 * @throws {InvalidArgumentError}
430 * If the <code>actionSequence</code> is invalid.
432 static fromJSON(actionState, actionSequence) {
433 const { id } = actionSequence;
439 * Update modifier state according to |key|.
441 * @param {string} key
442 * Normalized key value of a modifier key.
443 * @param {boolean} value
444 * Value to set the modifier attribute to.
446 * @throws {InvalidArgumentError}
447 * If |key| is not a modifier.
449 setModState(key, value) {
450 if (key in MODIFIER_NAME_LOOKUP) {
451 this[MODIFIER_NAME_LOOKUP[key]] = value;
453 throw new lazy.error.InvalidArgumentError(
454 lazy.pprint`Expected "key" to be one of ${Object.keys(
462 * Check whether |key| is pressed.
464 * @param {string} key
465 * Normalized key value.
468 * True if |key| is in set of pressed keys.
471 return this.pressed.has(key);
475 * Add |key| to the set of pressed keys.
477 * @param {string} key
478 * Normalized key value.
481 * True if |key| is in list of pressed keys.
484 return this.pressed.add(key);
488 * Remove |key| from the set of pressed keys.
490 * @param {string} key
491 * Normalized key value.
494 * True if |key| was present before removal, false otherwise.
497 return this.pressed.delete(key);
502 * Input state associated with a pointer-type device.
504 class PointerInputSource extends InputSource {
505 static type = "pointer";
508 * Creates a new {@link PointerInputSource} instance.
511 * Id of {@link InputSource}.
512 * @param {Pointer} pointer
513 * The specific {@link Pointer} type associated with this input source.
515 constructor(id, pointer) {
518 this.pointer = pointer;
521 this.pressed = new Set();
525 * Check whether |button| is pressed.
527 * @param {number} button
528 * Positive integer that refers to a mouse button.
531 * True if |button| is in set of pressed buttons.
534 lazy.assert.positiveInteger(
536 lazy.pprint`Expected "button" to be a positive integer, got ${button}`
539 return this.pressed.has(button);
543 * Add |button| to the set of pressed keys.
545 * @param {number} button
546 * Positive integer that refers to a mouse button.
549 lazy.assert.positiveInteger(
551 lazy.pprint`Expected "button" to be a positive integer, got ${button}`
554 this.pressed.add(button);
558 * Remove |button| from the set of pressed buttons.
560 * @param {number} button
561 * A positive integer that refers to a mouse button.
564 * True if |button| was present before removals, false otherwise.
567 lazy.assert.positiveInteger(
569 lazy.pprint`Expected "button" to be a positive integer, got ${button}`
572 return this.pressed.delete(button);
576 * Unmarshals a JSON Object to a {@link PointerInputSource}.
578 * @param {State} actionState
580 * @param {Sequence} actionSequence
581 * Actions for a specific input source.
583 * @returns {PointerInputSource}
584 * A {@link PointerInputSource} object for the type of the
585 * action {@link Sequence}.
587 * @throws {InvalidArgumentError}
588 * If the <code>actionSequence</code> is invalid.
590 static fromJSON(actionState, actionSequence) {
591 const { id, parameters } = actionSequence;
592 let pointerType = "mouse";
594 if (parameters !== undefined) {
597 lazy.pprint`Expected "parameters" to be an object, got ${parameters}`
600 if (parameters.pointerType !== undefined) {
601 pointerType = lazy.assert.string(
602 parameters.pointerType,
604 `Expected "pointerType" to be a string, got ${parameters.pointerType}`
608 if (!["mouse", "pen", "touch"].includes(pointerType)) {
609 throw new lazy.error.InvalidArgumentError(
610 lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"`
616 const pointerId = actionState.getPointerId(id, pointerType);
617 const pointer = Pointer.fromJSON(pointerId, pointerType);
619 return new this(id, pointer);
624 * Input state associated with a wheel-type device.
626 class WheelInputSource extends InputSource {
627 static type = "wheel";
630 * Unmarshals a JSON Object to a {@link WheelInputSource}.
632 * @param {State} actionState
634 * @param {Sequence} actionSequence
635 * Actions for a specific input source.
637 * @returns {WheelInputSource}
638 * A {@link WheelInputSource} object for the type of the
639 * action {@link Sequence}.
641 * @throws {InvalidArgumentError}
642 * If the <code>actionSequence</code> is invalid.
644 static fromJSON(actionState, actionSequence) {
645 const { id } = actionSequence;
651 const inputSourceTypes = new Map();
658 inputSourceTypes.set(cls.type, cls);
662 * Representation of a coordinate origin
666 * Viewport coordinates of the origin of this coordinate system.
668 * This is overridden in subclasses to provide a class-specific origin.
670 getOriginCoordinates() {
672 `originCoordinates not defined for ${this.constructor.name}`
677 * Convert [x, y] coordinates to viewport coordinates.
679 * @param {InputSource} inputSource
680 * State of the current input device
681 * @param {Array<number>} coords
682 * Coordinates [x, y] of the target relative to the origin.
683 * @param {ActionsOptions} options
684 * Configuration of actions dispatch.
686 * @returns {Array<number>}
687 * Viewport coordinates [x, y].
689 async getTargetCoordinates(inputSource, coords, options) {
690 const [x, y] = coords;
691 const origin = await this.getOriginCoordinates(inputSource, options);
693 return [origin.x + x, origin.y + y];
697 * Unmarshals a JSON Object to an {@link Origin}.
699 * @param {string|Element=} origin
700 * Type of origin, one of "viewport", "pointer", {@link Element}
702 * @param {ActionsOptions} options
703 * Configuration for actions.
705 * @returns {Promise<Origin>}
706 * Promise that resolves to an {@link Origin} object
707 * representing the origin.
709 * @throws {InvalidArgumentError}
710 * If <code>origin</code> isn't a valid origin.
712 static async fromJSON(origin, options) {
713 const { context, getElementOrigin, isElementOrigin } = options;
715 if (origin === undefined || origin === "viewport") {
716 return new ViewportOrigin();
719 if (origin === "pointer") {
720 return new PointerOrigin();
723 if (isElementOrigin(origin)) {
724 const element = await getElementOrigin(origin, context);
726 return new ElementOrigin(element);
729 throw new lazy.error.InvalidArgumentError(
730 `Expected "origin" to be undefined, "viewport", "pointer", ` +
731 lazy.pprint`or an element, got: ${origin}`
736 class ViewportOrigin extends Origin {
737 getOriginCoordinates() {
738 return { x: 0, y: 0 };
742 class PointerOrigin extends Origin {
743 getOriginCoordinates(inputSource) {
744 return { x: inputSource.x, y: inputSource.y };
749 * Representation of an element origin.
751 class ElementOrigin extends Origin {
753 * Creates a new {@link ElementOrigin} instance.
755 * @param {Element} element
756 * The element providing the coordinate origin.
758 constructor(element) {
761 this.element = element;
765 * Retrieve the coordinates of the origin's in-view center point.
767 * @param {InputSource} _inputSource
768 * [unused] Current input device.
769 * @param {ActionsOptions} options
771 * @returns {Promise<Array<number>>}
772 * Promise that resolves to the coordinates [x, y].
774 async getOriginCoordinates(_inputSource, options) {
775 const { context, getClientRects, getInViewCentrePoint } = options;
777 const clientRects = await getClientRects(this.element, context);
779 // The spec doesn't handle this case: https://github.com/w3c/webdriver/issues/1642
780 if (!clientRects.length) {
781 throw new lazy.error.MoveTargetOutOfBoundsError(
782 lazy.pprint`Origin element ${this.element} is not displayed`
786 return getInViewCentrePoint(clientRects[0], context);
791 * Represents the behavior of a single input source at a single
795 /** Type of the input source associated with this action */
797 /** Type of action specific to the input source */
798 static subtype = null;
799 /** Whether this kind of action affects the overall duration of a tick */
800 affectsWallClockTime = false;
803 * Creates a new {@link Action} instance.
806 * Id of {@link InputSource}.
810 this.type = this.constructor.type;
811 this.subtype = this.constructor.subtype;
815 return `[${this.constructor.name} ${this.type}:${this.subtype}]`;
819 * Dispatch the action to the relevant window.
821 * This is overridden by subclasses to implement the type-specific
822 * dispatch of the action.
825 * Promise that is resolved once the action is complete.
829 `Action subclass ${this.constructor.name} must override dispatch()`
834 * Unmarshals a JSON Object to an {@link Action}.
836 * @param {string} type
837 * Type of {@link InputSource}.
839 * Id of {@link InputSource}.
840 * @param {object} actionItem
841 * Object representing a single action.
842 * @param {ActionsOptions} options
843 * Configuration for actions.
845 * @returns {Promise<Action>}
846 * Promise that resolves to an action that can be dispatched.
848 * @throws {InvalidArgumentError}
849 * If the <code>actionItem</code> attribute is invalid.
851 static fromJSON(type, id, actionItem, options) {
854 lazy.pprint`Expected "action" to be an object, got ${actionItem}`
857 const subtype = actionItem.type;
858 const subtypeMap = actionTypes.get(type);
860 if (subtypeMap === undefined) {
861 throw new lazy.error.InvalidArgumentError(
862 lazy.pprint`Expected known action type, got ${type}`
866 let cls = subtypeMap.get(subtype);
867 // Non-device specific actions can happen for any action type
868 if (cls === undefined) {
869 cls = actionTypes.get("none").get(subtype);
871 if (cls === undefined) {
872 throw new lazy.error.InvalidArgumentError(
873 lazy.pprint`Expected known subtype for type ${type}, got ${subtype}`
877 return cls.fromJSON(id, actionItem, options);
882 * Action not associated with a specific input device.
884 class NullAction extends Action {
885 static type = "none";
889 * Action that waits for a given duration.
891 class PauseAction extends NullAction {
892 static subtype = "pause";
893 affectsWallClockTime = true;
896 * Creates a new {@link PauseAction} instance.
899 * Id of {@link InputSource}.
900 * @param {object} options
901 * @param {number} options.duration
902 * Time to pause, in ms.
904 constructor(id, options) {
907 const { duration } = options;
908 this.duration = duration;
912 * Dispatch pause action.
914 * @param {State} state
915 * The {@link State} of the action.
916 * @param {InputSource} inputSource
917 * Current input device.
918 * @param {number} tickDuration
919 * Length of the current tick, in ms.
922 * Promise that is resolved once the action is complete.
924 dispatch(state, inputSource, tickDuration) {
925 const ms = this.duration ?? tickDuration;
928 ` Dispatch ${this.constructor.name} with ${this.id} ${ms}`
931 return lazy.Sleep(ms);
935 * Unmarshals a JSON Object to a {@link PauseAction}.
937 * @see https://w3c.github.io/webdriver/#dfn-process-a-null-action
940 * Id of {@link InputSource}.
941 * @param {object} actionItem
942 * Object representing a single action.
944 * @returns {PauseAction}
945 * A pause action that can be dispatched.
947 * @throws {InvalidArgumentError}
948 * If the <code>actionItem</code> attribute is invalid.
950 static fromJSON(id, actionItem) {
951 const { duration } = actionItem;
953 if (duration !== undefined) {
954 lazy.assert.positiveInteger(
956 lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
960 return new this(id, { duration });
965 * Action associated with a keyboard input device
967 class KeyAction extends Action {
971 * Creates a new {@link KeyAction} instance.
974 * Id of {@link InputSource}.
975 * @param {object} options
976 * @param {string} options.value
979 constructor(id, options) {
982 const { value } = options;
987 getEventData(inputSource) {
988 let value = this.value;
990 if (inputSource.shift) {
991 value = lazy.keyData.getShiftedKey(value);
994 return new KeyEventData(value);
998 * Unmarshals a JSON Object to a {@link KeyAction}.
1000 * @see https://w3c.github.io/webdriver/#dfn-process-a-key-action
1002 * @param {string} id
1003 * Id of {@link InputSource}.
1004 * @param {object} actionItem
1005 * Object representing a single action.
1007 * @returns {KeyAction}
1008 * A key action that can be dispatched.
1010 * @throws {InvalidArgumentError}
1011 * If the <code>actionItem</code> attribute is invalid.
1013 static fromJSON(id, actionItem) {
1014 const { value } = actionItem;
1018 'Expected "value" to be a string that represents single code point ' +
1019 lazy.pprint`or grapheme cluster, got ${value}`
1022 let segmenter = new Intl.Segmenter();
1023 lazy.assert.that(v => {
1024 let graphemeIterator = segmenter.segment(v)[Symbol.iterator]();
1025 // We should have exactly one grapheme cluster, so the first iterator
1026 // value must be defined, but the second one must be undefined
1028 graphemeIterator.next().value !== undefined &&
1029 graphemeIterator.next().value === undefined
1031 }, `Expected "value" to be a string that represents single code point or grapheme cluster, got "${value}"`)(
1035 return new this(id, { value });
1040 * Action equivalent to pressing a key on a keyboard.
1042 * @param {string} id
1043 * Id of {@link InputSource}.
1044 * @param {object} options
1045 * @param {string} options.value
1046 * The key character.
1048 class KeyDownAction extends KeyAction {
1049 static subtype = "keyDown";
1052 * Dispatch a keydown action.
1054 * @param {State} state
1055 * The {@link State} of the action.
1056 * @param {InputSource} inputSource
1057 * Current input device.
1058 * @param {number} tickDuration
1059 * [unused] Length of the current tick, in ms.
1060 * @param {ActionsOptions} options
1061 * Configuration of actions dispatch.
1063 * @returns {Promise}
1064 * Promise that is resolved once the action is complete.
1066 async dispatch(state, inputSource, tickDuration, options) {
1067 const { context, dispatchEvent } = options;
1070 ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
1073 const keyEvent = this.getEventData(inputSource);
1074 keyEvent.repeat = inputSource.isPressed(keyEvent.key);
1075 inputSource.press(keyEvent.key);
1077 if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
1078 inputSource.setModState(keyEvent.key, true);
1081 keyEvent.update(state, inputSource);
1083 await dispatchEvent("synthesizeKeyDown", context, {
1086 eventData: keyEvent,
1089 // Append a copy of |this| with keyUp subtype if event dispatched
1090 state.inputsToCancel.push(new KeyUpAction(this.id, this));
1095 * Action equivalent to releasing a key on a keyboard.
1097 * @param {string} id
1098 * Id of {@link InputSource}.
1099 * @param {object} options
1100 * @param {string} options.value
1101 * The key character.
1103 class KeyUpAction extends KeyAction {
1104 static subtype = "keyUp";
1107 * Dispatch a keyup action.
1109 * @param {State} state
1110 * The {@link State} of the action.
1111 * @param {InputSource} inputSource
1112 * Current input device.
1113 * @param {number} tickDuration
1114 * [unused] Length of the current tick, in ms.
1115 * @param {ActionsOptions} options
1116 * Configuration of actions dispatch.
1118 * @returns {Promise}
1119 * Promise that is resolved once the action is complete.
1121 async dispatch(state, inputSource, tickDuration, options) {
1122 const { context, dispatchEvent } = options;
1125 ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
1128 const keyEvent = this.getEventData(inputSource);
1130 if (!inputSource.isPressed(keyEvent.key)) {
1134 if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
1135 inputSource.setModState(keyEvent.key, false);
1138 inputSource.release(keyEvent.key);
1139 keyEvent.update(state, inputSource);
1141 await dispatchEvent("synthesizeKeyUp", context, {
1144 eventData: keyEvent,
1150 * Action associated with a pointer input device
1152 class PointerAction extends Action {
1153 static type = "pointer";
1156 * Creates a new {@link PointerAction} instance.
1158 * @param {string} id
1159 * Id of {@link InputSource}.
1160 * @param {object} options
1161 * @param {number=} options.width
1162 * Width of pointer in pixels.
1163 * @param {number=} options.height
1164 * Height of pointer in pixels.
1165 * @param {number=} options.pressure
1166 * Pressure of pointer.
1167 * @param {number=} options.tangentialPressure
1168 * Tangential pressure of pointer.
1169 * @param {number=} options.tiltX
1170 * X tilt angle of pointer.
1171 * @param {number=} options.tiltY
1172 * Y tilt angle of pointer.
1173 * @param {number=} options.twist
1174 * Twist angle of pointer.
1175 * @param {number=} options.altitudeAngle
1176 * Altitude angle of pointer.
1177 * @param {number=} options.azimuthAngle
1178 * Azimuth angle of pointer.
1180 constructor(id, options) {
1196 this.height = height;
1197 this.pressure = pressure;
1198 this.tangentialPressure = tangentialPressure;
1202 this.altitudeAngle = altitudeAngle;
1203 this.azimuthAngle = azimuthAngle;
1207 * Validate properties common to all pointer types.
1209 * @param {object} actionItem
1210 * Object representing a single pointer action.
1213 * Properties of the pointer action; contains `width`, `height`,
1214 * `pressure`, `tangentialPressure`, `tiltX`, `tiltY`, `twist`,
1215 * `altitudeAngle`, and `azimuthAngle`.
1217 static validateCommon(actionItem) {
1230 if (width !== undefined) {
1231 lazy.assert.positiveInteger(
1233 lazy.pprint`Expected "width" to be a positive integer, got ${width}`
1236 if (height !== undefined) {
1237 lazy.assert.positiveInteger(
1239 lazy.pprint`Expected "height" to be a positive integer, got ${height}`
1242 if (pressure !== undefined) {
1243 lazy.assert.numberInRange(
1246 lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}`
1249 if (tangentialPressure !== undefined) {
1250 lazy.assert.numberInRange(
1253 'Expected "tangentialPressure" to be in range -1 to 1, ' +
1254 lazy.pprint`got ${tangentialPressure}`
1257 if (tiltX !== undefined) {
1258 lazy.assert.integerInRange(
1261 lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}`
1264 if (tiltY !== undefined) {
1265 lazy.assert.integerInRange(
1268 lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}`
1271 if (twist !== undefined) {
1272 lazy.assert.integerInRange(
1275 lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}`
1278 if (altitudeAngle !== undefined) {
1279 lazy.assert.numberInRange(
1282 'Expected "altitudeAngle" to be in range 0 to ${Math.PI / 2}, ' +
1283 lazy.pprint`got ${altitudeAngle}`
1286 if (azimuthAngle !== undefined) {
1287 lazy.assert.numberInRange(
1290 'Expected "azimuthAngle" to be in range 0 to ${2 * Math.PI}, ' +
1291 lazy.pprint`got ${azimuthAngle}`
1310 * Action associated with a pointer input device being depressed.
1312 class PointerDownAction extends PointerAction {
1313 static subtype = "pointerDown";
1316 * Creates a new {@link PointerAction} instance.
1318 * @param {string} id
1319 * Id of {@link InputSource}.
1320 * @param {object} options
1321 * @param {number} options.button
1322 * Button being pressed. For devices without buttons (e.g. touch),
1324 * @param {number=} options.width
1325 * Width of pointer in pixels.
1326 * @param {number=} options.height
1327 * Height of pointer in pixels.
1328 * @param {number=} options.pressure
1329 * Pressure of pointer.
1330 * @param {number=} options.tangentialPressure
1331 * Tangential pressure of pointer.
1332 * @param {number=} options.tiltX
1333 * X tilt angle of pointer.
1334 * @param {number=} options.tiltY
1335 * Y tilt angle of pointer.
1336 * @param {number=} options.twist
1337 * Twist angle of pointer.
1338 * @param {number=} options.altitudeAngle
1339 * Altitude angle of pointer.
1340 * @param {number=} options.azimuthAngle
1341 * Azimuth angle of pointer.
1343 constructor(id, options) {
1346 const { button } = options;
1347 this.button = button;
1351 * Dispatch a pointerdown action.
1353 * @param {State} state
1354 * The {@link State} of the action.
1355 * @param {InputSource} inputSource
1356 * Current input device.
1357 * @param {number} tickDuration
1358 * [unused] Length of the current tick, in ms.
1359 * @param {ActionsOptions} options
1360 * Configuration of actions dispatch.
1362 * @returns {Promise}
1363 * Promise that is resolved once the action is complete.
1365 async dispatch(state, inputSource, tickDuration, options) {
1367 `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}`
1370 if (inputSource.isPressed(this.button)) {
1374 inputSource.press(this.button);
1376 await inputSource.pointer.pointerDown(state, inputSource, this, options);
1378 // Append a copy of |this| with pointerUp subtype if event dispatched
1379 state.inputsToCancel.push(new PointerUpAction(this.id, this));
1383 * Unmarshals a JSON Object to a {@link PointerDownAction}.
1385 * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action
1387 * @param {string} id
1388 * Id of {@link InputSource}.
1389 * @param {object} actionItem
1390 * Object representing a single action.
1392 * @returns {PointerDownAction}
1393 * A pointer down action that can be dispatched.
1395 * @throws {InvalidArgumentError}
1396 * If the <code>actionItem</code> attribute is invalid.
1398 static fromJSON(id, actionItem) {
1399 const { button } = actionItem;
1400 const props = PointerAction.validateCommon(actionItem);
1402 lazy.assert.positiveInteger(
1404 lazy.pprint`Expected "button" to be a positive integer, got ${button}`
1407 props.button = button;
1409 return new this(id, props);
1414 * Action associated with a pointer input device being released.
1416 class PointerUpAction extends PointerAction {
1417 static subtype = "pointerUp";
1420 * Creates a new {@link PointerUpAction} instance.
1422 * @param {string} id
1423 * Id of {@link InputSource}.
1424 * @param {object} options
1425 * @param {number} options.button
1426 * Button being pressed. For devices without buttons (e.g. touch),
1428 * @param {number=} options.width
1429 * Width of pointer in pixels.
1430 * @param {number=} options.height
1431 * Height of pointer in pixels.
1432 * @param {number=} options.pressure
1433 * Pressure of pointer.
1434 * @param {number=} options.tangentialPressure
1435 * Tangential pressure of pointer.
1436 * @param {number=} options.tiltX
1437 * X tilt angle of pointer.
1438 * @param {number=} options.tiltY
1439 * Y tilt angle of pointer.
1440 * @param {number=} options.twist
1441 * Twist angle of pointer.
1442 * @param {number=} options.altitudeAngle
1443 * Altitude angle of pointer.
1444 * @param {number=} options.azimuthAngle
1445 * Azimuth angle of pointer.
1447 constructor(id, options) {
1450 const { button } = options;
1451 this.button = button;
1455 * Dispatch a pointerup action.
1457 * @param {State} state
1458 * The {@link State} of the action.
1459 * @param {InputSource} inputSource
1460 * Current input device.
1461 * @param {number} tickDuration
1462 * [unused] Length of the current tick, in ms.
1463 * @param {ActionsOptions} options
1464 * Configuration of actions dispatch.
1466 * @returns {Promise}
1467 * Promise that is resolved once the action is complete.
1469 async dispatch(state, inputSource, tickDuration, options) {
1471 `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}`
1474 if (!inputSource.isPressed(this.button)) {
1478 inputSource.release(this.button);
1480 await inputSource.pointer.pointerUp(state, inputSource, this, options);
1484 * Unmarshals a JSON Object to a {@link PointerUpAction}.
1486 * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action
1488 * @param {string} id
1489 * Id of {@link InputSource}.
1490 * @param {object} actionItem
1491 * Object representing a single action.
1493 * @returns {PointerUpAction}
1494 * A pointer up action that can be dispatched.
1496 * @throws {InvalidArgumentError}
1497 * If the <code>actionItem</code> attribute is invalid.
1499 static fromJSON(id, actionItem) {
1500 const { button } = actionItem;
1501 const props = PointerAction.validateCommon(actionItem);
1503 lazy.assert.positiveInteger(
1505 lazy.pprint`Expected "button" to be a positive integer, got ${button}`
1508 props.button = button;
1510 return new this(id, props);
1515 * Action associated with a pointer input device being moved.
1517 class PointerMoveAction extends PointerAction {
1518 static subtype = "pointerMove";
1519 affectsWallClockTime = true;
1522 * Creates a new {@link PointerMoveAction} instance.
1524 * @param {string} id
1525 * Id of {@link InputSource}.
1526 * @param {object} options
1527 * @param {number} options.button
1528 * Button being pressed. For devices without buttons (e.g. touch),
1530 * @param {number=} options.width
1531 * Width of pointer in pixels.
1532 * @param {number=} options.height
1533 * Height of pointer in pixels.
1534 * @param {number=} options.pressure
1535 * Pressure of pointer.
1536 * @param {number=} options.tangentialPressure
1537 * Tangential pressure of pointer.
1538 * @param {number=} options.tiltX
1539 * X tilt angle of pointer.
1540 * @param {number=} options.tiltY
1541 * Y tilt angle of pointer.
1542 * @param {number=} options.twist
1543 * Twist angle of pointer.
1544 * @param {number=} options.altitudeAngle
1545 * Altitude angle of pointer.
1546 * @param {number=} options.azimuthAngle
1547 * Azimuth angle of pointer.
1549 constructor(id, options) {
1552 const { duration, origin, x, y } = options;
1553 this.duration = duration;
1555 this.origin = origin;
1561 * Dispatch a pointermove action.
1563 * @param {State} state
1564 * The {@link State} of the action.
1565 * @param {InputSource} inputSource
1566 * Current input device.
1567 * @param {number} tickDuration
1568 * [unused] Length of the current tick, in ms.
1569 * @param {ActionsOptions} options
1570 * Configuration of actions dispatch.
1572 * @returns {Promise}
1573 * Promise that is resolved once the action is complete.
1575 async dispatch(state, inputSource, tickDuration, options) {
1576 const { assertInViewPort, context } = options;
1579 `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}`
1582 const target = await this.origin.getTargetCoordinates(
1588 await assertInViewPort(target, context);
1590 return moveOverTime(
1591 [[inputSource.x, inputSource.y]],
1593 this.duration ?? tickDuration,
1595 await this.performPointerMoveStep(state, inputSource, _target, options)
1600 * Perform one part of a pointer move corresponding to a specific emitted event.
1602 * @param {State} state
1603 * The {@link State} of actions.
1604 * @param {InputSource} inputSource
1605 * Current input device.
1606 * @param {Array<Array<number>>} targets
1607 * Array of [x, y] arrays specifying the viewport coordinates to move to.
1608 * @param {ActionsOptions} options
1609 * Configuration of actions dispatch.
1611 * @returns {Promise}
1613 async performPointerMoveStep(state, inputSource, targets, options) {
1614 if (targets.length !== 1) {
1616 "PointerMoveAction.performPointerMoveStep requires a single target"
1620 const target = targets[0];
1622 `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}`
1624 if (target[0] == inputSource.x && target[1] == inputSource.y) {
1628 await inputSource.pointer.pointerMove(
1637 inputSource.x = target[0];
1638 inputSource.y = target[1];
1642 * Unmarshals a JSON Object to a {@link PointerMoveAction}.
1644 * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-move-action
1646 * @param {string} id
1647 * Id of {@link InputSource}.
1648 * @param {object} actionItem
1649 * Object representing a single action.
1650 * @param {ActionsOptions} options
1651 * Configuration for actions.
1653 * @returns {Promise<PointerMoveAction>}
1654 * A pointer move action that can be dispatched.
1656 * @throws {InvalidArgumentError}
1657 * If the <code>actionItem</code> attribute is invalid.
1659 static async fromJSON(id, actionItem, options) {
1660 const { duration, origin, x, y } = actionItem;
1662 if (duration !== undefined) {
1663 lazy.assert.positiveInteger(
1665 lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
1669 const originObject = await Origin.fromJSON(origin, options);
1671 lazy.assert.integer(
1673 lazy.pprint`Expected "x" to be an integer, got ${x}`
1675 lazy.assert.integer(
1677 lazy.pprint`Expected "y" to be an integer, got ${y}`
1680 const props = PointerAction.validateCommon(actionItem);
1681 props.duration = duration;
1682 props.origin = originObject;
1686 return new this(id, props);
1691 * Action associated with a wheel input device.
1693 class WheelAction extends Action {
1694 static type = "wheel";
1698 * Action associated with scrolling a scroll wheel
1700 class WheelScrollAction extends WheelAction {
1701 static subtype = "scroll";
1702 affectsWallClockTime = true;
1705 * Creates a new {@link WheelScrollAction} instance.
1707 * @param {number} id
1708 * Id of {@link InputSource}.
1709 * @param {object} options
1710 * @param {Origin} options.origin
1711 * {@link Origin} of target coordinates.
1712 * @param {number} options.x
1713 * X value of scroll coordinates.
1714 * @param {number} options.y
1715 * Y value of scroll coordinates.
1716 * @param {number} options.deltaX
1717 * Number of CSS pixels to scroll in X direction.
1718 * @param {number} options.deltaY
1719 * Number of CSS pixels to scroll in Y direction.
1721 constructor(id, options) {
1724 const { duration, origin, x, y, deltaX, deltaY } = options;
1726 this.duration = duration;
1727 this.origin = origin;
1730 this.deltaX = deltaX;
1731 this.deltaY = deltaY;
1735 * Unmarshals a JSON Object to a {@link WheelScrollAction}.
1737 * @param {string} id
1738 * Id of {@link InputSource}.
1739 * @param {object} actionItem
1740 * Object representing a single action.
1741 * @param {ActionsOptions} options
1742 * Configuration for actions.
1744 * @returns {Promise<WheelScrollAction>}
1745 * Promise that resolves to a wheel scroll action
1746 * that can be dispatched.
1748 * @throws {InvalidArgumentError}
1749 * If the <code>actionItem</code> attribute is invalid.
1751 static async fromJSON(id, actionItem, options) {
1752 const { duration, origin, x, y, deltaX, deltaY } = actionItem;
1754 if (duration !== undefined) {
1755 lazy.assert.positiveInteger(
1757 lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
1761 const originObject = await Origin.fromJSON(origin, options);
1763 if (originObject instanceof PointerOrigin) {
1764 throw new lazy.error.InvalidArgumentError(
1765 `"pointer" origin not supported for "wheel" input source.`
1769 lazy.assert.integer(
1771 lazy.pprint`Expected "x" to be an Integer, got ${x}`
1773 lazy.assert.integer(
1775 lazy.pprint`Expected "y" to be an Integer, got ${y}`
1777 lazy.assert.integer(
1779 lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}`
1781 lazy.assert.integer(
1783 lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}`
1786 return new this(id, {
1788 origin: originObject,
1797 * Dispatch a wheel scroll action.
1799 * @param {State} state
1800 * The {@link State} of the action.
1801 * @param {InputSource} inputSource
1802 * Current input device.
1803 * @param {number} tickDuration
1804 * [unused] Length of the current tick, in ms.
1805 * @param {ActionsOptions} options
1806 * Configuration of actions dispatch.
1808 * @returns {Promise}
1809 * Promise that is resolved once the action is complete.
1811 async dispatch(state, inputSource, tickDuration, options) {
1812 const { assertInViewPort, context } = options;
1815 `Dispatch ${this.constructor.name} with id: ${this.id} deltaX: ${this.deltaX} deltaY: ${this.deltaY}`
1818 const scrollCoordinates = await this.origin.getTargetCoordinates(
1824 await assertInViewPort(scrollCoordinates, context);
1828 // This is an action-local state that holds the amount of scroll completed
1829 const deltaPosition = [startX, startY];
1831 return moveOverTime(
1833 [[this.deltaX, this.deltaY]],
1834 this.duration ?? tickDuration,
1835 async deltaTarget =>
1836 await this.performOneWheelScroll(
1847 * Perform one part of a wheel scroll corresponding to a specific emitted event.
1849 * @param {State} state
1850 * The {@link State} of actions.
1851 * @param {Array<number>} scrollCoordinates
1852 * The viewport coordinates [x, y] of the scroll action.
1853 * @param {Array<number>} deltaPosition
1854 * [deltaX, deltaY] coordinates of the scroll before this event.
1855 * @param {Array<Array<number>>} deltaTargets
1856 * Array of [deltaX, deltaY] coordinates to scroll to.
1857 * @param {ActionsOptions} options
1858 * Configuration of actions dispatch.
1860 * @returns {Promise}
1862 async performOneWheelScroll(
1869 const { context, dispatchEvent } = options;
1871 if (deltaTargets.length !== 1) {
1872 throw new Error("Can only scroll one wheel at a time");
1874 if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) {
1878 const deltaTarget = deltaTargets[0];
1879 const deltaX = deltaTarget[0] - deltaPosition[0];
1880 const deltaY = deltaTarget[1] - deltaPosition[1];
1881 const eventData = new WheelEventData({
1886 eventData.update(state);
1888 await dispatchEvent("synthesizeWheelAtPoint", context, {
1889 x: scrollCoordinates[0],
1890 y: scrollCoordinates[1],
1894 // Update the current scroll position for the caller
1895 deltaPosition[0] = deltaTarget[0];
1896 deltaPosition[1] = deltaTarget[1];
1901 * Group of actions representing behavior of all touch pointers during
1904 * For touch pointers, we need to call into the platform once with all
1905 * the actions so that they are regarded as simultaneous. This means
1906 * we don't use the `dispatch()` method on the underlying actions, but
1907 * instead use one on this group object.
1909 class TouchActionGroup {
1913 * Creates a new {@link TouchActionGroup} instance.
1916 this.type = this.constructor.type;
1917 this.actions = new Map();
1920 static forType(type) {
1921 const cls = touchActionGroupTypes.get(type);
1927 * Add action corresponding to a specific pointer to the group.
1929 * @param {InputSource} inputSource
1930 * Current input device.
1931 * @param {Action} action
1932 * Action to add to the group.
1934 addPointer(inputSource, action) {
1935 if (action.subtype !== this.type) {
1937 `Added action of unexpected type, got ${action.subtype}, expected ${this.type}`
1941 this.actions.set(action.id, [inputSource, action]);
1945 * Dispatch the action group to the relevant window.
1947 * This is overridden by subclasses to implement the type-specific
1948 * dispatch of the action.
1950 * @returns {Promise}
1951 * Promise that is resolved once the action is complete.
1955 "TouchActionGroup subclass missing dispatch implementation"
1961 * Group of actions representing behavior of all touch pointers
1962 * depressed during a single tick.
1964 class PointerDownTouchActionGroup extends TouchActionGroup {
1965 static type = "pointerDown";
1968 * Dispatch a pointerdown touch action.
1970 * @param {State} state
1971 * The {@link State} of the action.
1972 * @param {InputSource} inputSource
1973 * Current input device.
1974 * @param {number} tickDuration
1975 * [unused] Length of the current tick, in ms.
1976 * @param {ActionsOptions} options
1977 * Configuration of actions dispatch.
1979 * @returns {Promise}
1980 * Promise that is resolved once the action is complete.
1982 async dispatch(state, inputSource, tickDuration, options) {
1983 const { context, dispatchEvent } = options;
1986 `Dispatch ${this.constructor.name} with ${Array.from(
1987 this.actions.values()
1988 ).map(x => x[1].id)}`
1991 if (inputSource !== null) {
1993 "Expected null inputSource for PointerDownTouchActionGroup.dispatch"
1997 // Only include pointers that are not already depressed
1998 const filteredActions = Array.from(this.actions.values()).filter(
1999 ([actionInputSource, action]) =>
2000 !actionInputSource.isPressed(action.button)
2003 if (filteredActions.length) {
2004 const eventData = new MultiTouchEventData("touchstart");
2006 for (const [actionInputSource, action] of filteredActions) {
2007 eventData.addPointerEventData(actionInputSource, action);
2008 actionInputSource.press(action.button);
2009 eventData.update(state, actionInputSource);
2012 // Touch start events must include all depressed touch pointers
2013 for (const [id, pointerInputSource] of state.inputSourcesByType(
2017 pointerInputSource.pointer.type === "touch" &&
2018 !this.actions.has(id) &&
2019 pointerInputSource.isPressed(0)
2021 eventData.addPointerEventData(pointerInputSource, {});
2022 eventData.update(state, pointerInputSource);
2026 await dispatchEvent("synthesizeMultiTouch", context, { eventData });
2028 for (const [, action] of filteredActions) {
2029 // Append a copy of |action| with pointerUp subtype if event dispatched
2030 state.inputsToCancel.push(new PointerUpAction(action.id, action));
2037 * Group of actions representing behavior of all touch pointers
2038 * released during a single tick.
2040 class PointerUpTouchActionGroup extends TouchActionGroup {
2041 static type = "pointerUp";
2044 * Dispatch a pointerup touch action.
2046 * @param {State} state
2047 * The {@link State} of the action.
2048 * @param {InputSource} inputSource
2049 * Current input device.
2050 * @param {number} tickDuration
2051 * [unused] Length of the current tick, in ms.
2052 * @param {ActionsOptions} options
2053 * Configuration of actions dispatch.
2055 * @returns {Promise}
2056 * Promise that is resolved once the action is complete.
2058 async dispatch(state, inputSource, tickDuration, options) {
2059 const { context, dispatchEvent } = options;
2062 `Dispatch ${this.constructor.name} with ${Array.from(
2063 this.actions.values()
2064 ).map(x => x[1].id)}`
2067 if (inputSource !== null) {
2069 "Expected null inputSource for PointerUpTouchActionGroup.dispatch"
2073 // Only include pointers that are not already depressed
2074 const filteredActions = Array.from(this.actions.values()).filter(
2075 ([actionInputSource, action]) =>
2076 actionInputSource.isPressed(action.button)
2079 if (filteredActions.length) {
2080 const eventData = new MultiTouchEventData("touchend");
2081 for (const [actionInputSource, action] of filteredActions) {
2082 eventData.addPointerEventData(actionInputSource, action);
2083 actionInputSource.release(action.button);
2084 eventData.update(state, actionInputSource);
2087 await dispatchEvent("synthesizeMultiTouch", context, { eventData });
2093 * Group of actions representing behavior of all touch pointers
2094 * moved during a single tick.
2096 class PointerMoveTouchActionGroup extends TouchActionGroup {
2097 static type = "pointerMove";
2100 * Dispatch a pointermove touch action.
2102 * @param {State} state
2103 * The {@link State} of the action.
2104 * @param {InputSource} inputSource
2105 * Current input device.
2106 * @param {number} tickDuration
2107 * [unused] Length of the current tick, in ms.
2108 * @param {ActionsOptions} options
2109 * Configuration of actions dispatch.
2111 * @returns {Promise}
2112 * Promise that is resolved once the action is complete.
2114 async dispatch(state, inputSource, tickDuration, options) {
2115 const { assertInViewPort, context } = options;
2118 `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map(
2122 if (inputSource !== null) {
2124 "Expected null inputSource for PointerMoveTouchActionGroup.dispatch"
2128 let startCoords = [];
2129 let targetCoords = [];
2131 for (const [actionInputSource, action] of this.actions.values()) {
2132 const target = await action.origin.getTargetCoordinates(
2134 [action.x, action.y],
2138 await assertInViewPort(target, context);
2140 startCoords.push([actionInputSource.x, actionInputSource.y]);
2141 targetCoords.push(target);
2144 // Touch move events must include all depressed touch pointers, even if they are static
2145 // This can end up generating pointermove events even for static pointers, but Gecko
2146 // seems to generate a lot of pointermove events anyway, so this seems like the lesser
2148 // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206
2149 const staticTouchPointers = [];
2150 for (const [id, pointerInputSource] of state.inputSourcesByType(
2154 pointerInputSource.pointer.type === "touch" &&
2155 !this.actions.has(id) &&
2156 pointerInputSource.isPressed(0)
2158 staticTouchPointers.push(pointerInputSource);
2162 return moveOverTime(
2165 this.duration ?? tickDuration,
2166 async currentTargetCoords =>
2167 await this.performPointerMoveStep(
2169 staticTouchPointers,
2170 currentTargetCoords,
2177 * Perform one part of a pointer move corresponding to a specific emitted event.
2179 * @param {State} state
2180 * The {@link State} of actions.
2181 * @param {Array<PointerInputSource>} staticTouchPointers
2182 * Array of PointerInputSource objects for pointers that aren't
2183 * involved in the touch move.
2184 * @param {Array<Array<number>>} targetCoords
2185 * Array of [x, y] arrays specifying the viewport coordinates to move to.
2186 * @param {ActionsOptions} options
2187 * Configuration of actions dispatch.
2189 async performPointerMoveStep(
2191 staticTouchPointers,
2195 const { context, dispatchEvent } = options;
2197 if (targetCoords.length !== this.actions.size) {
2198 throw new Error("Expected one target per pointer");
2201 const perPointerData = Array.from(this.actions.values()).map(
2202 ([inputSource, action], i) => {
2203 const target = targetCoords[i];
2204 return [inputSource, action, target];
2207 const reachedTarget = perPointerData.every(
2208 ([inputSource, , target]) =>
2209 target[0] === inputSource.x && target[1] === inputSource.y
2212 if (reachedTarget) {
2216 const eventData = new MultiTouchEventData("touchmove");
2217 for (const [inputSource, action, target] of perPointerData) {
2218 inputSource.x = target[0];
2219 inputSource.y = target[1];
2220 eventData.addPointerEventData(inputSource, action);
2221 eventData.update(state, inputSource);
2224 for (const inputSource of staticTouchPointers) {
2225 eventData.addPointerEventData(inputSource, {});
2226 eventData.update(state, inputSource);
2229 await dispatchEvent("synthesizeMultiTouch", context, { eventData });
2233 const touchActionGroupTypes = new Map();
2235 PointerDownTouchActionGroup,
2236 PointerUpTouchActionGroup,
2237 PointerMoveTouchActionGroup,
2239 touchActionGroupTypes.set(cls.type, cls);
2243 * Split a transition from startCoord to targetCoord linearly over duration.
2245 * startCoords and targetCoords are lists of [x,y] positions in some space
2246 * (e.g. screen position or scroll delta). This function will linearly
2247 * interpolate intermediate positions, sending out roughly one event
2248 * per frame to simulate moving between startCoord and targetCoord in
2249 * a time of tickDuration milliseconds. The callback function is
2250 * responsible for actually emitting the event, given the current
2251 * position in the coordinate space.
2253 * @param {Array<Array>} startCoords
2254 * Array of initial [x, y] coordinates for each input source involved
2256 * @param {Array<Array<number>>} targetCoords
2257 * Array of target [x, y] coordinates for each input source involved
2259 * @param {number} duration
2260 * Time in ms the move will take.
2261 * @param {Function} callback
2262 * Function that actually performs the move. This takes a single parameter
2263 * which is an array of [x, y] coordinates corresponding to the move
2266 async function moveOverTime(startCoords, targetCoords, duration, callback) {
2268 `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}`
2271 if (startCoords.length !== targetCoords.length) {
2273 "Expected equal number of start coordinates and target coordinates"
2278 !startCoords.every(item => item.length == 2) ||
2279 !targetCoords.every(item => item.length == 2)
2282 "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates."
2286 if (duration === 0) {
2287 // transition to destination in one step
2288 await callback(targetCoords);
2292 const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
2293 // interval between transitions in ms, based on common vsync
2296 const distances = targetCoords.map((targetCoord, i) => {
2297 const startCoord = startCoords[i];
2298 return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]];
2300 const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
2301 const startTime = Date.now();
2302 const transitions = (async () => {
2303 // wait |fps60| ms before performing first incremental transition
2304 await new Promise(resolveTimer =>
2305 timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
2308 let durationRatio = Math.floor(Date.now() - startTime) / duration;
2309 const epsilon = fps60 / duration / 10;
2310 while (1 - durationRatio > epsilon) {
2311 const intermediateTargets = startCoords.map((startCoord, i) => {
2312 let distance = distances[i];
2314 Math.floor(durationRatio * distance[0] + startCoord[0]),
2315 Math.floor(durationRatio * distance[1] + startCoord[1]),
2320 callback(intermediateTargets),
2322 // wait |fps60| ms before performing next transition
2323 new Promise(resolveTimer =>
2324 timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
2328 durationRatio = Math.floor(Date.now() - startTime) / duration;
2334 // perform last transition after all incremental moves are resolved and
2335 // durationRatio is close enough to 1
2336 await callback(targetCoords);
2339 const actionTypes = new Map();
2349 if (!actionTypes.has(cls.type)) {
2350 actionTypes.set(cls.type, new Map());
2352 actionTypes.get(cls.type).set(cls.subtype, cls);
2356 * Implementation of the behavior of a specific type of pointer.
2361 /** Type of pointer */
2365 * Creates a new {@link Pointer} instance.
2367 * @param {number} id
2368 * Numeric pointer id.
2372 this.type = this.constructor.type;
2376 * Implementation of depressing the pointer.
2379 throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`);
2383 * Implementation of releasing the pointer.
2386 throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`);
2390 * Implementation of moving the pointer.
2393 throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`);
2397 * Unmarshals a JSON Object to a {@link Pointer}.
2399 * @param {number} pointerId
2400 * Numeric pointer id.
2401 * @param {string} pointerType
2404 * @returns {Pointer}
2405 * An instance of the Pointer class for {@link pointerType}.
2407 * @throws {InvalidArgumentError}
2408 * If {@link pointerType} is not a valid pointer type.
2410 static fromJSON(pointerId, pointerType) {
2411 const cls = pointerTypes.get(pointerType);
2413 if (cls === undefined) {
2414 throw new lazy.error.InvalidArgumentError(
2415 'Expected "pointerType" type to be one of ' +
2416 lazy.pprint`${pointerTypes}, got ${pointerType}`
2420 return new cls(pointerId);
2425 * Implementation of mouse pointer behavior.
2427 class MousePointer extends Pointer {
2428 static type = "mouse";
2431 * Emits a pointer down event.
2433 * @param {State} state
2434 * The {@link State} of the action.
2435 * @param {InputSource} inputSource
2436 * Current input device.
2437 * @param {PointerDownAction} action
2438 * The pointer down action to perform.
2439 * @param {ActionsOptions} options
2440 * Configuration of actions dispatch.
2442 * @returns {Promise}
2443 * Promise that resolves when the event has been dispatched.
2445 async pointerDown(state, inputSource, action, options) {
2446 const { context, dispatchEvent } = options;
2448 const mouseEvent = new MouseEventData("mousedown", {
2449 button: action.button,
2451 mouseEvent.update(state, inputSource);
2453 if (mouseEvent.ctrlKey) {
2454 if (lazy.AppInfo.isMac) {
2455 mouseEvent.button = 2;
2456 state.clickTracker.reset();
2459 mouseEvent.clickCount = state.clickTracker.count + 1;
2462 await dispatchEvent("synthesizeMouseAtPoint", context, {
2465 eventData: mouseEvent,
2469 lazy.event.MouseButton.isSecondary(mouseEvent.button) ||
2470 (mouseEvent.ctrlKey && lazy.AppInfo.isMac)
2472 const contextMenuEvent = { ...mouseEvent, type: "contextmenu" };
2474 await dispatchEvent("synthesizeMouseAtPoint", context, {
2477 eventData: contextMenuEvent,
2483 * Emits a pointer up event.
2485 * @param {State} state
2486 * The {@link State} of the action.
2487 * @param {InputSource} inputSource
2488 * Current input device.
2489 * @param {PointerUpAction} action
2490 * The pointer up action to perform.
2491 * @param {ActionsOptions} options
2492 * Configuration of actions dispatch.
2494 * @returns {Promise}
2495 * Promise that resolves when the event has been dispatched.
2497 async pointerUp(state, inputSource, action, options) {
2498 const { context, dispatchEvent } = options;
2500 const mouseEvent = new MouseEventData("mouseup", {
2501 button: action.button,
2503 mouseEvent.update(state, inputSource);
2505 state.clickTracker.setClick(action.button);
2506 mouseEvent.clickCount = state.clickTracker.count;
2508 await dispatchEvent("synthesizeMouseAtPoint", context, {
2511 eventData: mouseEvent,
2516 * Emits a pointer down event.
2518 * @param {State} state
2519 * The {@link State} of the action.
2520 * @param {InputSource} inputSource
2521 * Current input device.
2522 * @param {PointerMoveAction} action
2523 * The pointer down action to perform.
2524 * @param {number} targetX
2525 * Target x position to move the pointer to.
2526 * @param {number} targetY
2527 * Target y position to move the pointer to.
2528 * @param {ActionsOptions} options
2529 * Configuration of actions dispatch.
2531 * @returns {Promise}
2532 * Promise that resolves when the event has been dispatched.
2534 async pointerMove(state, inputSource, action, targetX, targetY, options) {
2535 const { context, dispatchEvent } = options;
2537 const mouseEvent = new MouseEventData("mousemove");
2538 mouseEvent.update(state, inputSource);
2540 await dispatchEvent("synthesizeMouseAtPoint", context, {
2543 eventData: mouseEvent,
2546 state.clickTracker.reset();
2551 * The implementation here is empty because touch actions have to go via the
2552 * TouchActionGroup. So if we end up calling these methods that's a bug in
2555 class TouchPointer extends Pointer {
2556 static type = "touch";
2560 * Placeholder for future pen type pointer support.
2562 class PenPointer extends Pointer {
2563 static type = "pen";
2566 const pointerTypes = new Map();
2567 for (const cls of [MousePointer, TouchPointer, PenPointer]) {
2568 pointerTypes.set(cls.type, cls);
2572 * Represents a series of ticks, specifying which actions to perform at
2575 actions.Chain = class extends Array {
2577 return `[chain ${super.toString()}]`;
2581 * Dispatch the action chain to the relevant window.
2583 * @param {State} state
2584 * The {@link State} of actions.
2585 * @param {ActionsOptions} options
2586 * Configuration of actions dispatch.
2588 * @returns {Promise}
2589 * Promise that is resolved once the action chain is complete.
2591 dispatch(state, options) {
2594 const chainEvents = (async () => {
2595 for (const tickActions of this) {
2596 lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`);
2597 await tickActions.dispatch(state, options);
2601 // Reset the current click tracker counter. We shouldn't be able to simulate
2602 // a double click with multiple action chains.
2603 state.clickTracker.reset();
2608 /* eslint-disable no-shadow */ // Shadowing is intentional for `actions`.
2611 * Unmarshals a JSON Object to a {@link Chain}.
2613 * @see https://w3c.github.io/webdriver/#dfn-extract-an-action-sequence
2615 * @param {State} actionState
2616 * The {@link State} of actions.
2617 * @param {Array<object>} actions
2618 * Array of objects that each represent an action sequence.
2619 * @param {ActionsOptions} options
2620 * Configuration for actions.
2622 * @returns {Promise<Chain>}
2623 * Promise resolving to an object that allows dispatching
2624 * a chain of actions.
2626 * @throws {InvalidArgumentError}
2627 * If <code>actions</code> doesn't correspond to a valid action chain.
2629 static async fromJSON(actionState, actions, options) {
2632 lazy.pprint`Expected "actions" to be an array, got ${actions}`
2635 const actionsByTick = new this();
2636 for (const actionSequence of actions) {
2639 'Expected "actions" item to be an object, ' +
2640 lazy.pprint`got ${actionSequence}`
2643 const inputSourceActions = await Sequence.fromJSON(
2649 for (let i = 0; i < inputSourceActions.length; i++) {
2651 if (actionsByTick.length < i + 1) {
2652 actionsByTick.push(new TickActions());
2654 actionsByTick[i].push(inputSourceActions[i]);
2658 return actionsByTick;
2660 /* eslint-enable no-shadow */
2664 * Represents the action for each input device to perform in a single tick.
2666 class TickActions extends Array {
2668 * Tick duration in milliseconds.
2671 * Longest action duration in |tickActions| if any, or 0.
2676 for (const action of this) {
2677 if (action.affectsWallClockTime && action.duration) {
2678 max = Math.max(action.duration, max);
2686 * Dispatch sequence of actions for this tick.
2688 * This creates a Promise for one tick that resolves once the Promise
2689 * for each tick-action is resolved, which takes at least |tickDuration|
2690 * milliseconds. The resolved set of events for each tick is followed by
2691 * firing of pending DOM events.
2693 * Note that the tick-actions are dispatched in order, but they may have
2694 * different durations and therefore may not end in the same order.
2696 * @param {State} state
2697 * The {@link State} of actions.
2698 * @param {ActionsOptions} options
2699 * Configuration of actions dispatch.
2701 * @returns {Promise}
2702 * Promise that resolves when tick is complete.
2704 dispatch(state, options) {
2705 const tickDuration = this.getDuration();
2706 const tickActions = this.groupTickActions(state);
2707 const pendingEvents = tickActions.map(([inputSource, action]) =>
2708 action.dispatch(state, inputSource, tickDuration, options)
2711 return Promise.all(pendingEvents);
2715 * Group together actions from input sources that have to be
2716 * dispatched together.
2718 * The actual transformation here is to group together touch pointer
2719 * actions into {@link TouchActionGroup} instances.
2721 * @param {State} state
2722 * The {@link State} of actions.
2724 * @returns {Array<Array<InputSource?,Action|TouchActionGroup>>}
2725 * Array of pairs. For ungrouped actions each element is
2726 * [InputSource, Action] For touch actions there are multiple
2727 * pointers handled at once, so the first item of the array is
2728 * null, meaning the group has to perform its own handling of the
2729 * relevant state, and the second element is a TouchActionGroup.
2731 groupTickActions(state) {
2732 const touchActions = new Map();
2733 const groupedActions = [];
2735 for (const action of this) {
2736 const inputSource = state.getInputSource(action.id);
2737 if (action.type == "pointer" && inputSource.pointer.type === "touch") {
2739 `Grouping action ${action.type} ${action.id} ${action.subtype}`
2741 let group = touchActions.get(action.subtype);
2742 if (group === undefined) {
2743 group = TouchActionGroup.forType(action.subtype);
2744 touchActions.set(action.subtype, group);
2745 groupedActions.push([null, group]);
2747 group.addPointer(inputSource, action);
2749 groupedActions.push([inputSource, action]);
2753 return groupedActions;
2758 * Represents one input source action sequence; this is essentially an
2761 * This is a temporary object only used when constructing an {@link
2764 class Sequence extends Array {
2766 return `[sequence ${super.toString()}]`;
2770 * Unmarshals a JSON Object to a {@link Sequence}.
2772 * @see https://w3c.github.io/webdriver/#dfn-process-an-input-source-action-sequence
2774 * @param {State} actionState
2775 * The {@link State} of actions.
2776 * @param {object} actionSequence
2777 * Protocol representation of the actions for a specific input source.
2778 * @param {ActionsOptions} options
2779 * Configuration for actions.
2781 * @returns {Promise<Array<Array<InputSource, Action | TouchActionGroup>>>}
2782 * Promise that resolves to an object that allows dispatching a
2783 * sequence of actions.
2785 * @throws {InvalidArgumentError}
2786 * If the <code>actionSequence</code> doesn't correspond to a valid action sequence.
2788 static async fromJSON(actionState, actionSequence, options) {
2789 // used here to validate 'type' in addition to InputSource type below
2790 const { actions: actionsFromSequence, id, type } = actionSequence;
2792 // type and id get validated in InputSource.fromJSON
2794 actionsFromSequence,
2795 'Expected "actionSequence.actions" to be an array, ' +
2796 lazy.pprint`got ${actionSequence.actions}`
2799 // This sets the input state in the global state map, if it's new.
2800 InputSource.fromJSON(actionState, actionSequence);
2802 const sequence = new this();
2803 for (const actionItem of actionsFromSequence) {
2804 sequence.push(await Action.fromJSON(type, id, actionItem, options));
2812 * Representation of an input event.
2814 class InputEventData {
2816 * Creates a new {@link InputEventData} instance.
2819 this.altKey = false;
2820 this.shiftKey = false;
2821 this.ctrlKey = false;
2822 this.metaKey = false;
2826 * Update the input data based on global and input state
2831 return `${this.constructor.name} ${JSON.stringify(this)}`;
2836 * Representation of a key input event.
2838 class KeyEventData extends InputEventData {
2840 * Creates a new {@link KeyEventData} instance.
2842 * @param {string} rawKey
2845 constructor(rawKey) {
2848 const { key, code, location, printable } = lazy.keyData.getData(rawKey);
2852 this.location = location;
2853 this.printable = printable;
2854 this.repeat = false;
2855 // keyCode will be computed by event.sendKeyDown
2858 update(state, inputSource) {
2859 this.altKey = inputSource.alt;
2860 this.shiftKey = inputSource.shift;
2861 this.ctrlKey = inputSource.ctrl;
2862 this.metaKey = inputSource.meta;
2867 * Representation of a pointer input event.
2869 class PointerEventData extends InputEventData {
2871 * Creates a new {@link PointerEventData} instance.
2873 * @param {string} type
2883 update(state, inputSource) {
2884 // set modifier properties based on whether any corresponding keys are
2885 // pressed on any key input source
2886 for (const [, otherInputSource] of state.inputSourcesByType("key")) {
2887 this.altKey = otherInputSource.alt || this.altKey;
2888 this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
2889 this.metaKey = otherInputSource.meta || this.metaKey;
2890 this.shiftKey = otherInputSource.shift || this.shiftKey;
2892 const allButtons = Array.from(inputSource.pressed);
2893 this.buttons = allButtons.reduce(
2894 (a, i) => a + PointerEventData.getButtonFlag(i),
2900 * Return a flag for buttons which indicates a button is pressed.
2902 * @param {integer} button
2903 * The mouse button number.
2905 static getButtonFlag(button) {
2912 return Math.pow(2, button);
2918 * Representation of a mouse input event.
2920 class MouseEventData extends PointerEventData {
2922 * Creates a new {@link MouseEventData} instance.
2924 * @param {string} type
2926 * @param {object=} options
2927 * @param {number=} options.button
2928 * The number of the mouse button. Defaults to 0.
2930 constructor(type, options = {}) {
2933 const { button = 0 } = options;
2935 this.button = button;
2938 // Some WPTs try to synthesize DnD only with mouse events. However,
2939 // Gecko waits DnD events directly and non-WPT-tests use Gecko specific
2940 // test API to synthesize DnD. Therefore, we want new path only for
2941 // synthesized events coming from the webdriver.
2942 this.allowToHandleDragDrop = true;
2945 update(state, inputSource) {
2946 super.update(state, inputSource);
2948 this.id = inputSource.pointer.id;
2953 * Representation of a wheel scroll event.
2955 class WheelEventData extends InputEventData {
2957 * Creates a new {@link WheelEventData} instance.
2959 * @param {object} options
2960 * @param {number} options.deltaX
2962 * @param {number} options.deltaY
2964 * @param {number} options.deltaZ
2965 * Scroll delta Z (current always 0).
2966 * @param {number=} options.deltaMode
2967 * Scroll delta mode (current always 0).
2969 constructor(options) {
2972 const { deltaX, deltaY, deltaZ, deltaMode = 0 } = options;
2974 this.deltaX = deltaX;
2975 this.deltaY = deltaY;
2976 this.deltaZ = deltaZ;
2977 this.deltaMode = deltaMode;
2979 this.altKey = false;
2980 this.ctrlKey = false;
2981 this.metaKey = false;
2982 this.shiftKey = false;
2986 // set modifier properties based on whether any corresponding keys are
2987 // pressed on any key input source
2988 for (const [, otherInputSource] of state.inputSourcesByType("key")) {
2989 this.altKey = otherInputSource.alt || this.altKey;
2990 this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
2991 this.metaKey = otherInputSource.meta || this.metaKey;
2992 this.shiftKey = otherInputSource.shift || this.shiftKey;
2998 * Representation of a multi touch event.
3000 class MultiTouchEventData extends PointerEventData {
3004 * Creates a new {@link MultiTouchEventData} instance.
3006 * @param {string} type
3022 this.#setGlobalState = false;
3026 * Add the data from one pointer to the event.
3028 * @param {InputSource} inputSource
3029 * The state of the pointer.
3030 * @param {PointerAction} action
3031 * Action for the pointer.
3033 addPointerEventData(inputSource, action) {
3034 this.x.push(inputSource.x);
3035 this.y.push(inputSource.y);
3036 this.id.push(inputSource.pointer.id);
3037 this.rx.push(action.width || 1);
3038 this.ry.push(action.height || 1);
3040 this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1));
3041 this.tiltx.push(action.tiltX || 0);
3042 this.tilty.push(action.tiltY || 0);
3043 this.twist.push(action.twist || 0);
3046 update(state, inputSource) {
3047 // We call update once per input source, but only want to update global state once.
3048 // Instead of introducing a new lifecycle method, or changing the API to allow multiple
3049 // input sources in a single call, use a small bit of state to avoid repeatedly setting
3051 if (!this.#setGlobalState) {
3052 // set modifier properties based on whether any corresponding keys are
3053 // pressed on any key input source
3054 for (const [, otherInputSource] of state.inputSourcesByType("key")) {
3055 this.altKey = otherInputSource.alt || this.altKey;
3056 this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
3057 this.metaKey = otherInputSource.meta || this.metaKey;
3058 this.shiftKey = otherInputSource.shift || this.shiftKey;
3060 this.#setGlobalState = true;
3063 // Note that we currently emit Touch events that don't have this property
3064 // but pointer events should have a `buttons` property, so we'll compute it
3066 const allButtons = Array.from(inputSource.pressed);
3069 allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0);
3076 * Assert that target is in the viewport of win.
3078 * @param {Array<number>} target
3079 * Coordinates [x, y] of the target relative to the viewport.
3080 * @param {WindowProxy} win
3081 * The target window.
3083 * @throws {MoveTargetOutOfBoundsError}
3084 * If target is outside the viewport.
3086 export function assertTargetInViewPort(target, win) {
3087 const [x, y] = target;
3091 lazy.pprint`Expected "x" to be finite number, got ${x}`
3095 lazy.pprint`Expected "y" to be finite number, got ${y}`
3098 // Viewport includes scrollbars if rendered.
3099 if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) {
3100 throw new lazy.error.MoveTargetOutOfBoundsError(
3101 `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` +
3102 `(${win.innerWidth}, ${win.innerHeight})`