cdp_core/page/
element.rs

1use super::page_core::Page;
2use crate::error::{CdpError, Result};
3use crate::input::mouse::{DoubleClickOptions, MouseClickOptions, MousePosition, PressHoldOptions};
4use cdp_protocol::input::MouseButton;
5use cdp_protocol::{dom, page as page_cdp, runtime};
6use futures_util::StreamExt;
7use serde_json::Value;
8use std::{
9    future::Future,
10    path::{Path, PathBuf},
11    sync::Arc,
12    time::Duration,
13};
14use tokio::{fs, fs::File, io::AsyncWriteExt, time::timeout};
15
16/// Defines the bounding box strategy used when capturing element screenshots.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ScreenshotBoxType {
19    /// Content box (excludes padding).
20    Content,
21    /// Padding box (includes padding but excludes border).
22    Padding,
23    /// Border box (includes border; ideal for rounded elements).
24    #[default]
25    Border,
26    /// Margin box (includes the element margin).
27    Margin,
28}
29
30const FILE_CHOOSER_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
31
32#[derive(Clone)] // So we can easily pass it around
33pub struct ElementHandle {
34    /// A stable identifier for the DOM node in the browser's backend.
35    /// This ID is persistent and doesn't change even if the DOM is modified.
36    pub(crate) backend_node_id: u32,
37
38    /// A temporary identifier for the DOM node.
39    /// This ID is more accurate for certain operations but may become invalid after DOM changes.
40    /// If None, commands will fall back to using backend_node_id.
41    pub(crate) node_id: Option<u32>,
42
43    // A shared reference to the page this element belongs to.
44    // This is how we send commands related to this element.
45    pub(crate) page: Arc<Page>,
46}
47
48impl ElementHandle {
49    async fn set_file_chooser_intercept(page: &Arc<Page>, enabled: bool) -> Result<()> {
50        page.session
51            .send_command::<_, page_cdp::SetInterceptFileChooserDialogReturnObject>(
52                page_cdp::SetInterceptFileChooserDialog {
53                    enabled,
54                    cancel: None,
55                },
56                None,
57            )
58            .await?;
59        Ok(())
60    }
61
62    /// Computes the center point used for mouse interactions, ensuring the
63    /// element is scrolled into view first.
64    async fn interaction_point(&self) -> Result<(f64, f64)> {
65        let scroll_params = dom::ScrollIntoViewIfNeeded {
66            node_id: self.node_id,
67            backend_node_id: if self.node_id.is_none() {
68                Some(self.backend_node_id)
69            } else {
70                None
71            },
72            object_id: None,
73            rect: None,
74        };
75        self.page
76            .session
77            .send_command::<_, dom::ScrollIntoViewIfNeededReturnObject>(scroll_params, None)
78            .await?;
79
80        // Use `getBoundingClientRect` to determine the on-screen centroid.
81        let resolve_params = dom::ResolveNode {
82            backend_node_id: Some(self.backend_node_id),
83            node_id: None,
84            object_group: Some("element-helper".to_string()),
85            execution_context_id: None,
86        };
87
88        let resolve_result: dom::ResolveNodeReturnObject =
89            self.page.session.send_command(resolve_params, None).await?;
90
91        let object_id = resolve_result
92            .object
93            .object_id
94            .ok_or_else(|| CdpError::element("Object ID is unavailable for element".to_string()))?;
95
96        let params = runtime::CallFunctionOn {
97            function_declaration:
98                "function() { const rect = this.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }"
99                    .to_string(),
100            object_id: Some(object_id),
101            arguments: None,
102            silent: None,
103            return_by_value: Some(true),
104            generate_preview: None,
105            user_gesture: None,
106            await_promise: None,
107            execution_context_id: None,
108            object_group: None,
109            throw_on_side_effect: None,
110            unique_context_id: None,
111            serialization_options: None,
112        };
113
114        let result: runtime::CallFunctionOnReturnObject =
115            self.page.session.send_command(params, None).await?;
116
117        if let Some(details) = result.exception_details.as_ref() {
118            return Err(CdpError::element(format!(
119                "Failed to compute interaction point: {:?}",
120                details
121            )));
122        }
123
124        let value = result
125            .result
126            .value
127            .ok_or_else(|| CdpError::element("No point returned for element".to_string()))?;
128
129        let center_x = value
130            .get("x")
131            .and_then(|v| v.as_f64())
132            .ok_or_else(|| CdpError::element("Invalid X coordinate for element".to_string()))?;
133        let center_y = value
134            .get("y")
135            .and_then(|v| v.as_f64())
136            .ok_or_else(|| CdpError::element("Invalid Y coordinate for element".to_string()))?;
137
138        Ok((center_x, center_y))
139    }
140
141    /// Clicks the element (left button).
142    pub async fn click(&self) -> Result<()> {
143        self.left_click().await
144    }
145
146    /// Left-clicks the element.
147    pub async fn left_click(&self) -> Result<()> {
148        let (x, y) = self.interaction_point().await?;
149        self.page.mouse().left_click(x, y).await
150    }
151
152    /// Right-clicks the element.
153    pub async fn right_click(&self) -> Result<()> {
154        let (x, y) = self.interaction_point().await?;
155        let options = MouseClickOptions {
156            button: MouseButton::Right,
157            ..Default::default()
158        };
159        self.page.mouse().click(x, y, options).await
160    }
161
162    /// Moves the mouse to the element center and returns the resulting pointer
163    /// position.
164    pub async fn hover(&self) -> Result<MousePosition> {
165        let (x, y) = self.interaction_point().await?;
166        self.page.mouse().move_to(x, y).await
167    }
168
169    /// Semantic alias for [`Self::hover`].
170    pub async fn move_mouse_to(&self) -> Result<MousePosition> {
171        self.hover().await
172    }
173
174    /// Double-clicks the element.
175    pub async fn double_click(&self) -> Result<()> {
176        let (x, y) = self.interaction_point().await?;
177        self.page
178            .mouse()
179            .double_click(x, y, DoubleClickOptions::default())
180            .await
181    }
182
183    /// Presses and holds the element for the provided duration using the left
184    /// mouse button.
185    pub async fn press_and_hold(&self, duration: Duration) -> Result<()> {
186        let (x, y) = self.interaction_point().await?;
187        self.page
188            .mouse()
189            .press_and_hold(x, y, MouseButton::Left, duration)
190            .await
191    }
192
193    /// Presses and holds the element until the provided condition returns
194    /// `true` or the configured timeout elapses.
195    pub async fn press_and_hold_until<F, Fut>(
196        &self,
197        options: PressHoldOptions,
198        condition: F,
199    ) -> Result<bool>
200    where
201        F: FnMut() -> Fut + Send,
202        Fut: Future<Output = Result<bool>> + Send,
203    {
204        let (x, y) = self.interaction_point().await?;
205        self.page
206            .mouse()
207            .press_and_hold_until(x, y, options, condition)
208            .await
209    }
210
211    /// Gets the text content of the element.
212    pub async fn text_content(&self) -> Result<String> {
213        // Step 1: resolve the backend node into an object reference.
214        let resolve_params = dom::ResolveNode {
215            backend_node_id: Some(self.backend_node_id),
216            node_id: None,
217            object_group: Some("element-helper".to_string()),
218            execution_context_id: None,
219        };
220
221        let resolve_result: dom::ResolveNodeReturnObject =
222            self.page.session.send_command(resolve_params, None).await?;
223
224        let object_id = resolve_result
225            .object
226            .object_id
227            .ok_or_else(|| CdpError::element("Object ID is unavailable for element".to_string()))?;
228
229        // Step 2: call into the runtime object to read textContent.
230        // Note: when objectId is provided executionContextId must stay `None`.
231        let params = runtime::CallFunctionOn {
232            function_declaration: "function() { return this.textContent; }".to_string(),
233            object_id: Some(object_id),
234            arguments: None,
235            silent: None,
236            return_by_value: Some(true),
237            generate_preview: None,
238            user_gesture: None,
239            await_promise: None,
240            execution_context_id: None, // Must remain None when using objectId
241            object_group: None,
242            throw_on_side_effect: None,
243            unique_context_id: None,
244            serialization_options: None,
245        };
246
247        let result: runtime::CallFunctionOnReturnObject =
248            self.page.session.send_command(params, None).await?;
249
250        // Step 3: propagate runtime exceptions explicitly.
251        if let Some(details) = result.exception_details.as_ref() {
252            return Err(CdpError::element(format!(
253                "Failed to get textContent: {:?}",
254                details
255            )));
256        }
257
258        // Step 4: normalize the runtime value into a String.
259        match result.result.value {
260            Some(Value::String(s)) => Ok(s),
261            Some(Value::Null) => Ok("".to_string()),
262            Some(v) => Ok(v.to_string()),
263            None => Ok("".to_string()),
264        }
265    }
266
267    /// Inserts the entire text payload into the element in a single CDP call.
268    ///
269    /// The element is focused before invoking `Input.insertText`, which means
270    /// the input behaves like a fast paste action instead of character-by-character typing.
271    ///
272    /// # Parameters
273    /// * `text` - The text to insert.
274    ///
275    /// # Examples
276    /// ```no_run
277    /// # use cdp_core::Page;
278    /// # use std::sync::Arc;
279    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
280    /// if let Some(input) = page.query_selector("input[type='text']").await? {
281    ///     input.type_text("Hello, World!").await?;
282    /// }
283    /// # Ok(())
284    /// # }
285    /// ```
286    pub async fn type_text(&self, text: &str) -> Result<()> {
287        // Focus the element before issuing the CDP command.
288        self.click().await?;
289
290        // Delegate to the page-level helper.
291        self.page.type_text(text).await
292    }
293
294    /// Opens the file chooser dialog and populates it with the provided paths.
295    pub async fn upload_files<P>(&self, files: impl IntoIterator<Item = P>) -> Result<()>
296    where
297        P: AsRef<Path>,
298    {
299        let mut resolved: Vec<PathBuf> = Vec::new();
300        for path in files.into_iter() {
301            let original = PathBuf::from(path.as_ref());
302            let canonical = fs::canonicalize(&original).await.map_err(|err| {
303                CdpError::element(format!(
304                    "Failed to resolve file path '{}': {err}",
305                    original.display()
306                ))
307            })?;
308            let metadata = fs::metadata(&canonical).await.map_err(|err| {
309                CdpError::element(format!(
310                    "Failed to inspect file '{}': {err}",
311                    canonical.display()
312                ))
313            })?;
314            if !metadata.is_file() {
315                return Err(CdpError::element(format!(
316                    "Path '{}' is not a regular file",
317                    canonical.display()
318                )));
319            }
320            resolved.push(canonical);
321        }
322
323        if resolved.is_empty() {
324            return Err(CdpError::element(
325                "upload_files requires at least one file path".to_string(),
326            ));
327        }
328
329        let _chooser_guard = self.page.file_chooser_lock.lock().await;
330
331        Self::set_file_chooser_intercept(&self.page, true).await?;
332
333        let upload_result: Result<()> = async {
334            let mut events = self.page.on::<page_cdp::events::FileChooserOpenedEvent>();
335
336            self.click().await?;
337
338            let chooser_event = timeout(FILE_CHOOSER_WAIT_TIMEOUT, events.next())
339                .await
340                .map_err(|_| {
341                    CdpError::element(
342                        "Timed out waiting for file chooser dialog to open".to_string(),
343                    )
344                })?
345                .ok_or_else(|| {
346                    CdpError::element(
347                        "File chooser event stream ended before a dialog was received".to_string(),
348                    )
349                })?;
350
351            if matches!(
352                chooser_event.params.mode,
353                page_cdp::FileChooserOpenedModeOption::SelectSingle
354            ) && resolved.len() > 1
355            {
356                return Err(CdpError::element(
357                    "File chooser allows selecting only one file but multiple paths were provided"
358                        .to_string(),
359                ));
360            }
361
362            let backend_node_id = chooser_event.params.backend_node_id.ok_or_else(|| {
363                CdpError::element(
364                    "File chooser event missing backendNodeId; cannot upload files".to_string(),
365                )
366            })?;
367
368            let payload: Vec<String> = resolved
369                .iter()
370                .map(|path| path.to_string_lossy().into_owned())
371                .collect();
372
373            self.page
374                .session
375                .send_command::<_, dom::SetFileInputFilesReturnObject>(
376                    dom::SetFileInputFiles {
377                        files: payload,
378                        node_id: None,
379                        backend_node_id: Some(backend_node_id),
380                        object_id: None,
381                    },
382                    None,
383                )
384                .await?;
385
386            Ok(())
387        }
388        .await;
389
390        let disable_result = Self::set_file_chooser_intercept(&self.page, false).await;
391
392        match (upload_result, disable_result) {
393            (Ok(()), Ok(())) => Ok(()),
394            (Err(err), Ok(())) => Err(err),
395            (Ok(()), Err(err)) => Err(err),
396            (Err(err), Err(disable_err)) => {
397                tracing::warn!(
398                    "Failed to disable file chooser interception after error: {:?}",
399                    disable_err
400                );
401                Err(err)
402            }
403        }
404    }
405
406    /// Clears the element's value or selection state (input, textarea, select,
407    /// form, and contentEditable are supported).
408    ///
409    /// This implementation mimics real user interactions where possible:
410    /// - Text inputs and textareas reset `value = ""` and emit `input` / `change`
411    /// - Checkboxes and radio buttons are unchecked
412    /// - Select elements drop the active selection
413    /// - ContentEditable nodes erase their HTML contents
414    /// - Form elements recursively clear all editable descendants
415    ///
416    /// Returns an error when an `<input type="file">` is encountered to match
417    /// browser-level restrictions.
418    pub async fn clear(&self) -> Result<()> {
419        let script = r#"
420            function () {
421                const summary = {
422                    cleared: 0,
423                    skippedFileInput: false,
424                    unsupported: false,
425                    target: (this.tagName || '').toLowerCase() || 'element'
426                };
427
428                const dispatch = (node, name) => {
429                    const opts = { bubbles: true, cancelable: name === 'input', composed: true };
430                    let event;
431                    if (name === 'input' && typeof InputEvent === 'function') {
432                        event = new InputEvent('input', opts);
433                    } else {
434                        event = new Event(name, opts);
435                    }
436                    node.dispatchEvent(event);
437                };
438
439                const clearEditable = (node) => {
440                    if (!node) {
441                        return false;
442                    }
443
444                    const tag = (node.tagName || '').toLowerCase();
445
446                    if (typeof node.focus === 'function') {
447                        try { node.focus({ preventScroll: true }); } catch (_) { /* ignore */ }
448                    }
449
450                    if (node.isContentEditable) {
451                        if (node.innerHTML !== '') {
452                            summary.cleared += 1;
453                        }
454                        node.innerHTML = '';
455                        dispatch(node, 'input');
456                        dispatch(node, 'change');
457                        return true;
458                    }
459
460                    if (tag === 'input') {
461                        const type = (node.type || '').toLowerCase();
462
463                        if (type === 'file') {
464                            summary.skippedFileInput = true;
465                            return false;
466                        }
467
468                        if (type === 'checkbox' || type === 'radio') {
469                            if (node.checked) {
470                                node.checked = false;
471                                summary.cleared += 1;
472                                dispatch(node, 'input');
473                                dispatch(node, 'change');
474                            }
475                            return true;
476                        }
477
478                        if (node.value !== '') {
479                            summary.cleared += 1;
480                        }
481                        node.value = '';
482                        dispatch(node, 'input');
483                        dispatch(node, 'change');
484                        return true;
485                    }
486
487                    if (tag === 'textarea') {
488                        if (node.value !== '') {
489                            summary.cleared += 1;
490                        }
491                        node.value = '';
492                        dispatch(node, 'input');
493                        dispatch(node, 'change');
494                        return true;
495                    }
496
497                    if (tag === 'select') {
498                        if (node.selectedIndex !== -1) {
499                            summary.cleared += 1;
500                        }
501                        node.selectedIndex = -1;
502                        dispatch(node, 'change');
503                        return true;
504                    }
505
506                    if (tag === 'form') {
507                        Array.from(node.elements || []).forEach((child) => {
508                            clearEditable(child);
509                        });
510                        return true;
511                    }
512
513                    return false;
514                };
515
516                if (!clearEditable(this)) {
517                    if (!this.isContentEditable) {
518                        summary.unsupported = true;
519                    }
520                }
521
522                return summary;
523            }
524        "#;
525
526        let result = self.call_js_function(script).await?;
527
528        let skipped_file = result
529            .get("skippedFileInput")
530            .and_then(Value::as_bool)
531            .unwrap_or(false);
532
533        if skipped_file {
534            return Err(CdpError::element(
535                "clear() cannot operate on <input type='file'> elements due to browser security restrictions".to_string(),
536            ));
537        }
538
539        let unsupported = result
540            .get("unsupported")
541            .and_then(Value::as_bool)
542            .unwrap_or(false);
543
544        if unsupported {
545            let target = result
546                .get("target")
547                .and_then(Value::as_str)
548                .unwrap_or("element");
549            return Err(CdpError::element(format!(
550                "clear() is not supported for <{}> elements",
551                target
552            )));
553        }
554
555        Ok(())
556    }
557
558    /// Types the provided text character by character while applying a random
559    /// delay between each keystroke.
560    ///
561    /// The element is first focused and then receives keyDown/keyUp events for
562    /// every character. The delay for each character is randomly chosen within
563    /// `[min_delay_ms, max_delay_ms]`.
564    ///
565    /// # Parameters
566    /// * `text` - Text to type into the element.
567    /// * `min_delay_ms` - Minimum delay (milliseconds) between characters.
568    /// * `max_delay_ms` - Maximum delay (milliseconds) between characters.
569    ///
570    /// # Examples
571    /// ```no_run
572    /// # use cdp_core::Page;
573    /// # use std::sync::Arc;
574    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
575    /// if let Some(input) = page.query_selector("input[type='text']").await? {
576    ///     input.type_text_with_delay("Hello, World!", 50, 150).await?;
577    /// }
578    /// # Ok(())
579    /// # }
580    /// ```
581    pub async fn type_text_with_delay(
582        &self,
583        text: &str,
584        min_delay_ms: u64,
585        max_delay_ms: u64,
586    ) -> Result<()> {
587        // Focus the element before typing.
588        self.click().await?;
589
590        // Delegate to the page helper that performs the actual typing.
591        self.page
592            .type_text_with_delay(text, min_delay_ms, max_delay_ms)
593            .await
594    }
595
596    /// Gets the value of an attribute on the element, if present.
597    ///
598    /// # Parameters
599    /// * `attribute_name` - The attribute to read (for example `id`, `class`, `data-value`).
600    ///
601    /// # Returns
602    /// * `Some(String)` when the attribute is defined.
603    /// * `None` when the attribute is missing or resolves to `null`.
604    ///
605    /// # Examples
606    /// ```no_run
607    /// # use cdp_core::Page;
608    /// # use std::sync::Arc;
609    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
610    /// if let Some(element) = page.query_selector("div.example").await? {
611    ///     if let Some(id) = element.get_attribute("id").await? {
612    ///         println!("Element ID: {}", id);
613    ///     }
614    ///     if let Some(data_value) = element.get_attribute("data-value").await? {
615    ///         println!("Data value: {}", data_value);
616    ///     }
617    /// }
618    /// # Ok(())
619    /// # }
620    /// ```
621    pub async fn get_attribute(&self, attribute_name: &str) -> Result<Option<String>> {
622        // Step 1: resolve the backend node into an object reference.
623        let resolve_params = dom::ResolveNode {
624            backend_node_id: Some(self.backend_node_id),
625            node_id: None,
626            object_group: Some("element-helper".to_string()),
627            execution_context_id: None,
628        };
629
630        let resolve_result: dom::ResolveNodeReturnObject =
631            self.page.session.send_command(resolve_params, None).await?;
632
633        let object_id = resolve_result
634            .object
635            .object_id
636            .ok_or_else(|| CdpError::element("Object ID is unavailable for element".to_string()))?;
637
638        // Step 2: call into the runtime to retrieve the attribute value.
639        let function_declaration = format!(
640            "function() {{ return this.getAttribute('{}'); }}",
641            attribute_name.replace("'", "\\'")
642        );
643
644        let params = runtime::CallFunctionOn {
645            function_declaration,
646            object_id: Some(object_id),
647            arguments: None,
648            silent: None,
649            return_by_value: Some(true),
650            generate_preview: None,
651            user_gesture: None,
652            await_promise: None,
653            execution_context_id: None,
654            object_group: None,
655            throw_on_side_effect: None,
656            unique_context_id: None,
657            serialization_options: None,
658        };
659
660        let result: runtime::CallFunctionOnReturnObject =
661            self.page.session.send_command(params, None).await?;
662
663        // Step 3: surface any runtime exceptions.
664        if let Some(details) = result.exception_details.as_ref() {
665            return Err(CdpError::element(format!(
666                "Failed to get attribute '{}': {:?}",
667                attribute_name, details
668            )));
669        }
670
671        // Step 4: convert runtime values into an idiomatic Option<String>.
672        match result.result.value {
673            Some(Value::String(s)) => Ok(Some(s)),
674            Some(Value::Null) => Ok(None),
675            None => Ok(None),
676            Some(v) => Ok(Some(v.to_string())),
677        }
678    }
679
680    /// Gets the element's outer HTML (including its own tag).
681    ///
682    /// # Returns
683    /// The full HTML string representing the element and its descendants.
684    ///
685    /// # Examples
686    /// ```no_run
687    /// # use cdp_core::Page;
688    /// # use std::sync::Arc;
689    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
690    /// if let Some(element) = page.query_selector("div.example").await? {
691    ///     let html = element.get_html().await?;
692    ///     println!("Element HTML: {}", html);
693    /// }
694    /// # Ok(())
695    /// # }
696    /// ```
697    pub async fn get_html(&self) -> Result<String> {
698        // Step 1: resolve the backend node into an object reference.
699        let resolve_params = dom::ResolveNode {
700            backend_node_id: Some(self.backend_node_id),
701            node_id: None,
702            object_group: Some("element-helper".to_string()),
703            execution_context_id: None,
704        };
705
706        let resolve_result: dom::ResolveNodeReturnObject =
707            self.page.session.send_command(resolve_params, None).await?;
708
709        let object_id = resolve_result
710            .object
711            .object_id
712            .ok_or_else(|| CdpError::element("Object ID is unavailable for element".to_string()))?;
713
714        // Step 2: invoke a runtime helper to read outerHTML.
715        let params = runtime::CallFunctionOn {
716            function_declaration: "function() { return this.outerHTML; }".to_string(),
717            object_id: Some(object_id),
718            arguments: None,
719            silent: None,
720            return_by_value: Some(true),
721            generate_preview: None,
722            user_gesture: None,
723            await_promise: None,
724            execution_context_id: None,
725            object_group: None,
726            throw_on_side_effect: None,
727            unique_context_id: None,
728            serialization_options: None,
729        };
730
731        let result: runtime::CallFunctionOnReturnObject =
732            self.page.session.send_command(params, None).await?;
733
734        // Step 3: surface any runtime exceptions.
735        if let Some(details) = result.exception_details.as_ref() {
736            return Err(CdpError::element(format!(
737                "Failed to get HTML: {:?}",
738                details
739            )));
740        }
741
742        // Step 4: normalize the returned value into a String.
743        match result.result.value {
744            Some(Value::String(s)) => Ok(s),
745            Some(Value::Null) => Ok("".to_string()),
746            Some(v) => Ok(v.to_string()),
747            None => Ok("".to_string()),
748        }
749    }
750
751    /// Takes a screenshot of the element.
752    ///
753    /// # Parameters
754    /// * `save_path` - Optional file path (including file name). When `None`, a
755    ///   timestamped file such as `element_screenshot_<ts>.png` is created in
756    ///   the current working directory.
757    ///
758    /// # Returns
759    /// The path where the screenshot was saved.
760    ///
761    /// # Notes
762    /// - Uses the default border box, which generally works well for rounded elements.
763    /// - Automatically adapts to the device pixel ratio (DPR) for crisp images.
764    /// - Use [`screenshot_with_options`](Self::screenshot_with_options) for
765    ///   additional control.
766    ///
767    /// # Examples
768    /// ```no_run
769    /// # use cdp_core::Page;
770    /// # use std::sync::Arc;
771    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
772    /// if let Some(element) = page.query_selector("div.example").await? {
773    ///     // Save to the current directory (with DPR auto-adjust)
774    ///     let path = element.screenshot(None).await?;
775    ///     println!("Element screenshot saved to: {}", path);
776    ///
777    ///     // Save to a custom location
778    ///     let path = element.screenshot(Some("screenshots/element.png".into())).await?;
779    ///     println!("Element screenshot saved to: {}", path);
780    /// }
781    /// # Ok(())
782    /// # }
783    /// ```
784    pub async fn screenshot(&self, save_path: Option<PathBuf>) -> Result<String> {
785        self.screenshot_with_options(save_path, ScreenshotBoxType::default(), true)
786            .await
787    }
788
789    /// Takes a screenshot of the element with a custom box type.
790    ///
791    /// # Parameters
792    /// * `save_path` - Optional file path (including file name).
793    /// * `box_type` - Bounding box strategy that determines the capture region.
794    /// * `auto_resolve_dpr` - Whether to adapt the screenshot to the device
795    ///   pixel ratio (`true` is recommended for high-DPI displays).
796    ///
797    /// # Bounding Box Types
798    ///
799    /// - `Content`: Captures only the content area, excluding padding.
800    ///   - Best for: screenshots that should omit interior padding.
801    ///   - Less ideal when padding needs to remain visible.
802    ///
803    /// - `Padding`: Captures content and padding, excluding the border.
804    ///   - Best for: including interior spacing while omitting borders.
805    ///   - Less ideal when borders or rounded corners must be preserved.
806    ///
807    /// - `Border` (default): Captures content, padding, and border.
808    ///   - Ideal for rounded corners (`border-radius`).
809    ///   - Suitable for elements where the border defines the visual shape.
810    ///   - Works well for most everyday use cases.
811    ///
812    /// - `Margin`: Captures content, padding, border, and margin.
813    ///   - Best for: including surrounding whitespace in the capture.
814    ///   - Less ideal when margins introduce too much empty space.
815    ///
816    /// # Returns
817    /// The path where the screenshot was saved.
818    ///
819    /// # Examples
820    /// ```no_run
821    /// # use cdp_core::Page;
822    /// # use cdp_core::page::element::ScreenshotBoxType;
823    /// # use std::sync::Arc;
824    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
825    /// if let Some(element) = page.query_selector("button.rounded").await? {
826    ///     // Rounded button with border box + DPR auto adjustment (recommended)
827    ///     let path = element.screenshot_with_options(
828    ///         Some("button.png".into()),
829    ///         ScreenshotBoxType::Border,
830    ///         true  // enable DPR auto adjustment
831    ///     ).await?;
832    ///
833    ///     // Content-only capture with DPR adaptation disabled
834    ///     let path = element.screenshot_with_options(
835    ///         Some("content.png".into()),
836    ///         ScreenshotBoxType::Content,
837    ///         false  // fixed scale = 1.0
838    ///     ).await?;
839    ///
840    ///     // Include the margin while keeping DPR auto adjustment
841    ///     let path = element.screenshot_with_options(
842    ///         Some("with-margin.png".into()),
843    ///         ScreenshotBoxType::Margin,
844    ///         true
845    ///     ).await?;
846    /// }
847    /// # Ok(())
848    /// # }
849    /// ```
850    pub async fn screenshot_with_options(
851        &self,
852        save_path: Option<PathBuf>,
853        box_type: ScreenshotBoxType,
854        auto_resolve_dpr: bool,
855    ) -> Result<String> {
856        use base64::Engine;
857        use cdp_protocol::page as page_cdp;
858
859        // Capture the target region using Page.captureScreenshot.
860        // Determine the device pixel ratio so the output looks sharp on high-DPI screens.
861        let device_scale = if auto_resolve_dpr {
862            // Query DPR via the page evaluate helper.
863            use cdp_protocol::runtime::{Evaluate, EvaluateReturnObject};
864
865            let eval_result = self
866                .page
867                .session
868                .send_command::<_, EvaluateReturnObject>(
869                    Evaluate {
870                        expression: "window.devicePixelRatio".to_string(),
871                        object_group: None,
872                        include_command_line_api: None,
873                        silent: None,
874                        context_id: None,
875                        return_by_value: Some(true),
876                        generate_preview: None,
877                        user_gesture: None,
878                        await_promise: None,
879                        throw_on_side_effect: None,
880                        timeout: None,
881                        disable_breaks: None,
882                        repl_mode: None,
883                        allow_unsafe_eval_blocked_by_csp: None,
884                        unique_context_id: None,
885                        serialization_options: None,
886                    },
887                    None,
888                )
889                .await?;
890
891            let dpr = eval_result
892                .result
893                .value
894                .and_then(|v| v.as_f64())
895                .unwrap_or(1.0);
896
897            // Clamp the DPR to a sensible range so wildly high values do not explode file sizes.
898            dpr.clamp(0.5, 3.0)
899        } else {
900            1.0
901        };
902
903        // Resolve the backend node to an object ID (CDP allows either nodeId or backendNodeId, never both).
904        let resolve_params = dom::ResolveNode {
905            backend_node_id: if self.node_id.is_none() {
906                Some(self.backend_node_id)
907            } else {
908                None
909            },
910            node_id: self.node_id,
911            object_group: Some("screenshot-helper".to_string()),
912            execution_context_id: None,
913        };
914
915        let resolve_result: dom::ResolveNodeReturnObject =
916            self.page.session.send_command(resolve_params, None).await?;
917
918        let object_id = resolve_result
919            .object
920            .object_id
921            .ok_or_else(|| CdpError::element("Object ID is unavailable for element".to_string()))?;
922
923        // Scroll the element into view using the object ID for stability.
924        let scroll_params = dom::ScrollIntoViewIfNeeded {
925            node_id: None,
926            backend_node_id: None,
927            object_id: Some(object_id.clone()),
928            rect: None,
929        };
930        self.page
931            .session
932            .send_command::<_, dom::ScrollIntoViewIfNeededReturnObject>(scroll_params, None)
933            .await?;
934
935        // Give the renderer a moment to settle after scrolling.
936        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
937
938        // Compute precise bounds via getBoundingClientRect(). This is more
939        // accurate than GetBoxModel because it comes directly from the render
940        // engine. Remember the values are viewport-relative, so scroll offsets
941        // must be reintroduced to get absolute coordinates.
942        let js_function = match box_type {
943            ScreenshotBoxType::Content => {
944                // Content box excludes padding.
945                r#"function() {
946                    const rect = this.getBoundingClientRect();
947                    const style = window.getComputedStyle(this);
948                    const paddingLeft = parseFloat(style.paddingLeft) || 0;
949                    const paddingTop = parseFloat(style.paddingTop) || 0;
950                    const paddingRight = parseFloat(style.paddingRight) || 0;
951                    const paddingBottom = parseFloat(style.paddingBottom) || 0;
952                    return {
953                        x: rect.left + window.scrollX + paddingLeft,
954                        y: rect.top + window.scrollY + paddingTop,
955                        width: rect.width - paddingLeft - paddingRight,
956                        height: rect.height - paddingTop - paddingBottom
957                    };
958                }"#
959            }
960            ScreenshotBoxType::Padding => {
961                // Padding box captures content and padding (no border).
962                r#"function() {
963                    const rect = this.getBoundingClientRect();
964                    const style = window.getComputedStyle(this);
965                    const borderLeft = parseFloat(style.borderLeftWidth) || 0;
966                    const borderTop = parseFloat(style.borderTopWidth) || 0;
967                    const borderRight = parseFloat(style.borderRightWidth) || 0;
968                    const borderBottom = parseFloat(style.borderBottomWidth) || 0;
969                    return {
970                        x: rect.left + window.scrollX + borderLeft,
971                        y: rect.top + window.scrollY + borderTop,
972                        width: rect.width - borderLeft - borderRight,
973                        height: rect.height - borderTop - borderBottom
974                    };
975                }"#
976            }
977            ScreenshotBoxType::Border => {
978                // Border box captures content, padding, and border (recommended).
979                r#"function() {
980                    const rect = this.getBoundingClientRect();
981                    return {
982                        x: rect.left + window.scrollX,
983                        y: rect.top + window.scrollY,
984                        width: rect.width,
985                        height: rect.height
986                    };
987                }"#
988            }
989            ScreenshotBoxType::Margin => {
990                // Margin box captures everything including the margin.
991                r#"function() {
992                    const rect = this.getBoundingClientRect();
993                    const style = window.getComputedStyle(this);
994                    const marginLeft = parseFloat(style.marginLeft) || 0;
995                    const marginTop = parseFloat(style.marginTop) || 0;
996                    const marginRight = parseFloat(style.marginRight) || 0;
997                    const marginBottom = parseFloat(style.marginBottom) || 0;
998                    return {
999                        x: rect.left + window.scrollX - marginLeft,
1000                        y: rect.top + window.scrollY - marginTop,
1001                        width: rect.width + marginLeft + marginRight,
1002                        height: rect.height + marginTop + marginBottom
1003                    };
1004                }"#
1005            }
1006        };
1007
1008        let call_params = runtime::CallFunctionOn {
1009            function_declaration: js_function.to_string(),
1010            object_id: Some(object_id),
1011            arguments: None,
1012            silent: None,
1013            return_by_value: Some(true),
1014            generate_preview: None,
1015            user_gesture: None,
1016            await_promise: None,
1017            execution_context_id: None,
1018            object_group: None,
1019            throw_on_side_effect: None,
1020            unique_context_id: None,
1021            serialization_options: None,
1022        };
1023
1024        let call_result: runtime::CallFunctionOnReturnObject =
1025            self.page.session.send_command(call_params, None).await?;
1026
1027        if let Some(details) = call_result.exception_details.as_ref() {
1028            return Err(CdpError::element(format!(
1029                "Failed to get element bounds: {:?}",
1030                details
1031            )));
1032        }
1033
1034        // Parse the bounding rectangle returned from the JavaScript helper.
1035        let bounds = call_result.result.value.ok_or_else(|| {
1036            CdpError::element("No bounds returned from JavaScript for element".to_string())
1037        })?;
1038
1039        let x = bounds["x"].as_f64().ok_or_else(|| {
1040            CdpError::element("Invalid x coordinate returned for element".to_string())
1041        })?;
1042        let y = bounds["y"].as_f64().ok_or_else(|| {
1043            CdpError::element("Invalid y coordinate returned for element".to_string())
1044        })?;
1045        let width = bounds["width"]
1046            .as_f64()
1047            .ok_or_else(|| CdpError::element("Invalid width returned for element".to_string()))?;
1048        let height = bounds["height"]
1049            .as_f64()
1050            .ok_or_else(|| CdpError::element("Invalid height returned for element".to_string()))?;
1051
1052        // Align to device pixels so the screenshot edges stay crisp on high-DPI screens.
1053        // Strategy:
1054        // - Floor the top-left corner to avoid accidentally clipping.
1055        // - Ceil the bottom-right corner to capture the full element.
1056        // - Recompute width/height from those adjusted edges.
1057        let aligned_x = (x * device_scale).floor() / device_scale;
1058        let aligned_y = (y * device_scale).floor() / device_scale;
1059
1060        // Compute the bottom-right corner using a ceiling operation.
1061        let right_edge = ((x + width) * device_scale).ceil() / device_scale;
1062        let bottom_edge = ((y + height) * device_scale).ceil() / device_scale;
1063
1064        // Determine the final width and height from the aligned edges.
1065        let aligned_width = right_edge - aligned_x;
1066        let aligned_height = bottom_edge - aligned_y;
1067
1068        // Ensure at least one logical pixel remains after alignment.
1069        let final_width = aligned_width.max(1.0 / device_scale);
1070        let final_height = aligned_height.max(1.0 / device_scale);
1071
1072        let clip = Some(page_cdp::Viewport {
1073            x: aligned_x,
1074            y: aligned_y,
1075            width: final_width,
1076            height: final_height,
1077            scale: device_scale,
1078        });
1079
1080        // Execute the screenshot command with the computed clip.
1081        let screenshot_params = page_cdp::CaptureScreenshot {
1082            format: Some(page_cdp::CaptureScreenshotFormatOption::Png),
1083            quality: None,
1084            clip,
1085            from_surface: Some(true),
1086            capture_beyond_viewport: Some(true),
1087            optimize_for_speed: None,
1088        };
1089
1090        let result: page_cdp::CaptureScreenshotReturnObject = self
1091            .page
1092            .session
1093            .send_command(screenshot_params, None)
1094            .await?;
1095
1096        // Derive the output path.
1097        let out_path_buf: std::path::PathBuf = match save_path {
1098            Some(pv) => {
1099                if pv.parent().is_none() || pv.parent().unwrap().as_os_str().is_empty() {
1100                    std::env::current_dir()?.join(pv)
1101                } else {
1102                    if let Some(parent) = pv.parent() {
1103                        std::fs::create_dir_all(parent)?;
1104                    }
1105                    pv.to_path_buf()
1106                }
1107            }
1108            None => {
1109                let out_dir = std::env::var("OUT_PATH").unwrap_or_else(|_| ".".to_string());
1110                let dir = std::path::PathBuf::from(out_dir);
1111                std::fs::create_dir_all(&dir)?;
1112                let nanos = std::time::SystemTime::now()
1113                    .duration_since(std::time::UNIX_EPOCH)
1114                    .unwrap_or_default()
1115                    .as_nanos();
1116                dir.join(format!("element-screenshot-{}.png", nanos))
1117            }
1118        };
1119
1120        // Decode the base64 payload and persist it to disk.
1121        let bytes = base64::engine::general_purpose::STANDARD
1122            .decode(&result.data)
1123            .map_err(|err| CdpError::element(format!("Failed to decode screenshot data: {err}")))?;
1124        let mut f = File::create(&out_path_buf).await?;
1125        f.write_all(&bytes).await?;
1126        f.flush().await?;
1127
1128        let out_path = out_path_buf.to_string_lossy();
1129        Ok(out_path.into_owned())
1130    }
1131
1132    // ========= Helper utilities =========
1133
1134    /// Executes a JavaScript function against the element.
1135    async fn call_js_function(&self, function_declaration: &str) -> Result<serde_json::Value> {
1136        use cdp_protocol::runtime::{CallFunctionOn, CallFunctionOnReturnObject};
1137
1138        // Resolve an object_id for the element.
1139        let obj_id = if let Some(node_id) = self.node_id {
1140            // Use DOM.resolveNode to obtain an object_id when node_id is present.
1141            use cdp_protocol::dom::{ResolveNode, ResolveNodeReturnObject};
1142
1143            let resolve_result: ResolveNodeReturnObject = self
1144                .page
1145                .session
1146                .send_command(
1147                    ResolveNode {
1148                        node_id: Some(node_id),
1149                        backend_node_id: None,
1150                        object_group: Some("element-utils".to_string()),
1151                        execution_context_id: None,
1152                    },
1153                    None,
1154                )
1155                .await?;
1156
1157            resolve_result.object.object_id.ok_or_else(|| {
1158                CdpError::element("No object ID available for element".to_string())
1159            })?
1160        } else {
1161            return Err(CdpError::element(
1162                "No node ID available for element".to_string(),
1163            ));
1164        };
1165
1166        // Invoke the function within the runtime.
1167        let params = CallFunctionOn {
1168            function_declaration: function_declaration.to_string(),
1169            object_id: Some(obj_id.clone()),
1170            arguments: Some(vec![]),
1171            silent: Some(true),
1172            return_by_value: Some(true),
1173            generate_preview: None,
1174            user_gesture: None,
1175            await_promise: None,
1176            execution_context_id: None,
1177            object_group: Some("element-utils".to_string()),
1178            throw_on_side_effect: None,
1179            unique_context_id: None,
1180            serialization_options: None,
1181        };
1182
1183        let result: CallFunctionOnReturnObject =
1184            self.page.session.send_command(params, None).await?;
1185
1186        if let Some(value) = result.result.value {
1187            Ok(value)
1188        } else {
1189            Ok(serde_json::Value::Null)
1190        }
1191    }
1192
1193    // ========= Visibility and state checks =========
1194
1195    /// Determines whether the element is visible.
1196    ///
1197    /// The element is considered visible when:
1198    /// - It is present in the DOM tree.
1199    /// - It does not use `display: none` or `visibility: hidden` (and `opacity` is not `0`).
1200    /// - Its rendered bounding box is non-zero.
1201    ///
1202    /// # Returns
1203    /// `true` if the element is visible, otherwise `false`.
1204    ///
1205    /// # Examples
1206    /// ```no_run
1207    /// # use cdp_core::Page;
1208    /// # use std::sync::Arc;
1209    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1210    /// let element = page.query_selector("#my-element").await?.unwrap();
1211    /// if element.is_visible().await? {
1212    ///     println!("Element is visible");
1213    /// }
1214    /// # Ok(())
1215    /// # }
1216    /// ```
1217    pub async fn is_visible(&self) -> Result<bool> {
1218        // Evaluate a JavaScript helper to assess visibility heuristics.
1219        let script = r#"
1220        function() {
1221            if (!this) return false;
1222            
1223            // Use offsetParent as the quick visibility hint; body/html are special-cased.
1224            if (this === document.body || this === document.documentElement) {
1225                return true;
1226            }
1227            if (!this.offsetParent && this.tagName !== 'BODY') {
1228                return false;
1229            }
1230            
1231            // Inspect computed styles for common hidden states.
1232            const style = window.getComputedStyle(this);
1233            if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
1234                return false;
1235            }
1236            
1237            // Reject zero-sized rectangles.
1238            const rect = this.getBoundingClientRect();
1239            if (rect.width === 0 || rect.height === 0) {
1240                return false;
1241            }
1242            
1243            return true;
1244        }
1245        "#;
1246
1247        let result = self.call_js_function(script).await?;
1248        Ok(result.as_bool().unwrap_or(false))
1249    }
1250
1251    /// Checks whether the element is enabled (not `disabled`).
1252    ///
1253    /// # Returns
1254    /// `true` when the element is enabled, otherwise `false`.
1255    pub async fn is_enabled(&self) -> Result<bool> {
1256        let script = r#"
1257        function() {
1258            if (!this) return false;
1259            return !this.disabled;
1260        }
1261        "#;
1262
1263        let result = self.call_js_function(script).await?;
1264        Ok(result.as_bool().unwrap_or(true))
1265    }
1266
1267    /// Determines whether the element can be clicked.
1268    ///
1269    /// The element is considered clickable when it is visible, enabled, and
1270    /// not occluded by another element at its center point.
1271    ///
1272    /// # Returns
1273    /// `true` if the element is clickable, otherwise `false`.
1274    pub async fn is_clickable(&self) -> Result<bool> {
1275        // Check visibility and enabled state first.
1276        if !self.is_visible().await? {
1277            return Ok(false);
1278        }
1279
1280        if !self.is_enabled().await? {
1281            return Ok(false);
1282        }
1283
1284        // Verify that no other element occludes the center point.
1285        let script = r#"
1286        function() {
1287            if (!this) return false;
1288            
1289            const rect = this.getBoundingClientRect();
1290            const centerX = rect.left + rect.width / 2;
1291            const centerY = rect.top + rect.height / 2;
1292            
1293            const topElement = document.elementFromPoint(centerX, centerY);
1294            if (!topElement) return false;
1295            
1296            // Ensure the target point belongs to the element or one of its descendants.
1297            return this.contains(topElement);
1298        }
1299        "#;
1300
1301        let result = self.call_js_function(script).await?;
1302        Ok(result.as_bool().unwrap_or(false))
1303    }
1304
1305    // ========= Waiting helpers =========
1306
1307    /// Waits for the element to become visible.
1308    ///
1309    /// # Parameters
1310    /// * `timeout_ms` - Timeout in milliseconds (defaults to `30000`).
1311    ///
1312    /// # Examples
1313    /// ```no_run
1314    /// # use cdp_core::Page;
1315    /// # use std::sync::Arc;
1316    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1317    /// let element = page.query_selector("#dynamic-content").await?.unwrap();
1318    /// element.wait_for_visible(Some(5000)).await?;
1319    /// # Ok(())
1320    /// # }
1321    /// ```
1322    pub async fn wait_for_visible(&self, timeout_ms: Option<u64>) -> Result<()> {
1323        let timeout = timeout_ms.unwrap_or(30000);
1324        let start = std::time::Instant::now();
1325        let poll_interval = std::time::Duration::from_millis(100);
1326
1327        loop {
1328            if start.elapsed().as_millis() > timeout as u128 {
1329                return Err(CdpError::element(format!(
1330                    "Timeout waiting for element to be visible ({}ms)",
1331                    timeout
1332                )));
1333            }
1334
1335            if self.is_visible().await? {
1336                return Ok(());
1337            }
1338
1339            tokio::time::sleep(poll_interval).await;
1340        }
1341    }
1342
1343    /// Waits for the element to become hidden.
1344    ///
1345    /// # Parameters
1346    /// * `timeout_ms` - Timeout in milliseconds (defaults to `30000`).
1347    pub async fn wait_for_hidden(&self, timeout_ms: Option<u64>) -> Result<()> {
1348        let timeout = timeout_ms.unwrap_or(30000);
1349        let start = std::time::Instant::now();
1350        let poll_interval = std::time::Duration::from_millis(100);
1351
1352        loop {
1353            if start.elapsed().as_millis() > timeout as u128 {
1354                return Err(CdpError::element(format!(
1355                    "Timeout waiting for element to be hidden ({}ms)",
1356                    timeout
1357                )));
1358            }
1359
1360            if !self.is_visible().await? {
1361                return Ok(());
1362            }
1363
1364            tokio::time::sleep(poll_interval).await;
1365        }
1366    }
1367
1368    /// Waits for the element to become clickable.
1369    ///
1370    /// # Parameters
1371    /// * `timeout_ms` - Timeout in milliseconds (defaults to `30000`).
1372    ///
1373    /// # Examples
1374    /// ```no_run
1375    /// # use cdp_core::Page;
1376    /// # use std::sync::Arc;
1377    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1378    /// let button = page.query_selector("#submit-btn").await?.unwrap();
1379    /// button.wait_for_clickable(Some(5000)).await?;
1380    /// button.click().await?;
1381    /// # Ok(())
1382    /// # }
1383    /// ```
1384    pub async fn wait_for_clickable(&self, timeout_ms: Option<u64>) -> Result<()> {
1385        let timeout = timeout_ms.unwrap_or(30000);
1386        let start = std::time::Instant::now();
1387        let poll_interval = std::time::Duration::from_millis(100);
1388
1389        loop {
1390            if start.elapsed().as_millis() > timeout as u128 {
1391                return Err(CdpError::element(format!(
1392                    "Timeout waiting for element to be clickable ({}ms)",
1393                    timeout
1394                )));
1395            }
1396
1397            if self.is_clickable().await? {
1398                return Ok(());
1399            }
1400
1401            tokio::time::sleep(poll_interval).await;
1402        }
1403    }
1404
1405    /// Waits for the element to become enabled (not `disabled`).
1406    ///
1407    /// # Parameters
1408    /// * `timeout_ms` - Timeout in milliseconds (defaults to `30000`).
1409    pub async fn wait_for_enabled(&self, timeout_ms: Option<u64>) -> Result<()> {
1410        let timeout = timeout_ms.unwrap_or(30000);
1411        let start = std::time::Instant::now();
1412        let poll_interval = std::time::Duration::from_millis(100);
1413
1414        loop {
1415            if start.elapsed().as_millis() > timeout as u128 {
1416                return Err(CdpError::element(format!(
1417                    "Timeout waiting for element to be enabled ({}ms)",
1418                    timeout
1419                )));
1420            }
1421
1422            if self.is_enabled().await? {
1423                return Ok(());
1424            }
1425
1426            tokio::time::sleep(poll_interval).await;
1427        }
1428    }
1429
1430    // ========= Shadow DOM helpers =========
1431
1432    /// Retrieves the element's shadow root (supports both open and closed modes).
1433    ///
1434    /// # Returns
1435    /// - `Ok(Some(ShadowRoot))` when the element exposes a shadow root.
1436    /// - `Ok(None)` when the element has no shadow root.
1437    /// - `Err(_)` if the operation failed.
1438    ///
1439    /// # Examples
1440    /// ```no_run
1441    /// # use cdp_core::Page;
1442    /// # use std::sync::Arc;
1443    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1444    /// let host = page.query_selector("#shadow-host").await?.unwrap();
1445    ///
1446    /// if let Some(shadow_root) = host.shadow_root().await? {
1447    ///     // Query inside the shadow DOM
1448    ///     let inner = shadow_root.query_selector(".inner-element").await?;
1449    /// }
1450    /// # Ok(())
1451    /// # }
1452    /// ```
1453    pub async fn shadow_root(&self) -> Result<Option<ShadowRoot>> {
1454        use cdp_protocol::dom::{DescribeNode, DescribeNodeReturnObject};
1455
1456        // Use DescribeNode to retrieve node details, including the shadow root.
1457        let describe_params = DescribeNode {
1458            node_id: self.node_id,
1459            backend_node_id: if self.node_id.is_none() {
1460                Some(self.backend_node_id)
1461            } else {
1462                None
1463            },
1464            object_id: None,
1465            depth: Some(1), // Include one level of children to expose the shadow root.
1466            pierce: Some(true), // Allow traversal through shadow boundaries.
1467        };
1468
1469        let describe_result: DescribeNodeReturnObject = self
1470            .page
1471            .session
1472            .send_command(describe_params, None)
1473            .await?;
1474
1475        // Determine whether a shadow root is available.
1476        if let Some(shadow_roots) = describe_result.node.shadow_roots
1477            && !shadow_roots.is_empty()
1478        {
1479            let shadow_root_node = &shadow_roots[0];
1480            return Ok(Some(ShadowRoot {
1481                node_id: shadow_root_node.node_id,
1482                backend_node_id: shadow_root_node.backend_node_id,
1483                shadow_root_type: shadow_root_node.shadow_root_type.clone(),
1484                page: Arc::clone(&self.page),
1485            }));
1486        }
1487
1488        Ok(None)
1489    }
1490
1491    /// Queries for a single element inside the shadow DOM (supports open and closed modes).
1492    ///
1493    /// This is a convenience wrapper around `element.shadow_root().await?.query_selector(selector)`.
1494    ///
1495    /// # Parameters
1496    /// * `selector` - CSS selector.
1497    ///
1498    /// # Returns
1499    /// - `Ok(Some(ElementHandle))` when a matching element is found.
1500    /// - `Ok(None)` when the element has no shadow root or no match is located.
1501    /// - `Err(_)` if querying fails.
1502    ///
1503    /// # Examples
1504    /// ```no_run
1505    /// # use cdp_core::Page;
1506    /// # use std::sync::Arc;
1507    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1508    /// let host = page.query_selector("#shadow-host").await?.unwrap();
1509    ///
1510    /// if let Some(inner) = host.query_selector_shadow(".inner-element").await? {
1511    ///     let text = inner.text_content().await?;
1512    ///     println!("Shadow DOM content: {}", text);
1513    /// }
1514    /// # Ok(())
1515    /// # }
1516    /// ```
1517    pub async fn query_selector_shadow(&self, selector: &str) -> Result<Option<ElementHandle>> {
1518        if let Some(shadow_root) = self.shadow_root().await? {
1519            shadow_root.query_selector(selector).await
1520        } else {
1521            Ok(None)
1522        }
1523    }
1524
1525    /// Queries for all matching elements within the shadow DOM (supports open and closed modes).
1526    ///
1527    /// # Parameters
1528    /// * `selector` - CSS selector used for the lookup.
1529    ///
1530    /// # Returns
1531    /// A vector of matching elements, or an empty vector when no shadow root is present.
1532    ///
1533    /// # Examples
1534    /// ```no_run
1535    /// # use cdp_core::Page;
1536    /// # use std::sync::Arc;
1537    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1538    /// let host = page.query_selector("#shadow-host").await?.unwrap();
1539    ///
1540    /// let items = host.query_selector_all_shadow(".list-item").await?;
1541    /// println!("Found {} items in Shadow DOM", items.len());
1542    /// # Ok(())
1543    /// # }
1544    /// ```
1545    pub async fn query_selector_all_shadow(&self, selector: &str) -> Result<Vec<ElementHandle>> {
1546        if let Some(shadow_root) = self.shadow_root().await? {
1547            shadow_root.query_selector_all(selector).await
1548        } else {
1549            Ok(Vec::new())
1550        }
1551    }
1552}
1553
1554/// Handle for a shadow root.
1555///
1556/// Represents the root of a shadow DOM tree and allows querying within it.
1557/// Supports both open and closed shadow roots.
1558#[derive(Clone)]
1559pub struct ShadowRoot {
1560    pub(crate) node_id: u32,
1561    pub(crate) backend_node_id: u32,
1562    pub(crate) shadow_root_type: Option<dom::ShadowRootType>,
1563    pub(crate) page: Arc<Page>,
1564}
1565
1566impl ShadowRoot {
1567    /// Returns the shadow root type (open or closed) if known.
1568    pub fn shadow_root_type(&self) -> Option<&dom::ShadowRootType> {
1569        self.shadow_root_type.as_ref()
1570    }
1571
1572    /// Checks whether the selector is an XPath expression.
1573    fn is_xpath(selector: &str) -> bool {
1574        selector.starts_with("xpath:") || selector.starts_with("/") || selector.starts_with("(")
1575    }
1576
1577    /// Strips the `xpath:` prefix when present.
1578    fn normalize_xpath(selector: &str) -> &str {
1579        selector.strip_prefix("xpath:").unwrap_or(selector)
1580    }
1581
1582    /// Queries the shadow DOM for the first matching element.
1583    ///
1584    /// # Parameters
1585    /// * `selector` - CSS selector or XPath expression.
1586    ///   - CSS selectors such as `"div.class"` or `"#id"` are recommended.
1587    ///   - XPath expressions start with `"xpath:"` or `/`, for example `"//div[@class='test']"`.
1588    ///
1589    /// # XPath Caveats
1590    /// XPath support within the shadow DOM is limited. Because CDP's
1591    /// `DOM.performSearch` operates globally, results may include nodes outside
1592    /// the shadow root. Prefer CSS selectors when possible.
1593    ///
1594    /// # Returns
1595    /// The first matching element handle, or `None` when nothing matches.
1596    ///
1597    /// # Examples
1598    /// ```no_run
1599    /// # use cdp_core::Page;
1600    /// # use std::sync::Arc;
1601    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1602    /// let host = page.query_selector("#shadow-host").await?.unwrap();
1603    /// let shadow_root = host.shadow_root().await?.unwrap();
1604    ///
1605    /// // Preferred CSS selector path
1606    /// if let Some(element) = shadow_root.query_selector(".target").await? {
1607    ///     element.click().await?;
1608    /// }
1609    ///
1610    /// // XPath fallback (limited support)
1611    /// if let Some(element) = shadow_root.query_selector("//button[text()='Click']").await? {
1612    ///     element.click().await?;
1613    /// }
1614    /// # Ok(())
1615    /// # }
1616    /// ```
1617    pub async fn query_selector(&self, selector: &str) -> Result<Option<ElementHandle>> {
1618        // For XPath selectors emit a warning; DOM.performSearch is global and unreliable within the shadow tree.
1619        if Self::is_xpath(selector) {
1620            eprintln!("Warning: XPath support inside shadow DOM is limited; prefer CSS selectors");
1621            eprintln!(
1622                "If XPath is required, use query_selector_shadow on the host element instead"
1623            );
1624            // Continue anyway so callers can attempt the lookup.
1625        }
1626
1627        use cdp_protocol::dom::{
1628            DescribeNode, DescribeNodeReturnObject, QuerySelector, QuerySelectorReturnObject,
1629        };
1630
1631        let query_result = self
1632            .page
1633            .session
1634            .send_command::<_, QuerySelectorReturnObject>(
1635                QuerySelector {
1636                    node_id: self.node_id,
1637                    selector: selector.to_string(),
1638                },
1639                None,
1640            )
1641            .await?;
1642
1643        if query_result.node_id == 0 {
1644            return Ok(None);
1645        }
1646
1647        let describe_result = self
1648            .page
1649            .session
1650            .send_command::<_, DescribeNodeReturnObject>(
1651                DescribeNode {
1652                    node_id: Some(query_result.node_id),
1653                    backend_node_id: None,
1654                    object_id: None,
1655                    depth: None,
1656                    pierce: None,
1657                },
1658                None,
1659            )
1660            .await?;
1661
1662        Ok(Some(ElementHandle {
1663            backend_node_id: describe_result.node.backend_node_id,
1664            node_id: Some(query_result.node_id),
1665            page: Arc::clone(&self.page),
1666        }))
1667    }
1668
1669    /// Queries the shadow DOM for all matching elements.
1670    ///
1671    /// # Parameters
1672    /// * `selector` - CSS selector or XPath expression.
1673    ///   - CSS selectors such as `"div.class"` or `"#id"` work reliably.
1674    ///   - XPath expressions start with `"xpath:"` or `/`, for example `"//div[@class='test']"`.
1675    ///
1676    /// # XPath Caveats
1677    /// XPath support within the shadow DOM is limited. Because CDP's
1678    /// `DOM.performSearch` is global, XPath queries may surface nodes outside
1679    /// of the shadow root. Prefer CSS selectors when possible.
1680    ///
1681    /// # Returns
1682    /// A vector of matching element handles.
1683    ///
1684    /// # Examples
1685    /// ```no_run
1686    /// # use cdp_core::Page;
1687    /// # use std::sync::Arc;
1688    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1689    /// let host = page.query_selector("#shadow-host").await?.unwrap();
1690    /// let shadow_root = host.shadow_root().await?.unwrap();
1691    ///
1692    /// // Preferred CSS selector path
1693    /// let items = shadow_root.query_selector_all(".item").await?;
1694    /// for (i, item) in items.iter().enumerate() {
1695    ///     println!("Item {}: {}", i + 1, item.text_content().await?);
1696    /// }
1697    ///
1698    /// // XPath fallback (limited support)
1699    /// let buttons = shadow_root.query_selector_all("//button").await?;
1700    /// # Ok(())
1701    /// # }
1702    /// ```
1703    pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementHandle>> {
1704        // Emit a warning for XPath selectors.
1705        if Self::is_xpath(selector) {
1706            eprintln!("Warning: XPath support inside shadow DOM is limited; prefer CSS selectors");
1707        }
1708
1709        use cdp_protocol::dom::{
1710            DescribeNode, DescribeNodeReturnObject, QuerySelectorAll, QuerySelectorAllReturnObject,
1711        };
1712
1713        let query_result = self
1714            .page
1715            .session
1716            .send_command::<_, QuerySelectorAllReturnObject>(
1717                QuerySelectorAll {
1718                    node_id: self.node_id,
1719                    selector: selector.to_string(),
1720                },
1721                None,
1722            )
1723            .await?;
1724
1725        let mut elements = Vec::new();
1726        for node_id in query_result.node_ids {
1727            if node_id == 0 {
1728                continue;
1729            }
1730
1731            let describe_result = self
1732                .page
1733                .session
1734                .send_command::<_, DescribeNodeReturnObject>(
1735                    DescribeNode {
1736                        node_id: Some(node_id),
1737                        backend_node_id: None,
1738                        object_id: None,
1739                        depth: None,
1740                        pierce: None,
1741                    },
1742                    None,
1743                )
1744                .await?;
1745
1746            elements.push(ElementHandle {
1747                backend_node_id: describe_result.node.backend_node_id,
1748                node_id: Some(node_id),
1749                page: Arc::clone(&self.page),
1750            });
1751        }
1752
1753        Ok(elements)
1754    }
1755
1756    /// Retrieves the HTML markup contained within the shadow root.
1757    ///
1758    /// # Returns
1759    /// The HTML string for the shadow root.
1760    ///
1761    /// # Examples
1762    /// ```no_run
1763    /// # use cdp_core::Page;
1764    /// # use std::sync::Arc;
1765    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
1766    /// let host = page.query_selector("#shadow-host").await?.unwrap();
1767    /// let shadow_root = host.shadow_root().await?.unwrap();
1768    ///
1769    /// let html = shadow_root.get_html().await?;
1770    /// println!("Shadow DOM HTML: {}", html);
1771    /// # Ok(())
1772    /// # }
1773    /// ```
1774    pub async fn get_html(&self) -> Result<String> {
1775        use cdp_protocol::dom::{GetOuterHTML, GetOuterHTMLReturnObject};
1776
1777        let result: GetOuterHTMLReturnObject = self
1778            .page
1779            .session
1780            .send_command(
1781                GetOuterHTML {
1782                    node_id: Some(self.node_id),
1783                    backend_node_id: None,
1784                    object_id: None,
1785                    include_shadow_dom: None,
1786                },
1787                None,
1788            )
1789            .await?;
1790
1791        Ok(result.outer_html)
1792    }
1793}