webdriver_server/
actions.rs

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
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::collections::HashMap;
6use std::thread;
7use std::time::{Duration, Instant};
8
9use base::id::BrowsingContextId;
10use crossbeam_channel::Select;
11use embedder_traits::{
12    InputEvent, KeyboardEvent, MouseButtonAction, MouseButtonEvent, MouseMoveEvent,
13    WebDriverCommandMsg, WebDriverScriptCommand, WebViewPoint, WheelDelta, WheelEvent, WheelMode,
14};
15use euclid::Point2D;
16use ipc_channel::ipc;
17use keyboard_types::webdriver::KeyInputState;
18use log::info;
19use rustc_hash::FxHashSet;
20use webdriver::actions::{
21    ActionSequence, ActionsType, GeneralAction, KeyAction, KeyActionItem, KeyDownAction,
22    KeyUpAction, NullActionItem, PointerAction, PointerActionItem, PointerDownAction,
23    PointerMoveAction, PointerOrigin, PointerType, PointerUpAction, WheelAction, WheelActionItem,
24    WheelScrollAction,
25};
26use webdriver::error::{ErrorStatus, WebDriverError};
27
28use crate::{Handler, VerifyBrowsingContextIsOpen, WebElement, wait_for_ipc_response};
29
30// Interval between wheelScroll and pointerMove increments in ms, based on common vsync
31static POINTERMOVE_INTERVAL: u64 = 17;
32static WHEELSCROLL_INTERVAL: u64 = 17;
33
34// https://262.ecma-international.org/6.0/#sec-number.max_safe_integer
35static MAXIMUM_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
36
37// A single action, corresponding to an `action object` in the spec.
38// In the spec, `action item` refers to a plain JSON object.
39// However, we use the name ActionItem here
40// to be consistent with type names from webdriver crate.
41#[derive(Debug, PartialEq)]
42pub(crate) enum ActionItem {
43    Null(NullActionItem),
44    Key(KeyActionItem),
45    Pointer(PointerActionItem),
46    Wheel(WheelActionItem),
47}
48
49/// A set of actions with multiple sources executed within a single tick.
50/// The `id` is used to identify the source of the actions.
51pub(crate) type TickActions = Vec<(String, ActionItem)>;
52
53/// Consumed by the `dispatch_actions` method.
54pub(crate) type ActionsByTick = Vec<TickActions>;
55
56/// <https://w3c.github.io/webdriver/#dfn-input-source-state>
57pub(crate) enum InputSourceState {
58    Null,
59    Key(KeyInputState),
60    Pointer(PointerInputState),
61    Wheel,
62}
63
64/// <https://w3c.github.io/webdriver/#dfn-pointer-input-source>
65/// TODO: subtype is used for <https://w3c.github.io/webdriver/#dfn-get-a-pointer-id>
66/// Need to add pointer-id to the following struct
67#[expect(dead_code)]
68pub(crate) struct PointerInputState {
69    subtype: PointerType,
70    pressed: FxHashSet<u64>,
71    pub(crate) pointer_id: u32,
72    x: f64,
73    y: f64,
74}
75
76impl PointerInputState {
77    /// <https://w3c.github.io/webdriver/#dfn-create-a-pointer-input-source>
78    pub(crate) fn new(subtype: PointerType, pointer_ids: FxHashSet<u32>) -> PointerInputState {
79        PointerInputState {
80            subtype,
81            pressed: FxHashSet::default(),
82            pointer_id: Self::get_pointer_id(subtype, pointer_ids),
83            x: 0.0,
84            y: 0.0,
85        }
86    }
87
88    /// <https://w3c.github.io/webdriver/#dfn-get-a-pointer-id>
89    fn get_pointer_id(subtype: PointerType, pointer_ids: FxHashSet<u32>) -> u32 {
90        // Step 2 - 4: Let pointer ids be all the values in input state map which is
91        // pointer input source. This is already done and passed by the caller.
92        if subtype == PointerType::Mouse {
93            for id in 0..=1 {
94                if !pointer_ids.contains(&id) {
95                    return id;
96                }
97            }
98        }
99
100        // We are dealing with subtype other than mouse, which has minimum id 2.
101        1 + pointer_ids.into_iter().max().unwrap_or(1)
102    }
103}
104
105/// <https://w3c.github.io/webdriver/#dfn-computing-the-tick-duration>
106fn compute_tick_duration(tick_actions: &TickActions) -> u64 {
107    // Step 1. Let max duration be 0.
108    // Step 2. For each action in tick actions:
109    tick_actions
110        .iter()
111        .filter_map(|(_, action_item)| {
112            // If action object has subtype property set to "pause" or
113            // action object has type property set to "pointer" and subtype property set to "pointerMove",
114            // or action object has type property set to "wheel" and subtype property set to "scroll",
115            // let duration be equal to the duration property of action object.
116            match action_item {
117                ActionItem::Null(NullActionItem::General(GeneralAction::Pause(pause_action))) |
118                ActionItem::Key(KeyActionItem::General(GeneralAction::Pause(pause_action))) |
119                ActionItem::Pointer(PointerActionItem::General(GeneralAction::Pause(
120                    pause_action,
121                ))) |
122                ActionItem::Wheel(WheelActionItem::General(GeneralAction::Pause(pause_action))) => {
123                    pause_action.duration
124                },
125                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Move(action))) => {
126                    action.duration
127                },
128                ActionItem::Wheel(WheelActionItem::Wheel(WheelAction::Scroll(action))) => {
129                    action.duration
130                },
131                _ => None,
132            }
133        })
134        .max()
135        .unwrap_or(0)
136}
137
138impl Handler {
139    /// <https://w3c.github.io/webdriver/#dfn-dispatch-actions>
140    /// <https://w3c.github.io/webdriver/#dfn-dispatch-actions-inner>
141    /// For Servo, "dispatch actions" is identical to "dispatch actions inner",
142    /// as they are only different for a session that can run commands in parallel.
143    pub(crate) fn dispatch_actions(
144        &mut self,
145        actions_by_tick: ActionsByTick,
146        browsing_context: BrowsingContextId,
147    ) -> Result<(), ErrorStatus> {
148        // Step 1. For each item tick actions in actions by tick
149        for tick_actions in actions_by_tick.iter() {
150            // Step 1.1. If browsing context is no longer open,
151            // return error with error code no such window.
152            self.verify_browsing_context_is_open(browsing_context)
153                .map_err(|e| e.error)?;
154            // Step 1.2. Let tick duration be the result of
155            // computing the tick duration with argument tick actions.
156            let tick_duration = compute_tick_duration(tick_actions);
157
158            // FIXME: This is out of spec, but the test `perform_actions/invalid.py` requires
159            // that duration more than `MAXIMUM_SAFE_INTEGER` is considered invalid.
160            if tick_duration > MAXIMUM_SAFE_INTEGER {
161                return Err(ErrorStatus::InvalidArgument);
162            }
163
164            let now = Instant::now();
165
166            // Step 1.3. Try to dispatch tick actions
167            self.dispatch_tick_actions(tick_actions, tick_duration)?;
168
169            // Step 1.4. Wait for
170            // The user agent event loop has spun enough times to process the DOM events
171            // generated by the last invocation of the dispatch tick actions steps.
172            self.wait_for_user_agent_handling_complete()?;
173            // At least tick duration milliseconds have passed.
174            let elapsed = now.elapsed().as_millis() as u64;
175            if elapsed < tick_duration {
176                let sleep_duration = tick_duration - elapsed;
177                thread::sleep(Duration::from_millis(sleep_duration));
178            }
179        }
180
181        // Step 2. Return success with data null.
182        info!("Dispatch actions completed successfully");
183        Ok(())
184    }
185
186    fn wait_for_user_agent_handling_complete(&self) -> Result<(), ErrorStatus> {
187        let mut pending_event_receivers =
188            std::mem::take(&mut *self.pending_input_event_receivers.borrow_mut());
189
190        while !pending_event_receivers.is_empty() {
191            let mut select = Select::new();
192            for receiver in &pending_event_receivers {
193                select.recv(receiver);
194            }
195
196            let operation = select.select();
197            let index = operation.index();
198            let _ = operation.recv(&pending_event_receivers[index]);
199
200            pending_event_receivers.remove(index);
201        }
202
203        self.num_pending_actions.set(0);
204
205        Ok(())
206    }
207
208    /// <https://w3c.github.io/webdriver/#dfn-dispatch-tick-actions>
209    fn dispatch_tick_actions(
210        &mut self,
211        tick_actions: &TickActions,
212        tick_duration: u64,
213    ) -> Result<(), ErrorStatus> {
214        // Step 1. For each action object in tick actions:
215        // Step 1.1. Let input_id be the value of the id property of action object.
216        for (input_id, action) in tick_actions.iter() {
217            // Step 6. Let subtype be action object's subtype.
218            // Steps 7, 8. Try to run specific algorithm based on the action type.
219            match action {
220                ActionItem::Null(_) |
221                ActionItem::Key(KeyActionItem::General(_)) |
222                ActionItem::Pointer(PointerActionItem::General(_)) |
223                ActionItem::Wheel(WheelActionItem::General(_)) => {
224                    self.dispatch_pause_action(input_id);
225                },
226                ActionItem::Key(KeyActionItem::Key(KeyAction::Down(keydown_action))) => {
227                    self.dispatch_keydown_action(input_id, keydown_action);
228                    // Step 9. If subtype is "keyDown", append a copy of action
229                    // object with the subtype property changed to "keyUp" to
230                    // input state's input cancel list.
231                    self.session_mut().unwrap().input_cancel_list.push((
232                        input_id.clone(),
233                        ActionItem::Key(KeyActionItem::Key(KeyAction::Up(KeyUpAction {
234                            value: keydown_action.value.clone(),
235                        }))),
236                    ));
237                },
238                ActionItem::Key(KeyActionItem::Key(KeyAction::Up(keyup_action))) => {
239                    self.dispatch_keyup_action(input_id, keyup_action);
240                },
241                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Down(
242                    pointer_down_action,
243                ))) => {
244                    self.dispatch_pointerdown_action(input_id, pointer_down_action);
245                    // Step 10. If subtype is "pointerDown", append a copy of action
246                    // object with the subtype property changed to "pointerUp" to
247                    // input state's input cancel list.
248                    self.session_mut().unwrap().input_cancel_list.push((
249                        input_id.clone(),
250                        ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Up(
251                            PointerUpAction {
252                                button: pointer_down_action.button,
253                                ..Default::default()
254                            },
255                        ))),
256                    ));
257                },
258                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Move(
259                    pointer_move_action,
260                ))) => {
261                    self.dispatch_pointermove_action(input_id, pointer_move_action, tick_duration)?;
262                },
263                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Up(
264                    pointer_up_action,
265                ))) => {
266                    self.dispatch_pointerup_action(input_id, pointer_up_action);
267                },
268                ActionItem::Wheel(WheelActionItem::Wheel(WheelAction::Scroll(scroll_action))) => {
269                    self.dispatch_scroll_action(input_id, scroll_action, tick_duration)?;
270                },
271                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Cancel)) => {
272                    info!("dispatch_tick_actions: PointerCancel action is not implemented yet");
273                },
274            }
275        }
276
277        Ok(())
278    }
279
280    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pause-action>
281    fn dispatch_pause_action(&mut self, source_id: &str) {
282        self.input_state_table_mut()
283            .entry(source_id.to_string())
284            .or_insert(InputSourceState::Null);
285    }
286
287    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-keydown-action>
288    fn dispatch_keydown_action(&mut self, source_id: &str, action: &KeyDownAction) {
289        let raw_key = action.value.chars().next().unwrap();
290        let key_input_state = match self.input_state_table_mut().get_mut(source_id).unwrap() {
291            InputSourceState::Key(key_input_state) => key_input_state,
292            _ => unreachable!(),
293        };
294
295        let keyboard_event = key_input_state.dispatch_keydown(raw_key);
296
297        // Step 12: Perform implementation-specific action dispatch steps on browsing
298        // context equivalent to pressing a key on the keyboard in accordance with the
299        // requirements of [UI-EVENTS], and producing the following events, as
300        // appropriate, with the specified properties. This will always produce events
301        // including at least a keyDown event.
302        self.send_blocking_input_event_to_embedder(InputEvent::Keyboard(KeyboardEvent::new(
303            keyboard_event,
304        )));
305    }
306
307    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-keyup-action>
308    fn dispatch_keyup_action(&mut self, source_id: &str, action: &KeyUpAction) {
309        let session = self.session_mut().unwrap();
310
311        // Remove the last matching keyUp from `[input_cancel_list]` due to bugs in spec
312        // See https://github.com/w3c/webdriver/issues/1905 &&
313        // https://github.com/servo/servo/issues/37579#issuecomment-2990762713
314        let input_cancel_list = &mut session.input_cancel_list;
315        if let Some(pos) = input_cancel_list.iter().rposition(|(id, item)| {
316            id == source_id &&
317                matches!(item,
318                        ActionItem::Key(KeyActionItem::Key(KeyAction::Up(KeyUpAction { value })))
319                    if *value == action.value )
320        }) {
321            info!("dispatch_keyup_action: removing last matching keyup from input_cancel_list");
322            input_cancel_list.remove(pos);
323        }
324
325        let raw_key = action.value.chars().next().unwrap();
326        let key_input_state = match session.input_state_table.get_mut(source_id).unwrap() {
327            InputSourceState::Key(key_input_state) => key_input_state,
328            _ => unreachable!(),
329        };
330
331        // Step 12: Perform implementation-specific action dispatch steps on browsing
332        // context equivalent to releasing a key on the keyboard in accordance with the
333        // requirements of [UI-EVENTS], ...
334        let Some(keyboard_event) = key_input_state.dispatch_keyup(raw_key) else {
335            return;
336        };
337        self.send_blocking_input_event_to_embedder(InputEvent::Keyboard(KeyboardEvent::new(
338            keyboard_event,
339        )));
340    }
341
342    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointerdown-action>
343    fn dispatch_pointerdown_action(&mut self, source_id: &str, action: &PointerDownAction) {
344        let pointer_input_state = self.get_pointer_input_state_mut(source_id);
345        // Step 3. If the source's pressed property contains button return success with data null.
346        if pointer_input_state.pressed.contains(&action.button) {
347            return;
348        }
349
350        let PointerInputState { x, y, .. } = *pointer_input_state;
351        // Step 6. Add button to the set corresponding to source's pressed property
352        pointer_input_state.pressed.insert(action.button);
353        // Step 7 - 15: Variable namings already done.
354
355        // Step 16. Perform implementation-specific action dispatch steps
356        // TODO: We have not considered pen/touch pointer type
357        self.send_blocking_input_event_to_embedder(InputEvent::MouseButton(MouseButtonEvent::new(
358            MouseButtonAction::Down,
359            action.button.into(),
360            WebViewPoint::Page(Point2D::new(x as f32, y as f32)),
361        )));
362
363        // Step 17. Return success with data null.
364    }
365
366    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointerup-action>
367    fn dispatch_pointerup_action(&mut self, source_id: &str, action: &PointerUpAction) {
368        let pointer_input_state = self.get_pointer_input_state_mut(source_id);
369        // Step 3. If the source's pressed property does not contain button, return success with data null.
370        if !pointer_input_state.pressed.contains(&action.button) {
371            return;
372        }
373
374        // Step 6. Remove button from the set corresponding to source's pressed property,
375        pointer_input_state.pressed.remove(&action.button);
376        let PointerInputState { x, y, .. } = *pointer_input_state;
377
378        // Remove matching pointerUp(must be unique) from `[input_cancel_list]` due to bugs in spec
379        // See https://github.com/w3c/webdriver/issues/1905 &&
380        // https://github.com/servo/servo/issues/37579#issuecomment-2990762713
381        let input_cancel_list = &mut self.session_mut().unwrap().input_cancel_list;
382        if let Some(pos) = input_cancel_list.iter().position(|(id, item)| {
383            id == source_id &&
384                matches!(item, ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Up(
385                    PointerUpAction { button, .. },
386                ))) if *button == action.button )
387        }) {
388            info!("dispatch_pointerup_action: removing matching pointerup from input_cancel_list");
389            input_cancel_list.remove(pos);
390        }
391
392        // Step 7. Perform implementation-specific action dispatch steps
393        self.send_blocking_input_event_to_embedder(InputEvent::MouseButton(MouseButtonEvent::new(
394            MouseButtonAction::Up,
395            action.button.into(),
396            WebViewPoint::Page(Point2D::new(x as f32, y as f32)),
397        )));
398
399        // Step 8. Return success with data null.
400    }
401
402    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointermove-action>
403    fn dispatch_pointermove_action(
404        &mut self,
405        source_id: &str,
406        action: &PointerMoveAction,
407        tick_duration: u64,
408    ) -> Result<(), ErrorStatus> {
409        let tick_start = Instant::now();
410
411        // Step 1. Let x offset be equal to the x property of action object.
412        let x_offset = action.x;
413
414        // Step 2. Let y offset be equal to the y property of action object.
415        let y_offset = action.y;
416
417        // Step 3. Let origin be equal to the origin property of action object.
418        let origin = &action.origin;
419
420        // Step 4. Let (x, y) be the result of trying to get coordinates relative to an origin
421        // with source, x offset, y offset, origin, browsing context, and actions options.
422
423        let (x, y) = self.get_origin_relative_coordinates(origin, x_offset, y_offset, source_id)?;
424
425        // Step 5. If x is less than 0 or greater than the width of the viewport in CSS pixels,
426        // then return error with error code move target out of bounds.
427        // Step 6. If y is less than 0 or greater than the height of the viewport in CSS pixels,
428        // then return error with error code move target out of bounds.
429        self.check_viewport_bound(x, y)?;
430
431        // Step 7. Let duration be equal to action object's duration property
432        // if it is not undefined, or tick duration otherwise.
433        let duration = match action.duration {
434            Some(duration) => duration,
435            None => tick_duration,
436        };
437
438        // Step 8. If duration is greater than 0 and inside any implementation-defined bounds,
439        // asynchronously wait for an implementation defined amount of time to pass.
440        if duration > 0 {
441            thread::sleep(Duration::from_millis(POINTERMOVE_INTERVAL));
442        }
443
444        let (start_x, start_y) = {
445            let pointer_input_state = self.get_pointer_input_state(source_id);
446            (pointer_input_state.x, pointer_input_state.y)
447        };
448
449        // Step 9 - 18
450        // Perform a pointer move with arguments source, global key state, duration, start x, start y,
451        // x, y, width, height, pressure, tangentialPressure, tiltX, tiltY, twist, altitudeAngle, azimuthAngle.
452        // TODO: We have not considered pen/touch pointer type
453        self.perform_pointer_move(source_id, duration, start_x, start_y, x, y, tick_start);
454
455        // Step 19. Return success with data null.
456        Ok(())
457    }
458
459    /// <https://w3c.github.io/webdriver/#dfn-perform-a-pointer-move>
460    #[allow(clippy::too_many_arguments)]
461    fn perform_pointer_move(
462        &mut self,
463        source_id: &str,
464        duration: u64,
465        start_x: f64,
466        start_y: f64,
467        target_x: f64,
468        target_y: f64,
469        tick_start: Instant,
470    ) {
471        loop {
472            // Step 1. Let time delta be the time since the beginning of the
473            // current tick, measured in milliseconds on a monotonic clock.
474            let time_delta = tick_start.elapsed().as_millis();
475
476            // Step 2. Let duration ratio be the ratio of time delta and duration,
477            // if duration is greater than 0, or 1 otherwise.
478            let duration_ratio = if duration > 0 {
479                time_delta as f64 / duration as f64
480            } else {
481                1.0
482            };
483
484            // Step 3. If duration ratio is 1, or close enough to 1 that the
485            // implementation will not further subdivide the move action,
486            // let last be true. Otherwise let last be false.
487            let last = 1.0 - duration_ratio < 0.001;
488
489            // Step 4. If last is true, let x equal target x and y equal target y.
490            // Otherwise
491            // let x equal an approximation to duration ratio × (target x - start x) + start x,
492            // and y equal an approximation to duration ratio × (target y - start y) + start y.
493            let (x, y) = if last {
494                (target_x, target_y)
495            } else {
496                (
497                    duration_ratio * (target_x - start_x) + start_x,
498                    duration_ratio * (target_y - start_y) + start_y,
499                )
500            };
501
502            // Step 5 - 6: Let current x/y equal the x/y property of input state.
503            let (current_x, current_y) = {
504                let pointer_input_state = self.get_pointer_input_state(source_id);
505                (pointer_input_state.x, pointer_input_state.y)
506            };
507
508            // Step 7. If x != current x or y != current y, run the following steps:
509            // FIXME: Actually "last" should not be checked here based on spec.
510            if x != current_x || y != current_y || last {
511                // Step 7.1. Let buttons be equal to input state's buttons property.
512                // Step 7.2. Perform implementation-specific action dispatch steps
513                let point = WebViewPoint::Page(Point2D::new(x as f32, y as f32));
514                let input_event = InputEvent::MouseMove(MouseMoveEvent::new(point));
515                if last {
516                    self.send_blocking_input_event_to_embedder(input_event);
517                } else {
518                    self.send_input_event_to_embedder(input_event);
519                }
520
521                // Step 7.3. Let input state's x property equal x and y property equal y.
522                let pointer_input_state = self.get_pointer_input_state_mut(source_id);
523                pointer_input_state.x = x;
524                pointer_input_state.y = y;
525            }
526
527            // Step 8. If last is true, return.
528            if last {
529                return;
530            }
531
532            // Step 9. Run the following substeps in parallel:
533            // Step 9.1. Asynchronously wait for an implementationdefined amount of time to pass
534            thread::sleep(Duration::from_millis(POINTERMOVE_INTERVAL));
535
536            // Step 9.2. Perform a pointer move with arguments
537            // input state, duration, start x, start y, target x, target y.
538            // Notice that this simply repeat what we have done until last is true.
539        }
540    }
541
542    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-scroll-action>
543    fn dispatch_scroll_action(
544        &self,
545        source_id: &str,
546        action: &WheelScrollAction,
547        tick_duration: u64,
548    ) -> Result<(), ErrorStatus> {
549        // TODO: We should verify each variable when processing a wheel action.
550        // <https://w3c.github.io/webdriver/#dfn-process-a-wheel-action>
551
552        let tick_start = Instant::now();
553
554        // Step 1. Let x offset be equal to the x property of action object.
555        let Some(x_offset) = action.x else {
556            return Err(ErrorStatus::InvalidArgument);
557        };
558
559        // Step 2. Let y offset be equal to the y property of action object.
560        let Some(y_offset) = action.y else {
561            return Err(ErrorStatus::InvalidArgument);
562        };
563
564        // Step 3. Let origin be equal to the origin property of action object.
565        let origin = &action.origin;
566
567        // Pointer origin isn't currently supported for wheel input source
568        // See: https://github.com/w3c/webdriver/issues/1758
569
570        if origin == &PointerOrigin::Pointer {
571            return Err(ErrorStatus::InvalidArgument);
572        }
573
574        // Step 4. Let (x, y) be the result of trying to get coordinates relative to an origin
575        // with source, x offset, y offset, origin, browsing context, and actions options.
576        let (x, y) =
577            self.get_origin_relative_coordinates(origin, x_offset as _, y_offset as _, source_id)?;
578
579        // Step 5. If x is less than 0 or greater than the width of the viewport in CSS pixels,
580        // then return error with error code move target out of bounds.
581        // Step 6. If y is less than 0 or greater than the height of the viewport in CSS pixels,
582        // then return error with error code move target out of bounds.
583        self.check_viewport_bound(x, y)?;
584
585        // Step 7. Let delta x be equal to the deltaX property of action object.
586        let Some(delta_x) = action.deltaX else {
587            return Err(ErrorStatus::InvalidArgument);
588        };
589
590        // Step 8. Let delta y be equal to the deltaY property of action object.
591        let Some(delta_y) = action.deltaY else {
592            return Err(ErrorStatus::InvalidArgument);
593        };
594
595        // Step 9. Let duration be equal to action object's duration property
596        // if it is not undefined, or tick duration otherwise.
597        let duration = match action.duration {
598            Some(duration) => duration,
599            None => tick_duration,
600        };
601
602        // Step 10. If duration is greater than 0 and inside any implementation-defined bounds,
603        // asynchronously wait for an implementation defined amount of time to pass.
604        if duration > 0 {
605            thread::sleep(Duration::from_millis(WHEELSCROLL_INTERVAL));
606        }
607
608        // Step 11. Perform a scroll with arguments global key state, duration, x, y, delta x, delta y, 0, 0.
609        self.perform_scroll(
610            duration,
611            x,
612            y,
613            delta_x as _,
614            delta_y as _,
615            0.0,
616            0.0,
617            tick_start,
618        );
619
620        // Step 12. Return success with data null.
621        Ok(())
622    }
623
624    /// <https://w3c.github.io/webdriver/#dfn-perform-a-scroll>
625    #[allow(clippy::too_many_arguments)]
626    fn perform_scroll(
627        &self,
628        duration: u64,
629        x: f64,
630        y: f64,
631        target_delta_x: f64,
632        target_delta_y: f64,
633        mut curr_delta_x: f64,
634        mut curr_delta_y: f64,
635        tick_start: Instant,
636    ) {
637        loop {
638            // Step 1. Let time delta be the time since the beginning of the current tick,
639            // measured in milliseconds on a monotonic clock.
640            let time_delta = tick_start.elapsed().as_millis();
641
642            // Step 2. Let duration ratio be the ratio of time delta and duration,
643            // if duration is greater than 0, or 1 otherwise.
644            let duration_ratio = if duration > 0 {
645                time_delta as f64 / duration as f64
646            } else {
647                1.0
648            };
649
650            // Step 3. If duration ratio is 1, or close enough to 1 that
651            // the implementation will not further subdivide the move action,
652            // let last be true. Otherwise let last be false.
653            let last = 1.0 - duration_ratio < 0.001;
654
655            // Step 4. If last is true,
656            // let delta x equal target delta x - current delta x and delta y equal target delta y - current delta y.
657            // Otherwise
658            // let delta x equal an approximation to duration ratio × target delta x - current delta x,
659            // and delta y equal an approximation to duration ratio × target delta y - current delta y.
660            let (delta_x, delta_y) = if last {
661                (target_delta_x - curr_delta_x, target_delta_y - curr_delta_y)
662            } else {
663                (
664                    duration_ratio * target_delta_x - curr_delta_x,
665                    duration_ratio * target_delta_y - curr_delta_y,
666                )
667            };
668
669            // Step 5. If delta x != 0 or delta y != 0, run the following steps:
670            // Actually "last" should not be checked here based on spec.
671            // However, we need to send the webdriver id at the final perform.
672            if delta_x != 0.0 || delta_y != 0.0 || last {
673                // Step 5.1. Perform implementation-specific action dispatch steps
674                let delta = WheelDelta {
675                    x: -delta_x,
676                    y: -delta_y,
677                    z: 0.0,
678                    mode: WheelMode::DeltaPixel,
679                };
680                let point = WebViewPoint::Page(Point2D::new(x as f32, y as f32));
681                let input_event = InputEvent::Wheel(WheelEvent::new(delta, point));
682                if last {
683                    self.send_blocking_input_event_to_embedder(input_event);
684                } else {
685                    self.send_input_event_to_embedder(input_event);
686                }
687
688                // Step 5.2. Let current delta x property equal delta x + current delta x
689                // and current delta y property equal delta y + current delta y.
690                curr_delta_x += delta_x;
691                curr_delta_y += delta_y;
692            }
693
694            // Step 6. If last is true, return.
695            if last {
696                return;
697            }
698
699            // Step 7
700            // TODO: The two steps should be done in parallel
701            // 7.1. Asynchronously wait for an implementation defined amount of time to pass.
702            thread::sleep(Duration::from_millis(WHEELSCROLL_INTERVAL));
703            // 7.2. Perform a scroll with arguments duration, x, y, target delta x,
704            // target delta y, current delta x, current delta y.
705            // Notice that this simply repeat what we have done until last is true.
706        }
707    }
708
709    /// Verify that the given coordinates are within the boundary of the viewport.
710    /// If x or y is less than 0 or greater than the width of the viewport in CSS pixels,
711    /// then return error with error code move target out of bounds.
712    fn check_viewport_bound(&self, x: f64, y: f64) -> Result<(), ErrorStatus> {
713        if x < 0.0 || y < 0.0 {
714            return Err(ErrorStatus::MoveTargetOutOfBounds);
715        }
716        let (sender, receiver) = ipc::channel().unwrap();
717        let cmd_msg = WebDriverCommandMsg::GetViewportSize(self.verified_webview_id(), sender);
718        self.send_message_to_embedder(cmd_msg)
719            .map_err(|_| ErrorStatus::UnknownError)?;
720
721        let viewport_size = match wait_for_ipc_response(receiver) {
722            Ok(response) => response,
723            Err(WebDriverError { error, .. }) => return Err(error),
724        };
725        if x > viewport_size.width.into() || y > viewport_size.height.into() {
726            Err(ErrorStatus::MoveTargetOutOfBounds)
727        } else {
728            Ok(())
729        }
730    }
731
732    /// <https://w3c.github.io/webdriver/#dfn-get-coordinates-relative-to-an-origin>
733    fn get_origin_relative_coordinates(
734        &self,
735        origin: &PointerOrigin,
736        x_offset: f64,
737        y_offset: f64,
738        source_id: &str,
739    ) -> Result<(f64, f64), ErrorStatus> {
740        match origin {
741            PointerOrigin::Viewport => Ok((x_offset, y_offset)),
742            PointerOrigin::Pointer => {
743                // Step 1. Let start x be equal to the x property of source.
744                // Step 2. Let start y be equal to the y property of source.
745                let (start_x, start_y) = {
746                    let pointer_input_state = self.get_pointer_input_state(source_id);
747                    (pointer_input_state.x, pointer_input_state.y)
748                };
749                // Step 3. Let x equal start x + x offset and y equal start y + y offset.
750                Ok((start_x + x_offset, start_y + y_offset))
751            },
752            PointerOrigin::Element(web_element) => {
753                // Steps 1 - 3
754                let (x_element, y_element) = self.get_element_in_view_center_point(web_element)?;
755                // Step 4. Let x equal x element + x offset, and y equal y element + y offset.
756                Ok((x_element as f64 + x_offset, y_element as f64 + y_offset))
757            },
758        }
759    }
760
761    /// <https://w3c.github.io/webdriver/#dfn-center-point>
762    fn get_element_in_view_center_point(
763        &self,
764        web_element: &WebElement,
765    ) -> Result<(i64, i64), ErrorStatus> {
766        let (sender, receiver) = ipc::channel().unwrap();
767        // Step 1. Let element be the result of trying to run actions options'
768        // get element origin steps with origin and browsing context.
769        self.browsing_context_script_command(
770            WebDriverScriptCommand::GetElementInViewCenterPoint(web_element.to_string(), sender),
771            VerifyBrowsingContextIsOpen::No,
772        )
773        .unwrap();
774
775        // Step 2. If element is null, return error with error code no such element.
776        let response = match wait_for_ipc_response(receiver) {
777            Ok(response) => response,
778            Err(WebDriverError { error, .. }) => return Err(error),
779        };
780
781        // Step 3. Let x element and y element be the result of calculating the in-view center point of element.
782        match response? {
783            Some(point) => Ok(point),
784            None => Err(ErrorStatus::UnknownError),
785        }
786    }
787
788    /// <https://w3c.github.io/webdriver/#dfn-extract-an-action-sequence>
789    pub(crate) fn extract_an_action_sequence(
790        &mut self,
791        actions: Vec<ActionSequence>,
792    ) -> ActionsByTick {
793        // Step 3. Let "actions by tick" be an empty list.
794        let mut actions_by_tick: ActionsByTick = Vec::new();
795
796        // Step 4. For each value "action sequence" corresponding to an indexed property in actions
797        for action_sequence in actions {
798            let id = action_sequence.id.clone();
799            // Step 4.1. Let "source actions" be the result of trying to process an input source action sequence
800            // given "action sequence".
801            let source_actions = self.process_an_input_source_action_sequence(action_sequence);
802
803            // Step 4.2.2. Ensure we have enough ticks to hold all actions
804            if actions_by_tick.len() < source_actions.len() {
805                actions_by_tick.resize_with(source_actions.len(), Vec::new);
806            }
807
808            // Step 4.2.3. Append "action" to the List at index i in "actions by tick",
809            // for each "action" in "source actions".
810            for (tick_index, action_item) in source_actions.into_iter().enumerate() {
811                actions_by_tick[tick_index].push((id.clone(), action_item));
812            }
813        }
814
815        actions_by_tick
816    }
817
818    /// <https://w3c.github.io/webdriver/#dfn-process-an-input-source-action-sequence>
819    fn process_an_input_source_action_sequence(
820        &mut self,
821        action_sequence: ActionSequence,
822    ) -> Vec<ActionItem> {
823        // Step 2. Let id be the value of the id property of action sequence.
824        let id = action_sequence.id;
825        match action_sequence.actions {
826            ActionsType::Null {
827                actions: null_actions,
828            } => {
829                self.input_state_table_mut()
830                    .entry(id)
831                    .or_insert(InputSourceState::Null);
832                null_actions.into_iter().map(ActionItem::Null).collect()
833            },
834            ActionsType::Key {
835                actions: key_actions,
836            } => {
837                self.input_state_table_mut()
838                    .entry(id)
839                    .or_insert(InputSourceState::Key(KeyInputState::new()));
840                key_actions.into_iter().map(ActionItem::Key).collect()
841            },
842            ActionsType::Pointer {
843                parameters: _,
844                actions: pointer_actions,
845            } => {
846                let pointer_ids = self.session().unwrap().pointer_ids();
847                self.input_state_table_mut()
848                    .entry(id)
849                    .or_insert(InputSourceState::Pointer(PointerInputState::new(
850                        PointerType::Mouse,
851                        pointer_ids,
852                    )));
853                pointer_actions
854                    .into_iter()
855                    .map(ActionItem::Pointer)
856                    .collect()
857            },
858            ActionsType::Wheel {
859                actions: wheel_actions,
860            } => {
861                self.input_state_table_mut()
862                    .entry(id)
863                    .or_insert(InputSourceState::Wheel);
864                wheel_actions.into_iter().map(ActionItem::Wheel).collect()
865            },
866        }
867    }
868
869    fn input_state_table_mut(&mut self) -> &mut HashMap<String, InputSourceState> {
870        &mut self.session_mut().unwrap().input_state_table
871    }
872
873    fn get_pointer_input_state_mut(&mut self, source_id: &str) -> &mut PointerInputState {
874        let InputSourceState::Pointer(pointer_input_state) =
875            self.input_state_table_mut().get_mut(source_id).unwrap()
876        else {
877            unreachable!();
878        };
879        pointer_input_state
880    }
881
882    fn get_pointer_input_state(&self, source_id: &str) -> &PointerInputState {
883        let InputSourceState::Pointer(pointer_input_state) = self
884            .session()
885            .unwrap()
886            .input_state_table
887            .get(source_id)
888            .unwrap()
889        else {
890            unreachable!();
891        };
892        pointer_input_state
893    }
894}