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