1 | // Licensed to the Software Freedom Conservancy (SFC) under one
|
2 | // or more contributor license agreements. See the NOTICE file
|
3 | // distributed with this work for additional information
|
4 | // regarding copyright ownership. The SFC licenses this file
|
5 | // to you under the Apache License, Version 2.0 (the
|
6 | // "License"); you may not use this file except in compliance
|
7 | // with the License. You may obtain a copy of the License at
|
8 | //
|
9 | // http://www.apache.org/licenses/LICENSE-2.0
|
10 | //
|
11 | // Unless required by applicable law or agreed to in writing,
|
12 | // software distributed under the License is distributed on an
|
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
14 | // KIND, either express or implied. See the License for the
|
15 | // specific language governing permissions and limitations
|
16 | // under the License.
|
17 |
|
18 |
|
19 |
|
20 | /**
|
21 | * @fileoverview Defines types related to user input with the WebDriver API.
|
22 | */
|
23 | const { Command, Name } = require('./command')
|
24 | const { InvalidArgumentError } = require('./error')
|
25 |
|
26 | /**
|
27 | * Enumeration of the buttons used in the advanced interactions API.
|
28 | * @enum {number}
|
29 | */
|
30 | const Button = {
|
31 | LEFT: 0,
|
32 | MIDDLE: 1,
|
33 | RIGHT: 2,
|
34 | }
|
35 |
|
36 | /**
|
37 | * Representations of pressable keys that aren't text. These are stored in
|
38 | * the Unicode PUA (Private Use Area) code points, 0xE000-0xF8FF. Refer to
|
39 | * http://www.google.com.au/search?&q=unicode+pua&btnK=Search
|
40 | *
|
41 | * @enum {string}
|
42 | * @see <https://www.w3.org/TR/webdriver/#keyboard-actions>
|
43 | */
|
44 | const Key = {
|
45 | NULL: '\uE000',
|
46 | CANCEL: '\uE001', // ^break
|
47 | HELP: '\uE002',
|
48 | BACK_SPACE: '\uE003',
|
49 | TAB: '\uE004',
|
50 | CLEAR: '\uE005',
|
51 | RETURN: '\uE006',
|
52 | ENTER: '\uE007',
|
53 | SHIFT: '\uE008',
|
54 | CONTROL: '\uE009',
|
55 | ALT: '\uE00A',
|
56 | PAUSE: '\uE00B',
|
57 | ESCAPE: '\uE00C',
|
58 | SPACE: '\uE00D',
|
59 | PAGE_UP: '\uE00E',
|
60 | PAGE_DOWN: '\uE00F',
|
61 | END: '\uE010',
|
62 | HOME: '\uE011',
|
63 | ARROW_LEFT: '\uE012',
|
64 | LEFT: '\uE012',
|
65 | ARROW_UP: '\uE013',
|
66 | UP: '\uE013',
|
67 | ARROW_RIGHT: '\uE014',
|
68 | RIGHT: '\uE014',
|
69 | ARROW_DOWN: '\uE015',
|
70 | DOWN: '\uE015',
|
71 | INSERT: '\uE016',
|
72 | DELETE: '\uE017',
|
73 | SEMICOLON: '\uE018',
|
74 | EQUALS: '\uE019',
|
75 |
|
76 | NUMPAD0: '\uE01A', // number pad keys
|
77 | NUMPAD1: '\uE01B',
|
78 | NUMPAD2: '\uE01C',
|
79 | NUMPAD3: '\uE01D',
|
80 | NUMPAD4: '\uE01E',
|
81 | NUMPAD5: '\uE01F',
|
82 | NUMPAD6: '\uE020',
|
83 | NUMPAD7: '\uE021',
|
84 | NUMPAD8: '\uE022',
|
85 | NUMPAD9: '\uE023',
|
86 | MULTIPLY: '\uE024',
|
87 | ADD: '\uE025',
|
88 | SEPARATOR: '\uE026',
|
89 | SUBTRACT: '\uE027',
|
90 | DECIMAL: '\uE028',
|
91 | DIVIDE: '\uE029',
|
92 |
|
93 | F1: '\uE031', // function keys
|
94 | F2: '\uE032',
|
95 | F3: '\uE033',
|
96 | F4: '\uE034',
|
97 | F5: '\uE035',
|
98 | F6: '\uE036',
|
99 | F7: '\uE037',
|
100 | F8: '\uE038',
|
101 | F9: '\uE039',
|
102 | F10: '\uE03A',
|
103 | F11: '\uE03B',
|
104 | F12: '\uE03C',
|
105 |
|
106 | COMMAND: '\uE03D', // Apple command key
|
107 | META: '\uE03D', // alias for Windows key
|
108 |
|
109 | /**
|
110 | * Japanese modifier key for switching between full- and half-width
|
111 | * characters.
|
112 | * @see <https://en.wikipedia.org/wiki/Language_input_keys>
|
113 | */
|
114 | ZENKAKU_HANKAKU: '\uE040',
|
115 | }
|
116 |
|
117 | /**
|
118 | * Simulate pressing many keys at once in a "chord". Takes a sequence of
|
119 | * {@linkplain Key keys} or strings, appends each of the values to a string,
|
120 | * adds the chord termination key ({@link Key.NULL}) and returns the resulting
|
121 | * string.
|
122 | *
|
123 | * Note: when the low-level webdriver key handlers see Keys.NULL, active
|
124 | * modifier keys (CTRL/ALT/SHIFT/etc) release via a keyup event.
|
125 | *
|
126 | * @param {...string} keys The key sequence to concatenate.
|
127 | * @return {string} The null-terminated key sequence.
|
128 | */
|
129 | Key.chord = function (...keys) {
|
130 | return keys.join('') + Key.NULL
|
131 | }
|
132 |
|
133 | /**
|
134 | * Used with {@link ./webelement.WebElement#sendKeys WebElement#sendKeys} on
|
135 | * file input elements (`<input type="file">`) to detect when the entered key
|
136 | * sequence defines the path to a file.
|
137 | *
|
138 | * By default, {@linkplain ./webelement.WebElement WebElement's} will enter all
|
139 | * key sequences exactly as entered. You may set a
|
140 | * {@linkplain ./webdriver.WebDriver#setFileDetector file detector} on the
|
141 | * parent WebDriver instance to define custom behavior for handling file
|
142 | * elements. Of particular note is the
|
143 | * {@link selenium-webdriver/remote.FileDetector}, which should be used when
|
144 | * running against a remote
|
145 | * [Selenium Server](https://selenium.dev/downloads/).
|
146 | */
|
147 | class FileDetector {
|
148 | /**
|
149 | * Handles the file specified by the given path, preparing it for use with
|
150 | * the current browser. If the path does not refer to a valid file, it will
|
151 | * be returned unchanged, otherwise a path suitable for use with the current
|
152 | * browser will be returned.
|
153 | *
|
154 | * This default implementation is a no-op. Subtypes may override this function
|
155 | * for custom tailored file handling.
|
156 | *
|
157 | * @param {!./webdriver.WebDriver} driver The driver for the current browser.
|
158 | * @param {string} path The path to process.
|
159 | * @return {!Promise<string>} A promise for the processed file path.
|
160 | * @package
|
161 | */
|
162 | handleFile(driver, path) { // eslint-disable-line
|
163 | return Promise.resolve(path)
|
164 | }
|
165 | }
|
166 |
|
167 | /**
|
168 | * Generic description of a single action to send to the remote end.
|
169 | *
|
170 | * @record
|
171 | * @package
|
172 | */
|
173 | class Action {
|
174 | constructor() {
|
175 | /** @type {!Action.Type} */
|
176 | this.type
|
177 | /** @type {(number|undefined)} */
|
178 | this.duration
|
179 | /** @type {(string|undefined)} */
|
180 | this.value
|
181 | /** @type {(Button|undefined)} */
|
182 | this.button
|
183 | /** @type {(number|undefined)} */
|
184 | this.x
|
185 | /** @type {(number|undefined)} */
|
186 | this.y
|
187 | }
|
188 | }
|
189 |
|
190 | /**
|
191 | * @enum {string}
|
192 | * @package
|
193 | * @see <https://w3c.github.io/webdriver/webdriver-spec.html#terminology-0>
|
194 | */
|
195 | Action.Type = {
|
196 | KEY_DOWN: 'keyDown',
|
197 | KEY_UP: 'keyUp',
|
198 | PAUSE: 'pause',
|
199 | POINTER_DOWN: 'pointerDown',
|
200 | POINTER_UP: 'pointerUp',
|
201 | POINTER_MOVE: 'pointerMove',
|
202 | POINTER_CANCEL: 'pointerCancel',
|
203 | }
|
204 |
|
205 | /**
|
206 | * Represents a user input device.
|
207 | *
|
208 | * @abstract
|
209 | */
|
210 | class Device {
|
211 | /**
|
212 | * @param {Device.Type} type the input type.
|
213 | * @param {string} id a unique ID for this device.
|
214 | */
|
215 | constructor(type, id) {
|
216 | /** @private @const */ this.type_ = type
|
217 | /** @private @const */ this.id_ = id
|
218 | }
|
219 |
|
220 | /** @return {!Object} the JSON encoding for this device. */
|
221 | toJSON() {
|
222 | return { type: this.type_, id: this.id_ }
|
223 | }
|
224 | }
|
225 |
|
226 | /**
|
227 | * Device types supported by the WebDriver protocol.
|
228 | *
|
229 | * @enum {string}
|
230 | * @see <https://w3c.github.io/webdriver/webdriver-spec.html#input-source-state>
|
231 | */
|
232 | Device.Type = {
|
233 | KEY: 'key',
|
234 | NONE: 'none',
|
235 | POINTER: 'pointer',
|
236 | }
|
237 |
|
238 | /**
|
239 | * @param {(string|Key|number)} key
|
240 | * @return {string}
|
241 | * @throws {!(InvalidArgumentError|RangeError)}
|
242 | */
|
243 | function checkCodePoint(key) {
|
244 | if (typeof key === 'number') {
|
245 | return String.fromCodePoint(key)
|
246 | }
|
247 |
|
248 | if (typeof key !== 'string') {
|
249 | throw new InvalidArgumentError(`key is not a string: ${key}`)
|
250 | }
|
251 |
|
252 | key = key.normalize()
|
253 | if (Array.from(key).length != 1) {
|
254 | throw new InvalidArgumentError(
|
255 | `key input is not a single code point: ${key}`
|
256 | )
|
257 | }
|
258 | return key
|
259 | }
|
260 |
|
261 | /**
|
262 | * Keyboard input device.
|
263 | *
|
264 | * @final
|
265 | * @see <https://www.w3.org/TR/webdriver/#dfn-key-input-source>
|
266 | */
|
267 | class Keyboard extends Device {
|
268 | /** @param {string} id the device ID. */
|
269 | constructor(id) {
|
270 | super(Device.Type.KEY, id)
|
271 | }
|
272 |
|
273 | /**
|
274 | * Generates a key down action.
|
275 | *
|
276 | * @param {(Key|string|number)} key the key to press. This key may be
|
277 | * specified as a {@link Key} value, a specific unicode code point,
|
278 | * or a string containing a single unicode code point.
|
279 | * @return {!Action} a new key down action.
|
280 | * @package
|
281 | */
|
282 | keyDown(key) {
|
283 | return { type: Action.Type.KEY_DOWN, value: checkCodePoint(key) }
|
284 | }
|
285 |
|
286 | /**
|
287 | * Generates a key up action.
|
288 | *
|
289 | * @param {(Key|string|number)} key the key to press. This key may be
|
290 | * specified as a {@link Key} value, a specific unicode code point,
|
291 | * or a string containing a single unicode code point.
|
292 | * @return {!Action} a new key up action.
|
293 | * @package
|
294 | */
|
295 | keyUp(key) {
|
296 | return { type: Action.Type.KEY_UP, value: checkCodePoint(key) }
|
297 | }
|
298 | }
|
299 |
|
300 | /**
|
301 | * Defines the reference point from which to compute offsets for
|
302 | * {@linkplain ./input.Pointer#move pointer move} actions.
|
303 | *
|
304 | * @enum {string}
|
305 | */
|
306 | const Origin = {
|
307 | /** Compute offsets relative to the pointer's current position. */
|
308 | POINTER: 'pointer',
|
309 | /** Compute offsets relative to the viewport. */
|
310 | VIEWPORT: 'viewport',
|
311 | }
|
312 |
|
313 | /**
|
314 | * Pointer input device.
|
315 | *
|
316 | * @final
|
317 | * @see <https://www.w3.org/TR/webdriver/#dfn-pointer-input-source>
|
318 | */
|
319 | class Pointer extends Device {
|
320 | /**
|
321 | * @param {string} id the device ID.
|
322 | * @param {Pointer.Type} type the pointer type.
|
323 | */
|
324 | constructor(id, type) {
|
325 | super(Device.Type.POINTER, id)
|
326 | /** @private @const */ this.pointerType_ = type
|
327 | }
|
328 |
|
329 | /** @override */
|
330 | toJSON() {
|
331 | return Object.assign(
|
332 | { parameters: { pointerType: this.pointerType_ } },
|
333 | super.toJSON()
|
334 | )
|
335 | }
|
336 |
|
337 | /**
|
338 | * @return {!Action} An action that cancels this pointer's current input.
|
339 | * @package
|
340 | */
|
341 | cancel() {
|
342 | return { type: Action.Type.POINTER_CANCEL }
|
343 | }
|
344 |
|
345 | /**
|
346 | * @param {!Button=} button The button to press.
|
347 | * @return {!Action} An action to press the specified button with this device.
|
348 | * @package
|
349 | */
|
350 | press(button = Button.LEFT) {
|
351 | return { type: Action.Type.POINTER_DOWN, button }
|
352 | }
|
353 |
|
354 | /**
|
355 | * @param {!Button=} button The button to release.
|
356 | * @return {!Action} An action to release the specified button with this
|
357 | * device.
|
358 | * @package
|
359 | */
|
360 | release(button = Button.LEFT) {
|
361 | return { type: Action.Type.POINTER_UP, button }
|
362 | }
|
363 |
|
364 | /**
|
365 | * Creates an action for moving the pointer `x` and `y` pixels from the
|
366 | * specified `origin`. The `origin` may be defined as the pointer's
|
367 | * {@linkplain Origin.POINTER current position}, the
|
368 | * {@linkplain Origin.VIEWPORT viewport}, or the center of a specific
|
369 | * {@linkplain ./webdriver.WebElement WebElement}.
|
370 | *
|
371 | * @param {{
|
372 | * x: (number|undefined),
|
373 | * y: (number|undefined),
|
374 | * duration: (number|undefined),
|
375 | * origin: (!Origin|!./webdriver.WebElement|undefined),
|
376 | * }=} options the move options.
|
377 | * @return {!Action} The new action.
|
378 | * @package
|
379 | */
|
380 | move({ x = 0, y = 0, duration = 100, origin = Origin.VIEWPORT }) {
|
381 | return { type: Action.Type.POINTER_MOVE, origin, duration, x, y }
|
382 | }
|
383 | }
|
384 |
|
385 | /**
|
386 | * The supported types of pointers.
|
387 | * @enum {string}
|
388 | */
|
389 | Pointer.Type = {
|
390 | MOUSE: 'mouse',
|
391 | PEN: 'pen',
|
392 | TOUCH: 'touch',
|
393 | }
|
394 |
|
395 | /**
|
396 | * User facing API for generating complex user gestures. This class should not
|
397 | * be instantiated directly. Instead, users should create new instances by
|
398 | * calling {@link ./webdriver.WebDriver#actions WebDriver.actions()}.
|
399 | *
|
400 | * ### Action Ticks
|
401 | *
|
402 | * Action sequences are divided into a series of "ticks". At each tick, the
|
403 | * WebDriver remote end will perform a single action for each device included
|
404 | * in the action sequence. At tick 0, the driver will perform the first action
|
405 | * defined for each device, at tick 1 the second action for each device, and
|
406 | * so on until all actions have been executed. If an individual device does
|
407 | * not have an action defined at a particular tick, it will automatically
|
408 | * pause.
|
409 | *
|
410 | * By default, action sequences will be synchronized so only one device has a
|
411 | * define action in each tick. Consider the following code sample:
|
412 | *
|
413 | * const actions = driver.actions();
|
414 | *
|
415 | * await actions
|
416 | * .keyDown(SHIFT)
|
417 | * .move({origin: el})
|
418 | * .press()
|
419 | * .release()
|
420 | * .keyUp(SHIFT)
|
421 | * .perform();
|
422 | *
|
423 | * This sample produces the following sequence of ticks:
|
424 | *
|
425 | * | Device | Tick 1 | Tick 2 | Tick 3 | Tick 4 | Tick 5 |
|
426 | * | -------- | -------------- | ------------------ | ------- | --------- | ------------ |
|
427 | * | Keyboard | keyDown(SHIFT) | pause() | pause() | pause() | keyUp(SHIFT) |
|
428 | * | Mouse | pause() | move({origin: el}) | press() | release() | pause() |
|
429 | *
|
430 | * If you'd like the remote end to execute actions with multiple devices
|
431 | * simultaneously, you may pass `{async: true}` when creating the actions
|
432 | * builder. With synchronization disabled (`{async: true}`), the ticks from our
|
433 | * previous example become:
|
434 | *
|
435 | * | Device | Tick 1 | Tick 2 | Tick 3 |
|
436 | * | -------- | ------------------ | ------------ | --------- |
|
437 | * | Keyboard | keyDown(SHIFT) | keyUp(SHIFT) | |
|
438 | * | Mouse | move({origin: el}) | press() | release() |
|
439 | *
|
440 | * When synchronization is disabled, it is your responsibility to insert
|
441 | * {@linkplain #pause() pauses} for each device, as needed:
|
442 | *
|
443 | * const actions = driver.actions({async: true});
|
444 | * const kb = actions.keyboard();
|
445 | * const mouse = actions.mouse();
|
446 | *
|
447 | * actions.keyDown(SHIFT).pause(kb).pause(kb).key(SHIFT);
|
448 | * actions.pause(mouse).move({origin: el}).press().release();
|
449 | * actions.perform();
|
450 | *
|
451 | * With pauses insert for individual devices, we're back to:
|
452 | *
|
453 | * | Device | Tick 1 | Tick 2 | Tick 3 | Tick 4 |
|
454 | * | -------- | -------------- | ------------------ | ------- | ------------ |
|
455 | * | Keyboard | keyDown(SHIFT) | pause() | pause() | keyUp(SHIFT) |
|
456 | * | Mouse | pause() | move({origin: el}) | press() | release() |
|
457 | *
|
458 | * #### Tick Durations
|
459 | *
|
460 | * The length of each action tick is however long it takes the remote end to
|
461 | * execute the actions for every device in that tick. Most actions are
|
462 | * "instantaneous", however, {@linkplain #pause pause} and
|
463 | * {@linkplain #move pointer move} actions allow you to specify a duration for
|
464 | * how long that action should take. The remote end will always wait for all
|
465 | * actions within a tick to finish before starting the next tick, so a device
|
466 | * may implicitly pause while waiting for other devices to finish.
|
467 | *
|
468 | * | Device | Tick 1 | Tick 2 |
|
469 | * | --------- | --------------------- | ------- |
|
470 | * | Pointer 1 | move({duration: 200}) | press() |
|
471 | * | Pointer 2 | move({duration: 300}) | press() |
|
472 | *
|
473 | * In table above, the move for Pointer 1 should only take 200 ms, but the
|
474 | * remote end will wait for the move for Pointer 2 to finish
|
475 | * (an additional 100 ms) before proceeding to Tick 2.
|
476 | *
|
477 | * This implicit waiting also applies to pauses. In the table below, even though
|
478 | * the keyboard only defines a pause of 100 ms, the remote end will wait an
|
479 | * additional 200 ms for the mouse move to finish before moving to Tick 2.
|
480 | *
|
481 | * | Device | Tick 1 | Tick 2 |
|
482 | * | -------- | --------------------- | -------------- |
|
483 | * | Keyboard | pause(100) | keyDown(SHIFT) |
|
484 | * | Mouse | move({duration: 300}) | |
|
485 | *
|
486 | * [client rect]: https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects
|
487 | * [bounding client rect]: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
|
488 | *
|
489 | * @final
|
490 | * @see <https://www.w3.org/TR/webdriver/#actions>
|
491 | */
|
492 | class Actions {
|
493 | /**
|
494 | * @param {!Executor} executor The object to execute the configured
|
495 | * actions with.
|
496 | * @param {{async: (boolean|undefined)}} options Options for this action
|
497 | * sequence (see class description for details).
|
498 | */
|
499 | constructor(executor, { async = false } = {}) {
|
500 | /** @private @const */
|
501 | this.executor_ = executor
|
502 |
|
503 | /** @private @const */
|
504 | this.sync_ = !async
|
505 |
|
506 | /** @private @const */
|
507 | this.keyboard_ = new Keyboard('default keyboard')
|
508 |
|
509 | /** @private @const */
|
510 | this.mouse_ = new Pointer('default mouse', Pointer.Type.MOUSE)
|
511 |
|
512 | /** @private @const {!Map<!Device, !Array<!Action>>} */
|
513 | this.sequences_ = new Map([
|
514 | [this.keyboard_, []],
|
515 | [this.mouse_, []],
|
516 | ])
|
517 | }
|
518 |
|
519 | /** @return {!Keyboard} the keyboard device handle. */
|
520 | keyboard() {
|
521 | return this.keyboard_
|
522 | }
|
523 |
|
524 | /** @return {!Pointer} the mouse pointer device handle. */
|
525 | mouse() {
|
526 | return this.mouse_
|
527 | }
|
528 |
|
529 | /**
|
530 | * @param {!Device} device
|
531 | * @return {!Array<!Action>}
|
532 | * @private
|
533 | */
|
534 | sequence_(device) {
|
535 | let sequence = this.sequences_.get(device)
|
536 | if (!sequence) {
|
537 | sequence = []
|
538 | this.sequences_.set(device, sequence)
|
539 | }
|
540 | return sequence
|
541 | }
|
542 |
|
543 | /**
|
544 | * Appends `actions` to the end of the current sequence for the given
|
545 | * `device`. If device synchronization is enabled, after inserting the
|
546 | * actions, pauses will be inserted for all other devices to ensure all action
|
547 | * sequences are the same length.
|
548 | *
|
549 | * @param {!Device} device the device to update.
|
550 | * @param {...!Action} actions the actions to insert.
|
551 | * @return {!Actions} a self reference.
|
552 | */
|
553 | insert(device, ...actions) {
|
554 | this.sequence_(device).push(...actions)
|
555 | return this.sync_ ? this.synchronize() : this
|
556 | }
|
557 |
|
558 | /**
|
559 | * Ensures the action sequence for every device referenced in this action
|
560 | * sequence is the same length. For devices whose sequence is too short,
|
561 | * this will insert {@linkplain #pause pauses} so that every device has an
|
562 | * explicit action defined at each tick.
|
563 | *
|
564 | * @param {...!Device} devices The specific devices to synchronize.
|
565 | * If unspecified, the action sequences for every device will be
|
566 | * synchronized.
|
567 | * @return {!Actions} a self reference.
|
568 | */
|
569 | synchronize(...devices) {
|
570 | let sequences
|
571 | let max = 0
|
572 | if (devices.length === 0) {
|
573 | for (const s of this.sequences_.values()) {
|
574 | max = Math.max(max, s.length)
|
575 | }
|
576 | sequences = this.sequences_.values()
|
577 | } else {
|
578 | sequences = []
|
579 | for (const device of devices) {
|
580 | const seq = this.sequence_(device)
|
581 | max = Math.max(max, seq.length)
|
582 | sequences.push(seq)
|
583 | }
|
584 | }
|
585 |
|
586 | const pause = { type: Action.Type.PAUSE, duration: 0 }
|
587 | for (const seq of sequences) {
|
588 | while (seq.length < max) {
|
589 | seq.push(pause)
|
590 | }
|
591 | }
|
592 |
|
593 | return this
|
594 | }
|
595 |
|
596 | /**
|
597 | * Inserts a pause action for the specified devices, ensuring each device is
|
598 | * idle for a tick. The length of the pause (in milliseconds) may be specified
|
599 | * as the first parameter to this method (defaults to 0). Otherwise, you may
|
600 | * just specify the individual devices that should pause.
|
601 | *
|
602 | * If no devices are specified, a pause action will be created (using the same
|
603 | * duration) for every device.
|
604 | *
|
605 | * When device synchronization is enabled (the default for new {@link Actions}
|
606 | * objects), there is no need to specify devices as pausing one automatically
|
607 | * pauses the others for the same duration. In other words, the following are
|
608 | * all equivalent:
|
609 | *
|
610 | * let a1 = driver.actions();
|
611 | * a1.pause(100).perform();
|
612 | *
|
613 | * let a2 = driver.actions();
|
614 | * a2.pause(100, a2.keyboard()).perform();
|
615 | * // Synchronization ensures a2.mouse() is automatically paused too.
|
616 | *
|
617 | * let a3 = driver.actions();
|
618 | * a3.pause(100, a3.keyboard(), a3.mouse()).perform();
|
619 | *
|
620 | * When device synchronization is _disabled_, you can cause individual devices
|
621 | * to pause during a tick. For example, to hold the SHIFT key down while
|
622 | * moving the mouse:
|
623 | *
|
624 | * let actions = driver.actions({async: true});
|
625 | *
|
626 | * actions.keyDown(Key.SHIFT);
|
627 | * actions.pause(actions.mouse()) // Pause for shift down
|
628 | * .press(Button.LEFT)
|
629 | * .move({x: 10, y: 10})
|
630 | * .release(Button.LEFT);
|
631 | * actions
|
632 | * .pause(
|
633 | * actions.keyboard(), // Pause for press left
|
634 | * actions.keyboard(), // Pause for move
|
635 | * actions.keyboard()) // Pause for release left
|
636 | * .keyUp(Key.SHIFT);
|
637 | * await actions.perform();
|
638 | *
|
639 | * @param {(number|!Device)=} duration The length of the pause to insert, in
|
640 | * milliseconds. Alternatively, the duration may be omitted (yielding a
|
641 | * default 0 ms pause), and the first device to pause may be specified.
|
642 | * @param {...!Device} devices The devices to insert the pause for. If no
|
643 | * devices are specified, the pause will be inserted for _all_ devices.
|
644 | * @return {!Actions} a self reference.
|
645 | */
|
646 | pause(duration, ...devices) {
|
647 | if (duration instanceof Device) {
|
648 | devices.push(duration)
|
649 | duration = 0
|
650 | } else if (!duration) {
|
651 | duration = 0
|
652 | }
|
653 |
|
654 | const action = { type: Action.Type.PAUSE, duration }
|
655 |
|
656 | // NB: need a properly typed variable for type checking.
|
657 | /** @type {!Iterable<!Device>} */
|
658 | const iterable = devices.length === 0 ? this.sequences_.keys() : devices
|
659 | for (const device of iterable) {
|
660 | this.sequence_(device).push(action)
|
661 | }
|
662 | return this.sync_ ? this.synchronize() : this
|
663 | }
|
664 |
|
665 | /**
|
666 | * Inserts an action to press a single key.
|
667 | *
|
668 | * @param {(Key|string|number)} key the key to press. This key may be
|
669 | * specified as a {@link Key} value, a specific unicode code point,
|
670 | * or a string containing a single unicode code point.
|
671 | * @return {!Actions} a self reference.
|
672 | */
|
673 | keyDown(key) {
|
674 | return this.insert(this.keyboard_, this.keyboard_.keyDown(key))
|
675 | }
|
676 |
|
677 | /**
|
678 | * Inserts an action to release a single key.
|
679 | *
|
680 | * @param {(Key|string|number)} key the key to release. This key may be
|
681 | * specified as a {@link Key} value, a specific unicode code point,
|
682 | * or a string containing a single unicode code point.
|
683 | * @return {!Actions} a self reference.
|
684 | */
|
685 | keyUp(key) {
|
686 | return this.insert(this.keyboard_, this.keyboard_.keyUp(key))
|
687 | }
|
688 |
|
689 | /**
|
690 | * Inserts a sequence of actions to type the provided key sequence.
|
691 | * For each key, this will record a pair of {@linkplain #keyDown keyDown}
|
692 | * and {@linkplain #keyUp keyUp} actions. An implication of this pairing
|
693 | * is that modifier keys (e.g. {@link ./input.Key.SHIFT Key.SHIFT}) will
|
694 | * always be immediately released. In other words, `sendKeys(Key.SHIFT, 'a')`
|
695 | * is the same as typing `sendKeys('a')`, _not_ `sendKeys('A')`.
|
696 | *
|
697 | * @param {...(Key|string|number)} keys the keys to type.
|
698 | * @return {!Actions} a self reference.
|
699 | */
|
700 | sendKeys(...keys) {
|
701 | const actions = []
|
702 | for (const key of keys) {
|
703 | if (typeof key === 'string') {
|
704 | for (const symbol of key) {
|
705 | actions.push(
|
706 | this.keyboard_.keyDown(symbol),
|
707 | this.keyboard_.keyUp(symbol)
|
708 | )
|
709 | }
|
710 | } else {
|
711 | actions.push(this.keyboard_.keyDown(key), this.keyboard_.keyUp(key))
|
712 | }
|
713 | }
|
714 | return this.insert(this.keyboard_, ...actions)
|
715 | }
|
716 |
|
717 | /**
|
718 | * Inserts an action to press a mouse button at the mouse's current location.
|
719 | *
|
720 | * @param {!Button=} button The button to press; defaults to `LEFT`.
|
721 | * @return {!Actions} a self reference.
|
722 | */
|
723 | press(button = Button.LEFT) {
|
724 | return this.insert(this.mouse_, this.mouse_.press(button))
|
725 | }
|
726 |
|
727 | /**
|
728 | * Inserts an action to release a mouse button at the mouse's current
|
729 | * location.
|
730 | *
|
731 | * @param {!Button=} button The button to release; defaults to `LEFT`.
|
732 | * @return {!Actions} a self reference.
|
733 | */
|
734 | release(button = Button.LEFT) {
|
735 | return this.insert(this.mouse_, this.mouse_.release(button))
|
736 | }
|
737 |
|
738 | /**
|
739 | * Inserts an action for moving the mouse `x` and `y` pixels relative to the
|
740 | * specified `origin`. The `origin` may be defined as the mouse's
|
741 | * {@linkplain ./input.Origin.POINTER current position}, the
|
742 | * {@linkplain ./input.Origin.VIEWPORT viewport}, or the center of a specific
|
743 | * {@linkplain ./webdriver.WebElement WebElement}.
|
744 | *
|
745 | * You may adjust how long the remote end should take, in milliseconds, to
|
746 | * perform the move using the `duration` parameter (defaults to 100 ms).
|
747 | * The number of incremental move events generated over this duration is an
|
748 | * implementation detail for the remote end.
|
749 | *
|
750 | * @param {{
|
751 | * x: (number|undefined),
|
752 | * y: (number|undefined),
|
753 | * duration: (number|undefined),
|
754 | * origin: (!Origin|!./webdriver.WebElement|undefined),
|
755 | * }=} options The move options. Defaults to moving the mouse to the top-left
|
756 | * corner of the viewport over 100ms.
|
757 | * @return {!Actions} a self reference.
|
758 | */
|
759 | move({ x = 0, y = 0, duration = 100, origin = Origin.VIEWPORT } = {}) {
|
760 | return this.insert(
|
761 | this.mouse_,
|
762 | this.mouse_.move({ x, y, duration, origin })
|
763 | )
|
764 | }
|
765 |
|
766 | /**
|
767 | * Short-hand for performing a simple left-click (down/up) with the mouse.
|
768 | *
|
769 | * @param {./webdriver.WebElement=} element If specified, the mouse will
|
770 | * first be moved to the center of the element before performing the
|
771 | * click.
|
772 | * @return {!Actions} a self reference.
|
773 | */
|
774 | click(element) {
|
775 | if (element) {
|
776 | this.move({ origin: element })
|
777 | }
|
778 | return this.press().release()
|
779 | }
|
780 |
|
781 | /**
|
782 | * Short-hand for performing a simple right-click (down/up) with the mouse.
|
783 | *
|
784 | * @param {./webdriver.WebElement=} element If specified, the mouse will
|
785 | * first be moved to the center of the element before performing the
|
786 | * click.
|
787 | * @return {!Actions} a self reference.
|
788 | */
|
789 | contextClick(element) {
|
790 | if (element) {
|
791 | this.move({ origin: element })
|
792 | }
|
793 | return this.press(Button.RIGHT).release(Button.RIGHT)
|
794 | }
|
795 |
|
796 | /**
|
797 | * Short-hand for performing a double left-click with the mouse.
|
798 | *
|
799 | * @param {./webdriver.WebElement=} element If specified, the mouse will
|
800 | * first be moved to the center of the element before performing the
|
801 | * click.
|
802 | * @return {!Actions} a self reference.
|
803 | */
|
804 | doubleClick(element) {
|
805 | return this.click(element).press().release()
|
806 | }
|
807 |
|
808 | /**
|
809 | * Configures a drag-and-drop action consisting of the following steps:
|
810 | *
|
811 | * 1. Move to the center of the `from` element (element to be dragged).
|
812 | * 2. Press the left mouse button.
|
813 | * 3. If the `to` target is a {@linkplain ./webdriver.WebElement WebElement},
|
814 | * move the mouse to its center. Otherwise, move the mouse by the
|
815 | * specified offset.
|
816 | * 4. Release the left mouse button.
|
817 | *
|
818 | * @param {!./webdriver.WebElement} from The element to press the left mouse
|
819 | * button on to start the drag.
|
820 | * @param {(!./webdriver.WebElement|{x: number, y: number})} to Either another
|
821 | * element to drag to (will drag to the center of the element), or an
|
822 | * object specifying the offset to drag by, in pixels.
|
823 | * @return {!Actions} a self reference.
|
824 | */
|
825 | dragAndDrop(from, to) {
|
826 | // Do not require up top to avoid a cycle that breaks static analysis.
|
827 | const { WebElement } = require('./webdriver')
|
828 | if (
|
829 | !(to instanceof WebElement) &&
|
830 | (!to || typeof to.x !== 'number' || typeof to.y !== 'number')
|
831 | ) {
|
832 | throw new InvalidArgumentError(
|
833 | 'Invalid drag target; must specify a WebElement or {x, y} offset'
|
834 | )
|
835 | }
|
836 |
|
837 | this.move({ origin: from }).press()
|
838 | if (to instanceof WebElement) {
|
839 | this.move({ origin: to })
|
840 | } else {
|
841 | this.move({ x: to.x, y: to.y, origin: Origin.POINTER })
|
842 | }
|
843 | return this.release()
|
844 | }
|
845 |
|
846 | /**
|
847 | * Releases all keys, pointers, and clears internal state.
|
848 | *
|
849 | * @return {!Promise<void>} a promise that will resolve when finished
|
850 | * clearing all action state.
|
851 | */
|
852 | clear() {
|
853 | for (const s of this.sequences_.values()) {
|
854 | s.length = 0
|
855 | }
|
856 | return this.executor_.execute(new Command(Name.CLEAR_ACTIONS))
|
857 | }
|
858 |
|
859 | /**
|
860 | * Performs the configured action sequence.
|
861 | *
|
862 | * @return {!Promise<void>} a promise that will resolve when all actions have
|
863 | * been completed.
|
864 | */
|
865 | async perform() {
|
866 | const _actions = []
|
867 | this.sequences_.forEach((actions, device) => {
|
868 | if (!isIdle(actions)) {
|
869 | actions = actions.concat() // Defensive copy.
|
870 | _actions.push(Object.assign({ actions }, device.toJSON()))
|
871 | }
|
872 | })
|
873 |
|
874 | if (_actions.length === 0) {
|
875 | return Promise.resolve()
|
876 | }
|
877 |
|
878 | await this.executor_.execute(
|
879 | new Command(Name.ACTIONS).setParameter('actions', _actions)
|
880 | )
|
881 | }
|
882 | }
|
883 |
|
884 | /**
|
885 | * @param {!Array<!Action>} actions
|
886 | * @return {boolean}
|
887 | */
|
888 | function isIdle(actions) {
|
889 | return (
|
890 | actions.length === 0 ||
|
891 | actions.every((a) => a.type === Action.Type.PAUSE && !a.duration)
|
892 | )
|
893 | }
|
894 |
|
895 | /**
|
896 | * Script used to compute the offset from the center of a DOM element's first
|
897 | * client rect from the top-left corner of the element's bounding client rect.
|
898 | * The element's center point is computed using the algorithm defined here:
|
899 | * <https://w3c.github.io/webdriver/webdriver-spec.html#dfn-center-point>.
|
900 | *
|
901 | * __This is only exported for use in internal unit tests. DO NOT USE.__
|
902 | *
|
903 | * @package
|
904 | */
|
905 | const INTERNAL_COMPUTE_OFFSET_SCRIPT = `
|
906 | function computeOffset(el) {
|
907 | var rect = el.getClientRects()[0];
|
908 | var left = Math.max(0, Math.min(rect.x, rect.x + rect.width));
|
909 | var right =
|
910 | Math.min(window.innerWidth, Math.max(rect.x, rect.x + rect.width));
|
911 | var top = Math.max(0, Math.min(rect.y, rect.y + rect.height));
|
912 | var bot =
|
913 | Math.min(window.innerHeight, Math.max(rect.y, rect.y + rect.height));
|
914 | var x = Math.floor(0.5 * (left + right));
|
915 | var y = Math.floor(0.5 * (top + bot));
|
916 |
|
917 | var bbox = el.getBoundingClientRect();
|
918 | return [x - bbox.left, y - bbox.top];
|
919 | }
|
920 | return computeOffset(arguments[0]);`
|
921 |
|
922 | // PUBLIC API
|
923 |
|
924 | module.exports = {
|
925 | Action, // For documentation only.
|
926 | Actions,
|
927 | Button,
|
928 | Device,
|
929 | Key,
|
930 | Keyboard,
|
931 | FileDetector,
|
932 | Origin,
|
933 | Pointer,
|
934 | INTERNAL_COMPUTE_OFFSET_SCRIPT,
|
935 | }
|