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