browser_commander/interactions/
click.rs1use crate::core::constants::TIMING;
7use crate::core::engine::{ClickVerificationResult, EngineAdapter, EngineError, PreClickState};
8use crate::core::navigation::is_navigation_error;
9use crate::interactions::scroll::{scroll_into_view_if_needed, ScrollBehavior, ScrollOptions};
10use std::time::Duration;
11
12#[derive(Debug, Clone)]
14pub struct ClickOptions {
15 pub scroll_into_view: bool,
17 pub scroll_behavior: ScrollBehavior,
19 pub wait_after_scroll: Duration,
21 pub wait_after_click: Duration,
23 pub verify: bool,
25 pub timeout: Duration,
27}
28
29impl Default for ClickOptions {
30 fn default() -> Self {
31 Self {
32 scroll_into_view: true,
33 scroll_behavior: ScrollBehavior::Smooth,
34 wait_after_scroll: TIMING.default_wait_after_scroll,
35 wait_after_click: Duration::from_millis(1000),
36 verify: true,
37 timeout: TIMING.default_timeout,
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct ClickResult {
45 pub clicked: bool,
47 pub verified: bool,
49 pub navigated: bool,
51 pub reason: String,
53}
54
55impl ClickResult {
56 pub fn success(reason: impl Into<String>) -> Self {
58 Self {
59 clicked: true,
60 verified: true,
61 navigated: false,
62 reason: reason.into(),
63 }
64 }
65
66 pub fn navigation(reason: impl Into<String>) -> Self {
68 Self {
69 clicked: false,
70 verified: true,
71 navigated: true,
72 reason: reason.into(),
73 }
74 }
75
76 pub fn failed(reason: impl Into<String>) -> Self {
78 Self {
79 clicked: false,
80 verified: false,
81 navigated: false,
82 reason: reason.into(),
83 }
84 }
85}
86
87pub async fn capture_pre_click_state(
98 adapter: &dyn EngineAdapter,
99 selector: &str,
100) -> Result<PreClickState, EngineError> {
101 let script = format!(
103 r#"
104 (function() {{
105 const el = document.querySelector('{}');
106 if (!el) return null;
107 return {{
108 disabled: el.disabled || false,
109 ariaPressed: el.getAttribute('aria-pressed'),
110 ariaExpanded: el.getAttribute('aria-expanded'),
111 ariaSelected: el.getAttribute('aria-selected'),
112 checked: el.checked || false,
113 className: el.className || '',
114 isConnected: el.isConnected
115 }};
116 }})()
117 "#,
118 selector.replace('\'', "\\'")
119 );
120
121 let result = adapter.evaluate(&script).await?;
122
123 if result.is_null() {
124 return Ok(PreClickState::default());
125 }
126
127 Ok(PreClickState {
128 disabled: result.get("disabled").and_then(|v| v.as_bool()),
129 aria_pressed: result
130 .get("ariaPressed")
131 .and_then(|v| v.as_str())
132 .map(String::from),
133 aria_expanded: result
134 .get("ariaExpanded")
135 .and_then(|v| v.as_str())
136 .map(String::from),
137 aria_selected: result
138 .get("ariaSelected")
139 .and_then(|v| v.as_str())
140 .map(String::from),
141 checked: result.get("checked").and_then(|v| v.as_bool()),
142 class_name: result
143 .get("className")
144 .and_then(|v| v.as_str())
145 .map(String::from),
146 is_connected: result
147 .get("isConnected")
148 .and_then(|v| v.as_bool())
149 .unwrap_or(false),
150 })
151}
152
153pub async fn verify_click(
165 adapter: &dyn EngineAdapter,
166 selector: &str,
167 pre_click_state: &PreClickState,
168) -> Result<ClickVerificationResult, EngineError> {
169 let post_click_state = match capture_pre_click_state(adapter, selector).await {
170 Ok(state) => state,
171 Err(e) if is_navigation_error(&e.to_string()) => {
172 return Ok(ClickVerificationResult {
173 verified: true,
174 reason: "navigation detected (expected for navigation clicks)".to_string(),
175 navigation_error: true,
176 });
177 }
178 Err(e) => return Err(e),
179 };
180
181 if pre_click_state.aria_pressed != post_click_state.aria_pressed {
183 return Ok(ClickVerificationResult {
184 verified: true,
185 reason: "aria-pressed changed".to_string(),
186 navigation_error: false,
187 });
188 }
189
190 if pre_click_state.aria_expanded != post_click_state.aria_expanded {
191 return Ok(ClickVerificationResult {
192 verified: true,
193 reason: "aria-expanded changed".to_string(),
194 navigation_error: false,
195 });
196 }
197
198 if pre_click_state.aria_selected != post_click_state.aria_selected {
199 return Ok(ClickVerificationResult {
200 verified: true,
201 reason: "aria-selected changed".to_string(),
202 navigation_error: false,
203 });
204 }
205
206 if pre_click_state.checked != post_click_state.checked {
207 return Ok(ClickVerificationResult {
208 verified: true,
209 reason: "checked state changed".to_string(),
210 navigation_error: false,
211 });
212 }
213
214 if pre_click_state.class_name != post_click_state.class_name {
215 return Ok(ClickVerificationResult {
216 verified: true,
217 reason: "className changed".to_string(),
218 navigation_error: false,
219 });
220 }
221
222 if post_click_state.is_connected {
224 return Ok(ClickVerificationResult {
225 verified: true,
226 reason: "element still connected (assumed success)".to_string(),
227 navigation_error: false,
228 });
229 }
230
231 Ok(ClickVerificationResult {
233 verified: true,
234 reason: "element removed from DOM (UI updated)".to_string(),
235 navigation_error: false,
236 })
237}
238
239pub async fn click_element(
251 adapter: &dyn EngineAdapter,
252 selector: &str,
253 options: &ClickOptions,
254) -> Result<ClickResult, EngineError> {
255 let pre_click_state = if options.verify {
257 capture_pre_click_state(adapter, selector).await?
258 } else {
259 PreClickState::default()
260 };
261
262 match adapter.click(selector).await {
264 Ok(_) => {}
265 Err(e) if is_navigation_error(&e.to_string()) => {
266 return Ok(ClickResult::navigation("navigation during click"));
267 }
268 Err(e) => return Err(e),
269 }
270
271 if options.verify {
273 let verification = verify_click(adapter, selector, &pre_click_state).await?;
274 Ok(ClickResult {
275 clicked: true,
276 verified: verification.verified,
277 navigated: verification.navigation_error,
278 reason: verification.reason,
279 })
280 } else {
281 Ok(ClickResult::success("click completed"))
282 }
283}
284
285pub async fn click_button(
303 adapter: &dyn EngineAdapter,
304 selector: &str,
305 options: &ClickOptions,
306) -> Result<ClickResult, EngineError> {
307 if options.scroll_into_view {
309 let scroll_options = ScrollOptions {
310 behavior: options.scroll_behavior,
311 wait_after_scroll: options.wait_after_scroll,
312 ..Default::default()
313 };
314
315 match scroll_into_view_if_needed(adapter, selector, &scroll_options).await {
316 Ok(_) => {}
317 Err(e) if is_navigation_error(&e.to_string()) => {
318 return Ok(ClickResult::navigation("navigation during scroll"));
319 }
320 Err(e) => return Err(e),
321 }
322 }
323
324 let result = click_element(adapter, selector, options).await?;
326
327 if result.clicked {
329 tokio::time::sleep(options.wait_after_click).await;
330 }
331
332 Ok(result)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn click_options_default() {
341 let options = ClickOptions::default();
342 assert!(options.scroll_into_view);
343 assert_eq!(options.scroll_behavior, ScrollBehavior::Smooth);
344 assert!(options.verify);
345 }
346
347 #[test]
348 fn click_result_success() {
349 let result = ClickResult::success("element clicked");
350 assert!(result.clicked);
351 assert!(result.verified);
352 assert!(!result.navigated);
353 assert_eq!(result.reason, "element clicked");
354 }
355
356 #[test]
357 fn click_result_navigation() {
358 let result = ClickResult::navigation("page navigated");
359 assert!(!result.clicked);
360 assert!(result.verified);
361 assert!(result.navigated);
362 }
363
364 #[test]
365 fn click_result_failed() {
366 let result = ClickResult::failed("element not found");
367 assert!(!result.clicked);
368 assert!(!result.verified);
369 assert!(!result.navigated);
370 }
371
372 #[test]
373 fn pre_click_state_default() {
374 let state = PreClickState::default();
375 assert!(state.disabled.is_none());
376 assert!(state.aria_pressed.is_none());
377 assert!(!state.is_connected);
378 }
379}