1064 lines
31 KiB
JavaScript
1064 lines
31 KiB
JavaScript
// Licensed to the Software Freedom Conservancy (SFC) under one
|
|
// or more contributor license agreements. See the NOTICE file
|
|
// distributed with this work for additional information
|
|
// regarding copyright ownership. The SFC licenses this file
|
|
// to you under the Apache License, Version 2.0 (the
|
|
// "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
'use strict'
|
|
|
|
/**
|
|
* @fileoverview Defines types related to user input with the WebDriver API.
|
|
*/
|
|
const { Command, Name } = require('./command')
|
|
const { InvalidArgumentError } = require('./error')
|
|
|
|
/**
|
|
* Enumeration of the buttons used in the advanced interactions API.
|
|
* @enum {number}
|
|
*/
|
|
const Button = {
|
|
LEFT: 0,
|
|
MIDDLE: 1,
|
|
RIGHT: 2,
|
|
BACK: 3,
|
|
FORWARD: 4,
|
|
}
|
|
|
|
/**
|
|
* Representations of pressable keys that aren't text. These are stored in
|
|
* the Unicode PUA (Private Use Area) code points, 0xE000-0xF8FF. Refer to
|
|
* http://www.google.com.au/search?&q=unicode+pua&btnK=Search
|
|
*
|
|
* @enum {string}
|
|
* @see <https://www.w3.org/TR/webdriver/#keyboard-actions>
|
|
*/
|
|
const Key = {
|
|
NULL: '\uE000',
|
|
CANCEL: '\uE001', // ^break
|
|
HELP: '\uE002',
|
|
BACK_SPACE: '\uE003',
|
|
TAB: '\uE004',
|
|
CLEAR: '\uE005',
|
|
RETURN: '\uE006',
|
|
ENTER: '\uE007',
|
|
SHIFT: '\uE008',
|
|
CONTROL: '\uE009',
|
|
ALT: '\uE00A',
|
|
PAUSE: '\uE00B',
|
|
ESCAPE: '\uE00C',
|
|
SPACE: '\uE00D',
|
|
PAGE_UP: '\uE00E',
|
|
PAGE_DOWN: '\uE00F',
|
|
END: '\uE010',
|
|
HOME: '\uE011',
|
|
ARROW_LEFT: '\uE012',
|
|
LEFT: '\uE012',
|
|
ARROW_UP: '\uE013',
|
|
UP: '\uE013',
|
|
ARROW_RIGHT: '\uE014',
|
|
RIGHT: '\uE014',
|
|
ARROW_DOWN: '\uE015',
|
|
DOWN: '\uE015',
|
|
INSERT: '\uE016',
|
|
DELETE: '\uE017',
|
|
SEMICOLON: '\uE018',
|
|
EQUALS: '\uE019',
|
|
|
|
NUMPAD0: '\uE01A', // number pad keys
|
|
NUMPAD1: '\uE01B',
|
|
NUMPAD2: '\uE01C',
|
|
NUMPAD3: '\uE01D',
|
|
NUMPAD4: '\uE01E',
|
|
NUMPAD5: '\uE01F',
|
|
NUMPAD6: '\uE020',
|
|
NUMPAD7: '\uE021',
|
|
NUMPAD8: '\uE022',
|
|
NUMPAD9: '\uE023',
|
|
MULTIPLY: '\uE024',
|
|
ADD: '\uE025',
|
|
SEPARATOR: '\uE026',
|
|
SUBTRACT: '\uE027',
|
|
DECIMAL: '\uE028',
|
|
DIVIDE: '\uE029',
|
|
|
|
F1: '\uE031', // function keys
|
|
F2: '\uE032',
|
|
F3: '\uE033',
|
|
F4: '\uE034',
|
|
F5: '\uE035',
|
|
F6: '\uE036',
|
|
F7: '\uE037',
|
|
F8: '\uE038',
|
|
F9: '\uE039',
|
|
F10: '\uE03A',
|
|
F11: '\uE03B',
|
|
F12: '\uE03C',
|
|
|
|
COMMAND: '\uE03D', // Apple command key
|
|
META: '\uE03D', // alias for Windows key
|
|
|
|
/**
|
|
* Japanese modifier key for switching between full- and half-width
|
|
* characters.
|
|
* @see <https://en.wikipedia.org/wiki/Language_input_keys>
|
|
*/
|
|
ZENKAKU_HANKAKU: '\uE040',
|
|
}
|
|
|
|
/**
|
|
* Simulate pressing many keys at once in a "chord". Takes a sequence of
|
|
* {@linkplain Key keys} or strings, appends each of the values to a string,
|
|
* adds the chord termination key ({@link Key.NULL}) and returns the resulting
|
|
* string.
|
|
*
|
|
* Note: when the low-level webdriver key handlers see Keys.NULL, active
|
|
* modifier keys (CTRL/ALT/SHIFT/etc) release via a keyup event.
|
|
*
|
|
* @param {...string} keys The key sequence to concatenate.
|
|
* @return {string} The null-terminated key sequence.
|
|
*/
|
|
Key.chord = function (...keys) {
|
|
return keys.join('') + Key.NULL
|
|
}
|
|
|
|
/**
|
|
* Used with {@link ./webelement.WebElement#sendKeys WebElement#sendKeys} on
|
|
* file input elements (`<input type="file">`) to detect when the entered key
|
|
* sequence defines the path to a file.
|
|
*
|
|
* By default, {@linkplain ./webelement.WebElement WebElement's} will enter all
|
|
* key sequences exactly as entered. You may set a
|
|
* {@linkplain ./webdriver.WebDriver#setFileDetector file detector} on the
|
|
* parent WebDriver instance to define custom behavior for handling file
|
|
* elements. Of particular note is the
|
|
* {@link selenium-webdriver/remote.FileDetector}, which should be used when
|
|
* running against a remote
|
|
* [Selenium Server](https://selenium.dev/downloads/).
|
|
*/
|
|
class FileDetector {
|
|
/**
|
|
* Handles the file specified by the given path, preparing it for use with
|
|
* the current browser. If the path does not refer to a valid file, it will
|
|
* be returned unchanged, otherwise a path suitable for use with the current
|
|
* browser will be returned.
|
|
*
|
|
* This default implementation is a no-op. Subtypes may override this function
|
|
* for custom tailored file handling.
|
|
*
|
|
* @param {!./webdriver.WebDriver} driver The driver for the current browser.
|
|
* @param {string} path The path to process.
|
|
* @return {!Promise<string>} A promise for the processed file path.
|
|
* @package
|
|
*/
|
|
handleFile(_driver, path) {
|
|
// eslint-disable-line
|
|
return Promise.resolve(path)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generic description of a single action to send to the remote end.
|
|
*
|
|
* @record
|
|
* @package
|
|
*/
|
|
class Action {
|
|
constructor() {
|
|
/** @type {!Action.Type} */
|
|
this.type
|
|
/** @type {(number|undefined)} */
|
|
this.duration
|
|
/** @type {(string|undefined)} */
|
|
this.value
|
|
/** @type {(Button|undefined)} */
|
|
this.button
|
|
/** @type {(number|undefined)} */
|
|
this.x
|
|
/** @type {(number|undefined)} */
|
|
this.y
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @enum {string}
|
|
* @package
|
|
* @see <https://w3c.github.io/webdriver/webdriver-spec.html#terminology-0>
|
|
*/
|
|
Action.Type = {
|
|
KEY_DOWN: 'keyDown',
|
|
KEY_UP: 'keyUp',
|
|
PAUSE: 'pause',
|
|
POINTER_DOWN: 'pointerDown',
|
|
POINTER_UP: 'pointerUp',
|
|
POINTER_MOVE: 'pointerMove',
|
|
POINTER_CANCEL: 'pointerCancel',
|
|
SCROLL: 'scroll',
|
|
}
|
|
|
|
/**
|
|
* Represents a user input device.
|
|
*
|
|
* @abstract
|
|
*/
|
|
class Device {
|
|
/**
|
|
* @param {Device.Type} type the input type.
|
|
* @param {string} id a unique ID for this device.
|
|
*/
|
|
constructor(type, id) {
|
|
/** @private @const */ this.type_ = type
|
|
/** @private @const */ this.id_ = id
|
|
}
|
|
|
|
/** @return {!Object} the JSON encoding for this device. */
|
|
toJSON() {
|
|
return { type: this.type_, id: this.id_ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Device types supported by the WebDriver protocol.
|
|
*
|
|
* @enum {string}
|
|
* @see <https://w3c.github.io/webdriver/webdriver-spec.html#input-source-state>
|
|
*/
|
|
Device.Type = {
|
|
KEY: 'key',
|
|
NONE: 'none',
|
|
POINTER: 'pointer',
|
|
WHEEL: 'wheel',
|
|
}
|
|
|
|
/**
|
|
* @param {(string|Key|number)} key
|
|
* @return {string}
|
|
* @throws {!(InvalidArgumentError|RangeError)}
|
|
*/
|
|
function checkCodePoint(key) {
|
|
if (typeof key === 'number') {
|
|
return String.fromCodePoint(key)
|
|
}
|
|
|
|
if (typeof key !== 'string') {
|
|
throw new InvalidArgumentError(`key is not a string: ${key}`)
|
|
}
|
|
|
|
key = key.normalize()
|
|
if (Array.from(key).length !== 1) {
|
|
throw new InvalidArgumentError(
|
|
`key input is not a single code point: ${key}`
|
|
)
|
|
}
|
|
return key
|
|
}
|
|
|
|
/**
|
|
* Keyboard input device.
|
|
*
|
|
* @final
|
|
* @see <https://www.w3.org/TR/webdriver/#dfn-key-input-source>
|
|
*/
|
|
class Keyboard extends Device {
|
|
/** @param {string} id the device ID. */
|
|
constructor(id) {
|
|
super(Device.Type.KEY, id)
|
|
}
|
|
|
|
/**
|
|
* Generates a key down action.
|
|
*
|
|
* @param {(Key|string|number)} key the key to press. This key may be
|
|
* specified as a {@link Key} value, a specific unicode code point,
|
|
* or a string containing a single unicode code point.
|
|
* @return {!Action} a new key down action.
|
|
* @package
|
|
*/
|
|
keyDown(key) {
|
|
return { type: Action.Type.KEY_DOWN, value: checkCodePoint(key) }
|
|
}
|
|
|
|
/**
|
|
* Generates a key up action.
|
|
*
|
|
* @param {(Key|string|number)} key the key to press. This key may be
|
|
* specified as a {@link Key} value, a specific unicode code point,
|
|
* or a string containing a single unicode code point.
|
|
* @return {!Action} a new key up action.
|
|
* @package
|
|
*/
|
|
keyUp(key) {
|
|
return { type: Action.Type.KEY_UP, value: checkCodePoint(key) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Defines the reference point from which to compute offsets for
|
|
* {@linkplain ./input.Pointer#move pointer move} actions.
|
|
*
|
|
* @enum {string}
|
|
*/
|
|
const Origin = {
|
|
/** Compute offsets relative to the pointer's current position. */
|
|
POINTER: 'pointer',
|
|
/** Compute offsets relative to the viewport. */
|
|
VIEWPORT: 'viewport',
|
|
}
|
|
|
|
/**
|
|
* Pointer input device.
|
|
*
|
|
* @final
|
|
* @see <https://www.w3.org/TR/webdriver/#dfn-pointer-input-source>
|
|
*/
|
|
class Pointer extends Device {
|
|
/**
|
|
* @param {string} id the device ID.
|
|
* @param {Pointer.Type} type the pointer type.
|
|
*/
|
|
constructor(id, type) {
|
|
super(Device.Type.POINTER, id)
|
|
/** @private @const */ this.pointerType_ = type
|
|
}
|
|
|
|
/** @override */
|
|
toJSON() {
|
|
return Object.assign(
|
|
{ parameters: { pointerType: this.pointerType_ } },
|
|
super.toJSON()
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @return {!Action} An action that cancels this pointer's current input.
|
|
* @package
|
|
*/
|
|
cancel() {
|
|
return { type: Action.Type.POINTER_CANCEL }
|
|
}
|
|
|
|
/**
|
|
* @param {!Button=} button The button to press.
|
|
* @param width
|
|
* @param height
|
|
* @param pressure
|
|
* @param tangentialPressure
|
|
* @param tiltX
|
|
* @param tiltY
|
|
* @param twist
|
|
* @param altitudeAngle
|
|
* @param azimuthAngle
|
|
* @return {!Action} An action to press the specified button with this device.
|
|
* @package
|
|
*/
|
|
press(
|
|
button = Button.LEFT,
|
|
width = 0,
|
|
height = 0,
|
|
pressure = 0,
|
|
tangentialPressure = 0,
|
|
tiltX = 0,
|
|
tiltY = 0,
|
|
twist = 0,
|
|
altitudeAngle = 0,
|
|
azimuthAngle = 0
|
|
) {
|
|
return {
|
|
type: Action.Type.POINTER_DOWN,
|
|
button,
|
|
width,
|
|
height,
|
|
pressure,
|
|
tangentialPressure,
|
|
tiltX,
|
|
tiltY,
|
|
twist,
|
|
altitudeAngle,
|
|
azimuthAngle,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!Button=} button The button to release.
|
|
* @return {!Action} An action to release the specified button with this
|
|
* device.
|
|
* @package
|
|
*/
|
|
release(button = Button.LEFT) {
|
|
return { type: Action.Type.POINTER_UP, button }
|
|
}
|
|
|
|
/**
|
|
* Creates an action for moving the pointer `x` and `y` pixels from the
|
|
* specified `origin`. The `origin` may be defined as the pointer's
|
|
* {@linkplain Origin.POINTER current position}, the
|
|
* {@linkplain Origin.VIEWPORT viewport}, or the center of a specific
|
|
* {@linkplain ./webdriver.WebElement WebElement}.
|
|
*
|
|
* @param {{
|
|
* x: (number|undefined),
|
|
* y: (number|undefined),
|
|
* duration: (number|undefined),
|
|
* origin: (!Origin|!./webdriver.WebElement|undefined),
|
|
* }=} options the move options.
|
|
* @return {!Action} The new action.
|
|
* @package
|
|
*/
|
|
move({
|
|
x = 0,
|
|
y = 0,
|
|
duration = 100,
|
|
origin = Origin.VIEWPORT,
|
|
width = 0,
|
|
height = 0,
|
|
pressure = 0,
|
|
tangentialPressure = 0,
|
|
tiltX = 0,
|
|
tiltY = 0,
|
|
twist = 0,
|
|
altitudeAngle = 0,
|
|
azimuthAngle = 0,
|
|
}) {
|
|
return {
|
|
type: Action.Type.POINTER_MOVE,
|
|
origin,
|
|
duration,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
pressure,
|
|
tangentialPressure,
|
|
tiltX,
|
|
tiltY,
|
|
twist,
|
|
altitudeAngle,
|
|
azimuthAngle,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The supported types of pointers.
|
|
* @enum {string}
|
|
*/
|
|
Pointer.Type = {
|
|
MOUSE: 'mouse',
|
|
PEN: 'pen',
|
|
TOUCH: 'touch',
|
|
}
|
|
|
|
class Wheel extends Device {
|
|
/**
|
|
* @param {string} id the device ID..
|
|
*/
|
|
constructor(id) {
|
|
super(Device.Type.WHEEL, id)
|
|
}
|
|
|
|
/**
|
|
* Scrolls a page via the coordinates given
|
|
* @param {number} x starting x coordinate
|
|
* @param {number} y starting y coordinate
|
|
* @param {number} deltaX Delta X to scroll to target
|
|
* @param {number} deltaY Delta Y to scroll to target
|
|
* @param {WebElement} origin element origin
|
|
* @param {number} duration duration ratio be the ratio of time delta and duration
|
|
* @returns {!Action} An action to scroll with this device.
|
|
*/
|
|
scroll(x, y, deltaX, deltaY, origin, duration) {
|
|
return {
|
|
type: Action.Type.SCROLL,
|
|
duration: duration,
|
|
x: x,
|
|
y: y,
|
|
deltaX: deltaX,
|
|
deltaY: deltaY,
|
|
origin: origin,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* User facing API for generating complex user gestures. This class should not
|
|
* be instantiated directly. Instead, users should create new instances by
|
|
* calling {@link ./webdriver.WebDriver#actions WebDriver.actions()}.
|
|
*
|
|
* ### Action Ticks
|
|
*
|
|
* Action sequences are divided into a series of "ticks". At each tick, the
|
|
* WebDriver remote end will perform a single action for each device included
|
|
* in the action sequence. At tick 0, the driver will perform the first action
|
|
* defined for each device, at tick 1 the second action for each device, and
|
|
* so on until all actions have been executed. If an individual device does
|
|
* not have an action defined at a particular tick, it will automatically
|
|
* pause.
|
|
*
|
|
* By default, action sequences will be synchronized so only one device has a
|
|
* define action in each tick. Consider the following code sample:
|
|
*
|
|
* const actions = driver.actions();
|
|
*
|
|
* await actions
|
|
* .keyDown(SHIFT)
|
|
* .move({origin: el})
|
|
* .press()
|
|
* .release()
|
|
* .keyUp(SHIFT)
|
|
* .perform();
|
|
*
|
|
* This sample produces the following sequence of ticks:
|
|
*
|
|
* | Device | Tick 1 | Tick 2 | Tick 3 | Tick 4 | Tick 5 |
|
|
* | -------- | -------------- | ------------------ | ------- | --------- | ------------ |
|
|
* | Keyboard | keyDown(SHIFT) | pause() | pause() | pause() | keyUp(SHIFT) |
|
|
* | Mouse | pause() | move({origin: el}) | press() | release() | pause() |
|
|
*
|
|
* If you'd like the remote end to execute actions with multiple devices
|
|
* simultaneously, you may pass `{async: true}` when creating the actions
|
|
* builder. With synchronization disabled (`{async: true}`), the ticks from our
|
|
* previous example become:
|
|
*
|
|
* | Device | Tick 1 | Tick 2 | Tick 3 |
|
|
* | -------- | ------------------ | ------------ | --------- |
|
|
* | Keyboard | keyDown(SHIFT) | keyUp(SHIFT) | |
|
|
* | Mouse | move({origin: el}) | press() | release() |
|
|
*
|
|
* When synchronization is disabled, it is your responsibility to insert
|
|
* {@linkplain #pause() pauses} for each device, as needed:
|
|
*
|
|
* const actions = driver.actions({async: true});
|
|
* const kb = actions.keyboard();
|
|
* const mouse = actions.mouse();
|
|
*
|
|
* actions.keyDown(SHIFT).pause(kb).pause(kb).key(SHIFT);
|
|
* actions.pause(mouse).move({origin: el}).press().release();
|
|
* actions.perform();
|
|
*
|
|
* With pauses insert for individual devices, we're back to:
|
|
*
|
|
* | Device | Tick 1 | Tick 2 | Tick 3 | Tick 4 |
|
|
* | -------- | -------------- | ------------------ | ------- | ------------ |
|
|
* | Keyboard | keyDown(SHIFT) | pause() | pause() | keyUp(SHIFT) |
|
|
* | Mouse | pause() | move({origin: el}) | press() | release() |
|
|
*
|
|
* #### Tick Durations
|
|
*
|
|
* The length of each action tick is however long it takes the remote end to
|
|
* execute the actions for every device in that tick. Most actions are
|
|
* "instantaneous", however, {@linkplain #pause pause} and
|
|
* {@linkplain #move pointer move} actions allow you to specify a duration for
|
|
* how long that action should take. The remote end will always wait for all
|
|
* actions within a tick to finish before starting the next tick, so a device
|
|
* may implicitly pause while waiting for other devices to finish.
|
|
*
|
|
* | Device | Tick 1 | Tick 2 |
|
|
* | --------- | --------------------- | ------- |
|
|
* | Pointer 1 | move({duration: 200}) | press() |
|
|
* | Pointer 2 | move({duration: 300}) | press() |
|
|
*
|
|
* In table above, the move for Pointer 1 should only take 200 ms, but the
|
|
* remote end will wait for the move for Pointer 2 to finish
|
|
* (an additional 100 ms) before proceeding to Tick 2.
|
|
*
|
|
* This implicit waiting also applies to pauses. In the table below, even though
|
|
* the keyboard only defines a pause of 100 ms, the remote end will wait an
|
|
* additional 200 ms for the mouse move to finish before moving to Tick 2.
|
|
*
|
|
* | Device | Tick 1 | Tick 2 |
|
|
* | -------- | --------------------- | -------------- |
|
|
* | Keyboard | pause(100) | keyDown(SHIFT) |
|
|
* | Mouse | move({duration: 300}) | |
|
|
*
|
|
* [client rect]: https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects
|
|
* [bounding client rect]: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
|
|
*
|
|
* @final
|
|
* @see <https://www.w3.org/TR/webdriver/#actions>
|
|
*/
|
|
class Actions {
|
|
/**
|
|
* @param {!Executor} executor The object to execute the configured
|
|
* actions with.
|
|
* @param {{async: (boolean|undefined)}} options Options for this action
|
|
* sequence (see class description for details).
|
|
*/
|
|
constructor(executor, { async = false } = {}) {
|
|
/** @private @const */
|
|
this.executor_ = executor
|
|
|
|
/** @private @const */
|
|
this.sync_ = !async
|
|
|
|
/** @private @const */
|
|
this.keyboard_ = new Keyboard('default keyboard')
|
|
|
|
/** @private @const */
|
|
this.mouse_ = new Pointer('default mouse', Pointer.Type.MOUSE)
|
|
|
|
/** @private @const */
|
|
this.wheel_ = new Wheel('default wheel')
|
|
|
|
/** @private @const {!Map<!Device, !Array<!Action>>} */
|
|
this.sequences_ = new Map([
|
|
[this.keyboard_, []],
|
|
[this.mouse_, []],
|
|
[this.wheel_, []],
|
|
])
|
|
}
|
|
|
|
/** @return {!Keyboard} the keyboard device handle. */
|
|
keyboard() {
|
|
return this.keyboard_
|
|
}
|
|
|
|
/** @return {!Pointer} the mouse pointer device handle. */
|
|
mouse() {
|
|
return this.mouse_
|
|
}
|
|
|
|
/** @return {!Wheel} the wheel device handle. */
|
|
wheel() {
|
|
return this.wheel_
|
|
}
|
|
|
|
/**
|
|
* @param {!Device} device
|
|
* @return {!Array<!Action>}
|
|
* @private
|
|
*/
|
|
sequence_(device) {
|
|
let sequence = this.sequences_.get(device)
|
|
if (!sequence) {
|
|
sequence = []
|
|
this.sequences_.set(device, sequence)
|
|
}
|
|
return sequence
|
|
}
|
|
|
|
/**
|
|
* Appends `actions` to the end of the current sequence for the given
|
|
* `device`. If device synchronization is enabled, after inserting the
|
|
* actions, pauses will be inserted for all other devices to ensure all action
|
|
* sequences are the same length.
|
|
*
|
|
* @param {!Device} device the device to update.
|
|
* @param {...!Action} actions the actions to insert.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
insert(device, ...actions) {
|
|
this.sequence_(device).push(...actions)
|
|
return this.sync_ ? this.synchronize() : this
|
|
}
|
|
|
|
/**
|
|
* Ensures the action sequence for every device referenced in this action
|
|
* sequence is the same length. For devices whose sequence is too short,
|
|
* this will insert {@linkplain #pause pauses} so that every device has an
|
|
* explicit action defined at each tick.
|
|
*
|
|
* @param {...!Device} devices The specific devices to synchronize.
|
|
* If unspecified, the action sequences for every device will be
|
|
* synchronized.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
synchronize(...devices) {
|
|
let sequences
|
|
let max = 0
|
|
if (devices.length === 0) {
|
|
for (const s of this.sequences_.values()) {
|
|
max = Math.max(max, s.length)
|
|
}
|
|
sequences = this.sequences_.values()
|
|
} else {
|
|
sequences = []
|
|
for (const device of devices) {
|
|
const seq = this.sequence_(device)
|
|
max = Math.max(max, seq.length)
|
|
sequences.push(seq)
|
|
}
|
|
}
|
|
|
|
const pause = { type: Action.Type.PAUSE, duration: 0 }
|
|
for (const seq of sequences) {
|
|
while (seq.length < max) {
|
|
seq.push(pause)
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Inserts a pause action for the specified devices, ensuring each device is
|
|
* idle for a tick. The length of the pause (in milliseconds) may be specified
|
|
* as the first parameter to this method (defaults to 0). Otherwise, you may
|
|
* just specify the individual devices that should pause.
|
|
*
|
|
* If no devices are specified, a pause action will be created (using the same
|
|
* duration) for every device.
|
|
*
|
|
* When device synchronization is enabled (the default for new {@link Actions}
|
|
* objects), there is no need to specify devices as pausing one automatically
|
|
* pauses the others for the same duration. In other words, the following are
|
|
* all equivalent:
|
|
*
|
|
* let a1 = driver.actions();
|
|
* a1.pause(100).perform();
|
|
*
|
|
* let a2 = driver.actions();
|
|
* a2.pause(100, a2.keyboard()).perform();
|
|
* // Synchronization ensures a2.mouse() is automatically paused too.
|
|
*
|
|
* let a3 = driver.actions();
|
|
* a3.pause(100, a3.keyboard(), a3.mouse()).perform();
|
|
*
|
|
* When device synchronization is _disabled_, you can cause individual devices
|
|
* to pause during a tick. For example, to hold the SHIFT key down while
|
|
* moving the mouse:
|
|
*
|
|
* let actions = driver.actions({async: true});
|
|
*
|
|
* actions.keyDown(Key.SHIFT);
|
|
* actions.pause(actions.mouse()) // Pause for shift down
|
|
* .press(Button.LEFT)
|
|
* .move({x: 10, y: 10})
|
|
* .release(Button.LEFT);
|
|
* actions
|
|
* .pause(
|
|
* actions.keyboard(), // Pause for press left
|
|
* actions.keyboard(), // Pause for move
|
|
* actions.keyboard()) // Pause for release left
|
|
* .keyUp(Key.SHIFT);
|
|
* await actions.perform();
|
|
*
|
|
* @param {(number|!Device)=} duration The length of the pause to insert, in
|
|
* milliseconds. Alternatively, the duration may be omitted (yielding a
|
|
* default 0 ms pause), and the first device to pause may be specified.
|
|
* @param {...!Device} devices The devices to insert the pause for. If no
|
|
* devices are specified, the pause will be inserted for _all_ devices.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
pause(duration, ...devices) {
|
|
if (duration instanceof Device) {
|
|
devices.push(duration)
|
|
duration = 0
|
|
} else if (!duration) {
|
|
duration = 0
|
|
}
|
|
|
|
const action = { type: Action.Type.PAUSE, duration }
|
|
|
|
// NB: need a properly typed variable for type checking.
|
|
/** @type {!Iterable<!Device>} */
|
|
const iterable = devices.length === 0 ? this.sequences_.keys() : devices
|
|
for (const device of iterable) {
|
|
this.sequence_(device).push(action)
|
|
}
|
|
return this.sync_ ? this.synchronize() : this
|
|
}
|
|
|
|
/**
|
|
* Inserts an action to press a single key.
|
|
*
|
|
* @param {(Key|string|number)} key the key to press. This key may be
|
|
* specified as a {@link Key} value, a specific unicode code point,
|
|
* or a string containing a single unicode code point.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
keyDown(key) {
|
|
return this.insert(this.keyboard_, this.keyboard_.keyDown(key))
|
|
}
|
|
|
|
/**
|
|
* Inserts an action to release a single key.
|
|
*
|
|
* @param {(Key|string|number)} key the key to release. This key may be
|
|
* specified as a {@link Key} value, a specific unicode code point,
|
|
* or a string containing a single unicode code point.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
keyUp(key) {
|
|
return this.insert(this.keyboard_, this.keyboard_.keyUp(key))
|
|
}
|
|
|
|
/**
|
|
* Inserts a sequence of actions to type the provided key sequence.
|
|
* For each key, this will record a pair of {@linkplain #keyDown keyDown}
|
|
* and {@linkplain #keyUp keyUp} actions. An implication of this pairing
|
|
* is that modifier keys (e.g. {@link ./input.Key.SHIFT Key.SHIFT}) will
|
|
* always be immediately released. In other words, `sendKeys(Key.SHIFT, 'a')`
|
|
* is the same as typing `sendKeys('a')`, _not_ `sendKeys('A')`.
|
|
*
|
|
* @param {...(Key|string|number)} keys the keys to type.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
sendKeys(...keys) {
|
|
const { WebElement } = require('./webdriver')
|
|
|
|
const actions = []
|
|
if (keys.length > 1 && keys[0] instanceof WebElement) {
|
|
this.click(keys[0])
|
|
keys.shift()
|
|
}
|
|
for (const key of keys) {
|
|
if (typeof key === 'string') {
|
|
for (const symbol of key) {
|
|
actions.push(
|
|
this.keyboard_.keyDown(symbol),
|
|
this.keyboard_.keyUp(symbol)
|
|
)
|
|
}
|
|
} else {
|
|
actions.push(this.keyboard_.keyDown(key), this.keyboard_.keyUp(key))
|
|
}
|
|
}
|
|
return this.insert(this.keyboard_, ...actions)
|
|
}
|
|
|
|
/**
|
|
* Inserts an action to press a mouse button at the mouse's current location.
|
|
*
|
|
* @param {!Button=} button The button to press; defaults to `LEFT`.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
press(button = Button.LEFT) {
|
|
return this.insert(this.mouse_, this.mouse_.press(button))
|
|
}
|
|
|
|
/**
|
|
* Inserts an action to release a mouse button at the mouse's current
|
|
* location.
|
|
*
|
|
* @param {!Button=} button The button to release; defaults to `LEFT`.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
release(button = Button.LEFT) {
|
|
return this.insert(this.mouse_, this.mouse_.release(button))
|
|
}
|
|
|
|
/**
|
|
* scrolls a page via the coordinates given
|
|
* @param {number} x starting x coordinate
|
|
* @param {number} y starting y coordinate
|
|
* @param {number} deltax delta x to scroll to target
|
|
* @param {number} deltay delta y to scroll to target
|
|
* @param {number} duration duration ratio be the ratio of time delta and duration
|
|
* @returns {!Actions} An action to scroll with this device.
|
|
*/
|
|
scroll(x, y, targetDeltaX, targetDeltaY, origin, duration) {
|
|
return this.insert(
|
|
this.wheel_,
|
|
this.wheel_.scroll(x, y, targetDeltaX, targetDeltaY, origin, duration)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Inserts an action for moving the mouse `x` and `y` pixels relative to the
|
|
* specified `origin`. The `origin` may be defined as the mouse's
|
|
* {@linkplain ./input.Origin.POINTER current position}, the top-left corner of the
|
|
* {@linkplain ./input.Origin.VIEWPORT viewport}, or the center of a specific
|
|
* {@linkplain ./webdriver.WebElement WebElement}. Default is top left corner of the view-port if origin is not specified
|
|
*
|
|
* You may adjust how long the remote end should take, in milliseconds, to
|
|
* perform the move using the `duration` parameter (defaults to 100 ms).
|
|
* The number of incremental move events generated over this duration is an
|
|
* implementation detail for the remote end.
|
|
*
|
|
* @param {{
|
|
* x: (number|undefined),
|
|
* y: (number|undefined),
|
|
* duration: (number|undefined),
|
|
* origin: (!Origin|!./webdriver.WebElement|undefined),
|
|
* }=} options The move options. Defaults to moving the mouse to the top-left
|
|
* corner of the viewport over 100ms.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
move({ x = 0, y = 0, duration = 100, origin = Origin.VIEWPORT } = {}) {
|
|
return this.insert(
|
|
this.mouse_,
|
|
this.mouse_.move({ x, y, duration, origin })
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Short-hand for performing a simple left-click (down/up) with the mouse.
|
|
*
|
|
* @param {./webdriver.WebElement=} element If specified, the mouse will
|
|
* first be moved to the center of the element before performing the
|
|
* click.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
click(element) {
|
|
if (element) {
|
|
this.move({ origin: element })
|
|
}
|
|
return this.press().release()
|
|
}
|
|
|
|
/**
|
|
* Short-hand for performing a simple right-click (down/up) with the mouse.
|
|
*
|
|
* @param {./webdriver.WebElement=} element If specified, the mouse will
|
|
* first be moved to the center of the element before performing the
|
|
* click.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
contextClick(element) {
|
|
if (element) {
|
|
this.move({ origin: element })
|
|
}
|
|
return this.press(Button.RIGHT).release(Button.RIGHT)
|
|
}
|
|
|
|
/**
|
|
* Short-hand for performing a double left-click with the mouse.
|
|
*
|
|
* @param {./webdriver.WebElement=} element If specified, the mouse will
|
|
* first be moved to the center of the element before performing the
|
|
* click.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
doubleClick(element) {
|
|
return this.click(element).press().release()
|
|
}
|
|
|
|
/**
|
|
* Configures a drag-and-drop action consisting of the following steps:
|
|
*
|
|
* 1. Move to the center of the `from` element (element to be dragged).
|
|
* 2. Press the left mouse button.
|
|
* 3. If the `to` target is a {@linkplain ./webdriver.WebElement WebElement},
|
|
* move the mouse to its center. Otherwise, move the mouse by the
|
|
* specified offset.
|
|
* 4. Release the left mouse button.
|
|
*
|
|
* @param {!./webdriver.WebElement} from The element to press the left mouse
|
|
* button on to start the drag.
|
|
* @param {(!./webdriver.WebElement|{x: number, y: number})} to Either another
|
|
* element to drag to (will drag to the center of the element), or an
|
|
* object specifying the offset to drag by, in pixels.
|
|
* @return {!Actions} a self reference.
|
|
*/
|
|
dragAndDrop(from, to) {
|
|
// Do not require up top to avoid a cycle that breaks static analysis.
|
|
const { WebElement } = require('./webdriver')
|
|
if (
|
|
!(to instanceof WebElement) &&
|
|
(!to || typeof to.x !== 'number' || typeof to.y !== 'number')
|
|
) {
|
|
throw new InvalidArgumentError(
|
|
'Invalid drag target; must specify a WebElement or {x, y} offset'
|
|
)
|
|
}
|
|
|
|
this.move({ origin: from }).press()
|
|
if (to instanceof WebElement) {
|
|
this.move({ origin: to })
|
|
} else {
|
|
this.move({ x: to.x, y: to.y, origin: Origin.POINTER })
|
|
}
|
|
return this.release()
|
|
}
|
|
|
|
/**
|
|
* Releases all keys, pointers, and clears internal state.
|
|
*
|
|
* @return {!Promise<void>} a promise that will resolve when finished
|
|
* clearing all action state.
|
|
*/
|
|
clear() {
|
|
for (const s of this.sequences_.values()) {
|
|
s.length = 0
|
|
}
|
|
return this.executor_.execute(new Command(Name.CLEAR_ACTIONS))
|
|
}
|
|
|
|
/**
|
|
* Performs the configured action sequence.
|
|
*
|
|
* @return {!Promise<void>} a promise that will resolve when all actions have
|
|
* been completed.
|
|
*/
|
|
async perform() {
|
|
const _actions = []
|
|
this.sequences_.forEach((actions, device) => {
|
|
if (!isIdle(actions)) {
|
|
actions = actions.concat() // Defensive copy.
|
|
_actions.push(Object.assign({ actions }, device.toJSON()))
|
|
}
|
|
})
|
|
|
|
if (_actions.length === 0) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
await this.executor_.execute(
|
|
new Command(Name.ACTIONS).setParameter('actions', _actions)
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!Action>} actions
|
|
* @return {boolean}
|
|
*/
|
|
function isIdle(actions) {
|
|
return (
|
|
actions.length === 0 ||
|
|
actions.every((a) => a.type === Action.Type.PAUSE && !a.duration)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Script used to compute the offset from the center of a DOM element's first
|
|
* client rect from the top-left corner of the element's bounding client rect.
|
|
* The element's center point is computed using the algorithm defined here:
|
|
* <https://w3c.github.io/webdriver/webdriver-spec.html#dfn-center-point>.
|
|
*
|
|
* __This is only exported for use in internal unit tests. DO NOT USE.__
|
|
*
|
|
* @package
|
|
*/
|
|
const INTERNAL_COMPUTE_OFFSET_SCRIPT = `
|
|
function computeOffset(el) {
|
|
var rect = el.getClientRects()[0];
|
|
var left = Math.max(0, Math.min(rect.x, rect.x + rect.width));
|
|
var right =
|
|
Math.min(window.innerWidth, Math.max(rect.x, rect.x + rect.width));
|
|
var top = Math.max(0, Math.min(rect.y, rect.y + rect.height));
|
|
var bot =
|
|
Math.min(window.innerHeight, Math.max(rect.y, rect.y + rect.height));
|
|
var x = Math.floor(0.5 * (left + right));
|
|
var y = Math.floor(0.5 * (top + bot));
|
|
|
|
var bbox = el.getBoundingClientRect();
|
|
return [x - bbox.left, y - bbox.top];
|
|
}
|
|
return computeOffset(arguments[0]);`
|
|
|
|
// PUBLIC API
|
|
|
|
module.exports = {
|
|
Action, // For documentation only.
|
|
Actions,
|
|
Button,
|
|
Device,
|
|
Key,
|
|
Keyboard,
|
|
FileDetector,
|
|
Origin,
|
|
Pointer,
|
|
INTERNAL_COMPUTE_OFFSET_SCRIPT,
|
|
}
|