viewpoint_core/page/locator/
actions.rs

1//! Locator actions for element interaction.
2
3use std::time::Duration;
4
5use viewpoint_cdp::protocol::input::{
6    DispatchKeyEventParams, DispatchMouseEventParams, InsertTextParams, MouseButton,
7};
8use viewpoint_cdp::protocol::runtime::EvaluateParams;
9use serde::Deserialize;
10use tracing::{debug, instrument};
11
12use super::selector::js_string_literal;
13use super::Locator;
14use crate::error::LocatorError;
15
16/// Result of querying element information.
17#[derive(Debug, Clone, Deserialize)]
18#[serde(rename_all = "camelCase")]
19#[allow(dead_code)] // Fields are deserialized from JS, may not all be used yet
20struct ElementInfo {
21    /// Whether the element exists.
22    found: bool,
23    /// Number of matching elements.
24    count: usize,
25    /// Whether the element is visible.
26    visible: Option<bool>,
27    /// Whether the element is enabled.
28    enabled: Option<bool>,
29    /// Bounding box of the element.
30    x: Option<f64>,
31    y: Option<f64>,
32    width: Option<f64>,
33    height: Option<f64>,
34    /// Text content of the element.
35    text: Option<String>,
36    /// Element tag name.
37    tag_name: Option<String>,
38}
39
40impl Locator<'_> {
41    /// Click the element.
42    ///
43    /// Waits for the element to be visible and enabled, then clicks its center.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error if:
48    /// - The element is not found within the timeout
49    /// - The element is not visible
50    /// - The CDP command fails
51    ///
52    /// # Panics
53    ///
54    /// Panics if a visible element lacks bounding box coordinates. This should
55    /// never occur as `wait_for_actionable` ensures visibility before returning.
56    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
57    pub async fn click(&self) -> Result<(), LocatorError> {
58        let info = self.wait_for_actionable().await?;
59
60        // These unwraps are safe because wait_for_actionable ensures the element is visible
61        // and visible elements always have valid bounding boxes
62        let x = info.x.expect("visible element has x") + info.width.expect("visible element has width") / 2.0;
63        let y = info.y.expect("visible element has y") + info.height.expect("visible element has height") / 2.0;
64
65        debug!(x, y, "Clicking element");
66
67        // Move to element
68        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_move(x, y))
69            .await?;
70
71        // Mouse down
72        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_down(x, y, MouseButton::Left))
73            .await?;
74
75        // Mouse up
76        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_up(x, y, MouseButton::Left))
77            .await?;
78
79        Ok(())
80    }
81
82    /// Double-click the element.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the element cannot be clicked.
87    ///
88    /// # Panics
89    ///
90    /// Panics if a visible element lacks bounding box coordinates. This should
91    /// never occur as `wait_for_actionable` ensures visibility before returning.
92    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
93    pub async fn dblclick(&self) -> Result<(), LocatorError> {
94        let info = self.wait_for_actionable().await?;
95
96        let x = info.x.expect("visible element has x") + info.width.expect("visible element has width") / 2.0;
97        let y = info.y.expect("visible element has y") + info.height.expect("visible element has height") / 2.0;
98
99        debug!(x, y, "Double-clicking element");
100
101        // First click
102        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_move(x, y))
103            .await?;
104        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_down(x, y, MouseButton::Left))
105            .await?;
106        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_up(x, y, MouseButton::Left))
107            .await?;
108
109        // Second click
110        let mut down = DispatchMouseEventParams::mouse_down(x, y, MouseButton::Left);
111        down.click_count = Some(2);
112        self.dispatch_mouse_event(down).await?;
113
114        let mut up = DispatchMouseEventParams::mouse_up(x, y, MouseButton::Left);
115        up.click_count = Some(2);
116        self.dispatch_mouse_event(up).await?;
117
118        Ok(())
119    }
120
121    /// Fill the element with text (clears existing content first).
122    ///
123    /// This is for input and textarea elements.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the element cannot be focused or text cannot be inserted.
128    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
129    pub async fn fill(&self, text: &str) -> Result<(), LocatorError> {
130        let _info = self.wait_for_actionable().await?;
131
132        debug!(text, "Filling element");
133
134        // Focus the element
135        self.focus_element().await?;
136
137        // Select all and delete (clear)
138        self.dispatch_key_event(DispatchKeyEventParams::key_down("a"))
139            .await?;
140        // Send Ctrl+A
141        let mut select_all = DispatchKeyEventParams::key_down("a");
142        select_all.modifiers = Some(viewpoint_cdp::protocol::input::modifiers::CTRL);
143        self.dispatch_key_event(select_all).await?;
144
145        // Delete selected text
146        self.dispatch_key_event(DispatchKeyEventParams::key_down("Backspace"))
147            .await?;
148
149        // Insert the new text
150        self.insert_text(text).await?;
151
152        Ok(())
153    }
154
155    /// Type text character by character.
156    ///
157    /// Unlike `fill`, this types each character with keydown/keyup events.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the element cannot be focused or keys cannot be dispatched.
162    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
163    pub async fn type_text(&self, text: &str) -> Result<(), LocatorError> {
164        self.wait_for_actionable().await?;
165
166        debug!(text, "Typing text");
167
168        // Focus the element
169        self.focus_element().await?;
170
171        // Type each character
172        for ch in text.chars() {
173            let char_str = ch.to_string();
174            self.dispatch_key_event(DispatchKeyEventParams::char(&char_str))
175                .await?;
176        }
177
178        Ok(())
179    }
180
181    /// Press a key or key combination.
182    ///
183    /// Examples: "Enter", "Backspace", "Control+a", "Shift+Tab"
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the element cannot be focused or the key cannot be pressed.
188    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
189    pub async fn press(&self, key: &str) -> Result<(), LocatorError> {
190        self.wait_for_actionable().await?;
191
192        debug!(key, "Pressing key");
193
194        // Focus the element
195        self.focus_element().await?;
196
197        // Parse modifiers and key
198        let parts: Vec<&str> = key.split('+').collect();
199        let actual_key = parts.last().unwrap_or(&key);
200
201        let mut modifiers = 0;
202        for part in &parts[..parts.len().saturating_sub(1)] {
203            match part.to_lowercase().as_str() {
204                "control" | "ctrl" => {
205                    modifiers |= viewpoint_cdp::protocol::input::modifiers::CTRL;
206                }
207                "alt" => modifiers |= viewpoint_cdp::protocol::input::modifiers::ALT,
208                "shift" => modifiers |= viewpoint_cdp::protocol::input::modifiers::SHIFT,
209                "meta" | "cmd" => modifiers |= viewpoint_cdp::protocol::input::modifiers::META,
210                _ => {}
211            }
212        }
213
214        // Key down
215        let mut key_down = DispatchKeyEventParams::key_down(actual_key);
216        if modifiers != 0 {
217            key_down.modifiers = Some(modifiers);
218        }
219        self.dispatch_key_event(key_down).await?;
220
221        // Key up
222        let mut key_up = DispatchKeyEventParams::key_up(actual_key);
223        if modifiers != 0 {
224            key_up.modifiers = Some(modifiers);
225        }
226        self.dispatch_key_event(key_up).await?;
227
228        Ok(())
229    }
230
231    /// Hover over the element.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the element cannot be found or the mouse event fails.
236    ///
237    /// # Panics
238    ///
239    /// Panics if a visible element lacks bounding box coordinates. This should
240    /// never occur as `wait_for_actionable` ensures visibility before returning.
241    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
242    pub async fn hover(&self) -> Result<(), LocatorError> {
243        let info = self.wait_for_actionable().await?;
244
245        let x = info.x.expect("visible element has x") + info.width.expect("visible element has width") / 2.0;
246        let y = info.y.expect("visible element has y") + info.height.expect("visible element has height") / 2.0;
247
248        debug!(x, y, "Hovering over element");
249
250        self.dispatch_mouse_event(DispatchMouseEventParams::mouse_move(x, y))
251            .await?;
252
253        Ok(())
254    }
255
256    /// Focus the element.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the element cannot be found or focused.
261    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
262    pub async fn focus(&self) -> Result<(), LocatorError> {
263        self.wait_for_actionable().await?;
264
265        debug!("Focusing element");
266        self.focus_element().await?;
267
268        Ok(())
269    }
270
271    /// Clear the element's content.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if the element cannot be cleared.
276    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
277    pub async fn clear(&self) -> Result<(), LocatorError> {
278        self.wait_for_actionable().await?;
279
280        debug!("Clearing element");
281
282        // Focus and select all, then delete
283        self.focus_element().await?;
284
285        let mut select_all = DispatchKeyEventParams::key_down("a");
286        select_all.modifiers = Some(viewpoint_cdp::protocol::input::modifiers::CTRL);
287        self.dispatch_key_event(select_all).await?;
288
289        self.dispatch_key_event(DispatchKeyEventParams::key_down("Backspace"))
290            .await?;
291
292        Ok(())
293    }
294
295    /// Check a checkbox or radio button.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if the element cannot be checked.
300    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
301    pub async fn check(&self) -> Result<(), LocatorError> {
302        let is_checked = self.is_checked().await?;
303
304        if is_checked {
305            debug!("Element already checked");
306        } else {
307            debug!("Checking element");
308            self.click().await?;
309        }
310
311        Ok(())
312    }
313
314    /// Uncheck a checkbox.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the element cannot be unchecked.
319    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
320    pub async fn uncheck(&self) -> Result<(), LocatorError> {
321        let is_checked = self.is_checked().await?;
322
323        if is_checked {
324            debug!("Unchecking element");
325            self.click().await?;
326        } else {
327            debug!("Element already unchecked");
328        }
329
330        Ok(())
331    }
332
333    /// Select an option in a `<select>` element by value, label, or index.
334    ///
335    /// # Arguments
336    ///
337    /// * `option` - The option to select. Can be:
338    ///   - A string value matching the option's `value` attribute
339    ///   - A string matching the option's visible text
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if the element is not a select or the option is not found.
344    ///
345    /// # Example
346    ///
347    /// ```ignore
348    /// // Select by value
349    /// page.locator("select#size").select_option("medium").await?;
350    ///
351    /// // Select by visible text
352    /// page.locator("select#size").select_option("Medium Size").await?;
353    /// ```
354    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
355    pub async fn select_option(&self, option: &str) -> Result<(), LocatorError> {
356        self.wait_for_actionable().await?;
357
358        debug!(option, "Selecting option");
359
360        // JavaScript to select option by value or text
361        let js = format!(
362            r"(function() {{
363                const elements = {selector};
364                if (elements.length === 0) return {{ success: false, error: 'Element not found' }};
365                
366                const select = elements[0];
367                if (select.tagName.toLowerCase() !== 'select') {{
368                    return {{ success: false, error: 'Element is not a select' }};
369                }}
370                
371                const optionValue = {option};
372                
373                // Try to find by value first
374                for (let i = 0; i < select.options.length; i++) {{
375                    if (select.options[i].value === optionValue) {{
376                        select.selectedIndex = i;
377                        select.dispatchEvent(new Event('change', {{ bubbles: true }}));
378                        return {{ success: true, selectedIndex: i, selectedValue: select.options[i].value }};
379                    }}
380                }}
381                
382                // Try to find by text content
383                for (let i = 0; i < select.options.length; i++) {{
384                    if (select.options[i].text === optionValue || 
385                        select.options[i].textContent.trim() === optionValue) {{
386                        select.selectedIndex = i;
387                        select.dispatchEvent(new Event('change', {{ bubbles: true }}));
388                        return {{ success: true, selectedIndex: i, selectedValue: select.options[i].value }};
389                    }}
390                }}
391                
392                return {{ success: false, error: 'Option not found: ' + optionValue }};
393            }})()",
394            selector = self.selector.to_js_expression(),
395            option = js_string_literal(option)
396        );
397
398        let result = self.evaluate_js(&js).await?;
399
400        let success = result
401            .get("success")
402            .and_then(serde_json::Value::as_bool)
403            .unwrap_or(false);
404        if !success {
405            let error = result
406                .get("error")
407                .and_then(|v| v.as_str())
408                .unwrap_or("Unknown error");
409            return Err(LocatorError::EvaluationError(error.to_string()));
410        }
411
412        Ok(())
413    }
414
415    /// Select multiple options in a `<select multiple>` element.
416    ///
417    /// # Arguments
418    ///
419    /// * `options` - A slice of option values or labels to select.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if the element is not a multi-select or options are not found.
424    #[instrument(level = "debug", skip(self, options), fields(selector = ?self.selector))]
425    pub async fn select_options(&self, options: &[&str]) -> Result<(), LocatorError> {
426        self.wait_for_actionable().await?;
427
428        debug!(?options, "Selecting multiple options");
429
430        // Build JavaScript array of options
431        let options_js: Vec<String> = options.iter().map(|o| js_string_literal(o)).collect();
432        let options_array = format!("[{}]", options_js.join(", "));
433
434        let js = format!(
435            r"(function() {{
436                const elements = {selector};
437                if (elements.length === 0) return {{ success: false, error: 'Element not found' }};
438                
439                const select = elements[0];
440                if (select.tagName.toLowerCase() !== 'select') {{
441                    return {{ success: false, error: 'Element is not a select' }};
442                }}
443                
444                const optionValues = {options_array};
445                const selectedIndices = [];
446                
447                // Clear current selection if not multiple
448                if (!select.multiple) {{
449                    return {{ success: false, error: 'select_options requires a <select multiple>' }};
450                }}
451                
452                // Deselect all first
453                for (let i = 0; i < select.options.length; i++) {{
454                    select.options[i].selected = false;
455                }}
456                
457                // Select each requested option
458                for (const optionValue of optionValues) {{
459                    let found = false;
460                    
461                    // Try to find by value
462                    for (let i = 0; i < select.options.length; i++) {{
463                        if (select.options[i].value === optionValue) {{
464                            select.options[i].selected = true;
465                            selectedIndices.push(i);
466                            found = true;
467                            break;
468                        }}
469                    }}
470                    
471                    // Try to find by text if not found by value
472                    if (!found) {{
473                        for (let i = 0; i < select.options.length; i++) {{
474                            if (select.options[i].text === optionValue || 
475                                select.options[i].textContent.trim() === optionValue) {{
476                                select.options[i].selected = true;
477                                selectedIndices.push(i);
478                                found = true;
479                                break;
480                            }}
481                        }}
482                    }}
483                    
484                    if (!found) {{
485                        return {{ success: false, error: 'Option not found: ' + optionValue }};
486                    }}
487                }}
488                
489                select.dispatchEvent(new Event('change', {{ bubbles: true }}));
490                return {{ success: true, selectedIndices: selectedIndices }};
491            }})()",
492            selector = self.selector.to_js_expression(),
493            options_array = options_array
494        );
495
496        let result = self.evaluate_js(&js).await?;
497
498        let success = result
499            .get("success")
500            .and_then(serde_json::Value::as_bool)
501            .unwrap_or(false);
502        if !success {
503            let error = result
504                .get("error")
505                .and_then(|v| v.as_str())
506                .unwrap_or("Unknown error");
507            return Err(LocatorError::EvaluationError(error.to_string()));
508        }
509
510        Ok(())
511    }
512
513    /// Get the text content of the element.
514    ///
515    /// # Errors
516    ///
517    /// Returns an error if the element cannot be queried.
518    pub async fn text_content(&self) -> Result<Option<String>, LocatorError> {
519        let info = self.query_element_info().await?;
520        Ok(info.text)
521    }
522
523    /// Check if the element is visible.
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if the element cannot be queried.
528    pub async fn is_visible(&self) -> Result<bool, LocatorError> {
529        let info = self.query_element_info().await?;
530        Ok(info.visible.unwrap_or(false))
531    }
532
533    /// Check if the element is checked (for checkboxes/radios).
534    ///
535    /// # Errors
536    ///
537    /// Returns an error if the element cannot be queried.
538    pub async fn is_checked(&self) -> Result<bool, LocatorError> {
539        let js = format!(
540            r"(function() {{
541                const elements = {};
542                if (elements.length === 0) return {{ found: false, checked: false }};
543                const el = elements[0];
544                return {{ found: true, checked: el.checked || false }};
545            }})()",
546            self.selector.to_js_expression()
547        );
548
549        let result = self.evaluate_js(&js).await?;
550        let checked: bool = result
551            .get("checked")
552            .and_then(serde_json::Value::as_bool)
553            .unwrap_or(false);
554        Ok(checked)
555    }
556
557    /// Count matching elements.
558    ///
559    /// # Errors
560    ///
561    /// Returns an error if the elements cannot be queried.
562    pub async fn count(&self) -> Result<usize, LocatorError> {
563        let info = self.query_element_info().await?;
564        Ok(info.count)
565    }
566
567    // =========================================================================
568    // Internal helpers
569    // =========================================================================
570
571    /// Wait for element to be actionable (visible, enabled, stable).
572    async fn wait_for_actionable(&self) -> Result<ElementInfo, LocatorError> {
573        let start = std::time::Instant::now();
574        let timeout = self.options.timeout;
575
576        loop {
577            let info = self.query_element_info().await?;
578
579            if !info.found {
580                if start.elapsed() >= timeout {
581                    return Err(LocatorError::NotFound(format!("{:?}", self.selector)));
582                }
583                tokio::time::sleep(Duration::from_millis(100)).await;
584                continue;
585            }
586
587            if !info.visible.unwrap_or(false) {
588                if start.elapsed() >= timeout {
589                    return Err(LocatorError::NotVisible);
590                }
591                tokio::time::sleep(Duration::from_millis(100)).await;
592                continue;
593            }
594
595            // Element is visible, return it
596            return Ok(info);
597        }
598    }
599
600    /// Query element information via JavaScript.
601    async fn query_element_info(&self) -> Result<ElementInfo, LocatorError> {
602        let js = format!(
603            r"(function() {{
604                const elements = Array.from({});
605                if (elements.length === 0) {{
606                    return {{ found: false, count: 0 }};
607                }}
608                const el = elements[0];
609                const rect = el.getBoundingClientRect();
610                const style = window.getComputedStyle(el);
611                const visible = rect.width > 0 && rect.height > 0 && 
612                    style.visibility !== 'hidden' && 
613                    style.display !== 'none' &&
614                    parseFloat(style.opacity) > 0;
615                return {{
616                    found: true,
617                    count: elements.length,
618                    visible: visible,
619                    enabled: !el.disabled,
620                    x: rect.x,
621                    y: rect.y,
622                    width: rect.width,
623                    height: rect.height,
624                    text: el.textContent,
625                    tagName: el.tagName.toLowerCase()
626                }};
627            }})()",
628            self.selector.to_js_expression()
629        );
630
631        let result = self.evaluate_js(&js).await?;
632        let info: ElementInfo = serde_json::from_value(result)
633            .map_err(|e| LocatorError::EvaluationError(e.to_string()))?;
634        Ok(info)
635    }
636
637    /// Focus the element via JavaScript.
638    async fn focus_element(&self) -> Result<(), LocatorError> {
639        let js = format!(
640            r"(function() {{
641                const elements = {};
642                if (elements.length > 0) {{
643                    elements[0].focus();
644                    return true;
645                }}
646                return false;
647            }})()",
648            self.selector.to_js_expression()
649        );
650
651        self.evaluate_js(&js).await?;
652        Ok(())
653    }
654
655    /// Evaluate JavaScript and return the result.
656    async fn evaluate_js(&self, expression: &str) -> Result<serde_json::Value, LocatorError> {
657        if self.page.is_closed() {
658            return Err(LocatorError::PageClosed);
659        }
660
661        let params = EvaluateParams {
662            expression: expression.to_string(),
663            object_group: None,
664            include_command_line_api: None,
665            silent: Some(true),
666            context_id: None,
667            return_by_value: Some(true),
668            await_promise: Some(false),
669        };
670
671        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
672            .page
673            .connection()
674            .send_command("Runtime.evaluate", Some(params), Some(self.page.session_id()))
675            .await?;
676
677        if let Some(exception) = result.exception_details {
678            return Err(LocatorError::EvaluationError(exception.text));
679        }
680
681        result
682            .result
683            .value
684            .ok_or_else(|| LocatorError::EvaluationError("No result value".to_string()))
685    }
686
687    /// Dispatch a mouse event.
688    async fn dispatch_mouse_event(
689        &self,
690        params: DispatchMouseEventParams,
691    ) -> Result<(), LocatorError> {
692        self.page
693            .connection()
694            .send_command::<_, serde_json::Value>(
695                "Input.dispatchMouseEvent",
696                Some(params),
697                Some(self.page.session_id()),
698            )
699            .await?;
700        Ok(())
701    }
702
703    /// Dispatch a key event.
704    async fn dispatch_key_event(
705        &self,
706        params: DispatchKeyEventParams,
707    ) -> Result<(), LocatorError> {
708        self.page
709            .connection()
710            .send_command::<_, serde_json::Value>(
711                "Input.dispatchKeyEvent",
712                Some(params),
713                Some(self.page.session_id()),
714            )
715            .await?;
716        Ok(())
717    }
718
719    /// Insert text directly.
720    async fn insert_text(&self, text: &str) -> Result<(), LocatorError> {
721        self.page
722            .connection()
723            .send_command::<_, serde_json::Value>(
724                "Input.insertText",
725                Some(InsertTextParams {
726                    text: text.to_string(),
727                }),
728                Some(self.page.session_id()),
729            )
730            .await?;
731        Ok(())
732    }
733}