Actions.sys.mjs (86768B)
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/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 11 setTimeout: "resource://gre/modules/Timer.sys.mjs", 12 13 AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", 14 assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", 15 AsyncQueue: "chrome://remote/content/shared/AsyncQueue.sys.mjs", 16 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 17 event: "chrome://remote/content/shared/webdriver/Event.sys.mjs", 18 keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs", 19 Log: "chrome://remote/content/shared/Log.sys.mjs", 20 pprint: "chrome://remote/content/shared/Format.sys.mjs", 21 Sleep: "chrome://remote/content/marionette/sync.sys.mjs", 22 }); 23 24 ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); 25 26 // TODO? With ES 2016 and Symbol you can make a safer approximation 27 // to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689 28 /** 29 * Implements WebDriver Actions API: a low-level interface for providing 30 * virtualized device input to the web browser. 31 * 32 * Typical usage is to construct an action chain and then dispatch it: 33 * const state = new actions.State(); 34 * const chain = await actions.Chain.fromJSON(state, protocolData); 35 * await chain.dispatch(state, window); 36 * 37 * @namespace 38 */ 39 export const actions = {}; 40 41 // Max interval between two clicks that should result in a dblclick or a tripleclick (in ms) 42 export const CLICK_INTERVAL = 640; 43 44 /** Map from normalized key value to UI Events modifier key name */ 45 const MODIFIER_NAME_LOOKUP = { 46 Alt: "alt", 47 Shift: "shift", 48 Control: "ctrl", 49 Meta: "meta", 50 }; 51 52 // Flag, that indicates if an async widget event should be used when dispatching a mouse event. 53 XPCOMUtils.defineLazyPreferenceGetter( 54 actions, 55 "useAsyncMouseEvents", 56 "remote.events.async.mouse.enabled", 57 false 58 ); 59 60 // Flag, that indicates if an async widget event should be used when dispatching a wheel scroll event. 61 XPCOMUtils.defineLazyPreferenceGetter( 62 actions, 63 "useAsyncWheelEvents", 64 "remote.events.async.wheel.enabled", 65 false 66 ); 67 68 /** 69 * Object containing various callback functions to be used when deserializing 70 * action sequences and dispatching these. 71 * 72 * @typedef {object} ActionsOptions 73 * @property {Function} isElementOrigin 74 * Function to check if it's a valid origin element. 75 * @property {Function} getElementOrigin 76 * Function to retrieve the element reference for an element origin. 77 * @property {Function} assertInViewPort 78 * Function to check if the coordinates [x, y] are in the visible viewport. 79 * @property {Function} dispatchEvent 80 * Function to use for dispatching events. 81 * @property {Function} getClientRects 82 * Function that retrieves the client rects for an element. 83 * @property {Function} getInViewCentrePoint 84 * Function that calculates the in-view center point for the given 85 * coordinates [x, y]. 86 */ 87 88 /** 89 * State associated with actions. 90 * 91 * Typically each top-level navigable in a WebDriver session should have a 92 * single State object. 93 */ 94 actions.State = class { 95 #actionsQueue; 96 97 /** 98 * Creates a new {@link State} instance. 99 */ 100 constructor() { 101 // A queue that ensures that access to the input state is serialized. 102 this.#actionsQueue = new lazy.AsyncQueue(); 103 104 // Tracker for mouse button clicks. 105 this.clickTracker = new ClickTracker(); 106 107 /** 108 * A map between input ID and the device state for that input 109 * source, with one entry for each active input source. 110 * 111 * Maps string => InputSource 112 */ 113 this.inputStateMap = new Map(); 114 115 /** 116 * List of {@link Action} associated with current session. Used to 117 * manage dispatching events when resetting the state of the input sources. 118 * Reset operations are assumed to be idempotent. 119 */ 120 this.inputsToCancel = new TickActions(); 121 122 // Map between string input id and numeric pointer id. 123 this.pointerIdMap = new Map(); 124 } 125 126 /** 127 * Returns the list of inputs to cancel when releasing the actions. 128 * 129 * @returns {TickActions} 130 * The inputs to cancel. 131 */ 132 get inputCancelList() { 133 return this.inputsToCancel; 134 } 135 136 toString() { 137 return `[object ${this.constructor.name} ${JSON.stringify(this)}]`; 138 } 139 140 /** 141 * Enqueue a new action task. 142 * 143 * @param {Function} task 144 * The task to queue. 145 * 146 * @returns {Promise} 147 * Promise that resolves when the task is completed, with the resolved 148 * value being the result of the task. 149 */ 150 enqueueAction(task) { 151 return this.#actionsQueue.enqueue(task); 152 } 153 154 /** 155 * Get the state for a given input source. 156 * 157 * @param {string} id 158 * Id of the input source. 159 * 160 * @returns {InputSource} 161 * State of the input source. 162 */ 163 getInputSource(id) { 164 return this.inputStateMap.get(id); 165 } 166 167 /** 168 * Find or add state for an input source. 169 * 170 * The caller should verify that the returned state is the expected type. 171 * 172 * @param {string} id 173 * Id of the input source. 174 * @param {InputSource} newInputSource 175 * State of the input source. 176 * 177 * @returns {InputSource} 178 * The input source. 179 */ 180 getOrAddInputSource(id, newInputSource) { 181 let inputSource = this.getInputSource(id); 182 183 if (inputSource === undefined) { 184 this.inputStateMap.set(id, newInputSource); 185 inputSource = newInputSource; 186 } 187 188 return inputSource; 189 } 190 191 /** 192 * Iterate over all input states of a given type. 193 * 194 * @param {string} type 195 * Type name of the input source (e.g. "pointer"). 196 * 197 * @returns {Iterator<string, InputSource>} 198 * Iterator over id and input source. 199 */ 200 *inputSourcesByType(type) { 201 for (const [id, inputSource] of this.inputStateMap) { 202 if (inputSource.type === type) { 203 yield [id, inputSource]; 204 } 205 } 206 } 207 208 /** 209 * Get a numerical pointer id for a given pointer. 210 * 211 * Pointer ids are positive integers. Mouse pointers are typically 212 * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each 213 * pointer gets a unique id. 214 * 215 * @param {string} id 216 * Id of the pointer. 217 * @param {string} type 218 * Type of the pointer. 219 * 220 * @returns {number} 221 * The numerical pointer id. 222 */ 223 getPointerId(id, type) { 224 let pointerId = this.pointerIdMap.get(id); 225 226 if (pointerId === undefined) { 227 // Reserve pointer ids 0 and 1 for mouse pointers 228 const idValues = Array.from(this.pointerIdMap.values()); 229 230 if (type === "mouse") { 231 for (const mouseId of [0, 1]) { 232 if (!idValues.includes(mouseId)) { 233 pointerId = mouseId; 234 break; 235 } 236 } 237 } 238 239 if (pointerId === undefined) { 240 pointerId = Math.max(1, ...idValues) + 1; 241 } 242 this.pointerIdMap.set(id, pointerId); 243 } 244 245 return pointerId; 246 } 247 }; 248 249 /** 250 * Tracker for mouse button clicks. 251 */ 252 export class ClickTracker { 253 #count; 254 #lastButtonClicked; 255 #timer; 256 257 /** 258 * Creates a new {@link ClickTracker} instance. 259 */ 260 constructor() { 261 this.#count = 0; 262 this.#lastButtonClicked = null; 263 } 264 265 get count() { 266 return this.#count; 267 } 268 269 #cancelTimer() { 270 lazy.clearTimeout(this.#timer); 271 } 272 273 #startTimer() { 274 this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL); 275 } 276 277 /** 278 * Reset tracking mouse click counter. 279 */ 280 reset() { 281 this.#cancelTimer(); 282 this.#count = 0; 283 this.#lastButtonClicked = null; 284 } 285 286 /** 287 * Track |button| click to identify possible double or triple click. 288 * 289 * @param {number} button 290 * A positive integer that refers to a mouse button. 291 */ 292 setClick(button) { 293 this.#cancelTimer(); 294 295 if ( 296 this.#lastButtonClicked === null || 297 this.#lastButtonClicked === button 298 ) { 299 this.#count++; 300 } else { 301 this.#count = 1; 302 } 303 304 this.#lastButtonClicked = button; 305 this.#startTimer(); 306 } 307 } 308 309 /** 310 * Device state for an input source. 311 */ 312 class InputSource { 313 #id; 314 static type = null; 315 316 /** 317 * Creates a new {@link InputSource} instance. 318 * 319 * @param {string} id 320 * Id of {@link InputSource}. 321 */ 322 constructor(id) { 323 this.#id = id; 324 this.type = this.constructor.type; 325 } 326 327 toString() { 328 return `[object ${this.constructor.name} id: ${this.#id} type: ${ 329 this.type 330 }]`; 331 } 332 333 /** 334 * Unmarshals a JSON Object to an {@link InputSource}. 335 * 336 * @see https://w3c.github.io/webdriver/#dfn-get-or-create-an-input-source 337 * 338 * @param {State} actionState 339 * Actions state. 340 * @param {Sequence} actionSequence 341 * Actions for a specific input source. 342 * 343 * @returns {InputSource} 344 * An {@link InputSource} object for the type of the 345 * action {@link Sequence}. 346 * 347 * @throws {InvalidArgumentError} 348 * If the <code>actionSequence</code> is invalid. 349 */ 350 static fromJSON(actionState, actionSequence) { 351 const { id, type } = actionSequence; 352 353 lazy.assert.string( 354 id, 355 lazy.pprint`Expected "id" to be a string, got ${id}` 356 ); 357 358 const cls = inputSourceTypes.get(type); 359 if (cls === undefined) { 360 throw new lazy.error.InvalidArgumentError( 361 lazy.pprint`Expected known action type, got ${type}` 362 ); 363 } 364 365 const sequenceInputSource = cls.fromJSON(actionState, actionSequence); 366 const inputSource = actionState.getOrAddInputSource( 367 id, 368 sequenceInputSource 369 ); 370 371 if (inputSource.type !== type) { 372 throw new lazy.error.InvalidArgumentError( 373 lazy.pprint`Expected input source ${id} to be ` + 374 `type ${inputSource.type}, got ${type}` 375 ); 376 } 377 } 378 } 379 380 /** 381 * Input state not associated with a specific physical device. 382 */ 383 class NullInputSource extends InputSource { 384 static type = "none"; 385 386 /** 387 * Unmarshals a JSON Object to a {@link NullInputSource}. 388 * 389 * @param {State} actionState 390 * Actions state. 391 * @param {Sequence} actionSequence 392 * Actions for a specific input source. 393 * 394 * @returns {NullInputSource} 395 * A {@link NullInputSource} object for the type of the 396 * action {@link Sequence}. 397 * 398 * @throws {InvalidArgumentError} 399 * If the <code>actionSequence</code> is invalid. 400 */ 401 static fromJSON(actionState, actionSequence) { 402 const { id } = actionSequence; 403 404 return new this(id); 405 } 406 } 407 408 /** 409 * Input state associated with a keyboard-type device. 410 */ 411 class KeyInputSource extends InputSource { 412 static type = "key"; 413 414 /** 415 * Creates a new {@link KeyInputSource} instance. 416 * 417 * @param {string} id 418 * Id of {@link InputSource}. 419 */ 420 constructor(id) { 421 super(id); 422 423 this.pressed = new Set(); 424 this.alt = false; 425 this.shift = false; 426 this.ctrl = false; 427 this.meta = false; 428 } 429 430 /** 431 * Unmarshals a JSON Object to a {@link KeyInputSource}. 432 * 433 * @param {State} actionState 434 * Actions state. 435 * @param {Sequence} actionSequence 436 * Actions for a specific input source. 437 * 438 * @returns {KeyInputSource} 439 * A {@link KeyInputSource} object for the type of the 440 * action {@link Sequence}. 441 * 442 * @throws {InvalidArgumentError} 443 * If the <code>actionSequence</code> is invalid. 444 */ 445 static fromJSON(actionState, actionSequence) { 446 const { id } = actionSequence; 447 448 return new this(id); 449 } 450 451 /** 452 * Update modifier state according to |key|. 453 * 454 * @param {string} key 455 * Normalized key value of a modifier key. 456 * @param {boolean} value 457 * Value to set the modifier attribute to. 458 * 459 * @throws {InvalidArgumentError} 460 * If |key| is not a modifier. 461 */ 462 setModState(key, value) { 463 if (key in MODIFIER_NAME_LOOKUP) { 464 this[MODIFIER_NAME_LOOKUP[key]] = value; 465 } else { 466 throw new lazy.error.InvalidArgumentError( 467 lazy.pprint`Expected "key" to be one of ${Object.keys( 468 MODIFIER_NAME_LOOKUP 469 )}, got ${key}` 470 ); 471 } 472 } 473 474 /** 475 * Check whether |key| is pressed. 476 * 477 * @param {string} key 478 * Normalized key value. 479 * 480 * @returns {boolean} 481 * True if |key| is in set of pressed keys. 482 */ 483 isPressed(key) { 484 return this.pressed.has(key); 485 } 486 487 /** 488 * Add |key| to the set of pressed keys. 489 * 490 * @param {string} key 491 * Normalized key value. 492 * 493 * @returns {boolean} 494 * True if |key| is in list of pressed keys. 495 */ 496 press(key) { 497 return this.pressed.add(key); 498 } 499 500 /** 501 * Remove |key| from the set of pressed keys. 502 * 503 * @param {string} key 504 * Normalized key value. 505 * 506 * @returns {boolean} 507 * True if |key| was present before removal, false otherwise. 508 */ 509 release(key) { 510 return this.pressed.delete(key); 511 } 512 } 513 514 /** 515 * Input state associated with a pointer-type device. 516 */ 517 class PointerInputSource extends InputSource { 518 #initialized; 519 #x; 520 #y; 521 522 static type = "pointer"; 523 524 /** 525 * Creates a new {@link PointerInputSource} instance. 526 * 527 * @param {string} id 528 * Id of {@link InputSource}. 529 * @param {Pointer} pointer 530 * The specific {@link Pointer} type associated with this input source. 531 */ 532 constructor(id, pointer) { 533 super(id); 534 535 this.pointer = pointer; 536 this.pressed = new Set(); 537 538 this.#initialized = false; 539 this.#x = 0; 540 this.#y = 0; 541 } 542 543 get initialized() { 544 return this.#initialized; 545 } 546 547 get x() { 548 return this.#x; 549 } 550 551 get y() { 552 return this.#y; 553 } 554 555 /** 556 * Check whether |button| is pressed. 557 * 558 * @param {number} button 559 * Positive integer that refers to a mouse button. 560 * 561 * @returns {boolean} 562 * True if |button| is in set of pressed buttons. 563 */ 564 isPressed(button) { 565 lazy.assert.positiveInteger( 566 button, 567 lazy.pprint`Expected "button" to be a positive integer, got ${button}` 568 ); 569 570 return this.pressed.has(button); 571 } 572 573 moveTo(x, y) { 574 this.#initialized = true; 575 this.#x = x; 576 this.#y = y; 577 } 578 579 /** 580 * Add |button| to the set of pressed keys. 581 * 582 * @param {number} button 583 * Positive integer that refers to a mouse button. 584 */ 585 press(button) { 586 lazy.assert.positiveInteger( 587 button, 588 lazy.pprint`Expected "button" to be a positive integer, got ${button}` 589 ); 590 591 this.pressed.add(button); 592 } 593 594 /** 595 * Remove |button| from the set of pressed buttons. 596 * 597 * @param {number} button 598 * A positive integer that refers to a mouse button. 599 * 600 * @returns {boolean} 601 * True if |button| was present before removals, false otherwise. 602 */ 603 release(button) { 604 lazy.assert.positiveInteger( 605 button, 606 lazy.pprint`Expected "button" to be a positive integer, got ${button}` 607 ); 608 609 return this.pressed.delete(button); 610 } 611 612 /** 613 * Unmarshals a JSON Object to a {@link PointerInputSource}. 614 * 615 * @param {State} actionState 616 * Actions state. 617 * @param {Sequence} actionSequence 618 * Actions for a specific input source. 619 * 620 * @returns {PointerInputSource} 621 * A {@link PointerInputSource} object for the type of the 622 * action {@link Sequence}. 623 * 624 * @throws {InvalidArgumentError} 625 * If the <code>actionSequence</code> is invalid. 626 */ 627 static fromJSON(actionState, actionSequence) { 628 const { id, parameters } = actionSequence; 629 let pointerType = "mouse"; 630 631 if (parameters !== undefined) { 632 lazy.assert.object( 633 parameters, 634 lazy.pprint`Expected "parameters" to be an object, got ${parameters}` 635 ); 636 637 if (parameters.pointerType !== undefined) { 638 pointerType = lazy.assert.string( 639 parameters.pointerType, 640 lazy.pprint( 641 `Expected "pointerType" to be a string, got ${parameters.pointerType}` 642 ) 643 ); 644 645 if (!["mouse", "pen", "touch"].includes(pointerType)) { 646 throw new lazy.error.InvalidArgumentError( 647 lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"` 648 ); 649 } 650 } 651 } 652 653 const pointerId = actionState.getPointerId(id, pointerType); 654 const pointer = Pointer.fromJSON(pointerId, pointerType); 655 656 return new this(id, pointer); 657 } 658 } 659 660 /** 661 * Input state associated with a wheel-type device. 662 */ 663 class WheelInputSource extends InputSource { 664 static type = "wheel"; 665 666 /** 667 * Unmarshals a JSON Object to a {@link WheelInputSource}. 668 * 669 * @param {State} actionState 670 * Actions state. 671 * @param {Sequence} actionSequence 672 * Actions for a specific input source. 673 * 674 * @returns {WheelInputSource} 675 * A {@link WheelInputSource} object for the type of the 676 * action {@link Sequence}. 677 * 678 * @throws {InvalidArgumentError} 679 * If the <code>actionSequence</code> is invalid. 680 */ 681 static fromJSON(actionState, actionSequence) { 682 const { id } = actionSequence; 683 684 return new this(id); 685 } 686 } 687 688 const inputSourceTypes = new Map(); 689 for (const cls of [ 690 NullInputSource, 691 KeyInputSource, 692 PointerInputSource, 693 WheelInputSource, 694 ]) { 695 inputSourceTypes.set(cls.type, cls); 696 } 697 698 /** 699 * Representation of a coordinate origin 700 */ 701 class Origin { 702 /** 703 * Viewport coordinates of the origin of this coordinate system. 704 * 705 * This is overridden in subclasses to provide a class-specific origin. 706 */ 707 getOriginCoordinates() { 708 throw new Error( 709 `originCoordinates not defined for ${this.constructor.name}` 710 ); 711 } 712 713 /** 714 * Convert [x, y] coordinates to viewport coordinates. 715 * 716 * @param {InputSource} inputSource 717 * State of the current input device 718 * @param {Array<number>} coords 719 * Coordinates [x, y] of the target relative to the origin. 720 * @param {ActionsOptions} options 721 * Configuration of actions dispatch. 722 * 723 * @returns {Array<number>} 724 * Viewport coordinates [x, y]. 725 */ 726 async getTargetCoordinates(inputSource, coords, options) { 727 const [x, y] = coords; 728 const origin = await this.getOriginCoordinates(inputSource, options); 729 730 return [origin.x + x, origin.y + y]; 731 } 732 733 /** 734 * Unmarshals a JSON Object to an {@link Origin}. 735 * 736 * @param {string|Element=} origin 737 * Type of origin, one of "viewport", "pointer", {@link Element} 738 * or undefined. 739 * @param {ActionsOptions} options 740 * Configuration for actions. 741 * 742 * @returns {Promise<Origin>} 743 * Promise that resolves to an {@link Origin} object 744 * representing the origin. 745 * 746 * @throws {InvalidArgumentError} 747 * If <code>origin</code> isn't a valid origin. 748 */ 749 static async fromJSON(origin, options) { 750 const { context, getElementOrigin, isElementOrigin } = options; 751 752 if (origin === undefined || origin === "viewport") { 753 return new ViewportOrigin(); 754 } 755 756 if (origin === "pointer") { 757 return new PointerOrigin(); 758 } 759 760 if (isElementOrigin(origin)) { 761 const element = await getElementOrigin(origin, context); 762 763 return new ElementOrigin(element); 764 } 765 766 throw new lazy.error.InvalidArgumentError( 767 `Expected "origin" to be undefined, "viewport", "pointer", ` + 768 lazy.pprint`or an element, got: ${origin}` 769 ); 770 } 771 } 772 773 class ViewportOrigin extends Origin { 774 getOriginCoordinates() { 775 return { x: 0, y: 0 }; 776 } 777 } 778 779 class PointerOrigin extends Origin { 780 getOriginCoordinates(inputSource) { 781 return { x: inputSource.x, y: inputSource.y }; 782 } 783 } 784 785 /** 786 * Representation of an element origin. 787 */ 788 class ElementOrigin extends Origin { 789 /** 790 * Creates a new {@link ElementOrigin} instance. 791 * 792 * @param {Element} element 793 * The element providing the coordinate origin. 794 */ 795 constructor(element) { 796 super(); 797 798 this.element = element; 799 } 800 801 /** 802 * Retrieve the coordinates of the origin's in-view center point. 803 * 804 * @param {InputSource} _inputSource 805 * [unused] Current input device. 806 * @param {ActionsOptions} options 807 * 808 * @returns {Promise<Array<number>>} 809 * Promise that resolves to the coordinates [x, y]. 810 */ 811 async getOriginCoordinates(_inputSource, options) { 812 const { context, getClientRects, getInViewCentrePoint } = options; 813 814 const clientRects = await getClientRects(this.element, context); 815 816 // The spec doesn't handle this case: https://github.com/w3c/webdriver/issues/1642 817 if (!clientRects.length) { 818 throw new lazy.error.MoveTargetOutOfBoundsError( 819 lazy.pprint`Origin element ${this.element} is not displayed` 820 ); 821 } 822 823 return getInViewCentrePoint(clientRects[0], context); 824 } 825 } 826 827 /** 828 * Represents the behavior of a single input source at a single 829 * point in time. 830 */ 831 class Action { 832 /** Type of the input source associated with this action */ 833 static type = null; 834 /** Type of action specific to the input source */ 835 static subtype = null; 836 /** Whether this kind of action affects the overall duration of a tick */ 837 affectsWallClockTime = false; 838 839 /** 840 * Creates a new {@link Action} instance. 841 * 842 * @param {string} id 843 * Id of {@link InputSource}. 844 */ 845 constructor(id) { 846 this.id = id; 847 this.type = this.constructor.type; 848 this.subtype = this.constructor.subtype; 849 } 850 851 toString() { 852 return `[${this.constructor.name} ${this.type}:${this.subtype}]`; 853 } 854 855 /** 856 * Dispatch the action to the relevant window. 857 * 858 * This is overridden by subclasses to implement the type-specific 859 * dispatch of the action. 860 * 861 * @returns {Promise} 862 * Promise that is resolved once the action is complete. 863 */ 864 dispatch() { 865 throw new Error( 866 `Action subclass ${this.constructor.name} must override dispatch()` 867 ); 868 } 869 870 /** 871 * Unmarshals a JSON Object to an {@link Action}. 872 * 873 * @param {string} type 874 * Type of {@link InputSource}. 875 * @param {string} id 876 * Id of {@link InputSource}. 877 * @param {object} actionItem 878 * Object representing a single action. 879 * @param {ActionsOptions} options 880 * Configuration for actions. 881 * 882 * @returns {Promise<Action>} 883 * Promise that resolves to an action that can be dispatched. 884 * 885 * @throws {InvalidArgumentError} 886 * If the <code>actionItem</code> attribute is invalid. 887 */ 888 static fromJSON(type, id, actionItem, options) { 889 lazy.assert.object( 890 actionItem, 891 lazy.pprint`Expected "action" to be an object, got ${actionItem}` 892 ); 893 894 const subtype = actionItem.type; 895 const subtypeMap = actionTypes.get(type); 896 897 if (subtypeMap === undefined) { 898 throw new lazy.error.InvalidArgumentError( 899 lazy.pprint`Expected known action type, got ${type}` 900 ); 901 } 902 903 let cls = subtypeMap.get(subtype); 904 // Non-device specific actions can happen for any action type 905 if (cls === undefined) { 906 cls = actionTypes.get("none").get(subtype); 907 } 908 if (cls === undefined) { 909 throw new lazy.error.InvalidArgumentError( 910 lazy.pprint`Expected known subtype for type ${type}, got ${subtype}` 911 ); 912 } 913 914 return cls.fromJSON(id, actionItem, options); 915 } 916 } 917 918 /** 919 * Action not associated with a specific input device. 920 */ 921 class NullAction extends Action { 922 static type = "none"; 923 } 924 925 /** 926 * Action that waits for a given duration. 927 */ 928 class PauseAction extends NullAction { 929 static subtype = "pause"; 930 affectsWallClockTime = true; 931 932 /** 933 * Creates a new {@link PauseAction} instance. 934 * 935 * @param {string} id 936 * Id of {@link InputSource}. 937 * @param {object} options 938 * @param {number} options.duration 939 * Time to pause, in ms. 940 */ 941 constructor(id, options) { 942 super(id); 943 944 const { duration } = options; 945 this.duration = duration; 946 } 947 948 /** 949 * Dispatch pause action. 950 * 951 * @param {State} state 952 * The {@link State} of the action. 953 * @param {InputSource} inputSource 954 * Current input device. 955 * @param {number} tickDuration 956 * Length of the current tick, in ms. 957 * 958 * @returns {Promise} 959 * Promise that is resolved once the action is complete. 960 */ 961 dispatch(state, inputSource, tickDuration) { 962 const ms = this.duration ?? tickDuration; 963 964 lazy.logger.trace( 965 ` Dispatch ${this.constructor.name} with ${this.id} ${ms}` 966 ); 967 968 return lazy.Sleep(ms); 969 } 970 971 /** 972 * Unmarshals a JSON Object to a {@link PauseAction}. 973 * 974 * @see https://w3c.github.io/webdriver/#dfn-process-a-null-action 975 * 976 * @param {string} id 977 * Id of {@link InputSource}. 978 * @param {object} actionItem 979 * Object representing a single action. 980 * 981 * @returns {PauseAction} 982 * A pause action that can be dispatched. 983 * 984 * @throws {InvalidArgumentError} 985 * If the <code>actionItem</code> attribute is invalid. 986 */ 987 static fromJSON(id, actionItem) { 988 const { duration } = actionItem; 989 990 if (duration !== undefined) { 991 lazy.assert.positiveInteger( 992 duration, 993 lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` 994 ); 995 } 996 997 return new this(id, { duration }); 998 } 999 } 1000 1001 /** 1002 * Action associated with a keyboard input device 1003 */ 1004 class KeyAction extends Action { 1005 static type = "key"; 1006 1007 /** 1008 * Creates a new {@link KeyAction} instance. 1009 * 1010 * @param {string} id 1011 * Id of {@link InputSource}. 1012 * @param {object} options 1013 * @param {string} options.value 1014 * The key character. 1015 */ 1016 constructor(id, options) { 1017 super(id); 1018 1019 const { value } = options; 1020 1021 this.value = value; 1022 } 1023 1024 getEventData(inputSource) { 1025 let value = this.value; 1026 1027 if (inputSource.shift) { 1028 value = lazy.keyData.getShiftedKey(value); 1029 } 1030 1031 return new KeyEventData(value); 1032 } 1033 1034 /** 1035 * Unmarshals a JSON Object to a {@link KeyAction}. 1036 * 1037 * @see https://w3c.github.io/webdriver/#dfn-process-a-key-action 1038 * 1039 * @param {string} id 1040 * Id of {@link InputSource}. 1041 * @param {object} actionItem 1042 * Object representing a single action. 1043 * 1044 * @returns {KeyAction} 1045 * A key action that can be dispatched. 1046 * 1047 * @throws {InvalidArgumentError} 1048 * If the <code>actionItem</code> attribute is invalid. 1049 */ 1050 static fromJSON(id, actionItem) { 1051 const { value } = actionItem; 1052 1053 lazy.assert.string( 1054 value, 1055 'Expected "value" to be a string that represents single code point ' + 1056 lazy.pprint`or grapheme cluster, got ${value}` 1057 ); 1058 1059 let segmenter = new Intl.Segmenter(); 1060 lazy.assert.that(v => { 1061 let graphemeIterator = segmenter.segment(v)[Symbol.iterator](); 1062 // We should have exactly one grapheme cluster, so the first iterator 1063 // value must be defined, but the second one must be undefined 1064 return ( 1065 graphemeIterator.next().value !== undefined && 1066 graphemeIterator.next().value === undefined 1067 ); 1068 }, `Expected "value" to be a string that represents single code point or grapheme cluster, got "${value}"`)( 1069 value 1070 ); 1071 1072 return new this(id, { value }); 1073 } 1074 } 1075 1076 /** 1077 * Action equivalent to pressing a key on a keyboard. 1078 * 1079 * @param {string} id 1080 * Id of {@link InputSource}. 1081 * @param {object} options 1082 * @param {string} options.value 1083 * The key character. 1084 */ 1085 class KeyDownAction extends KeyAction { 1086 static subtype = "keyDown"; 1087 1088 /** 1089 * Dispatch a keydown action. 1090 * 1091 * @param {State} state 1092 * The {@link State} of the action. 1093 * @param {InputSource} inputSource 1094 * Current input device. 1095 * @param {number} tickDuration 1096 * [unused] Length of the current tick, in ms. 1097 * @param {ActionsOptions} options 1098 * Configuration of actions dispatch. 1099 * 1100 * @returns {Promise} 1101 * Promise that is resolved once the action is complete. 1102 */ 1103 async dispatch(state, inputSource, tickDuration, options) { 1104 const { context, dispatchEvent } = options; 1105 1106 lazy.logger.trace( 1107 ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}` 1108 ); 1109 1110 const keyEvent = this.getEventData(inputSource); 1111 keyEvent.repeat = inputSource.isPressed(keyEvent.key); 1112 inputSource.press(keyEvent.key); 1113 1114 if (keyEvent.key in MODIFIER_NAME_LOOKUP) { 1115 inputSource.setModState(keyEvent.key, true); 1116 } 1117 1118 keyEvent.update(state, inputSource); 1119 1120 await dispatchEvent("synthesizeKeyDown", context, { 1121 x: inputSource.x, 1122 y: inputSource.y, 1123 eventData: keyEvent, 1124 }); 1125 1126 // Append a copy of |this| with keyUp subtype if event dispatched 1127 state.inputsToCancel.push(new KeyUpAction(this.id, this)); 1128 } 1129 } 1130 1131 /** 1132 * Action equivalent to releasing a key on a keyboard. 1133 * 1134 * @param {string} id 1135 * Id of {@link InputSource}. 1136 * @param {object} options 1137 * @param {string} options.value 1138 * The key character. 1139 */ 1140 class KeyUpAction extends KeyAction { 1141 static subtype = "keyUp"; 1142 1143 /** 1144 * Dispatch a keyup action. 1145 * 1146 * @param {State} state 1147 * The {@link State} of the action. 1148 * @param {InputSource} inputSource 1149 * Current input device. 1150 * @param {number} tickDuration 1151 * [unused] Length of the current tick, in ms. 1152 * @param {ActionsOptions} options 1153 * Configuration of actions dispatch. 1154 * 1155 * @returns {Promise} 1156 * Promise that is resolved once the action is complete. 1157 */ 1158 async dispatch(state, inputSource, tickDuration, options) { 1159 const { context, dispatchEvent } = options; 1160 1161 lazy.logger.trace( 1162 ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}` 1163 ); 1164 1165 const keyEvent = this.getEventData(inputSource); 1166 1167 if (!inputSource.isPressed(keyEvent.key)) { 1168 return; 1169 } 1170 1171 if (keyEvent.key in MODIFIER_NAME_LOOKUP) { 1172 inputSource.setModState(keyEvent.key, false); 1173 } 1174 1175 inputSource.release(keyEvent.key); 1176 keyEvent.update(state, inputSource); 1177 1178 await dispatchEvent("synthesizeKeyUp", context, { 1179 x: inputSource.x, 1180 y: inputSource.y, 1181 eventData: keyEvent, 1182 }); 1183 } 1184 } 1185 1186 /** 1187 * Action associated with a pointer input device 1188 */ 1189 class PointerAction extends Action { 1190 static type = "pointer"; 1191 1192 /** 1193 * Creates a new {@link PointerAction} instance. 1194 * 1195 * @param {string} id 1196 * Id of {@link InputSource}. 1197 * @param {object} options 1198 * @param {number=} options.width 1199 * Width of pointer in pixels. 1200 * @param {number=} options.height 1201 * Height of pointer in pixels. 1202 * @param {number=} options.pressure 1203 * Pressure of pointer. 1204 * @param {number=} options.tangentialPressure 1205 * Tangential pressure of pointer. 1206 * @param {number=} options.tiltX 1207 * X tilt angle of pointer. 1208 * @param {number=} options.tiltY 1209 * Y tilt angle of pointer. 1210 * @param {number=} options.twist 1211 * Twist angle of pointer. 1212 * @param {number=} options.altitudeAngle 1213 * Altitude angle of pointer. 1214 * @param {number=} options.azimuthAngle 1215 * Azimuth angle of pointer. 1216 */ 1217 constructor(id, options) { 1218 super(id); 1219 1220 const { 1221 width, 1222 height, 1223 pressure, 1224 tangentialPressure, 1225 tiltX, 1226 tiltY, 1227 twist, 1228 altitudeAngle, 1229 azimuthAngle, 1230 } = options; 1231 1232 this.width = width; 1233 this.height = height; 1234 this.pressure = pressure; 1235 this.tangentialPressure = tangentialPressure; 1236 this.tiltX = tiltX; 1237 this.tiltY = tiltY; 1238 this.twist = twist; 1239 this.altitudeAngle = altitudeAngle; 1240 this.azimuthAngle = azimuthAngle; 1241 } 1242 1243 /** 1244 * Validate properties common to all pointer types. 1245 * 1246 * @param {object} actionItem 1247 * Object representing a single pointer action. 1248 * 1249 * @returns {object} 1250 * Properties of the pointer action; contains `width`, `height`, 1251 * `pressure`, `tangentialPressure`, `tiltX`, `tiltY`, `twist`, 1252 * `altitudeAngle`, and `azimuthAngle`. 1253 */ 1254 static validateCommon(actionItem) { 1255 const { 1256 width, 1257 height, 1258 pressure, 1259 tangentialPressure, 1260 tiltX, 1261 tiltY, 1262 twist, 1263 altitudeAngle, 1264 azimuthAngle, 1265 } = actionItem; 1266 1267 if (width !== undefined) { 1268 lazy.assert.positiveInteger( 1269 width, 1270 lazy.pprint`Expected "width" to be a positive integer, got ${width}` 1271 ); 1272 } 1273 if (height !== undefined) { 1274 lazy.assert.positiveInteger( 1275 height, 1276 lazy.pprint`Expected "height" to be a positive integer, got ${height}` 1277 ); 1278 } 1279 if (pressure !== undefined) { 1280 lazy.assert.numberInRange( 1281 pressure, 1282 [0, 1], 1283 lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}` 1284 ); 1285 } 1286 if (tangentialPressure !== undefined) { 1287 lazy.assert.numberInRange( 1288 tangentialPressure, 1289 [-1, 1], 1290 'Expected "tangentialPressure" to be in range -1 to 1, ' + 1291 lazy.pprint`got ${tangentialPressure}` 1292 ); 1293 } 1294 if (tiltX !== undefined) { 1295 lazy.assert.integerInRange( 1296 tiltX, 1297 [-90, 90], 1298 lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}` 1299 ); 1300 } 1301 if (tiltY !== undefined) { 1302 lazy.assert.integerInRange( 1303 tiltY, 1304 [-90, 90], 1305 lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}` 1306 ); 1307 } 1308 if (twist !== undefined) { 1309 lazy.assert.integerInRange( 1310 twist, 1311 [0, 359], 1312 lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}` 1313 ); 1314 } 1315 if (altitudeAngle !== undefined) { 1316 lazy.assert.numberInRange( 1317 altitudeAngle, 1318 [0, Math.PI / 2], 1319 'Expected "altitudeAngle" to be in range 0 to ${Math.PI / 2}, ' + 1320 lazy.pprint`got ${altitudeAngle}` 1321 ); 1322 } 1323 if (azimuthAngle !== undefined) { 1324 lazy.assert.numberInRange( 1325 azimuthAngle, 1326 [0, 2 * Math.PI], 1327 'Expected "azimuthAngle" to be in range 0 to ${2 * Math.PI}, ' + 1328 lazy.pprint`got ${azimuthAngle}` 1329 ); 1330 } 1331 1332 return { 1333 width, 1334 height, 1335 pressure, 1336 tangentialPressure, 1337 tiltX, 1338 tiltY, 1339 twist, 1340 altitudeAngle, 1341 azimuthAngle, 1342 }; 1343 } 1344 } 1345 1346 /** 1347 * Action associated with a pointer input device being depressed. 1348 */ 1349 class PointerDownAction extends PointerAction { 1350 static subtype = "pointerDown"; 1351 1352 /** 1353 * Creates a new {@link PointerAction} instance. 1354 * 1355 * @param {string} id 1356 * Id of {@link InputSource}. 1357 * @param {object} options 1358 * @param {number} options.button 1359 * Button being pressed. For devices without buttons (e.g. touch), 1360 * this should be 0. 1361 * @param {number=} options.width 1362 * Width of pointer in pixels. 1363 * @param {number=} options.height 1364 * Height of pointer in pixels. 1365 * @param {number=} options.pressure 1366 * Pressure of pointer. 1367 * @param {number=} options.tangentialPressure 1368 * Tangential pressure of pointer. 1369 * @param {number=} options.tiltX 1370 * X tilt angle of pointer. 1371 * @param {number=} options.tiltY 1372 * Y tilt angle of pointer. 1373 * @param {number=} options.twist 1374 * Twist angle of pointer. 1375 * @param {number=} options.altitudeAngle 1376 * Altitude angle of pointer. 1377 * @param {number=} options.azimuthAngle 1378 * Azimuth angle of pointer. 1379 */ 1380 constructor(id, options) { 1381 super(id, options); 1382 1383 const { button } = options; 1384 this.button = button; 1385 } 1386 1387 /** 1388 * Dispatch a pointerdown action. 1389 * 1390 * @param {State} state 1391 * The {@link State} of the action. 1392 * @param {InputSource} inputSource 1393 * Current input device. 1394 * @param {number} tickDuration 1395 * [unused] Length of the current tick, in ms. 1396 * @param {ActionsOptions} options 1397 * Configuration of actions dispatch. 1398 * 1399 * @returns {Promise} 1400 * Promise that is resolved once the action is complete. 1401 */ 1402 async dispatch(state, inputSource, tickDuration, options) { 1403 if (inputSource.isPressed(this.button)) { 1404 return; 1405 } 1406 1407 lazy.logger.trace( 1408 `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} ` + 1409 `button: ${this.button} async: ${actions.useAsyncMouseEvents}` 1410 ); 1411 1412 inputSource.press(this.button); 1413 1414 await inputSource.pointer.pointerDown(state, inputSource, this, options); 1415 1416 // Append a copy of |this| with pointerUp subtype if event dispatched 1417 state.inputsToCancel.push(new PointerUpAction(this.id, this)); 1418 } 1419 1420 /** 1421 * Unmarshals a JSON Object to a {@link PointerDownAction}. 1422 * 1423 * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action 1424 * 1425 * @param {string} id 1426 * Id of {@link InputSource}. 1427 * @param {object} actionItem 1428 * Object representing a single action. 1429 * 1430 * @returns {PointerDownAction} 1431 * A pointer down action that can be dispatched. 1432 * 1433 * @throws {InvalidArgumentError} 1434 * If the <code>actionItem</code> attribute is invalid. 1435 */ 1436 static fromJSON(id, actionItem) { 1437 const { button } = actionItem; 1438 const props = PointerAction.validateCommon(actionItem); 1439 1440 lazy.assert.positiveInteger( 1441 button, 1442 lazy.pprint`Expected "button" to be a positive integer, got ${button}` 1443 ); 1444 1445 props.button = button; 1446 1447 return new this(id, props); 1448 } 1449 } 1450 1451 /** 1452 * Action associated with a pointer input device being released. 1453 */ 1454 class PointerUpAction extends PointerAction { 1455 static subtype = "pointerUp"; 1456 1457 /** 1458 * Creates a new {@link PointerUpAction} instance. 1459 * 1460 * @param {string} id 1461 * Id of {@link InputSource}. 1462 * @param {object} options 1463 * @param {number} options.button 1464 * Button being pressed. For devices without buttons (e.g. touch), 1465 * this should be 0. 1466 * @param {number=} options.width 1467 * Width of pointer in pixels. 1468 * @param {number=} options.height 1469 * Height of pointer in pixels. 1470 * @param {number=} options.pressure 1471 * Pressure of pointer. 1472 * @param {number=} options.tangentialPressure 1473 * Tangential pressure of pointer. 1474 * @param {number=} options.tiltX 1475 * X tilt angle of pointer. 1476 * @param {number=} options.tiltY 1477 * Y tilt angle of pointer. 1478 * @param {number=} options.twist 1479 * Twist angle of pointer. 1480 * @param {number=} options.altitudeAngle 1481 * Altitude angle of pointer. 1482 * @param {number=} options.azimuthAngle 1483 * Azimuth angle of pointer. 1484 */ 1485 constructor(id, options) { 1486 super(id, options); 1487 1488 const { button } = options; 1489 this.button = button; 1490 } 1491 1492 /** 1493 * Dispatch a pointerup action. 1494 * 1495 * @param {State} state 1496 * The {@link State} of the action. 1497 * @param {InputSource} inputSource 1498 * Current input device. 1499 * @param {number} tickDuration 1500 * [unused] Length of the current tick, in ms. 1501 * @param {ActionsOptions} options 1502 * Configuration of actions dispatch. 1503 * 1504 * @returns {Promise} 1505 * Promise that is resolved once the action is complete. 1506 */ 1507 async dispatch(state, inputSource, tickDuration, options) { 1508 if (!inputSource.isPressed(this.button)) { 1509 return; 1510 } 1511 1512 lazy.logger.trace( 1513 `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} ` + 1514 `button: ${this.button} async: ${actions.useAsyncMouseEvents}` 1515 ); 1516 1517 inputSource.release(this.button); 1518 1519 await inputSource.pointer.pointerUp(state, inputSource, this, options); 1520 } 1521 1522 /** 1523 * Unmarshals a JSON Object to a {@link PointerUpAction}. 1524 * 1525 * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action 1526 * 1527 * @param {string} id 1528 * Id of {@link InputSource}. 1529 * @param {object} actionItem 1530 * Object representing a single action. 1531 * 1532 * @returns {PointerUpAction} 1533 * A pointer up action that can be dispatched. 1534 * 1535 * @throws {InvalidArgumentError} 1536 * If the <code>actionItem</code> attribute is invalid. 1537 */ 1538 static fromJSON(id, actionItem) { 1539 const { button } = actionItem; 1540 const props = PointerAction.validateCommon(actionItem); 1541 1542 lazy.assert.positiveInteger( 1543 button, 1544 lazy.pprint`Expected "button" to be a positive integer, got ${button}` 1545 ); 1546 1547 props.button = button; 1548 1549 return new this(id, props); 1550 } 1551 } 1552 1553 /** 1554 * Action associated with a pointer input device being moved. 1555 */ 1556 class PointerMoveAction extends PointerAction { 1557 static subtype = "pointerMove"; 1558 affectsWallClockTime = true; 1559 1560 /** 1561 * Creates a new {@link PointerMoveAction} instance. 1562 * 1563 * @param {string} id 1564 * Id of {@link InputSource}. 1565 * @param {object} options 1566 * @param {number} options.origin 1567 * {@link Origin} of target coordinates. 1568 * @param {number} options.x 1569 * X value of scroll coordinates. 1570 * @param {number} options.y 1571 * Y value of scroll coordinates. 1572 * @param {number=} options.width 1573 * Width of pointer in pixels. 1574 * @param {number=} options.height 1575 * Height of pointer in pixels. 1576 * @param {number=} options.pressure 1577 * Pressure of pointer. 1578 * @param {number=} options.tangentialPressure 1579 * Tangential pressure of pointer. 1580 * @param {number=} options.tiltX 1581 * X tilt angle of pointer. 1582 * @param {number=} options.tiltY 1583 * Y tilt angle of pointer. 1584 * @param {number=} options.twist 1585 * Twist angle of pointer. 1586 * @param {number=} options.altitudeAngle 1587 * Altitude angle of pointer. 1588 * @param {number=} options.azimuthAngle 1589 * Azimuth angle of pointer. 1590 */ 1591 constructor(id, options) { 1592 super(id, options); 1593 1594 const { duration, origin, x, y } = options; 1595 this.duration = duration; 1596 1597 this.origin = origin; 1598 this.x = x; 1599 this.y = y; 1600 } 1601 1602 /** 1603 * Dispatch a pointermove action. 1604 * 1605 * @param {State} state 1606 * The {@link State} of the action. 1607 * @param {InputSource} inputSource 1608 * Current input device. 1609 * @param {number} tickDuration 1610 * [unused] Length of the current tick, in ms. 1611 * @param {ActionsOptions} options 1612 * Configuration of actions dispatch. 1613 * 1614 * @returns {Promise} 1615 * Promise that is resolved once the action is complete. 1616 */ 1617 async dispatch(state, inputSource, tickDuration, options) { 1618 const { assertInViewPort, context, toBrowserWindowCoordinates } = options; 1619 1620 let moveCoordinates = await this.origin.getTargetCoordinates( 1621 inputSource, 1622 [this.x, this.y], 1623 options 1624 ); 1625 1626 await assertInViewPort(moveCoordinates, context); 1627 1628 lazy.logger.trace( 1629 `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} ` + 1630 `x: ${moveCoordinates[0]} y: ${moveCoordinates[1]} ` + 1631 `async: ${actions.useAsyncMouseEvents}` 1632 ); 1633 1634 // Only convert coordinates if these are for a content process, and are not 1635 // relative to an already initialized pointer source. 1636 if ( 1637 !(this.origin instanceof PointerOrigin && inputSource.initialized) && 1638 context.isContent && 1639 actions.useAsyncMouseEvents 1640 ) { 1641 moveCoordinates = await toBrowserWindowCoordinates( 1642 moveCoordinates, 1643 context 1644 ); 1645 } 1646 1647 return moveOverTime( 1648 [[inputSource.x, inputSource.y]], 1649 [moveCoordinates], 1650 this.duration ?? tickDuration, 1651 async _target => 1652 await this.performPointerMoveStep(state, inputSource, _target, options) 1653 ); 1654 } 1655 1656 /** 1657 * Perform one part of a pointer move corresponding to a specific emitted event. 1658 * 1659 * @param {State} state 1660 * The {@link State} of actions. 1661 * @param {InputSource} inputSource 1662 * Current input device. 1663 * @param {Array<Array<number>>} targets 1664 * Array of [x, y] arrays specifying the viewport coordinates to move to. 1665 * @param {ActionsOptions} options 1666 * Configuration of actions dispatch. 1667 * 1668 * @returns {Promise} 1669 */ 1670 async performPointerMoveStep(state, inputSource, targets, options) { 1671 if (targets.length !== 1) { 1672 throw new Error( 1673 "PointerMoveAction.performPointerMoveStep requires a single target" 1674 ); 1675 } 1676 1677 const target = targets[0]; 1678 if (target[0] == inputSource.x && target[1] == inputSource.y) { 1679 return; 1680 } 1681 1682 lazy.logger.trace( 1683 `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}` 1684 ); 1685 1686 await inputSource.pointer.pointerMove( 1687 state, 1688 inputSource, 1689 this, 1690 target[0], 1691 target[1], 1692 options 1693 ); 1694 1695 inputSource.moveTo(target[0], target[1]); 1696 } 1697 1698 /** 1699 * Unmarshals a JSON Object to a {@link PointerMoveAction}. 1700 * 1701 * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-move-action 1702 * 1703 * @param {string} id 1704 * Id of {@link InputSource}. 1705 * @param {object} actionItem 1706 * Object representing a single action. 1707 * @param {ActionsOptions} options 1708 * Configuration for actions. 1709 * 1710 * @returns {Promise<PointerMoveAction>} 1711 * A pointer move action that can be dispatched. 1712 * 1713 * @throws {InvalidArgumentError} 1714 * If the <code>actionItem</code> attribute is invalid. 1715 */ 1716 static async fromJSON(id, actionItem, options) { 1717 const { duration, origin, x, y } = actionItem; 1718 1719 if (duration !== undefined) { 1720 lazy.assert.positiveInteger( 1721 duration, 1722 lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` 1723 ); 1724 } 1725 1726 const originObject = await Origin.fromJSON(origin, options); 1727 1728 lazy.assert.number( 1729 x, 1730 lazy.pprint`Expected "x" to be a finite number, got ${x}` 1731 ); 1732 lazy.assert.number( 1733 y, 1734 lazy.pprint`Expected "y" to be a finite number, got ${y}` 1735 ); 1736 1737 const props = PointerAction.validateCommon(actionItem); 1738 props.duration = duration; 1739 props.origin = originObject; 1740 props.x = x; 1741 props.y = y; 1742 1743 return new this(id, props); 1744 } 1745 } 1746 1747 /** 1748 * Action associated with a wheel input device. 1749 */ 1750 class WheelAction extends Action { 1751 static type = "wheel"; 1752 } 1753 1754 /** 1755 * Action associated with scrolling a scroll wheel 1756 */ 1757 class WheelScrollAction extends WheelAction { 1758 static subtype = "scroll"; 1759 affectsWallClockTime = true; 1760 1761 /** 1762 * Creates a new {@link WheelScrollAction} instance. 1763 * 1764 * @param {number} id 1765 * Id of {@link InputSource}. 1766 * @param {object} options 1767 * @param {Origin} options.origin 1768 * {@link Origin} of target coordinates. 1769 * @param {number} options.x 1770 * X value of scroll coordinates. 1771 * @param {number} options.y 1772 * Y value of scroll coordinates. 1773 * @param {number} options.deltaX 1774 * Number of CSS pixels to scroll in X direction. 1775 * @param {number} options.deltaY 1776 * Number of CSS pixels to scroll in Y direction. 1777 */ 1778 constructor(id, options) { 1779 super(id); 1780 1781 const { duration, origin, x, y, deltaX, deltaY } = options; 1782 1783 this.duration = duration; 1784 this.origin = origin; 1785 this.x = x; 1786 this.y = y; 1787 this.deltaX = deltaX; 1788 this.deltaY = deltaY; 1789 } 1790 1791 /** 1792 * Unmarshals a JSON Object to a {@link WheelScrollAction}. 1793 * 1794 * @param {string} id 1795 * Id of {@link InputSource}. 1796 * @param {object} actionItem 1797 * Object representing a single action. 1798 * @param {ActionsOptions} options 1799 * Configuration for actions. 1800 * 1801 * @returns {Promise<WheelScrollAction>} 1802 * Promise that resolves to a wheel scroll action 1803 * that can be dispatched. 1804 * 1805 * @throws {InvalidArgumentError} 1806 * If the <code>actionItem</code> attribute is invalid. 1807 */ 1808 static async fromJSON(id, actionItem, options) { 1809 const { duration, origin, x, y, deltaX, deltaY } = actionItem; 1810 1811 if (duration !== undefined) { 1812 lazy.assert.positiveInteger( 1813 duration, 1814 lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` 1815 ); 1816 } 1817 1818 const originObject = await Origin.fromJSON(origin, options); 1819 1820 if (originObject instanceof PointerOrigin) { 1821 throw new lazy.error.InvalidArgumentError( 1822 `"pointer" origin not supported for "wheel" input source.` 1823 ); 1824 } 1825 1826 lazy.assert.integer( 1827 x, 1828 lazy.pprint`Expected "x" to be an Integer, got ${x}` 1829 ); 1830 lazy.assert.integer( 1831 y, 1832 lazy.pprint`Expected "y" to be an Integer, got ${y}` 1833 ); 1834 lazy.assert.integer( 1835 deltaX, 1836 lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}` 1837 ); 1838 lazy.assert.integer( 1839 deltaY, 1840 lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}` 1841 ); 1842 1843 return new this(id, { 1844 duration, 1845 origin: originObject, 1846 x, 1847 y, 1848 deltaX, 1849 deltaY, 1850 }); 1851 } 1852 1853 /** 1854 * Dispatch a wheel scroll action. 1855 * 1856 * @param {State} state 1857 * The {@link State} of the action. 1858 * @param {InputSource} inputSource 1859 * Current input device. 1860 * @param {number} tickDuration 1861 * [unused] Length of the current tick, in ms. 1862 * @param {ActionsOptions} options 1863 * Configuration of actions dispatch. 1864 * 1865 * @returns {Promise} 1866 * Promise that is resolved once the action is complete. 1867 */ 1868 async dispatch(state, inputSource, tickDuration, options) { 1869 const { assertInViewPort, context, toBrowserWindowCoordinates } = options; 1870 1871 let scrollCoordinates = await this.origin.getTargetCoordinates( 1872 inputSource, 1873 [this.x, this.y], 1874 options 1875 ); 1876 1877 await assertInViewPort(scrollCoordinates, context); 1878 1879 lazy.logger.trace( 1880 `Dispatch ${this.constructor.name} with id: ${this.id} ` + 1881 `pageX: ${scrollCoordinates[0]} pageY: ${scrollCoordinates[1]} ` + 1882 `deltaX: ${this.deltaX} deltaY: ${this.deltaY} ` + 1883 `async: ${actions.useAsyncWheelEvents}` 1884 ); 1885 1886 // Only convert coordinates if those are for a content process 1887 if (context.isContent && actions.useAsyncWheelEvents) { 1888 scrollCoordinates = await toBrowserWindowCoordinates( 1889 scrollCoordinates, 1890 context 1891 ); 1892 } 1893 1894 const startX = 0; 1895 const startY = 0; 1896 // This is an action-local state that holds the amount of scroll completed 1897 const deltaPosition = [startX, startY]; 1898 1899 return moveOverTime( 1900 [[startX, startY]], 1901 [[this.deltaX, this.deltaY]], 1902 this.duration ?? tickDuration, 1903 async deltaTarget => 1904 await this.performOneWheelScroll( 1905 state, 1906 scrollCoordinates, 1907 deltaPosition, 1908 deltaTarget, 1909 options 1910 ) 1911 ); 1912 } 1913 1914 /** 1915 * Perform one part of a wheel scroll corresponding to a specific emitted event. 1916 * 1917 * @param {State} state 1918 * The {@link State} of actions. 1919 * @param {Array<number>} scrollCoordinates 1920 * The viewport coordinates [x, y] of the scroll action. 1921 * @param {Array<number>} deltaPosition 1922 * [deltaX, deltaY] coordinates of the scroll before this event. 1923 * @param {Array<Array<number>>} deltaTargets 1924 * Array of [deltaX, deltaY] coordinates to scroll to. 1925 * @param {ActionsOptions} options 1926 * Configuration of actions dispatch. 1927 * 1928 * @returns {Promise} 1929 */ 1930 async performOneWheelScroll( 1931 state, 1932 scrollCoordinates, 1933 deltaPosition, 1934 deltaTargets, 1935 options 1936 ) { 1937 const { context, dispatchEvent } = options; 1938 1939 if (deltaTargets.length !== 1) { 1940 throw new Error("Can only scroll one wheel at a time"); 1941 } 1942 if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) { 1943 return; 1944 } 1945 1946 const deltaTarget = deltaTargets[0]; 1947 const deltaX = deltaTarget[0] - deltaPosition[0]; 1948 const deltaY = deltaTarget[1] - deltaPosition[1]; 1949 const eventData = new WheelEventData({ 1950 deltaX, 1951 deltaY, 1952 deltaZ: 0, 1953 }); 1954 eventData.update(state); 1955 1956 lazy.logger.trace( 1957 `WheelScrollAction.performOneWheelScrollStep [${deltaX},${deltaY}]` 1958 ); 1959 1960 await dispatchEvent("synthesizeWheelAtPoint", context, { 1961 x: scrollCoordinates[0], 1962 y: scrollCoordinates[1], 1963 eventData, 1964 }); 1965 1966 // Update the current scroll position for the caller 1967 deltaPosition[0] = deltaTarget[0]; 1968 deltaPosition[1] = deltaTarget[1]; 1969 } 1970 } 1971 1972 /** 1973 * Group of actions representing behavior of all touch pointers during 1974 * a single tick. 1975 * 1976 * For touch pointers, we need to call into the platform once with all 1977 * the actions so that they are regarded as simultaneous. This means 1978 * we don't use the `dispatch()` method on the underlying actions, but 1979 * instead use one on this group object. 1980 */ 1981 class TouchActionGroup { 1982 static type = null; 1983 1984 /** 1985 * Creates a new {@link TouchActionGroup} instance. 1986 */ 1987 constructor() { 1988 this.type = this.constructor.type; 1989 this.actions = new Map(); 1990 } 1991 1992 static forType(type) { 1993 const cls = touchActionGroupTypes.get(type); 1994 1995 return new cls(); 1996 } 1997 1998 /** 1999 * Add action corresponding to a specific pointer to the group. 2000 * 2001 * @param {InputSource} inputSource 2002 * Current input device. 2003 * @param {Action} action 2004 * Action to add to the group. 2005 */ 2006 addPointer(inputSource, action) { 2007 if (action.subtype !== this.type) { 2008 throw new Error( 2009 `Added action of unexpected type, got ${action.subtype}, expected ${this.type}` 2010 ); 2011 } 2012 2013 this.actions.set(action.id, [inputSource, action]); 2014 } 2015 2016 /** 2017 * Dispatch the action group to the relevant window. 2018 * 2019 * This is overridden by subclasses to implement the type-specific 2020 * dispatch of the action. 2021 * 2022 * @returns {Promise} 2023 * Promise that is resolved once the action is complete. 2024 */ 2025 dispatch() { 2026 throw new Error( 2027 "TouchActionGroup subclass missing dispatch implementation" 2028 ); 2029 } 2030 } 2031 2032 /** 2033 * Group of actions representing behavior of all touch pointers 2034 * depressed during a single tick. 2035 */ 2036 class PointerDownTouchActionGroup extends TouchActionGroup { 2037 static type = "pointerDown"; 2038 2039 /** 2040 * Dispatch a pointerdown touch action. 2041 * 2042 * @param {State} state 2043 * The {@link State} of the action. 2044 * @param {InputSource} inputSource 2045 * Current input device. 2046 * @param {number} tickDuration 2047 * [unused] Length of the current tick, in ms. 2048 * @param {ActionsOptions} options 2049 * Configuration of actions dispatch. 2050 * 2051 * @returns {Promise} 2052 * Promise that is resolved once the action is complete. 2053 */ 2054 async dispatch(state, inputSource, tickDuration, options) { 2055 const { context, dispatchEvent } = options; 2056 2057 lazy.logger.trace( 2058 `Dispatch ${this.constructor.name} with ${Array.from( 2059 this.actions.values() 2060 ).map(x => x[1].id)}` 2061 ); 2062 2063 if (inputSource !== null) { 2064 throw new Error( 2065 "Expected null inputSource for PointerDownTouchActionGroup.dispatch" 2066 ); 2067 } 2068 2069 // Only include pointers that are not already depressed 2070 const filteredActions = Array.from(this.actions.values()).filter( 2071 ([actionInputSource, action]) => 2072 !actionInputSource.isPressed(action.button) 2073 ); 2074 2075 if (filteredActions.length) { 2076 const eventData = new MultiTouchEventData("touchstart"); 2077 2078 for (const [actionInputSource, action] of filteredActions) { 2079 eventData.addPointerEventData(actionInputSource, action); 2080 actionInputSource.press(action.button); 2081 eventData.update(state, actionInputSource); 2082 } 2083 2084 // Touch start events must include all depressed touch pointers 2085 for (const [id, pointerInputSource] of state.inputSourcesByType( 2086 "pointer" 2087 )) { 2088 if ( 2089 pointerInputSource.pointer.type === "touch" && 2090 !this.actions.has(id) && 2091 pointerInputSource.isPressed(0) 2092 ) { 2093 eventData.addPointerEventData(pointerInputSource, {}); 2094 eventData.update(state, pointerInputSource); 2095 } 2096 } 2097 2098 await dispatchEvent("synthesizeMultiTouch", context, { eventData }); 2099 2100 for (const [, action] of filteredActions) { 2101 // Append a copy of |action| with pointerUp subtype if event dispatched 2102 state.inputsToCancel.push(new PointerUpAction(action.id, action)); 2103 } 2104 } 2105 } 2106 } 2107 2108 /** 2109 * Group of actions representing behavior of all touch pointers 2110 * released during a single tick. 2111 */ 2112 class PointerUpTouchActionGroup extends TouchActionGroup { 2113 static type = "pointerUp"; 2114 2115 /** 2116 * Dispatch a pointerup touch action. 2117 * 2118 * @param {State} state 2119 * The {@link State} of the action. 2120 * @param {InputSource} inputSource 2121 * Current input device. 2122 * @param {number} tickDuration 2123 * [unused] Length of the current tick, in ms. 2124 * @param {ActionsOptions} options 2125 * Configuration of actions dispatch. 2126 * 2127 * @returns {Promise} 2128 * Promise that is resolved once the action is complete. 2129 */ 2130 async dispatch(state, inputSource, tickDuration, options) { 2131 const { context, dispatchEvent } = options; 2132 2133 lazy.logger.trace( 2134 `Dispatch ${this.constructor.name} with ${Array.from( 2135 this.actions.values() 2136 ).map(x => x[1].id)}` 2137 ); 2138 2139 if (inputSource !== null) { 2140 throw new Error( 2141 "Expected null inputSource for PointerUpTouchActionGroup.dispatch" 2142 ); 2143 } 2144 2145 // Only include pointers that are not already depressed 2146 const filteredActions = Array.from(this.actions.values()).filter( 2147 ([actionInputSource, action]) => 2148 actionInputSource.isPressed(action.button) 2149 ); 2150 2151 if (filteredActions.length) { 2152 const eventData = new MultiTouchEventData("touchend"); 2153 for (const [actionInputSource, action] of filteredActions) { 2154 eventData.addPointerEventData(actionInputSource, action); 2155 actionInputSource.release(action.button); 2156 eventData.update(state, actionInputSource); 2157 } 2158 2159 await dispatchEvent("synthesizeMultiTouch", context, { eventData }); 2160 } 2161 } 2162 } 2163 2164 /** 2165 * Group of actions representing behavior of all touch pointers 2166 * moved during a single tick. 2167 */ 2168 class PointerMoveTouchActionGroup extends TouchActionGroup { 2169 static type = "pointerMove"; 2170 2171 /** 2172 * Dispatch a pointermove touch action. 2173 * 2174 * @param {State} state 2175 * The {@link State} of the action. 2176 * @param {InputSource} inputSource 2177 * Current input device. 2178 * @param {number} tickDuration 2179 * [unused] Length of the current tick, in ms. 2180 * @param {ActionsOptions} options 2181 * Configuration of actions dispatch. 2182 * 2183 * @returns {Promise} 2184 * Promise that is resolved once the action is complete. 2185 */ 2186 async dispatch(state, inputSource, tickDuration, options) { 2187 const { assertInViewPort, context } = options; 2188 2189 lazy.logger.trace( 2190 `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map( 2191 x => x[1].id 2192 )}` 2193 ); 2194 if (inputSource !== null) { 2195 throw new Error( 2196 "Expected null inputSource for PointerMoveTouchActionGroup.dispatch" 2197 ); 2198 } 2199 2200 let startCoords = []; 2201 let targetCoords = []; 2202 2203 for (const [actionInputSource, action] of this.actions.values()) { 2204 const target = await action.origin.getTargetCoordinates( 2205 actionInputSource, 2206 [action.x, action.y], 2207 options 2208 ); 2209 2210 await assertInViewPort(target, context); 2211 2212 startCoords.push([actionInputSource.x, actionInputSource.y]); 2213 targetCoords.push(target); 2214 } 2215 2216 // Touch move events must include all depressed touch pointers, even if they are static 2217 // This can end up generating pointermove events even for static pointers, but Gecko 2218 // seems to generate a lot of pointermove events anyway, so this seems like the lesser 2219 // problem. 2220 // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206 2221 const staticTouchPointers = []; 2222 for (const [id, pointerInputSource] of state.inputSourcesByType( 2223 "pointer" 2224 )) { 2225 if ( 2226 pointerInputSource.pointer.type === "touch" && 2227 !this.actions.has(id) && 2228 pointerInputSource.isPressed(0) 2229 ) { 2230 staticTouchPointers.push(pointerInputSource); 2231 } 2232 } 2233 2234 return moveOverTime( 2235 startCoords, 2236 targetCoords, 2237 this.duration ?? tickDuration, 2238 async currentTargetCoords => 2239 await this.performPointerMoveStep( 2240 state, 2241 staticTouchPointers, 2242 currentTargetCoords, 2243 options 2244 ) 2245 ); 2246 } 2247 2248 /** 2249 * Perform one part of a pointer move corresponding to a specific emitted event. 2250 * 2251 * @param {State} state 2252 * The {@link State} of actions. 2253 * @param {Array<PointerInputSource>} staticTouchPointers 2254 * Array of PointerInputSource objects for pointers that aren't 2255 * involved in the touch move. 2256 * @param {Array<Array<number>>} targetCoords 2257 * Array of [x, y] arrays specifying the viewport coordinates to move to. 2258 * @param {ActionsOptions} options 2259 * Configuration of actions dispatch. 2260 */ 2261 async performPointerMoveStep( 2262 state, 2263 staticTouchPointers, 2264 targetCoords, 2265 options 2266 ) { 2267 const { context, dispatchEvent } = options; 2268 2269 if (targetCoords.length !== this.actions.size) { 2270 throw new Error("Expected one target per pointer"); 2271 } 2272 2273 const perPointerData = Array.from(this.actions.values()).map( 2274 ([inputSource, action], i) => { 2275 const target = targetCoords[i]; 2276 return [inputSource, action, target]; 2277 } 2278 ); 2279 const reachedTarget = perPointerData.every( 2280 ([inputSource, , target]) => 2281 target[0] === inputSource.x && target[1] === inputSource.y 2282 ); 2283 2284 if (reachedTarget) { 2285 return; 2286 } 2287 2288 const eventData = new MultiTouchEventData("touchmove"); 2289 for (const [inputSource, action, target] of perPointerData) { 2290 inputSource.moveTo(target[0], target[1]); 2291 eventData.addPointerEventData(inputSource, action); 2292 eventData.update(state, inputSource); 2293 } 2294 2295 for (const inputSource of staticTouchPointers) { 2296 eventData.addPointerEventData(inputSource, {}); 2297 eventData.update(state, inputSource); 2298 } 2299 2300 await dispatchEvent("synthesizeMultiTouch", context, { eventData }); 2301 } 2302 } 2303 2304 const touchActionGroupTypes = new Map(); 2305 for (const cls of [ 2306 PointerDownTouchActionGroup, 2307 PointerUpTouchActionGroup, 2308 PointerMoveTouchActionGroup, 2309 ]) { 2310 touchActionGroupTypes.set(cls.type, cls); 2311 } 2312 2313 /** 2314 * Split a transition from startCoord to targetCoord linearly over duration. 2315 * 2316 * startCoords and targetCoords are lists of [x,y] positions in some space 2317 * (e.g. screen position or scroll delta). This function will linearly 2318 * interpolate intermediate positions, sending out roughly one event 2319 * per frame to simulate moving between startCoord and targetCoord in 2320 * a time of tickDuration milliseconds. The callback function is 2321 * responsible for actually emitting the event, given the current 2322 * position in the coordinate space. 2323 * 2324 * @param {Array<Array>} startCoords 2325 * Array of initial [x, y] coordinates for each input source involved 2326 * in the move. 2327 * @param {Array<Array<number>>} targetCoords 2328 * Array of target [x, y] coordinates for each input source involved 2329 * in the move. 2330 * @param {number} duration 2331 * Time in ms the move will take. 2332 * @param {Function} callback 2333 * Function that actually performs the move. This takes a single parameter 2334 * which is an array of [x, y] coordinates corresponding to the move 2335 * targets. 2336 */ 2337 async function moveOverTime(startCoords, targetCoords, duration, callback) { 2338 lazy.logger.trace( 2339 `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}` 2340 ); 2341 2342 if (startCoords.length !== targetCoords.length) { 2343 throw new Error( 2344 "Expected equal number of start coordinates and target coordinates" 2345 ); 2346 } 2347 2348 if ( 2349 !startCoords.every(item => item.length == 2) || 2350 !targetCoords.every(item => item.length == 2) 2351 ) { 2352 throw new Error( 2353 "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates." 2354 ); 2355 } 2356 2357 if (duration === 0) { 2358 // transition to destination in one step 2359 await callback(targetCoords); 2360 return; 2361 } 2362 2363 const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 2364 // interval between transitions in ms, based on common vsync 2365 const fps60 = 17; 2366 2367 const distances = targetCoords.map((targetCoord, i) => { 2368 const startCoord = startCoords[i]; 2369 return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]]; 2370 }); 2371 const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT; 2372 const startTime = Date.now(); 2373 const transitions = (async () => { 2374 // wait |fps60| ms before performing first incremental transition 2375 await new Promise(resolveTimer => 2376 timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) 2377 ); 2378 2379 let durationRatio = Math.floor(Date.now() - startTime) / duration; 2380 const epsilon = fps60 / duration / 10; 2381 while (1 - durationRatio > epsilon) { 2382 const intermediateTargets = startCoords.map((startCoord, i) => { 2383 let distance = distances[i]; 2384 return [ 2385 Math.floor(durationRatio * distance[0] + startCoord[0]), 2386 Math.floor(durationRatio * distance[1] + startCoord[1]), 2387 ]; 2388 }); 2389 2390 await Promise.all([ 2391 callback(intermediateTargets), 2392 2393 // wait |fps60| ms before performing next transition 2394 new Promise(resolveTimer => 2395 timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) 2396 ), 2397 ]); 2398 2399 durationRatio = Math.floor(Date.now() - startTime) / duration; 2400 } 2401 })(); 2402 2403 await transitions; 2404 2405 // perform last transition after all incremental moves are resolved and 2406 // durationRatio is close enough to 1 2407 await callback(targetCoords); 2408 } 2409 2410 const actionTypes = new Map(); 2411 for (const cls of [ 2412 KeyDownAction, 2413 KeyUpAction, 2414 PauseAction, 2415 PointerDownAction, 2416 PointerUpAction, 2417 PointerMoveAction, 2418 WheelScrollAction, 2419 ]) { 2420 if (!actionTypes.has(cls.type)) { 2421 actionTypes.set(cls.type, new Map()); 2422 } 2423 actionTypes.get(cls.type).set(cls.subtype, cls); 2424 } 2425 2426 /** 2427 * Implementation of the behavior of a specific type of pointer. 2428 * 2429 * @abstract 2430 */ 2431 class Pointer { 2432 /** Type of pointer */ 2433 static type = null; 2434 2435 /** 2436 * Creates a new {@link Pointer} instance. 2437 * 2438 * @param {number} id 2439 * Numeric pointer id. 2440 */ 2441 constructor(id) { 2442 this.id = id; 2443 this.type = this.constructor.type; 2444 } 2445 2446 /** 2447 * Implementation of depressing the pointer. 2448 */ 2449 pointerDown() { 2450 throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`); 2451 } 2452 2453 /** 2454 * Implementation of releasing the pointer. 2455 */ 2456 pointerUp() { 2457 throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`); 2458 } 2459 2460 /** 2461 * Implementation of moving the pointer. 2462 */ 2463 pointerMove() { 2464 throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`); 2465 } 2466 2467 /** 2468 * Unmarshals a JSON Object to a {@link Pointer}. 2469 * 2470 * @param {number} pointerId 2471 * Numeric pointer id. 2472 * @param {string} pointerType 2473 * Pointer type. 2474 * 2475 * @returns {Pointer} 2476 * An instance of the Pointer class for {@link pointerType}. 2477 * 2478 * @throws {InvalidArgumentError} 2479 * If {@link pointerType} is not a valid pointer type. 2480 */ 2481 static fromJSON(pointerId, pointerType) { 2482 const cls = pointerTypes.get(pointerType); 2483 2484 if (cls === undefined) { 2485 throw new lazy.error.InvalidArgumentError( 2486 'Expected "pointerType" type to be one of ' + 2487 lazy.pprint`${pointerTypes}, got ${pointerType}` 2488 ); 2489 } 2490 2491 return new cls(pointerId); 2492 } 2493 } 2494 2495 /** 2496 * Implementation of mouse pointer behavior. 2497 */ 2498 class MousePointer extends Pointer { 2499 static type = "mouse"; 2500 2501 /** 2502 * Emits a pointer down event. 2503 * 2504 * @param {State} state 2505 * The {@link State} of the action. 2506 * @param {InputSource} inputSource 2507 * Current input device. 2508 * @param {PointerDownAction} action 2509 * The pointer down action to perform. 2510 * @param {ActionsOptions} options 2511 * Configuration of actions dispatch. 2512 * 2513 * @returns {Promise} 2514 * Promise that resolves when the event has been dispatched. 2515 */ 2516 async pointerDown(state, inputSource, action, options) { 2517 const { context, dispatchEvent } = options; 2518 2519 const mouseEvent = new MouseEventData("mousedown", { 2520 button: action.button, 2521 }); 2522 mouseEvent.update(state, inputSource); 2523 2524 if (mouseEvent.ctrlKey) { 2525 if (lazy.AppInfo.isMac) { 2526 mouseEvent.button = 2; 2527 state.clickTracker.reset(); 2528 } 2529 } else { 2530 mouseEvent.clickCount = state.clickTracker.count + 1; 2531 } 2532 2533 await dispatchEvent("synthesizeMouseAtPoint", context, { 2534 x: inputSource.x, 2535 y: inputSource.y, 2536 eventData: mouseEvent, 2537 }); 2538 2539 if ( 2540 lazy.event.MouseButton.isSecondary(mouseEvent.button) || 2541 (mouseEvent.ctrlKey && lazy.AppInfo.isMac) 2542 ) { 2543 const contextMenuEvent = { ...mouseEvent, type: "contextmenu" }; 2544 2545 await dispatchEvent("synthesizeMouseAtPoint", context, { 2546 x: inputSource.x, 2547 y: inputSource.y, 2548 eventData: contextMenuEvent, 2549 }); 2550 } 2551 } 2552 2553 /** 2554 * Emits a pointer up event. 2555 * 2556 * @param {State} state 2557 * The {@link State} of the action. 2558 * @param {InputSource} inputSource 2559 * Current input device. 2560 * @param {PointerUpAction} action 2561 * The pointer up action to perform. 2562 * @param {ActionsOptions} options 2563 * Configuration of actions dispatch. 2564 * 2565 * @returns {Promise} 2566 * Promise that resolves when the event has been dispatched. 2567 */ 2568 async pointerUp(state, inputSource, action, options) { 2569 const { context, dispatchEvent } = options; 2570 2571 const mouseEvent = new MouseEventData("mouseup", { 2572 button: action.button, 2573 }); 2574 mouseEvent.update(state, inputSource); 2575 2576 state.clickTracker.setClick(action.button); 2577 mouseEvent.clickCount = state.clickTracker.count; 2578 2579 await dispatchEvent("synthesizeMouseAtPoint", context, { 2580 x: inputSource.x, 2581 y: inputSource.y, 2582 eventData: mouseEvent, 2583 }); 2584 } 2585 2586 /** 2587 * Emits a pointer down event. 2588 * 2589 * @param {State} state 2590 * The {@link State} of the action. 2591 * @param {InputSource} inputSource 2592 * Current input device. 2593 * @param {PointerMoveAction} action 2594 * The pointer down action to perform. 2595 * @param {number} targetX 2596 * Target x position to move the pointer to. 2597 * @param {number} targetY 2598 * Target y position to move the pointer to. 2599 * @param {ActionsOptions} options 2600 * Configuration of actions dispatch. 2601 * 2602 * @returns {Promise} 2603 * Promise that resolves when the event has been dispatched. 2604 */ 2605 async pointerMove(state, inputSource, action, targetX, targetY, options) { 2606 const { context, dispatchEvent } = options; 2607 2608 const mouseEvent = new MouseEventData("mousemove"); 2609 mouseEvent.update(state, inputSource); 2610 2611 await dispatchEvent("synthesizeMouseAtPoint", context, { 2612 x: targetX, 2613 y: targetY, 2614 eventData: mouseEvent, 2615 }); 2616 2617 state.clickTracker.reset(); 2618 } 2619 } 2620 2621 /* 2622 * The implementation here is empty because touch actions have to go via the 2623 * TouchActionGroup. So if we end up calling these methods that's a bug in 2624 * the code. 2625 */ 2626 class TouchPointer extends Pointer { 2627 static type = "touch"; 2628 } 2629 2630 /* 2631 * Placeholder for future pen type pointer support. 2632 */ 2633 class PenPointer extends Pointer { 2634 static type = "pen"; 2635 } 2636 2637 const pointerTypes = new Map(); 2638 for (const cls of [MousePointer, TouchPointer, PenPointer]) { 2639 pointerTypes.set(cls.type, cls); 2640 } 2641 2642 /** 2643 * Represents a series of ticks, specifying which actions to perform at 2644 * each tick. 2645 */ 2646 actions.Chain = class extends Array { 2647 toString() { 2648 return `[chain ${super.toString()}]`; 2649 } 2650 2651 /** 2652 * Dispatch the action chain to the relevant window. 2653 * 2654 * @param {State} state 2655 * The {@link State} of actions. 2656 * @param {ActionsOptions} options 2657 * Configuration of actions dispatch. 2658 * 2659 * @returns {Promise} 2660 * Promise that is resolved once the action chain is complete. 2661 */ 2662 dispatch(state, options) { 2663 let i = 1; 2664 2665 const chainEvents = (async () => { 2666 for (const tickActions of this) { 2667 lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`); 2668 await tickActions.dispatch(state, options); 2669 } 2670 })(); 2671 2672 // Reset the current click tracker counter. We shouldn't be able to simulate 2673 // a double click with multiple action chains. 2674 state.clickTracker.reset(); 2675 2676 return chainEvents; 2677 } 2678 2679 /* eslint-disable no-shadow */ // Shadowing is intentional for `actions`. 2680 /** 2681 * 2682 * Unmarshals a JSON Object to a {@link Chain}. 2683 * 2684 * @see https://w3c.github.io/webdriver/#dfn-extract-an-action-sequence 2685 * 2686 * @param {State} actionState 2687 * The {@link State} of actions. 2688 * @param {Array<object>} actions 2689 * Array of objects that each represent an action sequence. 2690 * @param {ActionsOptions} options 2691 * Configuration for actions. 2692 * 2693 * @returns {Promise<Chain>} 2694 * Promise resolving to an object that allows dispatching 2695 * a chain of actions. 2696 * 2697 * @throws {InvalidArgumentError} 2698 * If <code>actions</code> doesn't correspond to a valid action chain. 2699 */ 2700 static async fromJSON(actionState, actions, options) { 2701 lazy.assert.array( 2702 actions, 2703 lazy.pprint`Expected "actions" to be an array, got ${actions}` 2704 ); 2705 2706 const actionsByTick = new this(); 2707 for (const actionSequence of actions) { 2708 lazy.assert.object( 2709 actionSequence, 2710 'Expected "actions" item to be an object, ' + 2711 lazy.pprint`got ${actionSequence}` 2712 ); 2713 2714 const inputSourceActions = await Sequence.fromJSON( 2715 actionState, 2716 actionSequence, 2717 options 2718 ); 2719 2720 for (let i = 0; i < inputSourceActions.length; i++) { 2721 // new tick 2722 if (actionsByTick.length < i + 1) { 2723 actionsByTick.push(new TickActions()); 2724 } 2725 actionsByTick[i].push(inputSourceActions[i]); 2726 } 2727 } 2728 2729 return actionsByTick; 2730 } 2731 /* eslint-enable no-shadow */ 2732 }; 2733 2734 /** 2735 * Represents the action for each input device to perform in a single tick. 2736 */ 2737 class TickActions extends Array { 2738 /** 2739 * Tick duration in milliseconds. 2740 * 2741 * @returns {number} 2742 * Longest action duration in |tickActions| if any, or 0. 2743 */ 2744 getDuration() { 2745 let max = 0; 2746 2747 for (const action of this) { 2748 if (action.affectsWallClockTime && action.duration) { 2749 max = Math.max(action.duration, max); 2750 } 2751 } 2752 2753 return max; 2754 } 2755 2756 /** 2757 * Dispatch sequence of actions for this tick. 2758 * 2759 * This creates a Promise for one tick that resolves once the Promise 2760 * for each tick-action is resolved, which takes at least |tickDuration| 2761 * milliseconds. The resolved set of events for each tick is followed by 2762 * firing of pending DOM events. 2763 * 2764 * Note that the tick-actions are dispatched in order, but they may have 2765 * different durations and therefore may not end in the same order. 2766 * 2767 * @param {State} state 2768 * The {@link State} of actions. 2769 * @param {ActionsOptions} options 2770 * Configuration of actions dispatch. 2771 * 2772 * @returns {Promise} 2773 * Promise that resolves when tick is complete. 2774 */ 2775 dispatch(state, options) { 2776 const tickDuration = this.getDuration(); 2777 const tickActions = this.groupTickActions(state); 2778 const pendingEvents = tickActions.map(([inputSource, action]) => 2779 action.dispatch(state, inputSource, tickDuration, options) 2780 ); 2781 2782 return Promise.all(pendingEvents); 2783 } 2784 2785 /** 2786 * Group together actions from input sources that have to be 2787 * dispatched together. 2788 * 2789 * The actual transformation here is to group together touch pointer 2790 * actions into {@link TouchActionGroup} instances. 2791 * 2792 * @param {State} state 2793 * The {@link State} of actions. 2794 * 2795 * @returns {Array<Array<InputSource?,Action|TouchActionGroup>>} 2796 * Array of pairs. For ungrouped actions each element is 2797 * [InputSource, Action] For touch actions there are multiple 2798 * pointers handled at once, so the first item of the array is 2799 * null, meaning the group has to perform its own handling of the 2800 * relevant state, and the second element is a TouchActionGroup. 2801 */ 2802 groupTickActions(state) { 2803 const touchActions = new Map(); 2804 const groupedActions = []; 2805 2806 for (const action of this) { 2807 const inputSource = state.getInputSource(action.id); 2808 if (action.type == "pointer" && inputSource.pointer.type === "touch") { 2809 lazy.logger.debug( 2810 `Grouping action ${action.type} ${action.id} ${action.subtype}` 2811 ); 2812 let group = touchActions.get(action.subtype); 2813 if (group === undefined) { 2814 group = TouchActionGroup.forType(action.subtype); 2815 touchActions.set(action.subtype, group); 2816 groupedActions.push([null, group]); 2817 } 2818 group.addPointer(inputSource, action); 2819 } else { 2820 groupedActions.push([inputSource, action]); 2821 } 2822 } 2823 2824 return groupedActions; 2825 } 2826 } 2827 2828 /** 2829 * Represents one input source action sequence; this is essentially an 2830 * |Array<Action>|. 2831 * 2832 * This is a temporary object only used when constructing an {@link 2833 * action.Chain}. 2834 */ 2835 class Sequence extends Array { 2836 toString() { 2837 return `[sequence ${super.toString()}]`; 2838 } 2839 2840 /** 2841 * Unmarshals a JSON Object to a {@link Sequence}. 2842 * 2843 * @see https://w3c.github.io/webdriver/#dfn-process-an-input-source-action-sequence 2844 * 2845 * @param {State} actionState 2846 * The {@link State} of actions. 2847 * @param {object} actionSequence 2848 * Protocol representation of the actions for a specific input source. 2849 * @param {ActionsOptions} options 2850 * Configuration for actions. 2851 * 2852 * @returns {Promise<Array<Array<InputSource, Action | TouchActionGroup>>>} 2853 * Promise that resolves to an object that allows dispatching a 2854 * sequence of actions. 2855 * 2856 * @throws {InvalidArgumentError} 2857 * If the <code>actionSequence</code> doesn't correspond to a valid action sequence. 2858 */ 2859 static async fromJSON(actionState, actionSequence, options) { 2860 // used here to validate 'type' in addition to InputSource type below 2861 const { actions: actionsFromSequence, id, type } = actionSequence; 2862 2863 // type and id get validated in InputSource.fromJSON 2864 lazy.assert.array( 2865 actionsFromSequence, 2866 'Expected "actionSequence.actions" to be an array, ' + 2867 lazy.pprint`got ${actionSequence.actions}` 2868 ); 2869 2870 // This sets the input state in the global state map, if it's new. 2871 InputSource.fromJSON(actionState, actionSequence); 2872 2873 const sequence = new this(); 2874 for (const actionItem of actionsFromSequence) { 2875 sequence.push(await Action.fromJSON(type, id, actionItem, options)); 2876 } 2877 2878 return sequence; 2879 } 2880 } 2881 2882 /** 2883 * Representation of an input event. 2884 * 2885 * @param {object} [options={}] 2886 * @param {boolean} [options.altKey] - If set to `true`, the Alt key will be 2887 * considered pressed. 2888 * @param {boolean} [options.ctrlKey] - If set to `true`, the Ctrl key will be 2889 * considered pressed. 2890 * @param {boolean} [options.metaKey] - If set to `true`, the Meta key will be 2891 * considered pressed. 2892 * @param {boolean} [options.shiftKey] - If set to `true`, the Shift key will be 2893 * considered pressed. 2894 */ 2895 class InputEventData { 2896 constructor(options = {}) { 2897 const { altKey, ctrlKey, metaKey, shiftKey } = options; 2898 2899 this.altKey = altKey; 2900 this.ctrlKey = ctrlKey; 2901 this.metaKey = metaKey; 2902 this.shiftKey = shiftKey; 2903 } 2904 2905 /** 2906 * Update the input data based on global and input state 2907 */ 2908 update(state) { 2909 for (const [, otherInputSource] of state.inputSourcesByType("key")) { 2910 // set modifier properties based on whether any corresponding keys are 2911 // pressed on any key input source 2912 this.altKey = otherInputSource.alt || this.altKey; 2913 this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; 2914 this.metaKey = otherInputSource.meta || this.metaKey; 2915 this.shiftKey = otherInputSource.shift || this.shiftKey; 2916 } 2917 } 2918 2919 toString() { 2920 return `${this.constructor.name} ${JSON.stringify(this)}`; 2921 } 2922 } 2923 2924 /** 2925 * Representation of a key input event. 2926 */ 2927 class KeyEventData extends InputEventData { 2928 /** 2929 * Creates a new {@link KeyEventData} instance. 2930 * 2931 * @param {string} rawKey 2932 * The key value. 2933 */ 2934 constructor(rawKey) { 2935 super(); 2936 2937 const { key, code, location, printable } = lazy.keyData.getData(rawKey); 2938 2939 this.key = key; 2940 this.code = code; 2941 this.location = location; 2942 this.printable = printable; 2943 this.repeat = false; 2944 // keyCode will be computed by event.sendKeyDown 2945 } 2946 2947 update(state, inputSource) { 2948 this.altKey = inputSource.alt; 2949 this.shiftKey = inputSource.shift; 2950 this.ctrlKey = inputSource.ctrl; 2951 this.metaKey = inputSource.meta; 2952 } 2953 } 2954 2955 /** 2956 * Representation of a pointer input event. 2957 */ 2958 class PointerEventData extends InputEventData { 2959 /** 2960 * Creates a new {@link PointerEventData} instance. 2961 * 2962 * @param {string} type 2963 * The event type. 2964 */ 2965 constructor(type) { 2966 super(); 2967 2968 this.type = type; 2969 this.buttons = 0; 2970 } 2971 2972 update(state, inputSource) { 2973 // set modifier properties based on whether any corresponding keys are 2974 // pressed on any key input source 2975 for (const [, otherInputSource] of state.inputSourcesByType("key")) { 2976 this.altKey = otherInputSource.alt || this.altKey; 2977 this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; 2978 this.metaKey = otherInputSource.meta || this.metaKey; 2979 this.shiftKey = otherInputSource.shift || this.shiftKey; 2980 } 2981 const allButtons = Array.from(inputSource.pressed); 2982 this.buttons = allButtons.reduce( 2983 (a, i) => a + PointerEventData.getButtonFlag(i), 2984 0 2985 ); 2986 } 2987 2988 /** 2989 * Return a flag for buttons which indicates a button is pressed. 2990 * 2991 * @param {integer} button 2992 * The mouse button number. 2993 */ 2994 static getButtonFlag(button) { 2995 switch (button) { 2996 case 1: 2997 return 4; 2998 case 2: 2999 return 2; 3000 default: 3001 return Math.pow(2, button); 3002 } 3003 } 3004 } 3005 3006 /** 3007 * Representation of a mouse input event. 3008 */ 3009 class MouseEventData extends PointerEventData { 3010 /** 3011 * Creates a new {@link MouseEventData} instance. 3012 * 3013 * @param {string} type 3014 * The event type. 3015 * @param {object=} options 3016 * @param {number=} options.button 3017 * The number of the mouse button. Defaults to 0. 3018 */ 3019 constructor(type, options = {}) { 3020 super(type); 3021 3022 const { button = 0 } = options; 3023 3024 this.button = button; 3025 this.buttons = 0; 3026 3027 // Some WPTs try to synthesize DnD only with mouse events. However, 3028 // Gecko waits DnD events directly and non-WPT-tests use Gecko specific 3029 // test API to synthesize DnD. Therefore, we want new path only for 3030 // synthesized events coming from the webdriver. 3031 this.allowToHandleDragDrop = true; 3032 } 3033 3034 update(state, inputSource) { 3035 super.update(state, inputSource); 3036 3037 this.id = inputSource.pointer.id; 3038 } 3039 } 3040 3041 /** 3042 * Representation of a wheel input event. 3043 */ 3044 class WheelEventData extends InputEventData { 3045 /** 3046 * Creates a new {@link WheelEventData} instance. 3047 * 3048 * @param {object} [options={}] 3049 * @param {number} [options.deltaX=0] - Floating-point value in CSS pixels to 3050 * scroll in the x direction. 3051 * @param {number} [options.deltaY=0] - Floating-point value in CSS pixels to 3052 * scroll in the y direction. 3053 * 3054 * @see event.synthesizeWheelAtPoint 3055 * @see InputEventData 3056 */ 3057 constructor(options) { 3058 super(options); 3059 3060 const { deltaX, deltaY } = options; 3061 this.deltaX = deltaX; 3062 this.deltaY = deltaY; 3063 this.deltaZ = 0; 3064 } 3065 } 3066 3067 /** 3068 * Representation of a multi touch event. 3069 */ 3070 class MultiTouchEventData extends PointerEventData { 3071 #setGlobalState; 3072 3073 /** 3074 * Creates a new {@link MultiTouchEventData} instance. 3075 * 3076 * @param {string} type 3077 * The event type. 3078 */ 3079 constructor(type) { 3080 super(type); 3081 3082 this.id = []; 3083 this.x = []; 3084 this.y = []; 3085 this.rx = []; 3086 this.ry = []; 3087 this.angle = []; 3088 this.force = []; 3089 this.tiltx = []; 3090 this.tilty = []; 3091 this.twist = []; 3092 this.#setGlobalState = false; 3093 } 3094 3095 /** 3096 * Add the data from one pointer to the event. 3097 * 3098 * @param {InputSource} inputSource 3099 * The state of the pointer. 3100 * @param {PointerAction} action 3101 * Action for the pointer. 3102 */ 3103 addPointerEventData(inputSource, action) { 3104 this.x.push(inputSource.x); 3105 this.y.push(inputSource.y); 3106 this.id.push(inputSource.pointer.id); 3107 this.rx.push(action.width || 1); 3108 this.ry.push(action.height || 1); 3109 this.angle.push(0); 3110 this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1)); 3111 this.tiltx.push(action.tiltX || 0); 3112 this.tilty.push(action.tiltY || 0); 3113 this.twist.push(action.twist || 0); 3114 } 3115 3116 update(state, inputSource) { 3117 // We call update once per input source, but only want to update global state once. 3118 // Instead of introducing a new lifecycle method, or changing the API to allow multiple 3119 // input sources in a single call, use a small bit of state to avoid repeatedly setting 3120 // global state. 3121 if (!this.#setGlobalState) { 3122 // set modifier properties based on whether any corresponding keys are 3123 // pressed on any key input source 3124 for (const [, otherInputSource] of state.inputSourcesByType("key")) { 3125 this.altKey = otherInputSource.alt || this.altKey; 3126 this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; 3127 this.metaKey = otherInputSource.meta || this.metaKey; 3128 this.shiftKey = otherInputSource.shift || this.shiftKey; 3129 } 3130 this.#setGlobalState = true; 3131 } 3132 3133 // Note that we currently emit Touch events that don't have this property 3134 // but pointer events should have a `buttons` property, so we'll compute it 3135 // anyway. 3136 const allButtons = Array.from(inputSource.pressed); 3137 this.buttons = 3138 this.buttons | 3139 allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0); 3140 } 3141 } 3142 3143 // Helpers 3144 3145 /** 3146 * Assert that target is in the viewport of win. 3147 * 3148 * @param {Array<number>} target 3149 * Coordinates [x, y] of the target relative to the viewport. 3150 * @param {WindowProxy} win 3151 * The target window. 3152 * 3153 * @throws {MoveTargetOutOfBoundsError} 3154 * If target is outside the viewport. 3155 */ 3156 export function assertTargetInViewPort(target, win) { 3157 const [x, y] = target; 3158 3159 lazy.assert.number( 3160 x, 3161 lazy.pprint`Expected "x" to be finite number, got ${x}` 3162 ); 3163 lazy.assert.number( 3164 y, 3165 lazy.pprint`Expected "y" to be finite number, got ${y}` 3166 ); 3167 3168 // Viewport includes scrollbars if rendered. 3169 if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) { 3170 throw new lazy.error.MoveTargetOutOfBoundsError( 3171 `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` + 3172 `(${win.innerWidth}, ${win.innerHeight})` 3173 ); 3174 } 3175 }