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}