1use std::collections::HashMap;
31use std::sync::{
32 Arc,
33 atomic::{AtomicU16, Ordering},
34};
35use std::time::Duration;
36
37use chromiumoxide::Page;
38use serde::{Deserialize, Serialize};
39use tokio::time::timeout;
40use tracing::{debug, warn};
41
42use crate::error::{BrowserError, Result};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ResourceType {
49 Image,
51 Font,
53 Stylesheet,
55 Media,
57}
58
59impl ResourceType {
60 #[must_use]
61 pub const fn as_cdp_str(&self) -> &'static str {
62 match self {
63 Self::Image => "Image",
64 Self::Font => "Font",
65 Self::Stylesheet => "Stylesheet",
66 Self::Media => "Media",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Default)]
82pub struct ResourceFilter {
83 blocked: Vec<ResourceType>,
84}
85
86impl ResourceFilter {
87 #[must_use]
89 pub fn block_media() -> Self {
90 Self {
91 blocked: vec![
92 ResourceType::Image,
93 ResourceType::Font,
94 ResourceType::Stylesheet,
95 ResourceType::Media,
96 ],
97 }
98 }
99
100 #[must_use]
101 pub fn block_images_and_fonts() -> Self {
102 Self {
103 blocked: vec![ResourceType::Image, ResourceType::Font],
104 }
105 }
106
107 #[must_use]
108 pub fn block(mut self, resource: ResourceType) -> Self {
109 if !self.blocked.contains(&resource) {
110 self.blocked.push(resource);
111 }
112 self
113 }
114
115 #[must_use]
116 pub fn should_block(&self, cdp_type: &str) -> bool {
117 self.blocked
118 .iter()
119 .any(|r| r.as_cdp_str().eq_ignore_ascii_case(cdp_type))
120 }
121
122 #[must_use]
123 pub const fn is_empty(&self) -> bool {
124 self.blocked.is_empty()
125 }
126}
127
128#[derive(Debug, Clone)]
138pub enum WaitUntil {
139 DomContentLoaded,
142 NetworkIdle,
143 Selector(String),
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
176pub enum OuterHtmlStrategy {
177 #[default]
179 Current,
180 Recursive,
183}
184
185impl OuterHtmlStrategy {
186 #[must_use]
188 pub const fn as_str(&self) -> &'static str {
189 match self {
190 Self::Current => "Current",
191 Self::Recursive => "Recursive",
192 }
193 }
194
195 #[must_use]
198 pub const fn all() -> [Self; 2] {
199 [Self::Current, Self::Recursive]
200 }
201}
202
203impl std::fmt::Display for OuterHtmlStrategy {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(self.as_str())
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221pub enum OuterHtmlResult {
222 Empty,
226 Content(String),
228 Failed {
232 backends: Vec<&'static str>,
234 },
235}
236
237impl OuterHtmlResult {
238 #[must_use]
241 pub const fn content(&self) -> Option<&str> {
242 match self {
243 Self::Content(s) => Some(s.as_str()),
244 Self::Empty | Self::Failed { .. } => None,
245 }
246 }
247
248 #[must_use]
251 pub const fn is_empty(&self) -> bool {
252 match self {
253 Self::Content(s) => s.is_empty(),
254 Self::Empty | Self::Failed { .. } => true,
255 }
256 }
257}
258
259impl std::fmt::Display for OuterHtmlResult {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 match self {
262 Self::Empty => f.write_str("Empty"),
263 Self::Content(s) => write!(f, "Content({} bytes)", s.len()),
264 Self::Failed { backends } => write!(f, "Failed({})", backends.join(", ")),
265 }
266 }
267}
268
269pub struct NodeHandle {
300 element: chromiumoxide::element::Element,
301 selector: Arc<str>,
304 cdp_timeout: Duration,
305 page: chromiumoxide::Page,
307}
308
309impl NodeHandle {
310 pub async fn attr(&self, name: &str) -> Result<Option<String>> {
319 timeout(self.cdp_timeout, self.element.attribute(name))
320 .await
321 .map_err(|_| BrowserError::Timeout {
322 operation: "NodeHandle::attr".to_string(),
323 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
324 })?
325 .map_err(|e| self.cdp_err_or_stale(&e, "attr"))
326 }
327
328 pub async fn attr_map(&self) -> Result<HashMap<String, String>> {
339 let flat = timeout(self.cdp_timeout, self.element.attributes())
340 .await
341 .map_err(|_| BrowserError::Timeout {
342 operation: "NodeHandle::attr_map".to_string(),
343 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
344 })?
345 .map_err(|e| self.cdp_err_or_stale(&e, "attr_map"))?;
346
347 let mut map = HashMap::with_capacity(flat.len() / 2);
348 for pair in flat.chunks_exact(2) {
349 if let [name, value] = pair {
350 map.insert(name.clone(), value.clone());
351 }
352 }
353 Ok(map)
354 }
355
356 pub async fn text_content(&self) -> Result<String> {
367 let returns = timeout(
368 self.cdp_timeout,
369 self.element
370 .call_js_fn(r"function() { return this.textContent ?? ''; }", true),
371 )
372 .await
373 .map_err(|_| BrowserError::Timeout {
374 operation: "NodeHandle::text_content".to_string(),
375 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
376 })?
377 .map_err(|e| self.cdp_err_or_stale(&e, "text_content"))?;
378
379 Ok(returns
380 .result
381 .value
382 .as_ref()
383 .and_then(|v| v.as_str())
384 .unwrap_or("")
385 .to_string())
386 }
387
388 pub async fn inner_html(&self) -> Result<String> {
395 timeout(self.cdp_timeout, self.element.inner_html())
396 .await
397 .map_err(|_| BrowserError::Timeout {
398 operation: "NodeHandle::inner_html".to_string(),
399 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
400 })?
401 .map_err(|e| self.cdp_err_or_stale(&e, "inner_html"))
402 .map(Option::unwrap_or_default)
403 }
404
405 pub async fn outer_html(&self) -> Result<String> {
437 match self
438 .outer_html_with_strategy(OuterHtmlStrategy::Current)
439 .await?
440 {
441 OuterHtmlResult::Content(s) => Ok(s),
442 OuterHtmlResult::Empty | OuterHtmlResult::Failed { .. } => Ok(String::new()),
443 }
444 }
445
446 pub async fn outer_html_with_strategy(
495 &self,
496 strategy: OuterHtmlStrategy,
497 ) -> Result<OuterHtmlResult> {
498 match strategy {
499 OuterHtmlStrategy::Current => self.outer_html_current().await,
500 OuterHtmlStrategy::Recursive => self.outer_html_recursive().await,
501 }
502 }
503
504 async fn outer_html_current(&self) -> Result<OuterHtmlResult> {
506 let primary = timeout(self.cdp_timeout, self.element.outer_html())
507 .await
508 .map_err(|_| BrowserError::Timeout {
509 operation: "NodeHandle::outer_html_with_strategy(Current)".to_string(),
510 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
511 })?
512 .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_current"))?;
513
514 if let Some(html) = primary
515 && !html.trim().is_empty()
516 {
517 return Ok(OuterHtmlResult::Content(html));
518 }
519
520 let fallback_html = self.outer_html_via_js().await?;
521 if !fallback_html.trim().is_empty() {
522 return Ok(OuterHtmlResult::Content(fallback_html));
523 }
524
525 Ok(OuterHtmlResult::Empty)
526 }
527
528 async fn outer_html_recursive(&self) -> Result<OuterHtmlResult> {
534 use chromiumoxide::cdp::browser_protocol::dom::{GetOuterHtmlParams, GetOuterHtmlReturns};
535 use chromiumoxide::types::CommandResponse;
536
537 let mut failed_backends: Vec<&'static str> = Vec::new();
538
539 let primary = timeout(
540 self.cdp_timeout,
541 self.page.execute(
542 GetOuterHtmlParams::builder()
543 .object_id(self.element.remote_object_id.clone())
551 .build(),
552 ),
553 )
554 .await
555 .map_err(|_| BrowserError::Timeout {
556 operation: "NodeHandle::outer_html_with_strategy(Recursive)".to_string(),
557 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
558 })?
559 .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_recursive::DOM.getOuterHTML"));
560
561 match primary {
562 Ok(CommandResponse {
563 result: GetOuterHtmlReturns { outer_html },
564 ..
565 }) if !outer_html.trim().is_empty() => {
566 return Ok(OuterHtmlResult::Content(outer_html));
567 }
568 Ok(CommandResponse {
569 result: GetOuterHtmlReturns { outer_html },
570 ..
571 }) => {
572 debug!(
573 selector = %self.selector,
574 bytes = outer_html.len(),
575 "DOM.getOuterHTML returned empty payload; falling back to DOM.describeNode walk"
576 );
577 }
578 Err(e) => {
579 failed_backends.push("DOM.getOuterHTML");
580 debug!(
581 selector = %self.selector,
582 error = %e,
583 "DOM.getOuterHTML failed; falling back to DOM.describeNode walk"
584 );
585 }
586 }
587
588 match self.outer_html_via_rust_walk().await {
589 Ok(html) if !html.trim().is_empty() => Ok(OuterHtmlResult::Content(html)),
590 Ok(_) => {
591 if failed_backends.is_empty() {
592 Ok(OuterHtmlResult::Empty)
595 } else {
596 Ok(OuterHtmlResult::Failed {
601 backends: failed_backends,
602 })
603 }
604 }
605 Err(e) => {
606 failed_backends.push("DOM.describeNode-walk");
607 debug!(
608 selector = %self.selector,
609 error = %e,
610 "Rust-side DOM.describeNode walk failed"
611 );
612 Ok(OuterHtmlResult::Failed {
613 backends: failed_backends,
614 })
615 }
616 }
617 }
618
619 async fn outer_html_via_rust_walk(&self) -> Result<String> {
623 use chromiumoxide::cdp::browser_protocol::dom::DescribeNodeParams;
624 use chromiumoxide::types::CommandResponse;
625
626 let described: CommandResponse<
627 chromiumoxide::cdp::browser_protocol::dom::DescribeNodeReturns,
628 > = timeout(
629 self.cdp_timeout,
630 self.page.execute(
631 DescribeNodeParams::builder()
632 .object_id(self.element.remote_object_id.clone())
636 .depth(-1)
637 .build(),
638 ),
639 )
640 .await
641 .map_err(|_| BrowserError::Timeout {
642 operation: "NodeHandle::outer_html_via_rust_walk".to_string(),
643 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
644 })?
645 .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_via_rust_walk"))?;
646
647 Ok(serialize_node_tree(&described.node))
648 }
649
650 async fn outer_html_via_js(&self) -> Result<String> {
651 let returns = timeout(
652 self.cdp_timeout,
653 self.element.call_js_fn(
654 r"function() {
655 if (typeof this.outerHTML === 'string' && this.outerHTML.length > 0) {
656 return this.outerHTML;
657 }
658 try {
659 return new XMLSerializer().serializeToString(this);
660 } catch (_) {
661 return '';
662 }
663 }",
664 true,
665 ),
666 )
667 .await
668 .map_err(|_| BrowserError::Timeout {
669 operation: "NodeHandle::outer_html_via_js".to_string(),
670 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
671 })?
672 .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_via_js"))?;
673
674 Ok(returns
675 .result
676 .value
677 .as_ref()
678 .and_then(serde_json::Value::as_str)
679 .unwrap_or_default()
680 .to_string())
681 }
682
683 pub async fn ancestors(&self) -> Result<Vec<String>> {
695 let returns = timeout(
696 self.cdp_timeout,
697 self.element.call_js_fn(
698 r"function() {
699 const a = [];
700 let n = this.parentElement;
701 while (n) { a.push(n.tagName.toLowerCase()); n = n.parentElement; }
702 return a;
703 }",
704 true,
705 ),
706 )
707 .await
708 .map_err(|_| BrowserError::Timeout {
709 operation: "NodeHandle::ancestors".to_string(),
710 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
711 })?
712 .map_err(|e| self.cdp_err_or_stale(&e, "ancestors"))?;
713
714 let arr = returns
718 .result
719 .value
720 .as_ref()
721 .and_then(|v| v.as_array())
722 .ok_or_else(|| BrowserError::ScriptExecutionFailed {
723 script: "NodeHandle::ancestors".to_string(),
724 reason: "CDP returned no value or a non-array value for ancestors()".to_string(),
725 })?;
726
727 arr.iter()
728 .map(|v| {
729 v.as_str().map(ToString::to_string).ok_or_else(|| {
730 BrowserError::ScriptExecutionFailed {
731 script: "NodeHandle::ancestors".to_string(),
732 reason: format!("ancestor entry is not a string: {v}"),
733 }
734 })
735 })
736 .collect()
737 }
738
739 pub async fn children_matching(&self, selector: &str) -> Result<Vec<Self>> {
746 let elements = timeout(self.cdp_timeout, self.element.find_elements(selector))
747 .await
748 .map_err(|_| BrowserError::Timeout {
749 operation: "NodeHandle::children_matching".to_string(),
750 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
751 })?
752 .map_err(|e| self.cdp_err_or_stale(&e, "children_matching"))?;
753
754 let selector_arc: Arc<str> = Arc::from(selector);
755 Ok(elements
756 .into_iter()
757 .map(|el| Self {
758 element: el,
759 selector: selector_arc.clone(),
760 cdp_timeout: self.cdp_timeout,
761 page: self.page.clone(),
762 })
763 .collect())
764 }
765
766 pub async fn parent(&self) -> Result<Option<Self>> {
797 let attr = format!(
798 "data-stygian-t-{}",
799 ulid::Ulid::new().to_string().to_lowercase()
800 );
801 let js = format!(
802 "function() {{ \
803 var t = this.parentElement; \
804 if (!t) {{ return false; }} \
805 t.setAttribute('{attr}', '1'); \
806 return true; \
807 }}"
808 );
809 self.call_traversal(&js, &attr, "parent").await
810 }
811
812 pub async fn next_sibling(&self) -> Result<Option<Self>> {
840 let attr = format!(
841 "data-stygian-t-{}",
842 ulid::Ulid::new().to_string().to_lowercase()
843 );
844 let js = format!(
845 "function() {{ \
846 var t = this.nextElementSibling; \
847 if (!t) {{ return false; }} \
848 t.setAttribute('{attr}', '1'); \
849 return true; \
850 }}"
851 );
852 self.call_traversal(&js, &attr, "next").await
853 }
854
855 pub async fn previous_sibling(&self) -> Result<Option<Self>> {
883 let attr = format!(
884 "data-stygian-t-{}",
885 ulid::Ulid::new().to_string().to_lowercase()
886 );
887 let js = format!(
888 "function() {{ \
889 var t = this.previousElementSibling; \
890 if (!t) {{ return false; }} \
891 t.setAttribute('{attr}', '1'); \
892 return true; \
893 }}"
894 );
895 self.call_traversal(&js, &attr, "prev").await
896 }
897
898 async fn call_traversal(
916 &self,
917 js_fn: &str,
918 attr_name: &str,
919 selector_suffix: &str,
920 ) -> Result<Option<Self>> {
921 let op_tag = format!("NodeHandle::{selector_suffix}::tag");
923 let returns = timeout(self.cdp_timeout, self.element.call_js_fn(js_fn, false))
924 .await
925 .map_err(|_| BrowserError::Timeout {
926 operation: op_tag.clone(),
927 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
928 })?
929 .map_err(|e| self.cdp_err_or_stale(&e, selector_suffix))?;
930
931 let has_target = returns
933 .result
934 .value
935 .as_ref()
936 .and_then(serde_json::Value::as_bool)
937 .unwrap_or(false);
938 if !has_target {
939 return Ok(None);
940 }
941
942 let css = format!("[{attr_name}]");
943 let op_resolve = format!("NodeHandle::{selector_suffix}::resolve");
944 let element = timeout(self.cdp_timeout, self.page.find_element(css))
945 .await
946 .map_err(|_| BrowserError::Timeout {
947 operation: op_resolve.clone(),
948 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
949 })?
950 .map_err(|e| BrowserError::CdpError {
951 operation: op_resolve,
952 message: format!("{e:?}"),
953 })?;
954
955 let cleanup = format!("function() {{ this.removeAttribute('{attr_name}'); }}");
957 let _ = element.call_js_fn(cleanup, false).await;
958
959 let new_selector: Arc<str> =
960 Arc::from(format!("{}::{selector_suffix}", self.selector).as_str());
961 Ok(Some(Self {
962 element,
963 selector: new_selector,
964 cdp_timeout: self.cdp_timeout,
965 page: self.page.clone(),
966 }))
967 }
968
969 fn cdp_err_or_stale(
971 &self,
972 err: &chromiumoxide::error::CdpError,
973 operation: &str,
974 ) -> BrowserError {
975 let msg = format!("{err:?}");
976 if msg.contains("Cannot find object with id")
977 || msg.contains("context with specified id")
978 || msg.contains("Cannot find context")
979 {
980 BrowserError::StaleNode {
981 selector: self.selector.to_string(),
982 }
983 } else {
984 BrowserError::CdpError {
985 operation: operation.to_string(),
986 message: msg,
987 }
988 }
989 }
990}
991
992pub struct PageHandle {
1015 page: Page,
1016 cdp_timeout: Duration,
1017 last_status_code: Arc<AtomicU16>,
1019 resource_filter_task: Option<tokio::task::JoinHandle<()>>,
1022}
1023
1024impl PageHandle {
1025 pub(crate) fn new(page: Page, cdp_timeout: Duration) -> Self {
1027 Self {
1028 page,
1029 cdp_timeout,
1030 last_status_code: Arc::new(AtomicU16::new(0)),
1031 resource_filter_task: None,
1032 }
1033 }
1034
1035 pub async fn navigate(
1040 &mut self,
1041 url: &str,
1042 condition: WaitUntil,
1043 nav_timeout: Duration,
1044 ) -> Result<()> {
1045 self.setup_status_capture().await;
1046 timeout(
1047 nav_timeout,
1048 self.navigate_inner(url, condition, nav_timeout),
1049 )
1050 .await
1051 .map_err(|_| BrowserError::NavigationFailed {
1052 url: url.to_string(),
1053 reason: format!("navigation timed out after {nav_timeout:?}"),
1054 })?
1055 }
1056
1057 async fn setup_status_capture(&self) {
1060 use chromiumoxide::cdp::browser_protocol::network::{
1061 EventResponseReceived, ResourceType as NetworkResourceType,
1062 };
1063 use futures::StreamExt;
1064
1065 self.last_status_code.store(0, Ordering::Release);
1067
1068 let page_for_listener = self.page.clone();
1069 let status_capture = Arc::clone(&self.last_status_code);
1070 match page_for_listener
1071 .event_listener::<EventResponseReceived>()
1072 .await
1073 {
1074 Ok(mut stream) => {
1075 tokio::spawn(async move {
1076 while let Some(event) = stream.next().await {
1077 if event.r#type == NetworkResourceType::Document {
1078 let code = u16::try_from(event.response.status).unwrap_or(0);
1079 if code > 0 {
1080 status_capture.store(code, Ordering::Release);
1081 }
1082 break;
1083 }
1084 }
1085 });
1086 }
1087 Err(e) => warn!("status-code capture unavailable: {e}"),
1088 }
1089 }
1090
1091 async fn navigate_inner(
1093 &self,
1094 url: &str,
1095 condition: WaitUntil,
1096 nav_timeout: Duration,
1097 ) -> Result<()> {
1098 use chromiumoxide::cdp::browser_protocol::page::{
1099 EventDomContentEventFired, EventLoadEventFired,
1100 };
1101 use futures::StreamExt;
1102
1103 let url_owned = url.to_string();
1104
1105 let mut dom_events = match &condition {
1106 WaitUntil::DomContentLoaded => Some(
1107 self.page
1108 .event_listener::<EventDomContentEventFired>()
1109 .await
1110 .map_err(|e| BrowserError::NavigationFailed {
1111 url: url_owned.clone(),
1112 reason: format!("{e:?}"),
1113 })?,
1114 ),
1115 _ => None,
1116 };
1117
1118 let mut load_events = match &condition {
1119 WaitUntil::NetworkIdle => Some(
1120 self.page
1121 .event_listener::<EventLoadEventFired>()
1122 .await
1123 .map_err(|e| BrowserError::NavigationFailed {
1124 url: url_owned.clone(),
1125 reason: e.to_string(),
1126 })?,
1127 ),
1128 _ => None,
1129 };
1130
1131 let inflight = if matches!(condition, WaitUntil::NetworkIdle) {
1132 Some(self.subscribe_inflight_counter().await)
1133 } else {
1134 None
1135 };
1136
1137 self.page
1138 .goto(url)
1139 .await
1140 .map_err(|e| BrowserError::NavigationFailed {
1141 url: url_owned.clone(),
1142 reason: e.to_string(),
1143 })?;
1144
1145 match &condition {
1146 WaitUntil::DomContentLoaded => {
1147 if let Some(ref mut events) = dom_events {
1148 let _ = events.next().await;
1149 }
1150 }
1151 WaitUntil::NetworkIdle => {
1152 if let Some(ref mut events) = load_events {
1153 let _ = events.next().await;
1154 }
1155 if let Some(ref counter) = inflight {
1156 Self::wait_network_idle(counter).await;
1157 }
1158 }
1159 WaitUntil::Selector(css) => {
1160 self.wait_for_selector(css, nav_timeout).await?;
1161 }
1162 }
1163 Ok(())
1164 }
1165
1166 async fn subscribe_inflight_counter(&self) -> Arc<std::sync::atomic::AtomicI32> {
1170 use std::sync::atomic::AtomicI32;
1171
1172 use chromiumoxide::cdp::browser_protocol::network::{
1173 EventLoadingFailed, EventLoadingFinished, EventRequestWillBeSent,
1174 };
1175 use futures::StreamExt;
1176
1177 let counter: Arc<AtomicI32> = Arc::new(AtomicI32::new(0));
1178 let pairs: [(Arc<AtomicI32>, i32); 3] = [
1179 (Arc::clone(&counter), 1),
1180 (Arc::clone(&counter), -1),
1181 (Arc::clone(&counter), -1),
1182 ];
1183 let [p1, p2, p3] = [self.page.clone(), self.page.clone(), self.page.clone()];
1184
1185 macro_rules! spawn_tracker {
1186 ($page:expr, $event:ty, $c:expr, $delta:expr) => {
1187 match $page.event_listener::<$event>().await {
1188 Ok(mut s) => {
1189 let c = $c;
1190 let d = $delta;
1191 tokio::spawn(async move {
1192 while s.next().await.is_some() {
1193 c.fetch_add(d, Ordering::Relaxed);
1194 }
1195 });
1196 }
1197 Err(e) => warn!("network-idle tracker unavailable: {e}"),
1198 }
1199 };
1200 }
1201
1202 let [(c1, d1), (c2, d2), (c3, d3)] = pairs;
1203 spawn_tracker!(p1, EventRequestWillBeSent, c1, d1);
1204 spawn_tracker!(p2, EventLoadingFinished, c2, d2);
1205 spawn_tracker!(p3, EventLoadingFailed, c3, d3);
1206
1207 counter
1208 }
1209
1210 async fn wait_network_idle(counter: &Arc<std::sync::atomic::AtomicI32>) {
1211 const IDLE_THRESHOLD: i32 = 2;
1212 const SETTLE: Duration = Duration::from_millis(500);
1213 loop {
1214 if counter.load(Ordering::Relaxed) <= IDLE_THRESHOLD {
1215 tokio::time::sleep(SETTLE).await;
1216 if counter.load(Ordering::Relaxed) <= IDLE_THRESHOLD {
1217 break;
1218 }
1219 } else {
1220 tokio::time::sleep(Duration::from_millis(50)).await;
1221 }
1222 }
1223 }
1224
1225 pub async fn wait_for_selector(&self, selector: &str, wait_timeout: Duration) -> Result<()> {
1230 let selector_owned = selector.to_string();
1231 let poll = async {
1232 loop {
1233 if self.page.find_element(selector_owned.clone()).await.is_ok() {
1234 return Ok(());
1235 }
1236 tokio::time::sleep(Duration::from_millis(100)).await;
1237 }
1238 };
1239
1240 timeout(wait_timeout, poll)
1241 .await
1242 .map_err(|_| BrowserError::NavigationFailed {
1243 url: String::new(),
1244 reason: format!("selector '{selector_owned}' not found within {wait_timeout:?}"),
1245 })?
1246 }
1247
1248 pub async fn set_resource_filter(&mut self, filter: ResourceFilter) -> Result<()> {
1256 use chromiumoxide::cdp::browser_protocol::fetch::{
1257 ContinueRequestParams, EnableParams, EventRequestPaused, FailRequestParams,
1258 RequestPattern,
1259 };
1260 use chromiumoxide::cdp::browser_protocol::network::ErrorReason;
1261 use futures::StreamExt as _;
1262
1263 if filter.is_empty() {
1264 return Ok(());
1265 }
1266
1267 if let Some(task) = self.resource_filter_task.take() {
1269 task.abort();
1270 }
1271
1272 let pattern = RequestPattern::builder().url_pattern("*").build();
1273 let params = EnableParams::builder()
1274 .patterns(vec![pattern])
1275 .handle_auth_requests(false)
1276 .build();
1277
1278 timeout(self.cdp_timeout, self.page.execute::<EnableParams>(params))
1279 .await
1280 .map_err(|_| BrowserError::Timeout {
1281 operation: "Fetch.enable".to_string(),
1282 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1283 })?
1284 .map_err(|e| BrowserError::CdpError {
1285 operation: "Fetch.enable".to_string(),
1286 message: e.to_string(),
1287 })?;
1288
1289 let mut events = self
1292 .page
1293 .event_listener::<EventRequestPaused>()
1294 .await
1295 .map_err(|e| BrowserError::CdpError {
1296 operation: "Fetch.requestPaused subscribe".to_string(),
1297 message: e.to_string(),
1298 })?;
1299
1300 let page = self.page.clone();
1301 debug!("Resource filter active: {:?}", filter);
1302 let task = tokio::spawn(async move {
1303 while let Some(event) = events.next().await {
1304 let request_id = event.request_id.clone();
1305 if filter.should_block(event.resource_type.as_ref()) {
1306 let params = FailRequestParams::new(request_id, ErrorReason::BlockedByClient);
1307 let _ = page.execute(params).await;
1308 } else {
1309 let _ = page.execute(ContinueRequestParams::new(request_id)).await;
1310 }
1311 }
1312 });
1313
1314 self.resource_filter_task = Some(task);
1315 Ok(())
1316 }
1317
1318 pub async fn url(&self) -> Result<String> {
1345 timeout(self.cdp_timeout, self.page.url())
1346 .await
1347 .map_err(|_| BrowserError::Timeout {
1348 operation: "page.url".to_string(),
1349 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1350 })?
1351 .map_err(|e| BrowserError::CdpError {
1352 operation: "page.url".to_string(),
1353 message: e.to_string(),
1354 })
1355 .map(Option::unwrap_or_default)
1356 }
1357
1358 pub fn status_code(&self) -> Result<Option<u16>> {
1389 let code = self.last_status_code.load(Ordering::Acquire);
1390 Ok(if code == 0 { None } else { Some(code) })
1391 }
1392
1393 pub async fn title(&self) -> Result<String> {
1398 timeout(self.cdp_timeout, self.page.get_title())
1399 .await
1400 .map_err(|_| BrowserError::Timeout {
1401 operation: "get_title".to_string(),
1402 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1403 })?
1404 .map_err(|e| BrowserError::ScriptExecutionFailed {
1405 script: "document.title".to_string(),
1406 reason: e.to_string(),
1407 })
1408 .map(Option::unwrap_or_default)
1409 }
1410
1411 pub async fn content(&self) -> Result<String> {
1416 timeout(self.cdp_timeout, self.page.content())
1417 .await
1418 .map_err(|_| BrowserError::Timeout {
1419 operation: "page.content".to_string(),
1420 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1421 })?
1422 .map_err(|e| BrowserError::ScriptExecutionFailed {
1423 script: "document.documentElement.outerHTML".to_string(),
1424 reason: e.to_string(),
1425 })
1426 }
1427
1428 pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeHandle>> {
1460 let elements = timeout(self.cdp_timeout, self.page.find_elements(selector))
1461 .await
1462 .map_err(|_| BrowserError::Timeout {
1463 operation: "PageHandle::query_selector_all".to_string(),
1464 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1465 })?
1466 .map_err(|e| BrowserError::CdpError {
1467 operation: "PageHandle::query_selector_all".to_string(),
1468 message: e.to_string(),
1469 })?;
1470
1471 let selector_arc: Arc<str> = Arc::from(selector);
1472 Ok(elements
1473 .into_iter()
1474 .map(|el| NodeHandle {
1475 element: el,
1476 selector: selector_arc.clone(),
1477 cdp_timeout: self.cdp_timeout,
1478 page: self.page.clone(),
1479 })
1480 .collect())
1481 }
1482
1483 pub async fn eval<T: serde::de::DeserializeOwned>(&self, script: &str) -> Result<T> {
1489 let script_owned = script.to_string();
1490 timeout(self.cdp_timeout, self.page.evaluate(script))
1491 .await
1492 .map_err(|_| BrowserError::Timeout {
1493 operation: "page.evaluate".to_string(),
1494 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1495 })?
1496 .map_err(|e| BrowserError::ScriptExecutionFailed {
1497 script: script_owned.clone(),
1498 reason: e.to_string(),
1499 })?
1500 .into_value::<T>()
1501 .map_err(|e| BrowserError::ScriptExecutionFailed {
1502 script: script_owned,
1503 reason: e.to_string(),
1504 })
1505 }
1506
1507 pub async fn save_cookies(
1511 &self,
1512 ) -> Result<Vec<chromiumoxide::cdp::browser_protocol::network::Cookie>> {
1513 use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
1514
1515 let url = self
1516 .page
1517 .url()
1518 .await
1519 .map_err(|e| BrowserError::CdpError {
1520 operation: "page.url".to_string(),
1521 message: e.to_string(),
1522 })?
1523 .unwrap_or_default();
1524
1525 timeout(
1526 self.cdp_timeout,
1527 self.page
1528 .execute(GetCookiesParams::builder().urls(vec![url]).build()),
1529 )
1530 .await
1531 .map_err(|_| BrowserError::Timeout {
1532 operation: "Network.getCookies".to_string(),
1533 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1534 })?
1535 .map_err(|e| BrowserError::CdpError {
1536 operation: "Network.getCookies".to_string(),
1537 message: e.to_string(),
1538 })
1539 .map(|r| r.cookies.clone())
1540 }
1541
1542 pub async fn inject_cookies(&self, cookies: &[crate::session::SessionCookie]) -> Result<()> {
1579 use chromiumoxide::cdp::browser_protocol::network::SetCookieParams;
1580
1581 for cookie in cookies {
1582 let params = match SetCookieParams::builder()
1583 .name(cookie.name.clone())
1584 .value(cookie.value.clone())
1585 .domain(cookie.domain.clone())
1586 .path(cookie.path.clone())
1587 .http_only(cookie.http_only)
1588 .secure(cookie.secure)
1589 .build()
1590 {
1591 Ok(p) => p,
1592 Err(e) => {
1593 warn!(cookie = %cookie.name, error = %e, "Failed to build cookie params");
1594 continue;
1595 }
1596 };
1597
1598 match timeout(self.cdp_timeout, self.page.execute(params)).await {
1599 Err(_) => {
1600 warn!(
1601 cookie = %cookie.name,
1602 timeout_ms = self.cdp_timeout.as_millis(),
1603 "Timed out injecting cookie"
1604 );
1605 }
1606 Ok(Err(e)) => {
1607 warn!(cookie = %cookie.name, error = %e, "Failed to inject cookie");
1608 }
1609 Ok(Ok(_)) => {}
1610 }
1611 }
1612
1613 debug!(count = cookies.len(), "Cookies injected");
1614 Ok(())
1615 }
1616
1617 pub async fn screenshot(&self) -> Result<Vec<u8>> {
1642 use chromiumoxide::page::ScreenshotParams;
1643
1644 let params = ScreenshotParams::builder().full_page(true).build();
1645
1646 timeout(self.cdp_timeout, self.page.screenshot(params))
1647 .await
1648 .map_err(|_| BrowserError::Timeout {
1649 operation: "Page.captureScreenshot".to_string(),
1650 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1651 })?
1652 .map_err(|e| BrowserError::CdpError {
1653 operation: "Page.captureScreenshot".to_string(),
1654 message: e.to_string(),
1655 })
1656 }
1657
1658 #[must_use]
1660 pub const fn inner(&self) -> &Page {
1661 &self.page
1662 }
1663
1664 pub async fn close(self) -> Result<()> {
1673 timeout(Duration::from_secs(5), self.page.clone().close())
1674 .await
1675 .map_err(|_| BrowserError::Timeout {
1676 operation: "page.close".to_string(),
1677 duration_ms: 5000,
1678 })?
1679 .map_err(|e| BrowserError::CdpError {
1680 operation: "page.close".to_string(),
1681 message: e.to_string(),
1682 })
1683 }
1684}
1685
1686#[cfg(feature = "stealth")]
1689impl PageHandle {
1690 pub async fn verify_stealth(&self) -> Result<crate::diagnostic::DiagnosticReport> {
1725 use crate::diagnostic::{CheckResult, DiagnosticReport, all_checks, all_limitation_probes};
1726
1727 let mut results: Vec<CheckResult> = Vec::new();
1728 let mut known_limitations = Vec::new();
1729
1730 for check in all_checks() {
1731 let result = match self.eval::<String>(check.script).await {
1732 Ok(json) => check.parse_output(&json),
1733 Err(e) => {
1734 tracing::warn!(
1735 check = ?check.id,
1736 error = %e,
1737 "stealth check script failed during evaluation"
1738 );
1739 CheckResult {
1740 id: check.id,
1741 description: check.description.to_string(),
1742 passed: false,
1743 details: format!("script error: {e}"),
1744 }
1745 }
1746 };
1747 tracing::debug!(
1748 check = ?result.id,
1749 passed = result.passed,
1750 details = %result.details,
1751 "stealth check result"
1752 );
1753 results.push(result);
1754 }
1755
1756 for probe in all_limitation_probes() {
1757 let limitation = match self.eval::<String>(probe.script).await {
1758 Ok(json) => probe.parse_output(&json),
1759 Err(error) => Some(crate::diagnostic::KnownLimitation {
1760 id: probe.id,
1761 description: probe.description.to_string(),
1762 details: format!("script error: {error}"),
1763 }),
1764 };
1765 if let Some(limitation) = limitation {
1766 tracing::debug!(
1767 limitation = ?limitation.id,
1768 details = %limitation.details,
1769 "stealth limitation observed"
1770 );
1771 known_limitations.push(limitation);
1772 }
1773 }
1774
1775 Ok(DiagnosticReport::new(results).with_known_limitations(known_limitations))
1776 }
1777
1778 pub async fn verify_stealth_with_transport(
1789 &self,
1790 observed: Option<crate::diagnostic::TransportObservations>,
1791 ) -> Result<crate::diagnostic::DiagnosticReport> {
1792 let report = self.verify_stealth().await?;
1793
1794 let user_agent = match self.eval::<String>("navigator.userAgent").await {
1795 Ok(ua) => ua,
1796 Err(e) => {
1797 tracing::warn!(error = %e, "failed to read navigator.userAgent for transport diagnostics");
1798 String::new()
1799 }
1800 };
1801
1802 let transport = crate::diagnostic::TransportDiagnostic::from_user_agent_and_observations(
1803 &user_agent,
1804 observed.as_ref(),
1805 );
1806
1807 Ok(report.with_transport(transport))
1808 }
1809}
1810
1811#[cfg(feature = "extract")]
1814impl PageHandle {
1815 pub async fn extract_all<T>(&self, selector: &str) -> Result<Vec<T>>
1851 where
1852 T: crate::extract::Extractable,
1853 {
1854 use futures::future::try_join_all;
1855
1856 let nodes = self.query_selector_all(selector).await?;
1857 try_join_all(nodes.iter().map(|n| T::extract_from(n)))
1858 .await
1859 .map_err(BrowserError::ExtractionFailed)
1860 }
1861
1862 pub async fn extract_all_with_fallback<T>(&self, selectors: &[&str]) -> Result<Vec<T>>
1896 where
1897 T: crate::extract::Extractable,
1898 {
1899 use futures::future::try_join_all;
1900
1901 for &selector in selectors {
1902 let nodes = self.query_selector_all(selector).await?;
1903 if nodes.is_empty() {
1904 continue;
1905 }
1906 return try_join_all(nodes.iter().map(|n| T::extract_from(n)))
1907 .await
1908 .map_err(BrowserError::ExtractionFailed);
1909 }
1910
1911 Ok(vec![])
1912 }
1913
1914 pub async fn extract_resilient<T>(&self, selector: &str) -> Result<Vec<T>>
1949 where
1950 T: crate::extract::Extractable,
1951 {
1952 use crate::extract::ExtractionError;
1953
1954 let nodes = self.query_selector_all(selector).await?;
1955 let mut results = Vec::with_capacity(nodes.len());
1956
1957 for node in &nodes {
1958 match T::extract_from(node).await {
1959 Ok(item) => results.push(item),
1960 Err(ExtractionError::Missing { .. }) => {
1961 tracing::debug!(
1962 selector,
1963 "extract_resilient: skipping node with missing required field"
1964 );
1965 }
1966 Err(e) => return Err(BrowserError::ExtractionFailed(e)),
1967 }
1968 }
1969
1970 Ok(results)
1971 }
1972}
1973
1974#[cfg(feature = "similarity")]
1977impl NodeHandle {
1978 pub async fn fingerprint(&self) -> Result<crate::similarity::ElementFingerprint> {
1988 const JS: &str = r"function() {
1989 var el = this;
1990 var tag = el.tagName.toLowerCase();
1991 var classes = Array.prototype.slice.call(el.classList).sort();
1992 var attrNames = Array.prototype.slice.call(el.attributes)
1993 .map(function(a) { return a.name; })
1994 .filter(function(n) { return n !== 'class' && n !== 'id'; })
1995 .sort();
1996 var depth = 0;
1997 var n = el.parentElement;
1998 while (n && n.tagName.toLowerCase() !== 'body') { depth++; n = n.parentElement; }
1999 return JSON.stringify({ tag: tag, classes: classes, attrNames: attrNames, depth: depth });
2000}";
2001
2002 let returns = tokio::time::timeout(self.cdp_timeout, self.element.call_js_fn(JS, true))
2003 .await
2004 .map_err(|_| BrowserError::Timeout {
2005 operation: "NodeHandle::fingerprint".to_string(),
2006 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
2007 })?
2008 .map_err(|e| self.cdp_err_or_stale(&e, "fingerprint"))?;
2009
2010 let json_str = returns
2011 .result
2012 .value
2013 .as_ref()
2014 .and_then(|v| v.as_str())
2015 .ok_or_else(|| BrowserError::ScriptExecutionFailed {
2016 script: "NodeHandle::fingerprint".to_string(),
2017 reason: "CDP returned no string value from fingerprint script".to_string(),
2018 })?;
2019
2020 serde_json::from_str::<crate::similarity::ElementFingerprint>(json_str).map_err(|e| {
2021 BrowserError::ScriptExecutionFailed {
2022 script: "NodeHandle::fingerprint".to_string(),
2023 reason: format!("failed to deserialise fingerprint JSON: {e}"),
2024 }
2025 })
2026 }
2027}
2028
2029#[cfg(feature = "similarity")]
2030impl PageHandle {
2031 pub async fn find_similar(
2064 &self,
2065 reference: &NodeHandle,
2066 config: crate::similarity::SimilarityConfig,
2067 ) -> Result<Vec<crate::similarity::SimilarMatch>> {
2068 use crate::similarity::{SimilarMatch, jaccard_weighted};
2069
2070 let ref_fp = reference.fingerprint().await?;
2071 let candidates = self.query_selector_all("*").await?;
2072
2073 let mut matches: Vec<SimilarMatch> = Vec::new();
2074 for node in candidates {
2075 if let Ok(cand_fp) = node.fingerprint().await {
2076 let score = jaccard_weighted(&ref_fp, &cand_fp);
2077 if score >= config.threshold {
2078 matches.push(SimilarMatch { node, score });
2079 }
2080 }
2081 }
2083
2084 matches.sort_by(|a, b| {
2085 b.score
2086 .partial_cmp(&a.score)
2087 .unwrap_or(std::cmp::Ordering::Equal)
2088 });
2089
2090 if config.max_results > 0 {
2091 matches.truncate(config.max_results);
2092 }
2093
2094 Ok(matches)
2095 }
2096}
2097
2098impl Drop for PageHandle {
2099 fn drop(&mut self) {
2100 warn!("PageHandle dropped without explicit close(); spawning cleanup task");
2101 let page = self.page.clone();
2104 tokio::spawn(async move {
2105 let _ = page.close().await;
2106 });
2107 }
2108}
2109
2110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2119#[serde(rename_all = "snake_case")]
2120pub enum WarmupWait {
2121 #[default]
2124 DomContentLoaded,
2125 NetworkIdle,
2128}
2129
2130impl WarmupWait {
2131 #[must_use]
2133 pub const fn into_wait_until(self) -> WaitUntil {
2134 match self {
2135 Self::DomContentLoaded => WaitUntil::DomContentLoaded,
2136 Self::NetworkIdle => WaitUntil::NetworkIdle,
2137 }
2138 }
2139}
2140
2141#[derive(Debug, Clone, Serialize, Deserialize)]
2157pub struct WarmupOptions {
2158 pub url: String,
2160 #[serde(default)]
2163 pub wait: WarmupWait,
2164 #[serde(default = "WarmupOptions::default_timeout_ms")]
2166 pub timeout_ms: u64,
2167 #[serde(default)]
2171 pub stabilize_ms: u64,
2172}
2173
2174impl WarmupOptions {
2175 #[must_use]
2177 pub const fn default_timeout_ms() -> u64 {
2178 30_000
2179 }
2180}
2181
2182impl Default for WarmupOptions {
2183 fn default() -> Self {
2184 Self {
2185 url: String::new(),
2186 wait: WarmupWait::DomContentLoaded,
2187 timeout_ms: Self::default_timeout_ms(),
2188 stabilize_ms: 0,
2189 }
2190 }
2191}
2192
2193#[derive(Debug, Clone, Serialize, Deserialize)]
2209pub struct WarmupReport {
2210 pub url: String,
2212 pub elapsed_ms: u64,
2214 pub status_code: Option<u16>,
2217 pub title: String,
2219 pub stabilized: bool,
2222}
2223
2224#[derive(Debug, Clone, Serialize, Deserialize)]
2239pub struct RefreshOptions {
2240 #[serde(default)]
2242 pub wait: WarmupWait,
2243 #[serde(default = "RefreshOptions::default_timeout_ms")]
2245 pub timeout_ms: u64,
2246 #[serde(default)]
2251 pub reset_connection: bool,
2252}
2253
2254impl RefreshOptions {
2255 #[must_use]
2257 pub const fn default_timeout_ms() -> u64 {
2258 30_000
2259 }
2260}
2261
2262impl Default for RefreshOptions {
2263 fn default() -> Self {
2264 Self {
2265 wait: WarmupWait::DomContentLoaded,
2266 timeout_ms: Self::default_timeout_ms(),
2267 reset_connection: false,
2268 }
2269 }
2270}
2271
2272#[derive(Debug, Clone, Serialize, Deserialize)]
2286pub struct RefreshReport {
2287 pub url: String,
2289 pub elapsed_ms: u64,
2291 pub status_code: Option<u16>,
2293}
2294
2295impl PageHandle {
2298 pub async fn warmup(&mut self, options: WarmupOptions) -> Result<WarmupReport> {
2332 let start = std::time::Instant::now();
2333 let nav_timeout = Duration::from_millis(options.timeout_ms);
2334 self.navigate(
2335 &options.url,
2336 options.wait.clone().into_wait_until(),
2337 nav_timeout,
2338 )
2339 .await?;
2340 let status_code = self.status_code()?;
2341 let title = self.title().await.unwrap_or_default();
2342 let stabilized = options.stabilize_ms > 0;
2343 if stabilized {
2344 tokio::time::sleep(Duration::from_millis(options.stabilize_ms)).await;
2345 }
2346 let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2347 Ok(WarmupReport {
2348 url: options.url,
2349 elapsed_ms,
2350 status_code,
2351 title,
2352 stabilized,
2353 })
2354 }
2355
2356 pub async fn refresh(&mut self, options: RefreshOptions) -> Result<RefreshReport> {
2394 let start = std::time::Instant::now();
2395 let nav_timeout = Duration::from_millis(options.timeout_ms);
2396 let wait = options.wait.clone().into_wait_until();
2397 let current_url = self.url().await?;
2399 if current_url.is_empty() || current_url == "about:blank" {
2400 return Err(BrowserError::NavigationFailed {
2401 url: current_url,
2402 reason: "page has not been navigated yet; call warmup() or navigate() first"
2403 .to_string(),
2404 });
2405 }
2406 self.navigate(¤t_url, wait, nav_timeout).await?;
2410 let status_code = self.status_code()?;
2411 let url = self.url().await?;
2412 let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2413 Ok(RefreshReport {
2414 url,
2415 elapsed_ms,
2416 status_code,
2417 })
2418 }
2419}
2420
2421mod node_type {
2425 pub const ELEMENT: i64 = 1;
2427 pub const TEXT: i64 = 3;
2429 pub const CDATA_SECTION: i64 = 4;
2431 pub const PROCESSING_INSTRUCTION: i64 = 7;
2433 pub const COMMENT: i64 = 8;
2435 pub const DOCUMENT: i64 = 9;
2437 pub const DOCUMENT_TYPE: i64 = 10;
2439 pub const DOCUMENT_FRAGMENT: i64 = 11;
2441}
2442
2443const VOID_ELEMENTS: &[&str] = &[
2445 "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
2446 "source", "track", "wbr",
2447];
2448
2449fn serialize_node_tree(node: &chromiumoxide::cdp::browser_protocol::dom::Node) -> String {
2477 let mut out = String::new();
2478 serialize_node_into(&mut out, node);
2479 out
2480}
2481
2482fn serialize_node_into(out: &mut String, node: &chromiumoxide::cdp::browser_protocol::dom::Node) {
2483 match node.node_type {
2484 node_type::ELEMENT => {
2485 let tag = node.local_name.as_str();
2486 out.push('<');
2487 out.push_str(tag);
2488 if let Some(attrs) = &node.attributes {
2489 for pair in attrs.chunks_exact(2) {
2490 if let [name, value] = pair {
2491 out.push(' ');
2492 escape_attr_name(out, name);
2493 out.push_str("=\"");
2494 escape_attr_value(out, value);
2495 out.push('"');
2496 }
2497 }
2498 }
2499 if VOID_ELEMENTS.contains(&tag) {
2500 out.push('>');
2501 return;
2502 }
2503 out.push('>');
2504 serialize_inline_children(out, node);
2505 out.push_str("</");
2506 out.push_str(tag);
2507 out.push('>');
2508 }
2509 node_type::TEXT => {
2510 escape_text(out, &node.node_value);
2511 }
2512 node_type::COMMENT => {
2513 out.push_str("<!--");
2514 out.push_str(&node.node_value);
2515 out.push_str("-->");
2516 }
2517 node_type::DOCUMENT | node_type::DOCUMENT_FRAGMENT => {
2518 serialize_inline_children(out, node);
2519 }
2520 node_type::DOCUMENT_TYPE => {
2521 out.push_str("<!DOCTYPE ");
2522 out.push_str(&node.node_name);
2523 if let Some(public_id) = &node.public_id {
2524 out.push(' ');
2525 out.push_str(public_id);
2526 }
2527 if let Some(system_id) = &node.system_id {
2528 out.push(' ');
2529 out.push_str(system_id);
2530 }
2531 out.push('>');
2532 }
2533 node_type::CDATA_SECTION => {
2534 out.push_str("<![CDATA[");
2535 out.push_str(&node.node_value);
2536 out.push_str("]]>");
2537 }
2538 node_type::PROCESSING_INSTRUCTION => {
2539 out.push_str("<?");
2540 out.push_str(&node.node_name);
2541 if !node.node_value.is_empty() {
2542 out.push(' ');
2543 out.push_str(&node.node_value);
2544 }
2545 out.push_str("?>");
2546 }
2547 _ => {
2548 if !node.node_value.is_empty() {
2549 escape_text(out, &node.node_value);
2550 }
2551 }
2552 }
2553}
2554
2555fn serialize_inline_children(
2559 out: &mut String,
2560 node: &chromiumoxide::cdp::browser_protocol::dom::Node,
2561) {
2562 if let Some(children) = &node.children {
2563 for child in children {
2564 serialize_node_into(out, child);
2565 }
2566 }
2567 if let Some(template_content) = &node.template_content {
2568 serialize_node_into(out, template_content);
2569 }
2570 if let Some(shadow_roots) = &node.shadow_roots {
2571 for shadow in shadow_roots {
2572 serialize_node_into(out, shadow);
2573 }
2574 }
2575 if let Some(content_document) = &node.content_document {
2576 serialize_node_into(out, content_document);
2577 }
2578}
2579
2580fn escape_text(out: &mut String, value: &str) {
2582 for ch in value.chars() {
2583 match ch {
2584 '&' => out.push_str("&"),
2585 '<' => out.push_str("<"),
2586 '>' => out.push_str(">"),
2587 _ => out.push(ch),
2588 }
2589 }
2590}
2591
2592fn escape_attr_name(out: &mut String, value: &str) {
2595 for ch in value.chars() {
2596 match ch {
2597 '&' => out.push_str("&"),
2598 '<' => out.push_str("<"),
2599 '"' => out.push_str("""),
2600 _ => out.push(ch),
2601 }
2602 }
2603}
2604
2605fn escape_attr_value(out: &mut String, value: &str) {
2607 for ch in value.chars() {
2608 match ch {
2609 '&' => out.push_str("&"),
2610 '<' => out.push_str("<"),
2611 '"' => out.push_str("""),
2612 _ => out.push(ch),
2613 }
2614 }
2615}
2616
2617#[cfg(test)]
2620mod tests {
2621 use super::*;
2622
2623 #[test]
2624 fn resource_filter_block_media_blocks_image() {
2625 let filter = ResourceFilter::block_media();
2626 assert!(filter.should_block("Image"));
2627 assert!(filter.should_block("Font"));
2628 assert!(filter.should_block("Stylesheet"));
2629 assert!(filter.should_block("Media"));
2630 assert!(!filter.should_block("Script"));
2631 assert!(!filter.should_block("XHR"));
2632 }
2633
2634 #[test]
2635 fn resource_filter_case_insensitive() {
2636 let filter = ResourceFilter::block_images_and_fonts();
2637 assert!(filter.should_block("image")); assert!(filter.should_block("IMAGE")); assert!(!filter.should_block("Stylesheet"));
2640 }
2641
2642 #[test]
2643 fn resource_filter_builder_chain() {
2644 let filter = ResourceFilter::default()
2645 .block(ResourceType::Image)
2646 .block(ResourceType::Font);
2647 assert!(filter.should_block("Image"));
2648 assert!(filter.should_block("Font"));
2649 assert!(!filter.should_block("Stylesheet"));
2650 }
2651
2652 #[test]
2653 fn resource_filter_dedup_block() {
2654 let filter = ResourceFilter::default()
2655 .block(ResourceType::Image)
2656 .block(ResourceType::Image); assert_eq!(filter.blocked.len(), 1);
2658 }
2659
2660 #[test]
2661 fn resource_filter_is_empty_when_default() {
2662 assert!(ResourceFilter::default().is_empty());
2663 assert!(!ResourceFilter::block_media().is_empty());
2664 }
2665
2666 #[test]
2667 fn wait_until_selector_stores_string() {
2668 let w = WaitUntil::Selector("#foo".to_string());
2669 assert!(matches!(w, WaitUntil::Selector(ref s) if s == "#foo"));
2670 }
2671
2672 #[test]
2673 fn resource_type_cdp_str() {
2674 assert_eq!(ResourceType::Image.as_cdp_str(), "Image");
2675 assert_eq!(ResourceType::Font.as_cdp_str(), "Font");
2676 assert_eq!(ResourceType::Stylesheet.as_cdp_str(), "Stylesheet");
2677 assert_eq!(ResourceType::Media.as_cdp_str(), "Media");
2678 }
2679
2680 #[test]
2681 fn page_handle_is_send_sync() {
2682 fn assert_send<T: Send>() {}
2683 fn assert_sync<T: Sync>() {}
2684 assert_send::<PageHandle>();
2685 assert_sync::<PageHandle>();
2686 }
2687
2688 #[cfg(feature = "extract")]
2691 #[test]
2692 fn extraction_error_missing_is_skippable() {
2693 use crate::extract::ExtractionError;
2694
2695 let missing = ExtractionError::Missing {
2696 field: "title",
2697 selector: "h1",
2698 };
2699 assert!(
2700 matches!(missing, ExtractionError::Missing { .. }),
2701 "ExtractionError::Missing should be the skip variant"
2702 );
2703
2704 let nested = ExtractionError::Nested {
2706 field: "link",
2707 source: Box::new(ExtractionError::Missing {
2708 field: "href",
2709 selector: "a",
2710 }),
2711 };
2712 assert!(
2713 !matches!(nested, ExtractionError::Missing { .. }),
2714 "ExtractionError::Nested must not match Missing"
2715 );
2716 }
2717
2718 #[test]
2720 fn status_code_sentinel_zero_maps_to_none() {
2721 use std::sync::atomic::{AtomicU16, Ordering};
2722 let atom = AtomicU16::new(0);
2723 let code = atom.load(Ordering::Acquire);
2724 assert_eq!(if code == 0 { None } else { Some(code) }, None::<u16>);
2725 }
2726
2727 #[test]
2728 fn status_code_non_zero_maps_to_some() {
2729 use std::sync::atomic::{AtomicU16, Ordering};
2730 for &expected in &[200u16, 301, 404, 503] {
2731 let atom = AtomicU16::new(expected);
2732 let code = atom.load(Ordering::Acquire);
2733 assert_eq!(if code == 0 { None } else { Some(code) }, Some(expected));
2734 }
2735 }
2736
2737 #[test]
2742 fn attr_map_chunking_pairs_correctly() {
2743 let flat = [
2744 "id".to_string(),
2745 "main".to_string(),
2746 "data-ux".to_string(),
2747 "Section".to_string(),
2748 "class".to_string(),
2749 "container".to_string(),
2750 ];
2751 let mut map = std::collections::HashMap::with_capacity(flat.len() / 2);
2752 for pair in flat.chunks_exact(2) {
2753 if let [name, value] = pair {
2754 map.insert(name.clone(), value.clone());
2755 }
2756 }
2757 assert_eq!(map.get("id").map(String::as_str), Some("main"));
2758 assert_eq!(map.get("data-ux").map(String::as_str), Some("Section"));
2759 assert_eq!(map.get("class").map(String::as_str), Some("container"));
2760 assert_eq!(map.len(), 3);
2761 }
2762
2763 #[test]
2765 fn attr_map_chunking_ignores_odd_trailing() {
2766 let flat = ["orphan".to_string()]; let mut map = std::collections::HashMap::new();
2768 for pair in flat.chunks_exact(2) {
2769 if let [name, value] = pair {
2770 map.insert(name.clone(), value.clone());
2771 }
2772 }
2773 assert!(map.is_empty());
2774 }
2775
2776 #[test]
2778 fn attr_map_chunking_empty_input() {
2779 let flat: Vec<String> = vec![];
2780 let map: std::collections::HashMap<String, String> = flat
2781 .chunks_exact(2)
2782 .filter_map(|pair| {
2783 if let [name, value] = pair {
2784 Some((name.clone(), value.clone()))
2785 } else {
2786 None
2787 }
2788 })
2789 .collect();
2790 assert!(map.is_empty());
2791 }
2792
2793 #[test]
2794 fn ancestors_json_parse_round_trip() -> std::result::Result<(), serde_json::Error> {
2795 let json = r#"["p","article","body","html"]"#;
2796 let result: Vec<String> = serde_json::from_str(json)?;
2797 assert_eq!(result, ["p", "article", "body", "html"]);
2798 Ok(())
2799 }
2800
2801 #[test]
2802 fn ancestors_json_parse_empty() -> std::result::Result<(), serde_json::Error> {
2803 let json = "[]";
2804 let result: Vec<String> = serde_json::from_str(json)?;
2805 assert!(result.is_empty());
2806 Ok(())
2807 }
2808
2809 #[test]
2812 fn traversal_selector_suffix_in_stale_error() {
2813 let e = crate::error::BrowserError::StaleNode {
2814 selector: "div::parent".to_string(),
2815 };
2816 let msg = e.to_string();
2817 assert!(
2818 msg.contains("div::parent"),
2819 "StaleNode display must include the full selector; got: {msg}"
2820 );
2821 }
2822
2823 #[test]
2824 fn traversal_next_suffix_in_stale_error() {
2825 let e = crate::error::BrowserError::StaleNode {
2826 selector: "li.price::next".to_string(),
2827 };
2828 assert!(e.to_string().contains("li.price::next"));
2829 }
2830
2831 #[test]
2832 fn traversal_prev_suffix_in_stale_error() {
2833 let e = crate::error::BrowserError::StaleNode {
2834 selector: "td.label::prev".to_string(),
2835 };
2836 assert!(e.to_string().contains("td.label::prev"));
2837 }
2838
2839 #[test]
2842 fn outer_html_strategy_default_is_current() {
2843 assert_eq!(OuterHtmlStrategy::default(), OuterHtmlStrategy::Current);
2844 }
2845
2846 #[test]
2847 fn outer_html_strategy_as_str_matches_variant() {
2848 assert_eq!(OuterHtmlStrategy::Current.as_str(), "Current");
2849 assert_eq!(OuterHtmlStrategy::Recursive.as_str(), "Recursive");
2850 }
2851
2852 #[test]
2853 fn outer_html_strategy_display_matches_as_str() {
2854 assert_eq!(
2855 format!("{}", OuterHtmlStrategy::Current),
2856 OuterHtmlStrategy::Current.as_str()
2857 );
2858 assert_eq!(
2859 format!("{}", OuterHtmlStrategy::Recursive),
2860 OuterHtmlStrategy::Recursive.as_str()
2861 );
2862 }
2863
2864 #[test]
2865 fn outer_html_strategy_is_copy_and_eq() {
2866 let s = OuterHtmlStrategy::Recursive;
2867 let copy = s;
2868 assert_eq!(s, copy);
2869 assert_eq!(s, OuterHtmlStrategy::Recursive);
2870 assert_ne!(s, OuterHtmlStrategy::Current);
2871 }
2872
2873 #[test]
2874 fn outer_html_strategy_all_iterates_both_variants() {
2875 let all = OuterHtmlStrategy::all();
2876 assert_eq!(all.len(), 2);
2877 assert_eq!(all[0], OuterHtmlStrategy::Current);
2878 assert_eq!(all[1], OuterHtmlStrategy::Recursive);
2879 }
2880
2881 #[test]
2882 fn outer_html_strategy_serialize_round_trip()
2883 -> std::result::Result<(), Box<dyn std::error::Error>> {
2884 for variant in OuterHtmlStrategy::all() {
2885 let json = serde_json::to_string(&variant)?;
2886 let restored: OuterHtmlStrategy = serde_json::from_str(&json)?;
2887 assert_eq!(restored, variant);
2888 }
2889 Ok(())
2890 }
2891
2892 #[test]
2893 fn outer_html_result_content_returns_some_for_content() {
2894 let r = OuterHtmlResult::Content("<div/>".to_string());
2895 assert_eq!(r.content(), Some("<div/>"));
2896 }
2897
2898 #[test]
2899 fn outer_html_result_content_returns_none_for_empty() {
2900 assert_eq!(OuterHtmlResult::Empty.content(), None);
2901 }
2902
2903 #[test]
2904 fn outer_html_result_content_returns_none_for_failed() {
2905 let r = OuterHtmlResult::Failed {
2906 backends: vec!["DOM.getOuterHTML"],
2907 };
2908 assert_eq!(r.content(), None);
2909 }
2910
2911 #[test]
2912 fn outer_html_result_is_empty_variants() {
2913 assert!(OuterHtmlResult::Empty.is_empty());
2914 assert!(
2915 OuterHtmlResult::Failed {
2916 backends: vec!["a"]
2917 }
2918 .is_empty()
2919 );
2920 assert!(!OuterHtmlResult::Content("<x/>".to_string()).is_empty());
2921 assert!(OuterHtmlResult::Content(String::new()).is_empty());
2922 }
2923
2924 #[test]
2925 fn outer_html_result_display_includes_state() {
2926 assert_eq!(format!("{}", OuterHtmlResult::Empty), "Empty");
2927 assert_eq!(
2928 format!("{}", OuterHtmlResult::Content("<div/>".to_string())),
2929 "Content(6 bytes)"
2930 );
2931 let failed = OuterHtmlResult::Failed {
2932 backends: vec!["DOM.getOuterHTML", "DOM.describeNode-walk"],
2933 };
2934 let s = format!("{failed}");
2935 assert!(s.contains("DOM.getOuterHTML"));
2936 assert!(s.contains("DOM.describeNode-walk"));
2937 }
2938
2939 #[test]
2940 fn outer_html_result_serializes_each_variant()
2941 -> std::result::Result<(), Box<dyn std::error::Error>> {
2942 let empty_json = serde_json::to_string(&OuterHtmlResult::Empty)?;
2943 assert_eq!(empty_json, "\"Empty\"");
2944
2945 let content_json =
2946 serde_json::to_string(&OuterHtmlResult::Content("<p>x</p>".to_string()))?;
2947 assert_eq!(content_json, r#"{"Content":"<p>x</p>"}"#);
2948
2949 let failed_json = serde_json::to_string(&OuterHtmlResult::Failed {
2950 backends: vec!["DOM.getOuterHTML", "DOM.describeNode-walk"],
2951 })?;
2952 assert_eq!(
2953 failed_json,
2954 r#"{"Failed":{"backends":["DOM.getOuterHTML","DOM.describeNode-walk"]}}"#
2955 );
2956 Ok(())
2957 }
2958
2959 use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, Node, NodeId};
2962
2963 fn mk_node(
2964 node_type: i64,
2965 local_name: &str,
2966 node_name: &str,
2967 node_value: &str,
2968 attributes: Option<Vec<String>>,
2969 children: Option<Vec<Node>>,
2970 ) -> Node {
2971 Node {
2972 node_id: NodeId::default(),
2973 parent_id: None,
2974 backend_node_id: BackendNodeId::default(),
2975 node_type,
2976 node_name: node_name.to_string(),
2977 local_name: local_name.to_string(),
2978 node_value: node_value.to_string(),
2979 child_node_count: None,
2980 children,
2981 attributes,
2982 document_url: None,
2983 base_url: None,
2984 public_id: None,
2985 system_id: None,
2986 internal_subset: None,
2987 xml_version: None,
2988 name: None,
2989 value: None,
2990 pseudo_type: None,
2991 pseudo_identifier: None,
2992 shadow_root_type: None,
2993 frame_id: None,
2994 content_document: None,
2995 shadow_roots: None,
2996 template_content: None,
2997 pseudo_elements: None,
2998 distributed_nodes: None,
2999 is_svg: None,
3000 compatibility_mode: None,
3001 assigned_slot: None,
3002 is_scrollable: None,
3003 affected_by_starting_styles: None,
3004 adopted_style_sheets: None,
3005 }
3006 }
3007
3008 #[test]
3009 fn serialize_element_with_text_child() {
3010 let text = mk_node(node_type::TEXT, "", "", "hello", None, None);
3011 let div = mk_node(node_type::ELEMENT, "div", "DIV", "", None, Some(vec![text]));
3012 assert_eq!(serialize_node_tree(&div), "<div>hello</div>");
3013 }
3014
3015 #[test]
3016 fn serialize_element_with_attributes() {
3017 let div = mk_node(
3018 node_type::ELEMENT,
3019 "div",
3020 "DIV",
3021 "",
3022 Some(vec![
3023 "id".into(),
3024 "main".into(),
3025 "class".into(),
3026 "container wide".into(),
3027 ]),
3028 None,
3029 );
3030 assert_eq!(
3031 serialize_node_tree(&div),
3032 r#"<div id="main" class="container wide"></div>"#
3033 );
3034 }
3035
3036 #[test]
3037 fn serialize_void_element_emits_self_closing() {
3038 let img = mk_node(
3039 node_type::ELEMENT,
3040 "img",
3041 "IMG",
3042 "",
3043 Some(vec!["src".into(), "/a.png".into()]),
3044 None,
3045 );
3046 assert_eq!(serialize_node_tree(&img), r#"<img src="/a.png">"#);
3047 let br = mk_node(node_type::ELEMENT, "br", "BR", "", None, None);
3048 assert_eq!(serialize_node_tree(&br), "<br>");
3049 }
3050
3051 #[test]
3052 fn serialize_nested_elements() {
3053 let p = mk_node(
3054 node_type::ELEMENT,
3055 "p",
3056 "P",
3057 "",
3058 None,
3059 Some(vec![mk_node(
3060 node_type::TEXT,
3061 "",
3062 "",
3063 "Mesh content here",
3064 None,
3065 None,
3066 )]),
3067 );
3068 let section = mk_node(
3069 node_type::ELEMENT,
3070 "section",
3071 "SECTION",
3072 "",
3073 None,
3074 Some(vec![p]),
3075 );
3076 let html = serialize_node_tree(§ion);
3077 assert_eq!(html, "<section><p>Mesh content here</p></section>");
3078 }
3079
3080 #[test]
3081 fn serialize_text_escapes_special_chars() {
3082 let n = mk_node(node_type::TEXT, "", "", "a < b && c > d", None, None);
3083 assert_eq!(serialize_node_tree(&n), "a < b && c > d");
3084 }
3085
3086 #[test]
3087 fn serialize_attribute_value_escapes_quotes_and_amp() {
3088 let div = mk_node(
3089 node_type::ELEMENT,
3090 "div",
3091 "DIV",
3092 "",
3093 Some(vec!["title".into(), "a & b \"c\"".into()]),
3094 None,
3095 );
3096 assert_eq!(
3097 serialize_node_tree(&div),
3098 r#"<div title="a & b "c""></div>"#
3099 );
3100 }
3101
3102 #[test]
3103 fn serialize_attribute_name_escapes_special_chars() {
3104 let div = mk_node(
3105 node_type::ELEMENT,
3106 "div",
3107 "DIV",
3108 "",
3109 Some(vec!["weird<\"&".into(), "v".into()]),
3110 None,
3111 );
3112 assert_eq!(
3113 serialize_node_tree(&div),
3114 r#"<div weird<"&="v"></div>"#
3115 );
3116 }
3117
3118 #[test]
3119 fn serialize_comment_node() {
3120 let n = mk_node(node_type::COMMENT, "", "", " a comment ", None, None);
3121 assert_eq!(serialize_node_tree(&n), "<!-- a comment -->");
3122 }
3123
3124 #[test]
3125 fn serialize_document_root_flattens_children() {
3126 let html = mk_node(
3127 node_type::ELEMENT,
3128 "html",
3129 "HTML",
3130 "",
3131 None,
3132 Some(vec![mk_node(
3133 node_type::ELEMENT,
3134 "body",
3135 "BODY",
3136 "",
3137 None,
3138 None,
3139 )]),
3140 );
3141 let doc = mk_node(
3142 node_type::DOCUMENT,
3143 "",
3144 "#document",
3145 "",
3146 None,
3147 Some(vec![html]),
3148 );
3149 assert_eq!(serialize_node_tree(&doc), "<html><body></body></html>");
3150 }
3151
3152 #[test]
3153 fn serialize_document_fragment_root_flattens_children() {
3154 let span = mk_node(
3155 node_type::ELEMENT,
3156 "span",
3157 "SPAN",
3158 "",
3159 None,
3160 Some(vec![mk_node(node_type::TEXT, "", "", "x", None, None)]),
3161 );
3162 let frag = mk_node(
3163 node_type::DOCUMENT_FRAGMENT,
3164 "",
3165 "#document-fragment",
3166 "",
3167 None,
3168 Some(vec![span]),
3169 );
3170 assert_eq!(serialize_node_tree(&frag), "<span>x</span>");
3171 }
3172
3173 #[test]
3174 fn serialize_doctype_node() {
3175 let dt = Node {
3176 public_id: Some("-//W3C//DTD HTML 4.01//EN".to_string()),
3177 system_id: Some("http://www.w3.org/TR/html4/strict.dtd".to_string()),
3178 ..mk_node(node_type::DOCUMENT_TYPE, "", "html", "", None, None)
3179 };
3180 assert_eq!(
3181 serialize_node_tree(&dt),
3182 "<!DOCTYPE html -//W3C//DTD HTML 4.01//EN http://www.w3.org/TR/html4/strict.dtd>"
3183 );
3184 }
3185
3186 #[test]
3187 fn serialize_doctype_node_no_ids() {
3188 let dt = mk_node(node_type::DOCUMENT_TYPE, "", "html", "", None, None);
3189 assert_eq!(serialize_node_tree(&dt), "<!DOCTYPE html>");
3190 }
3191
3192 #[test]
3193 fn serialize_cdata_section() {
3194 let n = mk_node(node_type::CDATA_SECTION, "", "", "raw & <data>", None, None);
3195 assert_eq!(serialize_node_tree(&n), "<![CDATA[raw & <data>]]>");
3196 }
3197
3198 #[test]
3199 fn serialize_processing_instruction() {
3200 let n = mk_node(
3201 node_type::PROCESSING_INSTRUCTION,
3202 "",
3203 "xml-stylesheet",
3204 "href=\"style.css\"",
3205 None,
3206 None,
3207 );
3208 assert_eq!(
3209 serialize_node_tree(&n),
3210 "<?xml-stylesheet href=\"style.css\"?>"
3211 );
3212 }
3213
3214 #[test]
3215 fn serialize_template_inlines_template_content() {
3216 let inner = mk_node(
3217 node_type::ELEMENT,
3218 "span",
3219 "SPAN",
3220 "",
3221 None,
3222 Some(vec![mk_node(node_type::TEXT, "", "", "tmpl", None, None)]),
3223 );
3224 let mut tmpl = mk_node(node_type::ELEMENT, "template", "TEMPLATE", "", None, None);
3225 tmpl.template_content = Some(Box::new(inner));
3226 assert_eq!(
3227 serialize_node_tree(&tmpl),
3228 "<template><span>tmpl</span></template>"
3229 );
3230 }
3231
3232 #[test]
3233 fn serialize_shadow_roots_inlined_into_host() {
3234 let shadow_text = mk_node(node_type::TEXT, "", "", "shadow-text", None, None);
3235 let shadow = Node {
3236 shadow_root_type: Some(chromiumoxide::cdp::browser_protocol::dom::ShadowRootType::Open),
3237 ..mk_node(
3238 node_type::DOCUMENT_FRAGMENT,
3239 "",
3240 "#document-fragment",
3241 "",
3242 None,
3243 Some(vec![mk_node(
3244 node_type::ELEMENT,
3245 "span",
3246 "SPAN",
3247 "",
3248 None,
3249 Some(vec![shadow_text]),
3250 )]),
3251 )
3252 };
3253 let mut host = mk_node(
3254 node_type::ELEMENT,
3255 "div",
3256 "DIV",
3257 "",
3258 None,
3259 Some(vec![mk_node(node_type::TEXT, "", "", "light", None, None)]),
3260 );
3261 host.shadow_roots = Some(vec![shadow]);
3262 assert_eq!(
3263 serialize_node_tree(&host),
3264 "<div>light<span>shadow-text</span></div>"
3265 );
3266 }
3267
3268 #[test]
3269 fn serialize_deeply_nested_subtree() {
3270 let tag_e = mk_node(
3272 node_type::ELEMENT,
3273 "e",
3274 "E",
3275 "",
3276 None,
3277 Some(vec![mk_node(node_type::TEXT, "", "", "deep", None, None)]),
3278 );
3279 let tag_d = mk_node(node_type::ELEMENT, "d", "D", "", None, Some(vec![tag_e]));
3280 let tag_c = mk_node(node_type::ELEMENT, "c", "C", "", None, Some(vec![tag_d]));
3281 let tag_b = mk_node(node_type::ELEMENT, "b", "B", "", None, Some(vec![tag_c]));
3282 let tag_a = mk_node(node_type::ELEMENT, "a", "A", "", None, Some(vec![tag_b]));
3283 assert_eq!(
3284 serialize_node_tree(&tag_a),
3285 "<a><b><c><d><e>deep</e></d></c></b></a>"
3286 );
3287 }
3288
3289 #[test]
3290 fn serialize_element_with_text_and_element_children() {
3291 let span = mk_node(
3292 node_type::ELEMENT,
3293 "span",
3294 "SPAN",
3295 "",
3296 None,
3297 Some(vec![mk_node(node_type::TEXT, "", "", "inline", None, None)]),
3298 );
3299 let div = mk_node(
3300 node_type::ELEMENT,
3301 "div",
3302 "DIV",
3303 "",
3304 None,
3305 Some(vec![
3306 mk_node(node_type::TEXT, "", "", "before", None, None),
3307 span,
3308 mk_node(node_type::TEXT, "", "", "after", None, None),
3309 ]),
3310 );
3311 assert_eq!(
3312 serialize_node_tree(&div),
3313 "<div>before<span>inline</span>after</div>"
3314 );
3315 }
3316
3317 #[test]
3318 fn serialize_attribute_pairs_drop_orphans() {
3319 let div = mk_node(
3321 node_type::ELEMENT,
3322 "div",
3323 "DIV",
3324 "",
3325 Some(vec!["orphan".into()]),
3326 None,
3327 );
3328 assert_eq!(serialize_node_tree(&div), "<div></div>");
3330 }
3331
3332 #[test]
3335 fn warmup_options_defaults() {
3336 let opts = WarmupOptions::default();
3337 assert_eq!(opts.wait, WarmupWait::DomContentLoaded);
3338 assert_eq!(opts.timeout_ms, WarmupOptions::default_timeout_ms());
3339 assert_eq!(opts.stabilize_ms, 0);
3340 }
3341
3342 #[test]
3343 fn warmup_options_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>>
3344 {
3345 let opts = WarmupOptions {
3346 url: "https://example.com".to_string(),
3347 wait: WarmupWait::NetworkIdle,
3348 timeout_ms: 15_000,
3349 stabilize_ms: 250,
3350 };
3351 let json = serde_json::to_string(&opts)?;
3352 let restored: WarmupOptions = serde_json::from_str(&json)?;
3353 assert_eq!(restored.url, "https://example.com");
3354 assert_eq!(restored.wait, WarmupWait::NetworkIdle);
3355 assert_eq!(restored.timeout_ms, 15_000);
3356 assert_eq!(restored.stabilize_ms, 250);
3357 Ok(())
3358 }
3359
3360 #[test]
3361 fn warmup_wait_default_is_dom_content_loaded() {
3362 assert_eq!(WarmupWait::default(), WarmupWait::DomContentLoaded);
3363 }
3364
3365 #[test]
3366 fn warmup_wait_into_wait_until_variants() {
3367 assert!(matches!(
3368 WarmupWait::DomContentLoaded.into_wait_until(),
3369 WaitUntil::DomContentLoaded
3370 ));
3371 assert!(matches!(
3372 WarmupWait::NetworkIdle.into_wait_until(),
3373 WaitUntil::NetworkIdle
3374 ));
3375 }
3376
3377 #[test]
3378 fn refresh_options_defaults() {
3379 let opts = RefreshOptions::default();
3380 assert_eq!(opts.wait, WarmupWait::DomContentLoaded);
3381 assert_eq!(opts.timeout_ms, RefreshOptions::default_timeout_ms());
3382 assert!(!opts.reset_connection);
3383 }
3384
3385 #[test]
3386 fn refresh_options_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>>
3387 {
3388 let opts = RefreshOptions {
3389 wait: WarmupWait::NetworkIdle,
3390 timeout_ms: 10_000,
3391 reset_connection: true,
3392 };
3393 let json = serde_json::to_string(&opts)?;
3394 let restored: RefreshOptions = serde_json::from_str(&json)?;
3395 assert_eq!(restored.wait, WarmupWait::NetworkIdle);
3396 assert_eq!(restored.timeout_ms, 10_000);
3397 assert!(restored.reset_connection);
3398 Ok(())
3399 }
3400
3401 #[test]
3402 fn warmup_report_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>> {
3403 let report = WarmupReport {
3404 url: "https://example.com".to_string(),
3405 elapsed_ms: 320,
3406 status_code: Some(200),
3407 title: "Example Domain".to_string(),
3408 stabilized: true,
3409 };
3410 let json = serde_json::to_string(&report)?;
3411 let restored: WarmupReport = serde_json::from_str(&json)?;
3412 assert_eq!(restored.url, "https://example.com");
3413 assert_eq!(restored.elapsed_ms, 320);
3414 assert_eq!(restored.status_code, Some(200));
3415 assert_eq!(restored.title, "Example Domain");
3416 assert!(restored.stabilized);
3417 Ok(())
3418 }
3419
3420 #[test]
3421 fn refresh_report_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>>
3422 {
3423 let report = RefreshReport {
3424 url: "https://example.com/".to_string(),
3425 elapsed_ms: 180,
3426 status_code: Some(304),
3427 };
3428 let json = serde_json::to_string(&report)?;
3429 let restored: RefreshReport = serde_json::from_str(&json)?;
3430 assert_eq!(restored.url, "https://example.com/");
3431 assert_eq!(restored.elapsed_ms, 180);
3432 assert_eq!(restored.status_code, Some(304));
3433 Ok(())
3434 }
3435
3436 #[test]
3437 fn warmup_options_missing_stabilize_ms_defaults_to_zero()
3438 -> std::result::Result<(), Box<dyn std::error::Error>> {
3439 let json = r#"{"url":"https://example.com","timeout_ms":30000}"#;
3442 let opts: WarmupOptions = serde_json::from_str(json)?;
3443 assert_eq!(opts.stabilize_ms, 0);
3444 Ok(())
3445 }
3446
3447 #[test]
3451 #[ignore = "requires live Chrome"]
3452 #[allow(clippy::expect_used)]
3453 fn integration_warmup_then_extraction() {
3454 let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
3455 rt.block_on(async {
3456 use crate::{BrowserConfig, BrowserPool};
3457 let pool = BrowserPool::new(BrowserConfig::default())
3458 .await
3459 .expect("pool");
3460 let handle = pool.acquire().await.expect("handle");
3461 let mut page = handle
3462 .browser()
3463 .expect("browser")
3464 .new_page()
3465 .await
3466 .expect("page");
3467
3468 let report = page
3469 .warmup(WarmupOptions {
3470 url: "https://example.com".to_string(),
3471 wait: WarmupWait::DomContentLoaded,
3472 timeout_ms: 30_000,
3473 stabilize_ms: 0,
3474 })
3475 .await
3476 .expect("warmup");
3477
3478 assert!(!report.title.is_empty(), "title populated after warmup");
3479 assert!(report.elapsed_ms > 0);
3480
3481 let html = page.content().await.expect("content");
3483 assert!(
3484 html.contains("example"),
3485 "page content available after warmup"
3486 );
3487
3488 page.close().await.expect("close");
3489 handle.release().await;
3490 });
3491 }
3492
3493 #[test]
3496 #[ignore = "requires live Chrome"]
3497 #[allow(clippy::expect_used)]
3498 fn integration_refresh_keeps_session_state() {
3499 let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
3500 rt.block_on(async {
3501 use crate::{BrowserConfig, BrowserPool};
3502 let pool = BrowserPool::new(BrowserConfig::default())
3503 .await
3504 .expect("pool");
3505 let handle = pool.acquire().await.expect("handle");
3506 let mut page = handle
3507 .browser()
3508 .expect("browser")
3509 .new_page()
3510 .await
3511 .expect("page");
3512
3513 page.navigate(
3514 "https://example.com",
3515 WaitUntil::DomContentLoaded,
3516 Duration::from_secs(30),
3517 )
3518 .await
3519 .expect("initial navigate");
3520
3521 let report = page
3522 .refresh(RefreshOptions::default())
3523 .await
3524 .expect("refresh");
3525
3526 assert!(
3527 report.url.contains("example.com"),
3528 "URL retained after refresh; got: {}",
3529 report.url
3530 );
3531 assert!(report.elapsed_ms > 0);
3532
3533 page.close().await.expect("close");
3534 handle.release().await;
3535 });
3536 }
3537}