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}