Skip to main content

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 embedder_traits::{
10    InputEvent, KeyboardEvent, MouseButtonAction, MouseButtonEvent, MouseMoveEvent, TouchEvent,
11    TouchEventType, TouchId, WebDriverCommandMsg, WebDriverScriptCommand, WebViewPoint, WheelDelta,
12    WheelEvent, WheelMode,
13};
14use euclid::Point2D;
15use keyboard_types::webdriver::KeyInputState;
16use log::info;
17use rustc_hash::FxHashSet;
18use servo_base::generic_channel;
19use servo_base::id::BrowsingContextId;
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::{
29    Handler, MAXIMUM_SAFE_INTEGER, VerifyBrowsingContextIsOpen, WebElement,
30    wait_for_oneshot_response,
31};
32
33/// Interval between wheelScroll and pointerMove increments in ms, based on common vsync
34static MOVESCROLL_INTERVAL: u64 = 16;
35
36/// <https://w3c.github.io/webdriver/#dfn-element-click>
37/// This is hard-coded as 0 in spec.
38pub(crate) static ELEMENT_CLICK_BUTTON: u64 = 0;
39
40// A single action, corresponding to an `action object` in the spec.
41// In the spec, `action item` refers to a plain JSON object.
42// However, we use the name ActionItem here
43// to be consistent with type names from webdriver crate.
44#[derive(Debug, PartialEq)]
45pub(crate) enum ActionItem {
46    Null(NullActionItem),
47    Key(KeyActionItem),
48    Pointer(PointerActionItem),
49    Wheel(WheelActionItem),
50}
51
52/// A set of actions with multiple sources executed within a single tick.
53/// The `id` is used to identify the source of the actions.
54pub(crate) type TickActions = Vec<(String, ActionItem)>;
55
56/// Consumed by the `dispatch_actions` method.
57pub(crate) type ActionsByTick = Vec<TickActions>;
58
59/// <https://w3c.github.io/webdriver/#dfn-input-source-state>
60pub(crate) enum InputSourceState {
61    Null,
62    Key(KeyInputState),
63    Pointer(PointerInputState),
64    Wheel,
65}
66
67#[expect(private_interfaces)]
68pub(crate) enum PendingActions {
69    Scroll(PendingScroll),
70    PointerMove(PendingPointerMove),
71}
72
73/// <https://github.com/w3c/webdriver/issues/1952>
74/// For some unknown reason, unlike Pointer Actions,
75/// Wheel Actions in spec has precision of integer.
76/// We just use double precision, since our [`WheelEvent`] also uses double precision.
77struct PendingScroll {
78    input_id: String,
79    duration: u64,
80    x: f64,
81    y: f64,
82    current_delta_x: f64,
83    current_delta_y: f64,
84    target_delta_x: f64,
85    target_delta_y: f64,
86}
87
88struct PendingPointerMove {
89    input_id: String,
90    duration: u64,
91    start_x: f64,
92    start_y: f64,
93    target_x: f64,
94    target_y: f64,
95}
96
97/// <https://w3c.github.io/webdriver/#dfn-pointer-input-source>
98pub(crate) struct PointerInputState {
99    subtype: PointerType,
100    pressed: FxHashSet<u64>,
101    pub(crate) pointer_id: u32,
102    x: f64,
103    y: f64,
104}
105
106impl PointerInputState {
107    /// <https://w3c.github.io/webdriver/#dfn-create-a-pointer-input-source>
108    pub(crate) fn new(
109        subtype: PointerType,
110        pointer_ids: FxHashSet<u32>,
111        x: f64,
112        y: f64,
113    ) -> PointerInputState {
114        PointerInputState {
115            subtype,
116            pressed: FxHashSet::default(),
117            pointer_id: Self::get_pointer_id(subtype, pointer_ids),
118            x,
119            y,
120        }
121    }
122
123    /// <https://w3c.github.io/webdriver/#dfn-get-a-pointer-id>
124    fn get_pointer_id(subtype: PointerType, pointer_ids: FxHashSet<u32>) -> u32 {
125        // Step 2 - 4: Let pointer ids be all the values in input state map which is
126        // pointer input source. This is already done and passed by the caller.
127        if subtype == PointerType::Mouse {
128            for id in 0..=1 {
129                if !pointer_ids.contains(&id) {
130                    return id;
131                }
132            }
133        }
134
135        // We are dealing with subtype other than mouse, which has minimum id 2.
136        1 + pointer_ids.into_iter().max().unwrap_or(1)
137    }
138}
139
140/// <https://w3c.github.io/webdriver/#dfn-computing-the-tick-duration>
141fn compute_tick_duration(tick_actions: &TickActions) -> u64 {
142    // Step 1. Let max duration be 0.
143    // Step 2. For each action in tick actions:
144    tick_actions
145        .iter()
146        .filter_map(|(_, action_item)| {
147            // If action object has subtype property set to "pause" or
148            // action object has type property set to "pointer" and subtype property set to "pointerMove",
149            // or action object has type property set to "wheel" and subtype property set to "scroll",
150            // let duration be equal to the duration property of action object.
151            match action_item {
152                ActionItem::Null(NullActionItem::General(GeneralAction::Pause(pause_action))) |
153                ActionItem::Key(KeyActionItem::General(GeneralAction::Pause(pause_action))) |
154                ActionItem::Pointer(PointerActionItem::General(GeneralAction::Pause(
155                    pause_action,
156                ))) |
157                ActionItem::Wheel(WheelActionItem::General(GeneralAction::Pause(pause_action))) => {
158                    pause_action.duration
159                },
160                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Move(action))) => {
161                    action.duration
162                },
163                ActionItem::Wheel(WheelActionItem::Wheel(WheelAction::Scroll(action))) => {
164                    action.duration
165                },
166                _ => None,
167            }
168        })
169        .max()
170        .unwrap_or(0)
171}
172
173impl Handler {
174    /// <https://w3c.github.io/webdriver/#dfn-dispatch-actions>
175    /// <https://w3c.github.io/webdriver/#dfn-dispatch-actions-inner>
176    /// For Servo, "dispatch actions" is identical to "dispatch actions inner",
177    /// as they are only different for a session that can run commands in parallel.
178    pub(crate) fn dispatch_actions(
179        &mut self,
180        actions_by_tick: ActionsByTick,
181        browsing_context: BrowsingContextId,
182    ) -> Result<(), ErrorStatus> {
183        // Step 1. For each item tick actions in actions by tick
184        for tick_actions in actions_by_tick.iter() {
185            // Step 1.1. If browsing context is no longer open,
186            // return error with error code no such window.
187            self.verify_browsing_context_is_open(browsing_context)
188                .map_err(|e| e.error)?;
189            // Step 1.2. Let tick duration be the result of
190            // computing the tick duration with argument tick actions.
191            let tick_duration = compute_tick_duration(tick_actions);
192
193            // FIXME: This is out of spec, but the test `perform_actions/invalid.py` requires
194            // that duration more than `MAXIMUM_SAFE_INTEGER` is considered invalid.
195            if tick_duration > MAXIMUM_SAFE_INTEGER {
196                return Err(ErrorStatus::InvalidArgument);
197            }
198
199            let tick_start = Instant::now();
200
201            // Step 1.3. Try to dispatch tick actions
202            self.dispatch_tick_actions(tick_actions, tick_duration, &tick_start)?;
203            self.process_pending_actions(&tick_start);
204            // Step 1.4.1
205            // There are no pending asynchronous waits arising
206            // from the last invocation of the dispatch tick actions steps.
207
208            // Step 1.4.2
209            // Wait for the user agent event loop has spun enough times
210            // to process the DOM events generated by the last invocation of
211            // the dispatch tick actions steps.
212
213            // Step 1.4.3
214            // At least tick duration milliseconds have passed.
215
216            // TODO: 1.4.2 is not fully implemented.
217            // Specifically, this happens for click simulation from touch events.
218            // You can guarantee to catch it with `time.sleep` in the test.
219            self.wait_for_input_event_handled()?;
220            // At least tick duration milliseconds have passed.
221            let elapsed = tick_start.elapsed().as_millis() as u64;
222            if elapsed < tick_duration {
223                thread::sleep(Duration::from_millis(tick_duration - elapsed));
224            }
225        }
226
227        // Step 2. Return success with data null.
228        info!("Dispatch actions completed successfully");
229        Ok(())
230    }
231
232    /// <https://w3c.github.io/webdriver/#dfn-perform-a-pointer-move>
233    /// Step 9. Run the following substeps in parallel:
234    /// Step 9.1. Asynchronously wait for an implementation defined amount of time to pass.
235    /// Step 9.2. Perform a pointer move with arguments input state,
236    /// duration, start x, start y, target x, target y.
237    /// <https://w3c.github.io/webdriver/#dfn-perform-a-scroll>
238    /// Step 7. Run the following substeps in parallel:
239    /// Step 7.1. Asynchronously wait for an implementation defined amount of time to pass.
240    /// Step 7.2. Perform a scroll with arguments duration, x, y,
241    /// target delta x, target delta y, current delta x, current delta y.
242    fn process_pending_actions(&mut self, tick_start: &Instant) {
243        while !self.pending_actions.is_empty() {
244            let pending_actions = std::mem::take(&mut self.pending_actions);
245            thread::sleep(Duration::from_millis(MOVESCROLL_INTERVAL));
246            for action in pending_actions {
247                match action {
248                    PendingActions::PointerMove(PendingPointerMove {
249                        input_id,
250                        duration,
251                        start_x,
252                        start_y,
253                        target_x,
254                        target_y,
255                    }) => {
256                        self.perform_pointer_move(
257                            &input_id, duration, start_x, start_y, target_x, target_y, tick_start,
258                        );
259                    },
260                    PendingActions::Scroll(PendingScroll {
261                        input_id,
262                        duration,
263                        x,
264                        y,
265                        current_delta_x,
266                        current_delta_y,
267                        target_delta_x,
268                        target_delta_y,
269                    }) => {
270                        self.perform_scroll(
271                            &input_id,
272                            duration,
273                            x,
274                            y,
275                            target_delta_x,
276                            target_delta_y,
277                            current_delta_x,
278                            current_delta_y,
279                            tick_start,
280                        );
281                    },
282                }
283            }
284        }
285    }
286
287    fn wait_for_input_event_handled(&mut self) -> Result<(), ErrorStatus> {
288        let pending_receivers = std::mem::take(&mut self.pending_input_event_receivers);
289
290        for receiver in pending_receivers {
291            let _ = receiver.recv();
292        }
293
294        Ok(())
295    }
296
297    /// <https://w3c.github.io/webdriver/#dfn-dispatch-tick-actions>
298    fn dispatch_tick_actions(
299        &mut self,
300        tick_actions: &TickActions,
301        tick_duration: u64,
302        tick_start: &Instant,
303    ) -> Result<(), ErrorStatus> {
304        // Step 1. For each action object in tick actions:
305        // Step 1.1. Let input_id be the value of the id property of action object.
306        for (input_id, action) in tick_actions.iter() {
307            // Step 6. Let subtype be action object's subtype.
308            // Steps 7, 8. Try to run specific algorithm based on the action type.
309            match action {
310                ActionItem::Null(_) |
311                ActionItem::Key(KeyActionItem::General(_)) |
312                ActionItem::Pointer(PointerActionItem::General(_)) |
313                ActionItem::Wheel(WheelActionItem::General(_)) => {
314                    self.dispatch_pause_action(input_id);
315                },
316                ActionItem::Key(KeyActionItem::Key(KeyAction::Down(keydown_action))) => {
317                    self.dispatch_keydown_action(input_id, keydown_action);
318                    // Step 9. If subtype is "keyDown", append a copy of action
319                    // object with the subtype property changed to "keyUp" to
320                    // input state's input cancel list.
321                    self.session_mut().unwrap().input_cancel_list.push((
322                        input_id.clone(),
323                        ActionItem::Key(KeyActionItem::Key(KeyAction::Up(KeyUpAction {
324                            value: keydown_action.value.clone(),
325                        }))),
326                    ));
327                },
328                ActionItem::Key(KeyActionItem::Key(KeyAction::Up(keyup_action))) => {
329                    self.dispatch_keyup_action(input_id, keyup_action);
330                },
331                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Down(
332                    pointer_down_action,
333                ))) => {
334                    self.dispatch_pointerdown_action(input_id, pointer_down_action);
335                    // Step 10. If subtype is "pointerDown", append a copy of action
336                    // object with the subtype property changed to "pointerUp" to
337                    // input state's input cancel list.
338                    self.session_mut().unwrap().input_cancel_list.push((
339                        input_id.clone(),
340                        ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Up(
341                            PointerUpAction {
342                                button: pointer_down_action.button,
343                                ..Default::default()
344                            },
345                        ))),
346                    ));
347                },
348                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Move(
349                    pointer_move_action,
350                ))) => {
351                    self.dispatch_pointermove_action(
352                        input_id,
353                        pointer_move_action,
354                        tick_duration,
355                        tick_start,
356                    )?;
357                },
358                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Up(
359                    pointer_up_action,
360                ))) => {
361                    self.dispatch_pointerup_action(input_id, pointer_up_action);
362                },
363                ActionItem::Wheel(WheelActionItem::Wheel(WheelAction::Scroll(scroll_action))) => {
364                    self.dispatch_scroll_action(
365                        input_id,
366                        scroll_action,
367                        tick_duration,
368                        tick_start,
369                    )?;
370                },
371                ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Cancel)) => {
372                    self.dispatch_pointercancel_action(input_id);
373                },
374            }
375        }
376
377        Ok(())
378    }
379
380    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pause-action>
381    /// This is a dummy action. The sole effect of the action is that
382    /// its `duration` parameter may decide "tick duration".
383    fn dispatch_pause_action(&mut self, _input_id: &str) {}
384
385    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-keydown-action>
386    fn dispatch_keydown_action(&mut self, input_id: &str, action: &KeyDownAction) {
387        let raw_key = action.value.chars().next().unwrap();
388        let key_input_state = match self.input_state_table_mut().get_mut(input_id).unwrap() {
389            InputSourceState::Key(key_input_state) => key_input_state,
390            _ => unreachable!(),
391        };
392
393        let keyboard_event = key_input_state.dispatch_keydown(raw_key);
394
395        // Step 12: Perform implementation-specific action dispatch steps on browsing
396        // context equivalent to pressing a key on the keyboard in accordance with the
397        // requirements of [UI-EVENTS], and producing the following events, as
398        // appropriate, with the specified properties. This will always produce events
399        // including at least a keyDown event.
400        self.send_blocking_input_event_to_embedder(InputEvent::Keyboard(KeyboardEvent::new(
401            keyboard_event,
402        )));
403    }
404
405    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-keyup-action>
406    fn dispatch_keyup_action(&mut self, input_id: &str, action: &KeyUpAction) {
407        let session = self.session_mut().unwrap();
408
409        // Remove the last matching keyUp from `[input_cancel_list]` due to bugs in spec
410        // See https://github.com/w3c/webdriver/issues/1905 &&
411        // https://github.com/servo/servo/issues/37579#issuecomment-2990762713
412        let input_cancel_list = &mut session.input_cancel_list;
413        if let Some(pos) = input_cancel_list.iter().rposition(|(id, item)| {
414            id == input_id &&
415                matches!(item,
416                        ActionItem::Key(KeyActionItem::Key(KeyAction::Up(KeyUpAction { value })))
417                    if *value == action.value )
418        }) {
419            info!("dispatch_keyup_action: removing last matching keyup from input_cancel_list");
420            input_cancel_list.remove(pos);
421        }
422
423        let raw_key = action.value.chars().next().unwrap();
424        let key_input_state = match session.input_state_table.get_mut(input_id).unwrap() {
425            InputSourceState::Key(key_input_state) => key_input_state,
426            _ => unreachable!(),
427        };
428
429        // Step 12: Perform implementation-specific action dispatch steps on browsing
430        // context equivalent to releasing a key on the keyboard in accordance with the
431        // requirements of [UI-EVENTS], ...
432        let Some(keyboard_event) = key_input_state.dispatch_keyup(raw_key) else {
433            return;
434        };
435        self.send_blocking_input_event_to_embedder(InputEvent::Keyboard(KeyboardEvent::new(
436            keyboard_event,
437        )));
438    }
439
440    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointercancel-action>
441    fn dispatch_pointercancel_action(&mut self, source_id: &str) {
442        // Perform implementation-specific action dispatch steps on browsing context equivalent
443        // to cancelling the any action of the pointer with
444        // pointerId equal to source's pointerId item. having type pointerType,
445        // in accordance with the requirements of [UI-EVENTS] and [POINTER-EVENTS].
446        let PointerInputState {
447            subtype,
448            pointer_id,
449            x,
450            y,
451            ..
452        } = *self.get_pointer_input_state(source_id);
453        match subtype {
454            PointerType::Pen | PointerType::Touch => {
455                self.send_blocking_input_event_to_embedder(InputEvent::Touch(TouchEvent::new(
456                    TouchEventType::Cancel,
457                    TouchId(pointer_id as i32),
458                    WebViewPoint::Page(Point2D::new(x as f32, y as f32)),
459                )));
460            },
461            PointerType::Mouse => {
462                info!("WebDriver pointerCancel is not implemented for mouse yet");
463            },
464        }
465    }
466
467    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointerdown-action>
468    fn dispatch_pointerdown_action(&mut self, input_id: &str, action: &PointerDownAction) {
469        let pointer_input_state = self.get_pointer_input_state_mut(input_id);
470        // Step 3. If the source's pressed property contains button return success with data null.
471        if pointer_input_state.pressed.contains(&action.button) {
472            return;
473        }
474
475        let PointerInputState { x, y, subtype, .. } = *pointer_input_state;
476        // Step 6. Add button to the set corresponding to source's pressed property
477        pointer_input_state.pressed.insert(action.button);
478        // Step 7 - 15: Variable namings already done.
479
480        // Step 16. Perform implementation-specific action dispatch steps
481        // TODO: We have not considered pen pointer type
482        let point = WebViewPoint::Page(Point2D::new(x as f32, y as f32));
483        let input_event = match subtype {
484            PointerType::Mouse => InputEvent::MouseButton(MouseButtonEvent::new(
485                MouseButtonAction::Down,
486                action.button.into(),
487                point,
488            )),
489            PointerType::Pen | PointerType::Touch => InputEvent::Touch(TouchEvent::new(
490                TouchEventType::Down,
491                TouchId(pointer_input_state.pointer_id as i32),
492                point,
493            )),
494        };
495        self.send_blocking_input_event_to_embedder(input_event);
496
497        // Step 17. Return success with data null.
498    }
499
500    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointerup-action>
501    fn dispatch_pointerup_action(&mut self, input_id: &str, action: &PointerUpAction) {
502        let pointer_input_state = self.get_pointer_input_state_mut(input_id);
503        // Step 3. If the source's pressed property does not contain button, return success with data null.
504        if !pointer_input_state.pressed.contains(&action.button) {
505            return;
506        }
507
508        // Step 6. Remove button from the set corresponding to source's pressed property,
509        pointer_input_state.pressed.remove(&action.button);
510        let PointerInputState {
511            x,
512            y,
513            subtype,
514            pointer_id,
515            ..
516        } = *pointer_input_state;
517
518        // Remove matching pointerUp(must be unique) from `[input_cancel_list]` due to bugs in spec
519        // See https://github.com/w3c/webdriver/issues/1905 &&
520        // https://github.com/servo/servo/issues/37579#issuecomment-2990762713
521        let input_cancel_list = &mut self.session_mut().unwrap().input_cancel_list;
522        if let Some(pos) = input_cancel_list.iter().position(|(id, item)| {
523            id == input_id &&
524                matches!(item, ActionItem::Pointer(PointerActionItem::Pointer(PointerAction::Up(
525                    PointerUpAction { button, .. },
526                ))) if *button == action.button )
527        }) {
528            info!("dispatch_pointerup_action: removing matching pointerup from input_cancel_list");
529            input_cancel_list.remove(pos);
530        }
531
532        // Step 7. Perform implementation-specific action dispatch steps
533        let point = WebViewPoint::Page(Point2D::new(x as f32, y as f32));
534        let input_event = match subtype {
535            PointerType::Mouse => InputEvent::MouseButton(MouseButtonEvent::new(
536                MouseButtonAction::Up,
537                action.button.into(),
538                point,
539            )),
540            PointerType::Pen | PointerType::Touch => InputEvent::Touch(TouchEvent::new(
541                TouchEventType::Up,
542                TouchId(pointer_id as i32),
543                point,
544            )),
545        };
546        self.send_blocking_input_event_to_embedder(input_event);
547
548        // Step 8. Return success with data null.
549    }
550
551    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-pointermove-action>
552    fn dispatch_pointermove_action(
553        &mut self,
554        input_id: &str,
555        action: &PointerMoveAction,
556        tick_duration: u64,
557        tick_start: &Instant,
558    ) -> Result<(), ErrorStatus> {
559        // Step 1. Let x offset be equal to the x property of action object.
560        let x_offset = action.x;
561
562        // Step 2. Let y offset be equal to the y property of action object.
563        let y_offset = action.y;
564
565        // Step 3. Let origin be equal to the origin property of action object.
566        let origin = &action.origin;
567
568        // Step 4. Let (x, y) be the result of trying to get coordinates relative to an origin
569        // with source, x offset, y offset, origin, browsing context, and actions options.
570
571        let (x, y) = self.get_origin_relative_coordinates(origin, x_offset, y_offset, input_id)?;
572
573        // Step 5. If x is less than 0 or greater than the width of the viewport in CSS pixels,
574        // then return error with error code move target out of bounds.
575        // Step 6. If y is less than 0 or greater than the height of the viewport in CSS pixels,
576        // then return error with error code move target out of bounds.
577        self.check_viewport_bound(x, y)?;
578
579        // Step 7. Let duration be equal to action object's duration property
580        // if it is not undefined, or tick duration otherwise.
581        let duration = match action.duration {
582            Some(duration) => duration,
583            None => tick_duration,
584        };
585
586        // Step 8. If duration is greater than 0 and inside any implementation-defined bounds,
587        // asynchronously wait for an implementation defined amount of time to pass.
588
589        // Asynchronously wait means do not block browser to process event loop.
590        // In the context of Servo, it means block the webdriver server thread.
591        if duration > 0 {
592            thread::sleep(Duration::from_millis(MOVESCROLL_INTERVAL));
593        }
594
595        let (start_x, start_y) = {
596            let pointer_input_state = self.get_pointer_input_state(input_id);
597            (pointer_input_state.x, pointer_input_state.y)
598        };
599
600        // Step 9 - 18
601        // Perform a pointer move with arguments source, global key state, duration, start x, start y,
602        // x, y, width, height, pressure, tangentialPressure, tiltX, tiltY, twist, altitudeAngle, azimuthAngle.
603        // TODO: We have not considered pen pointer type
604        self.perform_pointer_move(input_id, duration, start_x, start_y, x, y, tick_start);
605
606        // Step 19. Return success with data null.
607        Ok(())
608    }
609
610    /// <https://w3c.github.io/webdriver/#dfn-perform-a-pointer-move>
611    #[expect(clippy::too_many_arguments)]
612    fn perform_pointer_move(
613        &mut self,
614        input_id: &str,
615        duration: u64,
616        start_x: f64,
617        start_y: f64,
618        target_x: f64,
619        target_y: f64,
620        tick_start: &Instant,
621    ) {
622        // Step 1. Let time delta be the time since the beginning of the
623        // current tick, measured in milliseconds on a monotonic clock.
624        let time_delta = tick_start.elapsed().as_millis();
625
626        // Step 2. Let duration ratio be the ratio of time delta and duration,
627        // if duration is greater than 0, or 1 otherwise.
628        let duration_ratio = if duration > 0 {
629            time_delta as f64 / duration as f64
630        } else {
631            1.0
632        };
633
634        // Step 3. If duration ratio is 1, or close enough to 1 that the
635        // implementation will not further subdivide the move action,
636        // let last be true. Otherwise let last be false.
637        let last = 1.0 - duration_ratio < 0.001;
638
639        // Step 4. If last is true, let x equal target x and y equal target y.
640        // Otherwise
641        // let x equal an approximation to duration ratio × (target x - start x) + start x,
642        // and y equal an approximation to duration ratio × (target y - start y) + start y.
643        let (x, y) = if last {
644            (target_x, target_y)
645        } else {
646            (
647                duration_ratio * (target_x - start_x) + start_x,
648                duration_ratio * (target_y - start_y) + start_y,
649            )
650        };
651
652        // Step 5 - 6: Let current x/y equal the x/y property of input state.
653        let PointerInputState {
654            x: current_x,
655            y: current_y,
656            subtype,
657            pressed,
658            pointer_id,
659        } = self.get_pointer_input_state(input_id);
660
661        // Step 7. If x != current x or y != current y, run the following steps:
662        if x != *current_x || y != *current_y {
663            // Step 7.1. Let buttons be equal to input state's pressed property.
664            // Step 7.2. Perform implementation-specific action dispatch steps
665            let point = WebViewPoint::Page(Point2D::new(x as f32, y as f32));
666
667            // For a pointer of type "mouse"
668            // this will always produce events including at least a pointerMove event.
669            match subtype {
670                PointerType::Mouse => {
671                    let input_event = InputEvent::MouseMove(MouseMoveEvent::new(point));
672                    self.send_blocking_input_event_to_embedder(input_event);
673                },
674                // In the case where the pointerType is "pen" or "touch", and buttons is empty,
675                // this may be a no-op.
676                PointerType::Touch | PointerType::Pen => {
677                    if pressed.contains(&ELEMENT_CLICK_BUTTON) {
678                        let input_event = InputEvent::Touch(TouchEvent::new(
679                            TouchEventType::Move,
680                            TouchId(*pointer_id as i32),
681                            point,
682                        ));
683                        // FIXME: Should replace with `send_blocking_input_event_to_embedder`
684                        // after we revamp the touch chain.
685                        self.send_input_event_to_embedder(input_event);
686                    }
687                },
688            }
689
690            // Step 7.3. Let input state's x property equal x and y property equal y.
691            let pointer_input_state = self.get_pointer_input_state_mut(input_id);
692            pointer_input_state.x = x;
693            pointer_input_state.y = y;
694        }
695
696        // Step 8. If last is true, return.
697        if last {
698            return;
699        }
700
701        // Step 9. Run the following substeps in parallel:
702        // Step 9.1. Asynchronously wait for an implementation defined amount of time to pass.
703        // Step 9.2. Perform a pointer move with arguments input state,
704        // duration, start x, start y, target x, target y.
705        // This is done in `fn process_pending_actions`.
706
707        // NOTE: The initial pointer movement is performed synchronously.
708        // This ensures determinism in the sequence of the first event
709        // triggered by each action in the tick.
710        // Subsequent movements (if any) are performed asynchronously.
711        // This allows events from two pointerMove actions in the tick to be interspersed.
712
713        // We use [`PendingPointerMove`] to achieve the same effect as asynchronous wait and
714        // parallelism required by spec.
715        // This conveniently unify the wait interval between ticks.
716        self.pending_actions
717            .push(PendingActions::PointerMove(PendingPointerMove {
718                input_id: input_id.to_owned(),
719                duration,
720                start_x,
721                start_y,
722                target_x,
723                target_y,
724            }));
725    }
726
727    /// <https://w3c.github.io/webdriver/#dfn-dispatch-a-scroll-action>
728    fn dispatch_scroll_action(
729        &mut self,
730        input_id: &str,
731        action: &WheelScrollAction,
732        tick_duration: u64,
733        tick_start: &Instant,
734    ) -> Result<(), ErrorStatus> {
735        // <https://w3c.github.io/webdriver/#dfn-process-a-wheel-action>
736        // The validation is normally done already by webdriver crate,
737        // but it is not the case for this action currently.
738
739        // Step 1. Let x offset be equal to the x property of action object.
740        let Some(x_offset) = action.x else {
741            return Err(ErrorStatus::InvalidArgument);
742        };
743
744        // Step 2. Let y offset be equal to the y property of action object.
745        let Some(y_offset) = action.y else {
746            return Err(ErrorStatus::InvalidArgument);
747        };
748
749        // Step 7. Let delta x be equal to the deltaX property of action object.
750        let Some(delta_x) = action.deltaX else {
751            return Err(ErrorStatus::InvalidArgument);
752        };
753
754        // Step 8. Let delta y be equal to the deltaY property of action object.
755        let Some(delta_y) = action.deltaY else {
756            return Err(ErrorStatus::InvalidArgument);
757        };
758
759        // Step 3. Let origin be equal to the origin property of action object.
760        let origin = &action.origin;
761
762        // Pointer origin isn't currently supported for wheel input source
763        // See: https://github.com/w3c/webdriver/issues/1758
764
765        if origin == &PointerOrigin::Pointer {
766            return Err(ErrorStatus::InvalidArgument);
767        }
768
769        // Step 4. Let (x, y) be the result of trying to get coordinates relative to an origin
770        // with source, x offset, y offset, origin, browsing context, and actions options.
771        let (x, y) =
772            self.get_origin_relative_coordinates(origin, x_offset as _, y_offset as _, input_id)?;
773
774        // Step 5. If x is less than 0 or greater than the width of the viewport in CSS pixels,
775        // then return error with error code move target out of bounds.
776        // Step 6. If y is less than 0 or greater than the height of the viewport in CSS pixels,
777        // then return error with error code move target out of bounds.
778        self.check_viewport_bound(x, y)?;
779
780        // Step 9. Let duration be equal to action object's duration property
781        // if it is not undefined, or tick duration otherwise.
782        let duration = match action.duration {
783            Some(duration) => duration,
784            None => tick_duration,
785        };
786
787        // Step 10. If duration is greater than 0 and inside any implementation-defined bounds,
788        // asynchronously wait for an implementation defined amount of time to pass.
789        if duration > 0 {
790            thread::sleep(Duration::from_millis(MOVESCROLL_INTERVAL));
791        }
792
793        // Step 11. Perform a scroll with arguments global key state, duration, x, y, delta x, delta y, 0, 0.
794        self.perform_scroll(
795            input_id,
796            duration,
797            x,
798            y,
799            delta_x as _,
800            delta_y as _,
801            0.0,
802            0.0,
803            tick_start,
804        );
805
806        // Step 12. Return success with data null.
807        Ok(())
808    }
809
810    /// <https://w3c.github.io/webdriver/#dfn-perform-a-scroll>
811    #[expect(clippy::too_many_arguments)]
812    fn perform_scroll(
813        &mut self,
814        input_id: &str,
815        duration: u64,
816        x: f64,
817        y: f64,
818        target_delta_x: f64,
819        target_delta_y: f64,
820        mut current_delta_x: f64,
821        mut current_delta_y: f64,
822        tick_start: &Instant,
823    ) {
824        // Step 1. Let time delta be the time since the beginning of the current tick,
825        // measured in milliseconds on a monotonic clock.
826        let time_delta = tick_start.elapsed().as_millis();
827
828        // Step 2. Let duration ratio be the ratio of time delta and duration,
829        // if duration is greater than 0, or 1 otherwise.
830        let duration_ratio = if duration > 0 {
831            time_delta as f64 / duration as f64
832        } else {
833            1.0
834        };
835
836        // Step 3. If duration ratio is 1, or close enough to 1 that
837        // the implementation will not further subdivide the move action,
838        // let last be true. Otherwise let last be false.
839        let last = 1.0 - duration_ratio < 0.001;
840
841        // Step 4. If last is true,
842        // let delta x equal target delta x - current delta x and delta y equal target delta y - current delta y.
843        // Otherwise
844        // let delta x equal an approximation to duration ratio × target delta x - current delta x,
845        // and delta y equal an approximation to duration ratio × target delta y - current delta y.
846        let (delta_x, delta_y) = if last {
847            (
848                target_delta_x - current_delta_x,
849                target_delta_y - current_delta_y,
850            )
851        } else {
852            (
853                duration_ratio * target_delta_x - current_delta_x,
854                duration_ratio * target_delta_y - current_delta_y,
855            )
856        };
857
858        // Step 5. If delta x != 0 or delta y != 0, run the following steps:
859        if delta_x != 0.0 || delta_y != 0.0 {
860            // Step 5.1. Perform implementation-specific action dispatch steps
861            let delta = WheelDelta {
862                x: -delta_x,
863                y: -delta_y,
864                z: 0.0,
865                mode: WheelMode::DeltaPixel,
866            };
867            let point = WebViewPoint::Page(Point2D::new(x as f32, y as f32));
868            let input_event = InputEvent::Wheel(WheelEvent::new(delta, point));
869
870            self.send_blocking_input_event_to_embedder(input_event);
871
872            // Step 5.2. Let current delta x property equal delta x + current delta x
873            // and current delta y property equal delta y + current delta y.
874            current_delta_x += delta_x;
875            current_delta_y += delta_y;
876        }
877
878        // Step 6. If last is true, return.
879        if last {
880            return;
881        }
882
883        // Step 7. Run the following substeps in parallel:
884        // Step 7.1. Asynchronously wait for an implementation defined amount of time to pass.
885        // Step 7.2. Perform a scroll with arguments duration, x, y,
886        // target delta x, target delta y, current delta x, current delta y.
887        // This is done in `fn process_pending_actions`.
888
889        // NOTE: The initial scroll is performed synchronously.
890        // This ensures determinism in the sequence of the first event
891        // triggered by each action in the tick.
892        // Subsequent scrolls (if any) are performed asynchronously.
893        // This allows events from two scroll actions in the tick to be interspersed.
894
895        // We use [`PendingScroll`] to achieve the same effect as asynchronous wait and
896        // parallelism required by spec.
897        // This conveniently unify the wait interval between ticks.
898        self.pending_actions
899            .push(PendingActions::Scroll(PendingScroll {
900                input_id: input_id.to_owned(),
901                duration,
902                x,
903                y,
904                current_delta_x,
905                current_delta_y,
906                target_delta_x,
907                target_delta_y,
908            }));
909    }
910
911    /// Verify that the given coordinates are within the boundary of the viewport.
912    /// If x or y is less than 0 or greater than the width of the viewport in CSS pixels,
913    /// then return error with error code move target out of bounds.
914    fn check_viewport_bound(&self, x: f64, y: f64) -> Result<(), ErrorStatus> {
915        if x < 0.0 || y < 0.0 {
916            return Err(ErrorStatus::MoveTargetOutOfBounds);
917        }
918        let (sender, receiver) = generic_channel::oneshot().unwrap();
919        let cmd_msg = WebDriverCommandMsg::GetViewportSize(self.verified_webview_id(), sender);
920        self.send_message_to_embedder(cmd_msg)
921            .map_err(|_| ErrorStatus::UnknownError)?;
922
923        let viewport_size = match wait_for_oneshot_response(receiver) {
924            Ok(response) => response,
925            Err(WebDriverError { error, .. }) => return Err(error),
926        };
927        if x > viewport_size.width.into() || y > viewport_size.height.into() {
928            Err(ErrorStatus::MoveTargetOutOfBounds)
929        } else {
930            Ok(())
931        }
932    }
933
934    /// <https://w3c.github.io/webdriver/#dfn-get-coordinates-relative-to-an-origin>
935    pub(crate) fn get_origin_relative_coordinates(
936        &self,
937        origin: &PointerOrigin,
938        x_offset: f64,
939        y_offset: f64,
940        input_id: &str,
941    ) -> Result<(f64, f64), ErrorStatus> {
942        match origin {
943            PointerOrigin::Viewport => Ok((x_offset, y_offset)),
944            PointerOrigin::Pointer => {
945                // Step 1. Let start x be equal to the x property of source.
946                // Step 2. Let start y be equal to the y property of source.
947                let (start_x, start_y) = {
948                    let pointer_input_state = self.get_pointer_input_state(input_id);
949                    (pointer_input_state.x, pointer_input_state.y)
950                };
951                // Step 3. Let x equal start x + x offset and y equal start y + y offset.
952                Ok((start_x + x_offset, start_y + y_offset))
953            },
954            PointerOrigin::Element(web_element) => {
955                // Steps 1 - 3
956                let (x_element, y_element) = self.get_element_in_view_center_point(web_element)?;
957                // Step 4. Let x equal x element + x offset, and y equal y element + y offset.
958                Ok((x_element as f64 + x_offset, y_element as f64 + y_offset))
959            },
960        }
961    }
962
963    /// <https://w3c.github.io/webdriver/#dfn-center-point>
964    fn get_element_in_view_center_point(
965        &self,
966        web_element: &WebElement,
967    ) -> Result<(i64, i64), ErrorStatus> {
968        let (sender, receiver) = generic_channel::oneshot().unwrap();
969        // Step 1. Let element be the result of trying to run actions options'
970        // get element origin steps with origin and browsing context.
971        self.browsing_context_script_command(
972            WebDriverScriptCommand::GetElementInViewCenterPoint(web_element.to_string(), sender),
973            VerifyBrowsingContextIsOpen::No,
974        )
975        .unwrap();
976
977        // Step 2. If element is null, return error with error code no such element.
978        let response = match wait_for_oneshot_response(receiver) {
979            Ok(response) => response,
980            Err(WebDriverError { error, .. }) => return Err(error),
981        };
982
983        // Step 3. Let x element and y element be the result of calculating the in-view center point of element.
984        match response? {
985            Some(point) => Ok(point),
986            None => Err(ErrorStatus::UnknownError),
987        }
988    }
989
990    /// <https://w3c.github.io/webdriver/#dfn-extract-an-action-sequence>
991    pub(crate) fn extract_an_action_sequence(
992        &mut self,
993        actions: Vec<ActionSequence>,
994    ) -> ActionsByTick {
995        // Step 3. Let "actions by tick" be an empty list.
996        let mut actions_by_tick: ActionsByTick = Vec::new();
997
998        // Step 4. For each value "action sequence" corresponding to an indexed property in actions
999        for action_sequence in actions {
1000            let id = action_sequence.id.clone();
1001            // Step 4.1. Let "source actions" be the result of trying to process an input source action sequence
1002            // given "action sequence".
1003            let source_actions = self.process_an_input_source_action_sequence(action_sequence);
1004
1005            // Step 4.2.2. Ensure we have enough ticks to hold all actions
1006            if actions_by_tick.len() < source_actions.len() {
1007                actions_by_tick.resize_with(source_actions.len(), Vec::new);
1008            }
1009
1010            // Step 4.2.3. Append "action" to the List at index i in "actions by tick",
1011            // for each "action" in "source actions".
1012            for (tick_index, action_item) in source_actions.into_iter().enumerate() {
1013                actions_by_tick[tick_index].push((id.clone(), action_item));
1014            }
1015        }
1016
1017        actions_by_tick
1018    }
1019
1020    /// <https://w3c.github.io/webdriver/#dfn-process-an-input-source-action-sequence>
1021    fn process_an_input_source_action_sequence(
1022        &mut self,
1023        action_sequence: ActionSequence,
1024    ) -> Vec<ActionItem> {
1025        // Step 2. Let id be the value of the id property of action sequence.
1026        let id = action_sequence.id;
1027        match action_sequence.actions {
1028            ActionsType::Null {
1029                actions: null_actions,
1030            } => {
1031                self.input_state_table_mut()
1032                    .entry(id)
1033                    .or_insert(InputSourceState::Null);
1034                null_actions.into_iter().map(ActionItem::Null).collect()
1035            },
1036            ActionsType::Key {
1037                actions: key_actions,
1038            } => {
1039                self.input_state_table_mut()
1040                    .entry(id)
1041                    .or_insert(InputSourceState::Key(KeyInputState::new()));
1042                key_actions.into_iter().map(ActionItem::Key).collect()
1043            },
1044            ActionsType::Pointer {
1045                parameters,
1046                actions: pointer_actions,
1047            } => {
1048                let pointer_ids = self.session().unwrap().pointer_ids();
1049                // Get or create a pointer input source with subtype, and other iterms
1050                // set to default values.
1051                self.input_state_table_mut()
1052                    .entry(id)
1053                    .or_insert(InputSourceState::Pointer(PointerInputState::new(
1054                        parameters.pointer_type,
1055                        pointer_ids,
1056                        0.0,
1057                        0.0,
1058                    )));
1059                pointer_actions
1060                    .into_iter()
1061                    .map(ActionItem::Pointer)
1062                    .collect()
1063            },
1064            ActionsType::Wheel {
1065                actions: wheel_actions,
1066            } => {
1067                self.input_state_table_mut()
1068                    .entry(id)
1069                    .or_insert(InputSourceState::Wheel);
1070                wheel_actions.into_iter().map(ActionItem::Wheel).collect()
1071            },
1072        }
1073    }
1074
1075    fn input_state_table_mut(&mut self) -> &mut HashMap<String, InputSourceState> {
1076        &mut self.session_mut().unwrap().input_state_table
1077    }
1078
1079    fn get_pointer_input_state_mut(&mut self, input_id: &str) -> &mut PointerInputState {
1080        let InputSourceState::Pointer(pointer_input_state) =
1081            self.input_state_table_mut().get_mut(input_id).unwrap()
1082        else {
1083            unreachable!();
1084        };
1085        pointer_input_state
1086    }
1087
1088    fn get_pointer_input_state(&self, input_id: &str) -> &PointerInputState {
1089        let InputSourceState::Pointer(pointer_input_state) = self
1090            .session()
1091            .unwrap()
1092            .input_state_table
1093            .get(input_id)
1094            .unwrap()
1095        else {
1096            unreachable!();
1097        };
1098        pointer_input_state
1099    }
1100}