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}