Skip to main content

stygian_browser/
page.rs

1//!
2//! ## Resource blocking
3//!
4//! ## Wait strategies
5//!
6//! [`PageHandle`] exposes three wait strategies via [`WaitUntil`]:
7//! - `DomContentLoaded` — fires when the HTML is parsed
8//!
9//! # Example
10//!
11//! ```no_run
12//! use stygian_browser::{BrowserPool, BrowserConfig};
13//! use stygian_browser::page::{ResourceFilter, WaitUntil};
14//! use std::time::Duration;
15//!
16//! # async fn run() -> stygian_browser::error::Result<()> {
17//! let pool = BrowserPool::new(BrowserConfig::default()).await?;
18//! let handle = pool.acquire().await?;
19//!
20//! let mut page = handle.browser().expect("valid browser").new_page().await?;
21//! page.set_resource_filter(ResourceFilter::block_media()).await?;
22//! page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
23//! let title = page.title().await?;
24//! println!("title: {title}");
25//! handle.release().await;
26//! # Ok(())
27//! # }
28//! ```
29
30use 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// ─── ResourceType ─────────────────────────────────────────────────────────────
45
46/// CDP resource types that can be intercepted.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ResourceType {
49    /// `<img>`, `<picture>`, background images
50    Image,
51    /// Web fonts loaded via CSS `@font-face`
52    Font,
53    /// External CSS stylesheets
54    Stylesheet,
55    /// Media files (audio/video)
56    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// ─── ResourceFilter ───────────────────────────────────────────────────────────
72
73///
74/// # Example
75///
76/// ```
77/// use stygian_browser::page::ResourceFilter;
78/// let filter = ResourceFilter::block_media();
79/// assert!(filter.should_block("Image"));
80/// ```
81#[derive(Debug, Clone, Default)]
82pub struct ResourceFilter {
83    blocked: Vec<ResourceType>,
84}
85
86impl ResourceFilter {
87    /// Block all media resources (images, fonts, CSS, audio/video).
88    #[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// ─── WaitUntil ────────────────────────────────────────────────────────────────
129
130///
131/// # Example
132///
133/// ```
134/// use stygian_browser::page::WaitUntil;
135/// ```
136/// Specifies what condition to wait for after a page navigation.
137#[derive(Debug, Clone)]
138pub enum WaitUntil {
139    /// Fires when the initial HTML is fully parsed, without waiting for
140    /// subresources such as images and stylesheets to finish loading.
141    DomContentLoaded,
142    NetworkIdle,
143    Selector(String),
144}
145
146// ─── OuterHtmlStrategy / OuterHtmlResult ──────────────────────────────────────
147
148/// Selector for [`NodeHandle::outer_html_with_strategy`].
149///
150/// The default [`OuterHtmlStrategy::Current`] preserves the historical call
151/// path used by [`NodeHandle::outer_html`]: a Chromium element-level
152/// `outer_html()` call (which evaluates `this.outerHTML` via JS) followed
153/// by a direct `XMLSerializer` fallback when the primary call returns an
154/// empty payload.
155///
156/// [`OuterHtmlStrategy::Recursive`] uses the dedicated Chromium `DevTools`
157/// Protocol command `DOM.getOuterHTML` (a single round-trip, browser-side
158/// serialisation that already includes shadow-DOM roots) with a Rust-side
159/// fallback that calls `DOM.describeNode` with `depth = -1` and walks the
160/// resulting CDP `Node` tree to produce HTML locally.
161///
162/// Both strategies are **generic** — neither relies on Wix, SPA, or vendor
163/// attributes, classes, or heuristics. `Recursive` simply selects a different
164/// CDP backend that already handles deeply nested subtrees, large SPAs, and
165/// shadow-DOM trees correctly in a single browser-side pass.
166///
167/// # Example
168///
169/// ```
170/// use stygian_browser::page::OuterHtmlStrategy;
171/// assert_eq!(OuterHtmlStrategy::default(), OuterHtmlStrategy::Current);
172/// assert_eq!(OuterHtmlStrategy::Current.as_str(), "Current");
173/// assert_eq!(OuterHtmlStrategy::Recursive.as_str(), "Recursive");
174/// ```
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
176pub enum OuterHtmlStrategy {
177    /// Legacy behaviour: element-level JS eval + `XMLSerializer` fallback.
178    #[default]
179    Current,
180    /// CDP `DOM.getOuterHTML` (single round-trip) + Rust-side
181    /// `DOM.describeNode` walk fallback.
182    Recursive,
183}
184
185impl OuterHtmlStrategy {
186    /// Stable identifier suitable for logs, metrics, and serialization.
187    #[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    /// All known variants in declaration order. Useful for exhaustive
196    /// iteration in tests and diagnostics.
197    #[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/// Outcome of [`NodeHandle::outer_html_with_strategy`].
210///
211/// The default `String`-returning [`NodeHandle::outer_html`] flattens this
212/// into a `Result<String>` where `Empty` and `Failed` both surface as the
213/// empty string — preserving the historical contract.
214///
215/// Derives [`Serialize`] so callers can include the outcome in structured
216/// logs, metrics, or per-request reports. `Deserialize` is intentionally not
217/// derived because the `Failed::backends` field holds `&'static str`
218/// backend names — a deserialised value would need owned `String`s and
219/// would lose the typed backend taxonomy this enum encodes.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221pub enum OuterHtmlResult {
222    /// The chosen strategy's backends all returned an empty payload. This
223    /// typically means the page is still rendering or the node has been
224    /// detached since the handle was created.
225    Empty,
226    /// Successfully serialised outer markup for the target node.
227    Content(String),
228    /// Every backend the strategy tried returned an error. The list names
229    /// the backends in the order they were attempted so callers can build
230    /// retry strategies or surface diagnostics.
231    Failed {
232        /// Names of the backends that returned an error.
233        backends: Vec<&'static str>,
234    },
235}
236
237impl OuterHtmlResult {
238    /// Return the serialized markup, or `None` if the result is `Empty` or
239    /// `Failed`.
240    #[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    /// `true` when the result carries no usable markup — either `Empty` or
249    /// `Failed`.
250    #[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
269// ─── NodeHandle ───────────────────────────────────────────────────────────────
270
271///
272/// more CDP `Runtime.callFunctionOn` calls against the held V8 remote object
273/// reference — no HTML serialisation occurs.
274///
275/// A handle becomes **stale** after page navigation or if the underlying DOM
276/// node is removed.  Stale calls return [`BrowserError::StaleNode`] so callers
277/// can distinguish them from other CDP failures.
278///
279/// # Example
280///
281/// ```no_run
282/// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
283/// use std::time::Duration;
284///
285/// # async fn run() -> stygian_browser::error::Result<()> {
286/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
287/// let handle = pool.acquire().await?;
288/// let mut page = handle.browser().expect("valid browser").new_page().await?;
289/// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
290/// # let nodes = page.query_selector_all("a").await?;
291/// # for node in &nodes {
292///     let href = node.attr("href").await?;
293///     let text = node.text_content().await?;
294///     println!("{text}: {href:?}");
295/// # }
296/// # Ok(())
297/// # }
298/// ```
299pub struct NodeHandle {
300    element: chromiumoxide::element::Element,
301    /// Shared via `Arc<str>` so all handles from a single query reuse the
302    /// same allocation rather than cloning a `String` per node.
303    selector: Arc<str>,
304    cdp_timeout: Duration,
305    /// during DOM traversal (parent / sibling navigation).
306    page: chromiumoxide::Page,
307}
308
309impl NodeHandle {
310    /// Return a single attribute value, or `None` if the attribute is absent.
311    ///
312    /// Issues one `Runtime.callFunctionOn` CDP call (`el.getAttribute(name)`).
313    ///
314    /// # Errors
315    ///
316    /// invalidated, or [`BrowserError::Timeout`] / [`BrowserError::CdpError`]
317    /// on transport-level failures.
318    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    /// Return all attributes as a `HashMap<name, value>` in a **single**
329    /// CDP round-trip.
330    ///
331    /// Uses `DOM.getAttributes` (via the chromiumoxide `attributes()` API)
332    /// which returns a flat `[name, value, name, value, …]` list from the node
333    /// description — no per-attribute calls are needed.
334    ///
335    /// # Errors
336    ///
337    /// invalidated.
338    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    /// Return the element's `textContent` (all text inside, no markup).
357    ///
358    /// Reads the DOM `textContent` property via a single JS eval — this is the
359    /// raw text concatenation of all descendant text nodes, independent of
360    /// layout or visibility (unlike `innerText`).
361    ///
362    ///
363    /// # Errors
364    ///
365    /// invalidated.
366    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    /// Return the element's `innerHTML`.
389    ///
390    ///
391    /// # Errors
392    ///
393    /// invalidated.
394    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    /// Return the element's `outerHTML`.
406    ///
407    /// Backwards-compatible thin wrapper around
408    /// [`outer_html_with_strategy`][Self::outer_html_with_strategy] using the
409    /// default [`OuterHtmlStrategy::Current`] strategy. Preserves the
410    /// historical return contract: `Ok(String)` where the string may be
411    /// empty when both the primary and fallback backends return empty
412    /// payloads.
413    ///
414    /// Callers that need to distinguish an empty payload from a hard failure
415    /// — or that want the deeper `DOM.getOuterHTML` + Rust-side walk path —
416    /// should call [`outer_html_with_strategy`][Self::outer_html_with_strategy]
417    /// directly.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error when any CDP call the chosen strategy actually
422    /// invokes fails — that includes both the primary call and any fallback
423    /// call (the `XMLSerializer` JS fallback for [`OuterHtmlStrategy::Current`],
424    /// the `DOM.describeNode` walk for [`OuterHtmlStrategy::Recursive`]).
425    /// Errors surface as [`BrowserError::Timeout`] (CDP call exceeded
426    /// `cdp_timeout`), [`BrowserError::StaleNode`] (the handle was
427    /// invalidated mid-call), or [`BrowserError::CdpError`] (transport-level
428    /// failure).
429    ///
430    /// Empty or partially-empty payloads from any individual backend do
431    /// **not** error — they are flattened to an empty `String` so the
432    /// historical `Ok(String)` contract is preserved. Callers that need to
433    /// distinguish an empty payload from a hard failure should call
434    /// [`outer_html_with_strategy`][Self::outer_html_with_strategy]
435    /// directly and inspect the [`OuterHtmlResult`] variant.
436    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    /// Return the element's `outerHTML` using an explicit resolution strategy.
447    ///
448    /// The [`OuterHtmlStrategy::Current`] strategy matches the historical
449    /// [`outer_html`][Self::outer_html] path: a Chromium element-level JS
450    /// evaluation of `this.outerHTML`, followed by a JS
451    /// `new XMLSerializer().serializeToString(this)` fallback when the
452    /// primary call returns an empty payload.
453    ///
454    /// The [`OuterHtmlStrategy::Recursive`] strategy resolves [#66] for
455    /// sites where the JS-side `outerHTML` accessor intermittently returns
456    /// a truncated or empty payload — most notably Wix Studio / Editor X
457    /// pages and large SPAs with deeply nested shadow-DOM subtrees. It
458    /// prefers the dedicated Chromium `DevTools` Protocol command
459    /// `DOM.getOuterHTML` (a single round-trip that performs the
460    /// serialisation inside the browser, with shadow-DOM roots included by
461    /// default) and falls back to a Rust-side walk that calls
462    /// `DOM.describeNode` with `depth = -1` and serialises the resulting
463    /// `Node` tree to HTML locally. Neither path relies on Wix-specific
464    /// selectors, attributes, or heuristics — the resolution is entirely
465    /// driven by CDP commands Chromium already exposes.
466    ///
467    /// Both strategies return [`OuterHtmlResult::Empty`] (rather than
468    /// `Failed`) when every backend returns an empty payload — this is
469    /// indistinguishable from "node legitimately empty" at the CDP layer.
470    ///
471    /// [#66]: https://github.com/greysquirr3l/stygian/issues/66
472    ///
473    /// # Errors
474    ///
475    /// Returns [`BrowserError::Timeout`] if the primary CDP call exceeds
476    /// `cdp_timeout`, [`BrowserError::StaleNode`] if the handle was
477    /// invalidated, or [`BrowserError::CdpError`] on transport-level
478    /// failure.
479    ///
480    /// # Example
481    ///
482    /// ```no_run
483    /// use stygian_browser::page::OuterHtmlStrategy;
484    /// # use stygian_browser::error::Result;
485    /// # async fn run(handle: stygian_browser::NodeHandle) -> Result<()> {
486    /// // Use the deep-resolution path for SPA / Wix Studio / shadow-DOM pages.
487    /// let html = handle
488    ///     .outer_html_with_strategy(OuterHtmlStrategy::Recursive)
489    ///     .await?;
490    /// # let _ = html;
491    /// # Ok(())
492    /// # }
493    /// ```
494    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    /// Strategy body for [`OuterHtmlStrategy::Current`].
505    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    /// Strategy body for [`OuterHtmlStrategy::Recursive`].
529    ///
530    /// Primary: `DOM.getOuterHTML` (single round-trip, browser-side
531    /// serialisation via stable `objectId`). Fallback: `DOM.describeNode`
532    /// with `objectId` + `depth=-1`, Rust-side `Node` → HTML serializer.
533    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                    // Use the stable V8 RemoteObjectId instead of the
544                    // ephemeral CDP NodeId. NodeIds are invalidated whenever
545                    // the page's JavaScript mutates the DOM (e.g. React
546                    // re-renders on SPAs like Wix), causing DOM.getOuterHTML
547                    // to silently return an empty string for a valid node.
548                    // RemoteObjectId is tied to the V8 heap object reference
549                    // and survives DOM mutations.
550                    .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                    // Every backend returned an empty payload (no errors
593                    // raised). Surface this as `Empty` rather than `Failed`.
594                    Ok(OuterHtmlResult::Empty)
595                } else {
596                    // At least one backend errored and the other returned
597                    // empty — surface as `Failed` so callers can
598                    // distinguish "nothing to serialize" from "backends
599                    // broke".
600                    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    /// Rust-side fallback: `DOM.describeNode` with `depth = -1` and
620    /// `objectId` returns the entire subtree rooted at the target node;
621    /// we walk it locally and emit HTML using [`serialize_node_tree`].
622    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                    // Use stable RemoteObjectId rather than ephemeral NodeId
633                    // for the same reason as outer_html_recursive — NodeIds
634                    // become stale after SPA DOM mutations.
635                    .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    ///
684    /// Executes a single `Runtime.callFunctionOn` JavaScript function that
685    /// walks `parentElement` and collects tag names — no repeated CDP calls.
686    ///
687    /// ```text
688    /// ["p", "article", "body", "html"]
689    /// ```
690    ///
691    /// # Errors
692    ///
693    /// invalidated, or [`BrowserError::ScriptExecutionFailed`] when CDP
694    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        // With returnByValue=true and an array return, CDP delivers the value
715        // as a JSON array directly — no JSON.stringify/re-parse needed.
716        // A missing or wrong-type value indicates an unexpected CDP failure.
717        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    ///
740    ///
741    ///
742    /// # Errors
743    ///
744    /// invalidated, or [`BrowserError::CdpError`] on transport failure.
745    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    /// Return the immediate parent element, or `None` if this element has no
767    /// parent (i.e. it is the document root).
768    ///
769    /// Issues a single `Runtime.callFunctionOn` CDP call that temporarily tags
770    /// the parent element with a unique attribute, then resolves it via a
771    /// CSS attribute selector.
772    ///
773    /// # Errors
774    ///
775    /// Returns an error if the CDP call fails or the page handle is invalidated.
776    ///
777    /// # Example
778    ///
779    /// ```no_run
780    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
781    /// use std::time::Duration;
782    ///
783    /// # async fn run() -> stygian_browser::error::Result<()> {
784    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
785    /// let handle = pool.acquire().await?;
786    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
787    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
788    /// # let nodes = page.query_selector_all("a").await?;
789    /// if let Some(parent) = nodes[0].parent().await? {
790    ///     let html = parent.outer_html().await?;
791    ///     println!("parent: {}", &html[..html.len().min(80)]);
792    /// }
793    /// # Ok(())
794    /// # }
795    /// ```
796    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    /// Return the next element sibling, or `None` if this element is the last
813    /// child of its parent.
814    ///
815    /// Uses `nextElementSibling` (skips text/comment nodes).
816    ///
817    /// # Errors
818    ///
819    /// invalidated.
820    ///
821    /// # Example
822    ///
823    /// ```no_run
824    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
825    /// use std::time::Duration;
826    ///
827    /// # async fn run() -> stygian_browser::error::Result<()> {
828    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
829    /// let handle = pool.acquire().await?;
830    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
831    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
832    /// # let nodes = page.query_selector_all("a").await?;
833    /// if let Some(next) = nodes[0].next_sibling().await? {
834    ///     println!("next sibling: {}", next.text_content().await?);
835    /// }
836    /// # Ok(())
837    /// # }
838    /// ```
839    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    /// Return the previous element sibling, or `None` if this element is the
856    /// first child of its parent.
857    ///
858    /// Uses `previousElementSibling` (skips text/comment nodes).
859    ///
860    /// # Errors
861    ///
862    /// invalidated.
863    ///
864    /// # Example
865    ///
866    /// ```no_run
867    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
868    /// use std::time::Duration;
869    ///
870    /// # async fn run() -> stygian_browser::error::Result<()> {
871    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
872    /// let handle = pool.acquire().await?;
873    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
874    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
875    /// # let nodes = page.query_selector_all("a").await?;
876    /// if let Some(prev) = nodes[1].previous_sibling().await? {
877    ///     println!("prev sibling: {}", prev.text_content().await?);
878    /// }
879    /// # Ok(())
880    /// # }
881    /// ```
882    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    /// Shared traversal implementation used by [`parent`], [`next_sibling`],
899    /// and [`previous_sibling`].
900    ///
901    /// The caller provides a JS function that:
902    /// 1. Computes the traversal target (for example, the parent, next
903    ///    sibling, or previous sibling) and stores it in a local variable.
904    /// 2. If the target is non-null, sets a unique attribute (`attr_name`)
905    ///    on it and returns `true`.
906    /// 3. Returns `false` when the target is null (no such neighbour).
907    ///
908    /// This helper then resolves the tagged element from the document root,
909    /// removes the temporary attribute, and wraps the result in a
910    /// `NodeHandle`.
911    ///
912    /// [`parent`]: Self::parent
913    /// [`next_sibling`]: Self::next_sibling
914    /// [`previous_sibling`]: Self::previous_sibling
915    async fn call_traversal(
916        &self,
917        js_fn: &str,
918        attr_name: &str,
919        selector_suffix: &str,
920    ) -> Result<Option<Self>> {
921        // Step 1: Run the JS that tags the target element and reports null/non-null.
922        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        // JS returns false → no such neighbour.
932        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        // is non-fatal — it leaves a harmless stale attribute in the DOM).
956        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    /// (when the remote object reference has been invalidated) or
970    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
992// ─── PageHandle ───────────────────────────────────────────────────────────────
993
994///
995///
996/// # Example
997///
998/// ```no_run
999/// use stygian_browser::{BrowserPool, BrowserConfig};
1000/// use stygian_browser::page::WaitUntil;
1001/// use std::time::Duration;
1002///
1003/// # async fn run() -> stygian_browser::error::Result<()> {
1004/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1005/// let handle = pool.acquire().await?;
1006/// let mut page = handle.browser().expect("valid browser").new_page().await?;
1007/// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1008/// let html = page.content().await?;
1009/// drop(page); // closes the tab
1010/// handle.release().await;
1011/// # Ok(())
1012/// # }
1013/// ```
1014pub struct PageHandle {
1015    page: Page,
1016    cdp_timeout: Duration,
1017    /// HTTP status code of the most recent main-frame navigation, or `0` if not
1018    last_status_code: Arc<AtomicU16>,
1019    /// Background task processing `Fetch.requestPaused` events. Aborted and
1020    /// replaced each time `set_resource_filter` is called.
1021    resource_filter_task: Option<tokio::task::JoinHandle<()>>,
1022}
1023
1024impl PageHandle {
1025    /// Wrap a raw chromiumoxide [`Page`] in a handle.
1026    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    ///
1036    /// # Errors
1037    ///
1038    /// the CDP call fails.
1039    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    /// Reset the last status code and wire up the `Network.responseReceived`
1058    /// so that a missing network domain never blocks navigation.
1059    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        // Reset so a stale code is not returned if the new navigation fails
1066        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    /// described in issue #7.
1092    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    /// Spawn three detached tasks that maintain a signed in-flight request
1167    /// counter via `Network.requestWillBeSent` (+1) and
1168    /// `Network.loadingFinished`/`Network.loadingFailed` (−1 each).
1169    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    ///
1226    /// # Errors
1227    ///
1228    /// within the given timeout.
1229    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    ///
1249    /// Enables `Fetch` interception and spawns a background task that continues
1250    /// allowed requests and fails blocked ones with `BlockedByClient`. Any
1251    /// previously set filter task is cancelled first.
1252    ///
1253    /// # Errors
1254    ///
1255    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        // Cancel any previously running filter task.
1268        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        // is never blocked. Without this handler Chrome holds every intercepted
1290        // request indefinitely and the page hangs.
1291        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    /// Return the current page URL (post-navigation, post-redirect).
1319    ///
1320    /// internally by [`save_cookies`](Self::save_cookies); no extra network
1321    /// request is made.  Returns an empty string if the URL is not yet set
1322    ///
1323    /// # Errors
1324    ///
1325    /// [`BrowserError::Timeout`] if it exceeds `cdp_timeout`.
1326    ///
1327    /// # Example
1328    ///
1329    /// ```no_run
1330    /// use stygian_browser::{BrowserPool, BrowserConfig};
1331    /// use stygian_browser::page::WaitUntil;
1332    /// use std::time::Duration;
1333    ///
1334    /// # async fn run() -> stygian_browser::error::Result<()> {
1335    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1336    /// let handle = pool.acquire().await?;
1337    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1338    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1339    /// let url = page.url().await?;
1340    /// println!("Final URL after redirects: {url}");
1341    /// # Ok(())
1342    /// # }
1343    /// ```
1344    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    /// Return the HTTP status code of the most recent main-frame navigation.
1359    ///
1360    /// The status is captured from the `Network.responseReceived` CDP event
1361    /// wired up inside [`navigate`](Self::navigate), so it reflects the
1362    /// *final* response after any server-side redirects.
1363    ///
1364    /// navigations, when [`navigate`](Self::navigate) has not yet been called,
1365    /// or if the network event subscription failed.
1366    ///
1367    /// # Errors
1368    ///
1369    ///
1370    /// # Example
1371    ///
1372    /// ```no_run
1373    /// use stygian_browser::{BrowserPool, BrowserConfig};
1374    /// use stygian_browser::page::WaitUntil;
1375    /// use std::time::Duration;
1376    ///
1377    /// # async fn run() -> stygian_browser::error::Result<()> {
1378    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1379    /// let handle = pool.acquire().await?;
1380    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1381    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1382    /// if let Some(code) = page.status_code()? {
1383    ///     println!("HTTP {code}");
1384    /// }
1385    /// # Ok(())
1386    /// # }
1387    /// ```
1388    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    /// Return the page's `<title>` text.
1394    ///
1395    /// # Errors
1396    ///
1397    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    /// Return the page's full outer HTML.
1412    ///
1413    /// # Errors
1414    ///
1415    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    /// lightweight [`NodeHandle`]s backed by CDP `RemoteObjectId`s.
1429    ///
1430    /// No HTML serialisation occurs — the browser's in-memory DOM is queried
1431    /// directly over the CDP connection, eliminating the `page.content()` +
1432    /// `scraper::Html::parse_document` round-trip.
1433    ///
1434    ///
1435    /// # Errors
1436    ///
1437    /// [`BrowserError::Timeout`] if it exceeds `cdp_timeout`.
1438    ///
1439    /// # Example
1440    ///
1441    /// ```no_run
1442    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
1443    /// use std::time::Duration;
1444    ///
1445    /// # async fn run() -> stygian_browser::error::Result<()> {
1446    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1447    /// let handle = pool.acquire().await?;
1448    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1449    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1450    /// # let nodes = page.query_selector_all("div[data-ux]").await?;
1451    /// # for node in &nodes {
1452    ///     let ux_type = node.attr("data-ux").await?;
1453    ///     let text    = node.text_content().await?;
1454    ///     println!("{ux_type:?}: {text}");
1455    /// # }
1456    /// # Ok(())
1457    /// # }
1458    /// ```
1459    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    /// Evaluate arbitrary JavaScript and return the result as `T`.
1484    ///
1485    /// # Errors
1486    ///
1487    /// deserialization error.
1488    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    ///
1508    /// # Errors
1509    ///
1510    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    ///
1543    /// [`SessionSnapshot`][crate::session::SessionSnapshot] and without
1544    /// requiring a direct `chromiumoxide` dependency in calling code.
1545    ///
1546    /// Individual cookie failures are logged as warnings and do not abort the
1547    /// remaining cookies.
1548    ///
1549    /// # Errors
1550    ///
1551    /// call exceeds `cdp_timeout`.
1552    ///
1553    /// # Example
1554    ///
1555    /// ```no_run
1556    /// use stygian_browser::{BrowserPool, BrowserConfig};
1557    /// use stygian_browser::session::SessionCookie;
1558    /// use std::time::Duration;
1559    ///
1560    /// # async fn run() -> stygian_browser::error::Result<()> {
1561    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1562    /// let handle = pool.acquire().await?;
1563    /// let page = handle.browser().expect("valid browser").new_page().await?;
1564    /// let cookies = vec![SessionCookie {
1565    ///     name: "session".to_string(),
1566    ///     value: "abc123".to_string(),
1567    ///     domain: ".example.com".to_string(),
1568    ///     path: "/".to_string(),
1569    ///     expires: -1.0,
1570    ///     http_only: true,
1571    ///     secure: true,
1572    ///     same_site: "Lax".to_string(),
1573    /// }];
1574    /// page.inject_cookies(&cookies).await?;
1575    /// # Ok(())
1576    /// # }
1577    /// ```
1578    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    /// Capture a screenshot of the current page as PNG bytes.
1618    ///
1619    /// them in-memory.
1620    ///
1621    /// # Errors
1622    ///
1623    /// command fails, or [`BrowserError::Timeout`] if it exceeds
1624    /// `cdp_timeout`.
1625    ///
1626    /// # Example
1627    ///
1628    /// ```no_run
1629    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
1630    /// use std::{time::Duration, fs};
1631    ///
1632    /// # async fn run() -> stygian_browser::error::Result<()> {
1633    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1634    /// let handle = pool.acquire().await?;
1635    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1636    /// let png = page.screenshot().await?;
1637    /// fs::write("screenshot.png", &png).unwrap();
1638    /// # Ok(())
1639    /// # }
1640    /// ```
1641    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    /// Borrow the underlying chromiumoxide [`Page`].
1659    #[must_use]
1660    pub const fn inner(&self) -> &Page {
1661        &self.page
1662    }
1663
1664    /// Close this page (tab).
1665    ///
1666    /// # Errors
1667    ///
1668    /// Returns [`BrowserError::Timeout`] when the close call does not
1669    /// complete within the 5-second timeout, and
1670    /// [`BrowserError::CdpError`] for underlying chromiumoxide failures
1671    /// while issuing the `Page.close` CDP command.
1672    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// ─── Stealth diagnostics ──────────────────────────────────────────────────────
1687
1688#[cfg(feature = "stealth")]
1689impl PageHandle {
1690    /// Run all built-in stealth detection checks against the current page.
1691    ///
1692    /// Iterates [`crate::diagnostic::all_checks`], evaluates each check's
1693    /// JavaScript via CDP `Runtime.evaluate`, and returns an aggregate
1694    /// [`crate::diagnostic::DiagnosticReport`].
1695    ///
1696    /// recorded as failing checks and do **not** abort the whole run.
1697    ///
1698    /// # Errors
1699    ///
1700    /// Individual check failures are captured in the report.
1701    ///
1702    /// # Example
1703    ///
1704    /// ```no_run
1705    /// # async fn run() -> stygian_browser::error::Result<()> {
1706    /// use stygian_browser::{BrowserPool, BrowserConfig};
1707    /// use stygian_browser::page::WaitUntil;
1708    /// use std::time::Duration;
1709    ///
1710    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1711    /// let handle = pool.acquire().await?;
1712    /// let browser = handle.browser().expect("valid browser");
1713    /// let mut page = browser.new_page().await?;
1714    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(10)).await?;
1715    ///
1716    /// let report = page.verify_stealth().await?;
1717    /// println!("Stealth: {}/{} checks passed", report.passed_count, report.checks.len());
1718    /// # for failure in report.failures() {
1719    ///     eprintln!("  FAIL  {}: {}", failure.description, failure.details);
1720    /// # }
1721    /// # Ok(())
1722    /// # }
1723    /// ```
1724    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    /// Run stealth checks and attach transport diagnostics (JA3/JA4/HTTP3).
1779    ///
1780    /// # Errors
1781    ///
1782    /// Propagates any [`BrowserError`] returned by the inner
1783    /// [`Self::verify_stealth`] call (which surfaces CDP / selector /
1784    /// evaluation failures from the underlying stealth probe). The
1785    /// `navigator.userAgent` read uses `eval` and is best-effort — its
1786    /// failure is logged and downgraded to an empty string so the
1787    /// transport-diagnostic block can still be attached.
1788    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// ─── extract feature ─────────────────────────────────────────────────────────
1812
1813#[cfg(feature = "extract")]
1814impl PageHandle {
1815    ///
1816    ///
1817    /// All per-node extractions are driven concurrently via
1818    /// [`futures::future::try_join_all`].
1819    ///
1820    /// # Errors
1821    ///
1822    /// fails, or [`BrowserError::ExtractionFailed`] if any field extraction
1823    /// fails.
1824    ///
1825    /// # Example
1826    ///
1827    /// ```ignore
1828    /// use stygian_browser::extract::Extract;
1829    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
1830    /// use std::time::Duration;
1831    ///
1832    /// #[derive(Extract)]
1833    /// struct Link {
1834    ///     href: Option<String>,
1835    /// }
1836    ///
1837    /// # async fn run() -> stygian_browser::error::Result<()> {
1838    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1839    /// let handle = pool.acquire().await?;
1840    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1841    /// page.navigate(
1842    ///     "https://example.com",
1843    ///     WaitUntil::DomContentLoaded,
1844    ///     Duration::from_secs(30),
1845    /// ).await?;
1846    /// let links: Vec<Link> = page.extract_all::<Link>("nav li").await?;
1847    /// # Ok(())
1848    /// # }
1849    /// ```
1850    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    /// Try each selector in `selectors` in order and return the extracted
1863    /// results from the **first** selector that matches at least one node.
1864    ///
1865    /// This is useful when a page may use different markup across versions or
1866    /// A/B variants — supply the preferred selector first and progressively
1867    /// wider fallbacks afterwards.
1868    ///
1869    /// Returns an empty `Vec` only when *all* selectors match zero nodes
1870    /// (i.e. the element is genuinely absent from the page).  A non-empty
1871    /// intermediate selector result that then fails during extraction **will**
1872    /// return an error.
1873    ///
1874    /// # Errors
1875    ///
1876    /// Returns [`BrowserError::CdpError`] if the selector query fails, or
1877    /// [`BrowserError::ExtractionFailed`] if a matched node fails extraction.
1878    ///
1879    /// # Example
1880    ///
1881    /// ```ignore
1882    /// use stygian_browser::extract::Extract;
1883    ///
1884    /// #[derive(Extract)]
1885    /// struct Headline { title: String }
1886    ///
1887    /// # async fn run(page: &stygian_browser::PageHandle) -> stygian_browser::error::Result<()> {
1888    /// // Try modern selector first, fall back to legacy markup.
1889    /// let items = page
1890    ///     .extract_all_with_fallback::<Headline>(&["h2.headline", "h2.title", "h2"])
1891    ///     .await?;
1892    /// # Ok(())
1893    /// # }
1894    /// ```
1895    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    /// Extract from every node matching `selector`, **skipping** nodes where
1915    /// a required field is absent (i.e. [`ExtractionError::Missing`]).
1916    ///
1917    /// Unlike [`extract_all`], this method is lenient about structural
1918    /// mismatches: nodes that fail with [`ExtractionError::Missing`] are
1919    /// silently dropped from the result set.  All other extraction errors
1920    /// (CDP failures, stale nodes, nested errors) still propagate as hard
1921    /// failures.
1922    ///
1923    /// This is useful when scraping heterogeneous lists where some items
1924    /// lack an optional field that your struct treats as required.
1925    ///
1926    /// [`extract_all`]: Self::extract_all
1927    /// [`ExtractionError::Missing`]: crate::extract::ExtractionError::Missing
1928    ///
1929    /// # Errors
1930    ///
1931    /// Returns [`BrowserError::CdpError`] if the selector query fails, or
1932    /// [`BrowserError::ExtractionFailed`] for non-`Missing` extraction errors.
1933    ///
1934    /// # Example
1935    ///
1936    /// ```ignore
1937    /// use stygian_browser::extract::Extract;
1938    ///
1939    /// #[derive(Extract)]
1940    /// struct Price { amount: String }
1941    ///
1942    /// # async fn run(page: &stygian_browser::PageHandle) -> stygian_browser::error::Result<()> {
1943    /// // Products without a price tag are silently skipped.
1944    /// let prices = page.extract_resilient::<Price>(".product").await?;
1945    /// # Ok(())
1946    /// # }
1947    /// ```
1948    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// ─── similarity feature ──────────────────────────────────────────────────────
1975
1976#[cfg(feature = "similarity")]
1977impl NodeHandle {
1978    /// node.
1979    ///
1980    /// Issues a single `Runtime.callFunctionOn` JS eval that extracts the tag,
1981    /// class list, attribute names, and body-depth in one round-trip.
1982    ///
1983    /// # Errors
1984    ///
1985    /// invalidated, or [`BrowserError::ScriptExecutionFailed`] if the script
1986    /// produces unexpected output.
1987    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    /// `reference`, scored by [`crate::similarity::SimilarityConfig`].
2032    ///
2033    /// [`NodeHandle::fingerprint`]), then fingerprints every candidate returned
2034    /// [`crate::similarity::jaccard_weighted`] score exceeds
2035    /// `config.threshold`.  Results are ordered by score descending.
2036    ///
2037    /// # Example
2038    ///
2039    /// ```no_run
2040    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
2041    /// use stygian_browser::similarity::SimilarityConfig;
2042    /// use std::time::Duration;
2043    ///
2044    /// # async fn run() -> stygian_browser::error::Result<()> {
2045    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
2046    /// let handle = pool.acquire().await?;
2047    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
2048    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
2049    ///
2050    /// # let nodes = page.query_selector_all("h1").await?;
2051    /// # let reference = nodes.into_iter().next().ok_or(stygian_browser::error::BrowserError::StaleNode { selector: "h1".to_string() })?;
2052    ///     let similar = page.find_similar(&reference, SimilarityConfig::default()).await?;
2053    /// # for m in &similar {
2054    ///         println!("score={:.2}", m.score);
2055    /// # }
2056    /// # Ok(())
2057    /// # }
2058    /// ```
2059    ///
2060    /// # Errors
2061    ///
2062    /// [`BrowserError::ScriptExecutionFailed`] if a scoring script fails.
2063    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            // Stale / detached nodes are silently skipped.
2082        }
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        // chromiumoxide Page does not implement close on Drop, so we spawn
2102        // swap it out. We clone the Page handle (it's Arc-backed internally).
2103        let page = self.page.clone();
2104        tokio::spawn(async move {
2105            let _ = page.close().await;
2106        });
2107    }
2108}
2109
2110// ─── Session warmup & refresh ─────────────────────────────────────────────────
2111
2112/// Simplified, JSON-serializable wait strategy used in [`WarmupOptions`] and
2113/// [`RefreshOptions`].
2114///
2115/// This is a serialization-friendly analogue of [`WaitUntil`].  Use
2116/// [`WarmupWait::into_wait_until`] to convert before calling
2117/// [`PageHandle::navigate`].
2118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2119#[serde(rename_all = "snake_case")]
2120pub enum WarmupWait {
2121    /// Wait until the HTML is fully parsed (`DOMContentLoaded`).  This is the
2122    /// default and works for most pages.
2123    #[default]
2124    DomContentLoaded,
2125    /// Wait until there are no more than two in-flight network requests for at
2126    /// least 500 ms after navigation.
2127    NetworkIdle,
2128}
2129
2130impl WarmupWait {
2131    /// Convert into the lower-level [`WaitUntil`] enum.
2132    #[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/// Options for [`PageHandle::warmup`].
2142///
2143/// # Example
2144///
2145/// ```
2146/// use stygian_browser::page::{WarmupOptions, WarmupWait};
2147///
2148/// let opts = WarmupOptions {
2149///     url: "https://example.com".to_string(),
2150///     wait: WarmupWait::DomContentLoaded,
2151///     timeout_ms: 30_000,
2152///     stabilize_ms: 500,
2153/// };
2154/// assert_eq!(opts.timeout_ms, 30_000);
2155/// ```
2156#[derive(Debug, Clone, Serialize, Deserialize)]
2157pub struct WarmupOptions {
2158    /// The URL to navigate to during warmup.
2159    pub url: String,
2160    /// Wait strategy applied after the navigation commit (default:
2161    /// `DomContentLoaded`).
2162    #[serde(default)]
2163    pub wait: WarmupWait,
2164    /// Navigation timeout in milliseconds.  Default: `30 000`.
2165    #[serde(default = "WarmupOptions::default_timeout_ms")]
2166    pub timeout_ms: u64,
2167    /// Additional pause after navigation to let dynamic resources (XHR,
2168    /// lazy-loaded images) settle, in milliseconds.  `0` disables the
2169    /// stabilization step (default).
2170    #[serde(default)]
2171    pub stabilize_ms: u64,
2172}
2173
2174impl WarmupOptions {
2175    /// Returns the default navigation timeout (30 000 ms).
2176    #[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/// Diagnostic report produced by [`PageHandle::warmup`].
2194///
2195/// # Example
2196///
2197/// ```
2198/// use stygian_browser::page::WarmupReport;
2199/// let report = WarmupReport {
2200///     url: "https://example.com".to_string(),
2201///     elapsed_ms: 250,
2202///     status_code: Some(200),
2203///     title: "Example Domain".to_string(),
2204///     stabilized: false,
2205/// };
2206/// assert_eq!(report.status_code, Some(200));
2207/// ```
2208#[derive(Debug, Clone, Serialize, Deserialize)]
2209pub struct WarmupReport {
2210    /// The URL that was warmed.
2211    pub url: String,
2212    /// Elapsed wall-time in milliseconds.
2213    pub elapsed_ms: u64,
2214    /// HTTP status code of the warmup navigation, if captured by the
2215    /// `Network.responseReceived` listener.
2216    pub status_code: Option<u16>,
2217    /// Page title after warmup navigation.
2218    pub title: String,
2219    /// Whether a stabilization pause (`stabilize_ms > 0`) was applied after
2220    /// navigation.
2221    pub stabilized: bool,
2222}
2223
2224/// Options for [`PageHandle::refresh`].
2225///
2226/// # Example
2227///
2228/// ```
2229/// use stygian_browser::page::{RefreshOptions, WarmupWait};
2230///
2231/// let opts = RefreshOptions {
2232///     wait: WarmupWait::DomContentLoaded,
2233///     timeout_ms: 15_000,
2234///     reset_connection: true,
2235/// };
2236/// assert!(opts.reset_connection);
2237/// ```
2238#[derive(Debug, Clone, Serialize, Deserialize)]
2239pub struct RefreshOptions {
2240    /// Wait strategy applied after the reload (default: `DomContentLoaded`).
2241    #[serde(default)]
2242    pub wait: WarmupWait,
2243    /// Reload timeout in milliseconds.  Default: `30 000`.
2244    #[serde(default = "RefreshOptions::default_timeout_ms")]
2245    pub timeout_ms: u64,
2246    /// When `true`, re-navigates to the current URL rather than issuing a
2247    /// browser-level reload.  This signals to the calling code that a new TCP
2248    /// connection is desired while cookies and storage are retained in the
2249    /// browser process.  Default: `false`.
2250    #[serde(default)]
2251    pub reset_connection: bool,
2252}
2253
2254impl RefreshOptions {
2255    /// Returns the default reload timeout (30 000 ms).
2256    #[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/// Diagnostic report produced by [`PageHandle::refresh`].
2273///
2274/// # Example
2275///
2276/// ```
2277/// use stygian_browser::page::RefreshReport;
2278/// let report = RefreshReport {
2279///     url: "https://example.com".to_string(),
2280///     elapsed_ms: 180,
2281///     status_code: Some(200),
2282/// };
2283/// assert_eq!(report.elapsed_ms, 180);
2284/// ```
2285#[derive(Debug, Clone, Serialize, Deserialize)]
2286pub struct RefreshReport {
2287    /// URL of the page after the refresh navigation.
2288    pub url: String,
2289    /// Elapsed wall-time in milliseconds.
2290    pub elapsed_ms: u64,
2291    /// HTTP status code of the refresh navigation, if captured.
2292    pub status_code: Option<u16>,
2293}
2294
2295// ─── PageHandle warmup / refresh ──────────────────────────────────────────────
2296
2297impl PageHandle {
2298    /// Warm up a browser session by navigating to `options.url` and
2299    /// optionally waiting for dynamic resources to settle.
2300    ///
2301    /// Warmup is **idempotent**: calling it repeatedly re-navigates and
2302    /// re-warms the same session without adverse side effects.
2303    ///
2304    /// # Errors
2305    ///
2306    /// Returns [`BrowserError::NavigationFailed`] if the navigation times out
2307    /// or the underlying CDP call fails.
2308    ///
2309    /// # Example
2310    ///
2311    /// ```no_run
2312    /// # async fn run() -> stygian_browser::error::Result<()> {
2313    /// use stygian_browser::{BrowserPool, BrowserConfig};
2314    /// use stygian_browser::page::{WarmupOptions, WarmupWait};
2315    ///
2316    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
2317    /// let handle = pool.acquire().await?;
2318    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
2319    ///
2320    /// let report = page.warmup(WarmupOptions {
2321    ///     url: "https://example.com".to_string(),
2322    ///     wait: WarmupWait::DomContentLoaded,
2323    ///     timeout_ms: 30_000,
2324    ///     stabilize_ms: 500,
2325    /// }).await?;
2326    /// println!("warmed in {}ms: {}", report.elapsed_ms, report.title);
2327    /// handle.release().await;
2328    /// # Ok(())
2329    /// # }
2330    /// ```
2331    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    /// Refresh the current page, retaining all in-browser session state
2357    /// (cookies, `localStorage`, `sessionStorage`).
2358    ///
2359    /// When `options.reset_connection` is `false` (default) a standard
2360    /// CDP reload is issued.  When `true`, the current URL is re-navigated,
2361    /// which expresses the caller's intent to force a new underlying TCP/TLS
2362    /// connection while keeping all browser-side state intact.
2363    ///
2364    /// Refresh is **idempotent**: repeated calls simply reload the page again.
2365    ///
2366    /// # Errors
2367    ///
2368    /// Returns [`BrowserError::NavigationFailed`] if the current URL cannot be
2369    /// determined or the reload times out.
2370    ///
2371    /// # Example
2372    ///
2373    /// ```no_run
2374    /// # async fn run() -> stygian_browser::error::Result<()> {
2375    /// use stygian_browser::{BrowserPool, BrowserConfig};
2376    /// use stygian_browser::page::{RefreshOptions, WaitUntil};
2377    ///
2378    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
2379    /// let handle = pool.acquire().await?;
2380    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
2381    /// page.navigate(
2382    ///     "https://example.com",
2383    ///     WaitUntil::DomContentLoaded,
2384    ///     std::time::Duration::from_secs(30),
2385    /// ).await?;
2386    ///
2387    /// let report = page.refresh(RefreshOptions::default()).await?;
2388    /// println!("refreshed in {}ms", report.elapsed_ms);
2389    /// handle.release().await;
2390    /// # Ok(())
2391    /// # }
2392    /// ```
2393    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        // Resolve the current URL before any navigation changes it.
2398        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        // Both code paths navigate to the same URL.  `reset_connection: true`
2407        // expresses the *intent* to use a new TCP connection; the browser is free
2408        // to reuse or create a new connection as its connection pool dictates.
2409        self.navigate(&current_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
2421// ─── Rust-side CDP Node → HTML serializer (Recursive fallback) ───────────────
2422
2423/// CDP `DOM.Node.nodeType` constants (matches the WHATWG DOM spec).
2424mod node_type {
2425    /// `Element` node.
2426    pub const ELEMENT: i64 = 1;
2427    /// Text node (`Text`).
2428    pub const TEXT: i64 = 3;
2429    /// `CDATASection` node.
2430    pub const CDATA_SECTION: i64 = 4;
2431    /// `ProcessingInstruction` node.
2432    pub const PROCESSING_INSTRUCTION: i64 = 7;
2433    /// `Comment` node.
2434    pub const COMMENT: i64 = 8;
2435    /// `Document` node.
2436    pub const DOCUMENT: i64 = 9;
2437    /// `DocumentType` node.
2438    pub const DOCUMENT_TYPE: i64 = 10;
2439    /// `DocumentFragment` node.
2440    pub const DOCUMENT_FRAGMENT: i64 = 11;
2441}
2442
2443/// HTML elements that have no closing tag (per the WHATWG spec).
2444const VOID_ELEMENTS: &[&str] = &[
2445    "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
2446    "source", "track", "wbr",
2447];
2448
2449/// Serialise a CDP `Node` subtree (rooted at `node`) to an HTML string.
2450///
2451/// Used by [`NodeHandle::outer_html_via_rust_walk`] as the
2452/// [`OuterHtmlStrategy::Recursive`] fallback when `DOM.getOuterHTML`
2453/// returns an empty payload or errors out. The implementation is a
2454/// straightforward depth-first walk that mirrors what Chromium's own
2455/// `Element.outerHTML` accessor produces for the same tree:
2456/// - element nodes emit `<tag attrs>children</tag>`. [`VOID_ELEMENTS`]
2457///   emit `<tag attrs>` with no closing slash and no children, matching
2458///   Chromium's `outerHTML` byte-for-byte (which uses HTML5 syntax, not
2459///   XHTML self-closing).
2460/// - text nodes are HTML-escaped
2461/// - comment nodes emit `<!--value-->`
2462/// - `<!DOCTYPE …>` declarations are emitted for `DocumentType` roots
2463/// - `Document` / `DocumentFragment` roots emit only their children
2464///   (no outer wrapper), matching how `XMLSerializer` treats them
2465/// - `template` content (`template_content`) is inlined as additional
2466///   children of the `<template>` element, mirroring browser behaviour
2467/// - shadow roots are inlined as additional children of their host
2468///   (no `<shadowroot>` wrapper, since shadow content is what
2469///   `outerHTML` is expected to surface)
2470///
2471/// This serializer is not intended to be a perfect drop-in for
2472/// `Element.outerHTML` on every edge case (`CDATA`, `ProcessingInstruction`,
2473/// and namespace prefixes are simplified) — it is the second-line fallback
2474/// for the `Recursive` strategy and only fires when `DOM.getOuterHTML`
2475/// itself fails.
2476fn 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
2555/// Emit the inline children of a node (regular `children`, plus
2556/// `template_content`, `shadow_roots`, and `content_document`) in the order
2557/// Chromium's own `Element.outerHTML` accessor surfaces them.
2558fn 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
2580/// Escape a text node payload for safe inclusion in HTML element content.
2581fn escape_text(out: &mut String, value: &str) {
2582    for ch in value.chars() {
2583        match ch {
2584            '&' => out.push_str("&amp;"),
2585            '<' => out.push_str("&lt;"),
2586            '>' => out.push_str("&gt;"),
2587            _ => out.push(ch),
2588        }
2589    }
2590}
2591
2592/// Escape an attribute name (same rules as text — `&` and `<` cannot appear
2593/// in well-formed attribute names but are escaped defensively).
2594fn escape_attr_name(out: &mut String, value: &str) {
2595    for ch in value.chars() {
2596        match ch {
2597            '&' => out.push_str("&amp;"),
2598            '<' => out.push_str("&lt;"),
2599            '"' => out.push_str("&quot;"),
2600            _ => out.push(ch),
2601        }
2602    }
2603}
2604
2605/// Escape an attribute value for inclusion inside `"…"` quoted form.
2606fn escape_attr_value(out: &mut String, value: &str) {
2607    for ch in value.chars() {
2608        match ch {
2609            '&' => out.push_str("&amp;"),
2610            '<' => out.push_str("&lt;"),
2611            '"' => out.push_str("&quot;"),
2612            _ => out.push(ch),
2613        }
2614    }
2615}
2616
2617// ─── Tests ────────────────────────────────────────────────────────────────────
2618
2619#[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")); // lowercase
2638        assert!(filter.should_block("IMAGE")); // uppercase
2639        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); // duplicate
2657        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    /// Verify the resilient extractor correctly classifies `ExtractionError`
2689    /// variants — `Missing` must be treated as "skip", others as hard errors.
2690    #[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        // Non-Missing variants should NOT match the skip pattern
2705        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    /// `Option<u16>` are pure-logic invariants testable without a live browser.
2719    #[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    // ── NodeHandle pure-logic tests ───────────────────────────────────────────
2738
2739    /// `attr_map` relies on `chunks_exact(2)` — verify the pairing logic is
2740    /// correct without a live browser by exercising it directly.
2741    #[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    /// gracefully — the trailing element is silently ignored.
2764    #[test]
2765    fn attr_map_chunking_ignores_odd_trailing() {
2766        let flat = ["orphan".to_string()]; // no value
2767        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    /// Empty flat list → empty map.
2777    #[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    /// `"div::parent"`) must surface that suffix in its `Display` output so
2810    /// callers can locate the failed traversal in logs.
2811    #[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    // ── OuterHtmlStrategy / OuterHtmlResult type tests (T101) ─────────────────
2840
2841    #[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    // ── Rust-side CDP Node → HTML serializer tests (T101) ─────────────────────
2960
2961    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(&section);
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 &lt; b &amp;&amp; c &gt; 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 &amp; b &quot;c&quot;"></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&lt;&quot;&amp;="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        // Build a 5-level deep subtree: <a><b><c><d><e>deep</e></d></c></b></a>
3271        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        // An odd-length attribute list (one name with no value) must not crash.
3320        let div = mk_node(
3321            node_type::ELEMENT,
3322            "div",
3323            "DIV",
3324            "",
3325            Some(vec!["orphan".into()]),
3326            None,
3327        );
3328        // The orphan name has no value so it is silently skipped (pairs of 2).
3329        assert_eq!(serialize_node_tree(&div), "<div></div>");
3330    }
3331
3332    // ── Warmup / Refresh type tests ───────────────────────────────────────────
3333
3334    #[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        // stabilize_ms has `#[serde(default)]`; omitting it from JSON should
3440        // deserialize to 0 rather than erroring.
3441        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    // ── Integration tests (require live Chrome — skipped in CI) ──────────────
3448
3449    /// Warm up a page then immediately extract content from the same origin.
3450    #[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            // Confirm the page is still usable for further queries.
3482            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    /// Refresh a page and verify session continuity (URL unchanged, page
3494    /// still navigable).
3495    #[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}