diff --git a/packages/dioxus-embedded-driver/src/server/handlers/actions.rs b/packages/dioxus-embedded-driver/src/server/handlers/actions.rs index 356ee3d4d..9b37f9d63 100644 --- a/packages/dioxus-embedded-driver/src/server/handlers/actions.rs +++ b/packages/dioxus-embedded-driver/src/server/handlers/actions.rs @@ -68,6 +68,30 @@ impl ActionSequence { ActionSequence::None { actions, .. } => actions.len(), } } + + /// The duration (ms) this source's action at `tick` contributes to the tick. Pause, pointer-move + /// and wheel-scroll actions carry a duration; everything else is instantaneous (`None`). The tick's + /// duration is the max of these across sources (W3C §17.4.3), not their sum. + fn duration_at(&self, tick: usize) -> Option { + match self { + ActionSequence::Key { actions, .. } => match actions.get(tick) { + Some(KeyAction::Pause { duration }) => *duration, + _ => None, + }, + ActionSequence::Pointer { actions, .. } => match actions.get(tick) { + Some(PointerAction::PointerMove { duration, .. }) | Some(PointerAction::Pause { duration }) => *duration, + _ => None, + }, + ActionSequence::Wheel { actions, .. } => match actions.get(tick) { + Some(WheelAction::Scroll { duration, .. }) | Some(WheelAction::Pause { duration }) => *duration, + _ => None, + }, + ActionSequence::None { actions, .. } => match actions.get(tick) { + Some(PauseAction::Pause { duration }) => *duration, + _ => None, + }, + } + } } #[derive(Debug, Deserialize)] @@ -405,6 +429,9 @@ pub async fn perform( // click lands). A source with fewer actions contributes nothing on later ticks. let tick_count = request.actions.iter().map(ActionSequence::action_count).max().unwrap_or(0); for tick in 0..tick_count { + // W3C §17.4.3: a tick lasts the *maximum* pause/move/scroll duration across its sources (not the + // sum), slept once after the tick's actions are dispatched. + let tick_duration_ms = request.actions.iter().filter_map(|seq| seq.duration_at(tick)).max().unwrap_or(0); for action_seq in &request.actions { match action_seq { ActionSequence::Key { _id: _, actions } => { @@ -431,7 +458,7 @@ pub async fn perform( } eval(key_event_js("keyup", value, modifiers), timeout_ms).await?; } - KeyAction::Pause { duration } => sleep_for(*duration).await, + KeyAction::Pause { .. } => {} } } } @@ -485,7 +512,7 @@ pub async fn perform( } } } - PointerAction::PointerMove { x, y, duration, origin } => { + PointerAction::PointerMove { x, y, origin, .. } => { let (target_x, target_y) = resolve_origin(&state, &session_id, origin, *x, *y, &pointer_state, timeout_ms).await?; pointer_state.x = target_x; @@ -493,14 +520,13 @@ pub async fn perform( // A move between clicks breaks a double-click chain. element.doubleClick() never moves // between its two press/release pairs, so this can't suppress a legitimate dblclick. last_click_pos = None; - sleep_for(*duration).await; eval( pointer_event_js("mousemove", pointer_state.x, pointer_state.y, 0, held_mask, modifiers), timeout_ms, ) .await?; } - PointerAction::Pause { duration } => sleep_for(*duration).await, + PointerAction::Pause { .. } => {} PointerAction::PointerCancel => { // A pointer cancel aborts the gesture: release this source's buttons without a click // (there's no MouseEvent equivalent), so drop its tracked state and clear the held mask. @@ -523,23 +549,26 @@ pub async fn perform( ActionSequence::Wheel { _id: _, actions } => { if let Some(action) = actions.get(tick) { match action { - WheelAction::Scroll { x, y, delta_x, delta_y, duration } => { - sleep_for(*duration).await; + WheelAction::Scroll { x, y, delta_x, delta_y, .. } => { eval(wheel_event_js(*x, *y, *delta_x, *delta_y), timeout_ms).await?; } - WheelAction::Pause { duration } => sleep_for(*duration).await, + WheelAction::Pause { .. } => {} } } } ActionSequence::None { _id: _, actions } => { if let Some(action) = actions.get(tick) { match action { - PauseAction::Pause { duration } => sleep_for(*duration).await, + PauseAction::Pause { .. } => {} } } } } } + // The tick's actions are dispatched; wait its longest source duration once. + if tick_duration_ms > 0 { + tokio::time::sleep(Duration::from_millis(tick_duration_ms)).await; + } } // Persist the final pointer position for later `origin: "pointer"` resolution. @@ -592,14 +621,6 @@ async fn resolve_origin( } } -async fn sleep_for(duration: Option) { - if let Some(ms) = duration { - if ms > 0 { - tokio::time::sleep(Duration::from_millis(ms)).await; - } - } -} - /// DELETE `/session/{session_id}/actions` — release actions. pub async fn release( State(state): State>, @@ -892,4 +913,23 @@ mod tests { let ticks = req.actions.iter().map(ActionSequence::action_count).max().unwrap_or(0); assert_eq!(ticks, 3); } + + #[test] + fn tick_duration_is_max_across_sources_not_sum() { + // W3C §17.4.3: a tick lasts the max pause/move/scroll duration across sources, not their sum. + let req = parse( + r#"{"actions":[ + {"type":"none","id":"n","actions":[{"type":"pause","duration":100},{"type":"pause","duration":50}]}, + {"type":"pointer","id":"p","actions":[ + {"type":"pointerMove","x":0,"y":0,"duration":200},{"type":"pause","duration":30} + ]}, + {"type":"wheel","id":"w","actions":[{"type":"scroll","x":0,"y":0,"deltaX":0,"deltaY":0,"duration":80}]} + ]}"#, + ); + let tick_ms = |tick: usize| req.actions.iter().filter_map(|s| s.duration_at(tick)).max().unwrap_or(0); + // tick 0: max(100, 200, 80) = 200 — not the sum (380). + assert_eq!(tick_ms(0), 200); + // tick 1: max(50, 30) = 50 — the wheel source is exhausted and contributes nothing. + assert_eq!(tick_ms(1), 50); + } } diff --git a/packages/tauri-plugin-webdriver/src/server/handlers/actions.rs b/packages/tauri-plugin-webdriver/src/server/handlers/actions.rs index 0fb1199e2..efa598d74 100644 --- a/packages/tauri-plugin-webdriver/src/server/handlers/actions.rs +++ b/packages/tauri-plugin-webdriver/src/server/handlers/actions.rs @@ -53,6 +53,33 @@ impl ActionSequence { ActionSequence::None { actions, .. } => actions.len(), } } + + /// The duration (ms) this source's action at `tick` contributes to the tick. Pause, pointer-move + /// and wheel-scroll actions carry a duration; everything else is instantaneous (`None`). The tick's + /// duration is the max of these across sources (W3C §17.4.3), not their sum. + fn duration_at(&self, tick: usize) -> Option { + match self { + ActionSequence::Key { actions, .. } => match actions.get(tick) { + Some(KeyAction::Pause { duration }) => *duration, + _ => None, + }, + ActionSequence::Pointer { actions, .. } => match actions.get(tick) { + Some(PointerAction::PointerMove { duration, .. }) + | Some(PointerAction::Pause { duration }) => *duration, + _ => None, + }, + ActionSequence::Wheel { actions, .. } => match actions.get(tick) { + Some(WheelAction::Scroll { duration, .. }) | Some(WheelAction::Pause { duration }) => { + *duration + } + _ => None, + }, + ActionSequence::None { actions, .. } => match actions.get(tick) { + Some(PauseAction::Pause { duration }) => *duration, + _ => None, + }, + } + } } #[derive(Debug, Deserialize)] @@ -173,9 +200,15 @@ pub async fn perform( .map(ActionSequence::action_count) .max() .unwrap_or(0); - // Each source's pause within a tick currently sleeps sequentially (the tick takes their sum); - // W3C §17.4.3 wants the tick to take the max pause across sources. Tracked in #496. for tick in 0..tick_count { + // W3C §17.4.3: a tick lasts the *maximum* pause/move/scroll duration across its sources (not + // the sum), slept once after the tick's actions are dispatched. + let tick_duration_ms = request + .actions + .iter() + .filter_map(|seq| seq.duration_at(tick)) + .max() + .unwrap_or(0); for action_seq in &request.actions { match action_seq { ActionSequence::Key { _id: _, actions } => { @@ -203,11 +236,7 @@ pub async fn perform( session.action_state.pressed_keys.remove(value); } } - KeyAction::Pause { duration } => { - if let Some(ms) = duration { - tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; - } - } + KeyAction::Pause { .. } => {} } } } @@ -275,12 +304,7 @@ pub async fn perform( } } } - PointerAction::PointerMove { - x, - y, - duration, - origin, - } => { + PointerAction::PointerMove { x, y, origin, .. } => { let (target_x, target_y) = match origin { // No origin (the default) or "viewport": x/y are absolute viewport coords. None => (*x, *y), @@ -317,11 +341,6 @@ pub async fn perform( }; pointer_state.x = target_x; pointer_state.y = target_y; - if let Some(ms) = duration { - if *ms > 0 { - tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; - } - } executor .dispatch_pointer_event( PointerEventType::Move, @@ -331,54 +350,35 @@ pub async fn perform( ) .await?; } - PointerAction::Pause { duration } => { - if let Some(ms) = duration { - tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; - } - } + PointerAction::Pause { .. } => {} } } } ActionSequence::Wheel { _id: _, actions } => { if let Some(action) = actions.get(tick) { match action { - WheelAction::Scroll { - x, - y, - delta_x, - delta_y, - duration, - } => { - if let Some(ms) = duration { - if *ms > 0 { - tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; - } - } + WheelAction::Scroll { x, y, delta_x, delta_y, .. } => { executor .dispatch_scroll_event(*x, *y, *delta_x, *delta_y) .await?; } - WheelAction::Pause { duration } => { - if let Some(ms) = duration { - tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; - } - } + WheelAction::Pause { .. } => {} } } } ActionSequence::None { _id: _, actions } => { if let Some(action) = actions.get(tick) { match action { - PauseAction::Pause { duration } => { - if let Some(ms) = duration { - tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; - } - } + PauseAction::Pause { .. } => {} } } } } } + // The tick's actions are dispatched; wait its longest source duration once. + if tick_duration_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(tick_duration_ms)).await; + } } // Persist the final pointer position so a later performActions call with @@ -488,4 +488,24 @@ mod tests { .unwrap_or(0); assert_eq!(ticks, 0); } + + #[test] + fn tick_duration_is_max_across_sources_not_sum() { + // W3C §17.4.3: a tick lasts the max pause/move/scroll duration across sources, not their sum. + let req = parse( + r#"{"actions":[ + {"type":"none","id":"n","actions":[{"type":"pause","duration":100},{"type":"pause","duration":50}]}, + {"type":"pointer","id":"p","actions":[ + {"type":"pointerMove","x":0,"y":0,"duration":200},{"type":"pause","duration":30} + ]}, + {"type":"wheel","id":"w","actions":[{"type":"scroll","x":0,"y":0,"deltaX":0,"deltaY":0,"duration":80}]} + ]}"#, + ); + let tick_ms = + |tick: usize| req.actions.iter().filter_map(|s| s.duration_at(tick)).max().unwrap_or(0); + // tick 0: max(100, 200, 80) = 200 — not the sum (380). + assert_eq!(tick_ms(0), 200); + // tick 1: max(50, 30) = 50 — the wheel source is exhausted and contributes nothing. + assert_eq!(tick_ms(1), 50); + } }