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 [name, value] in flat.as_chunks::<2>().0 {
349            map.insert(name.clone(), value.clone());
350        }
351        Ok(map)
352    }
353
354    /// Return the element's `textContent` (all text inside, no markup).
355    ///
356    /// Reads the DOM `textContent` property via a single JS eval — this is the
357    /// raw text concatenation of all descendant text nodes, independent of
358    /// layout or visibility (unlike `innerText`).
359    ///
360    ///
361    /// # Errors
362    ///
363    /// invalidated.
364    pub async fn text_content(&self) -> Result<String> {
365        let returns = timeout(
366            self.cdp_timeout,
367            self.element
368                .call_js_fn(r"function() { return this.textContent ?? ''; }", true),
369        )
370        .await
371        .map_err(|_| BrowserError::Timeout {
372            operation: "NodeHandle::text_content".to_string(),
373            duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
374        })?
375        .map_err(|e| self.cdp_err_or_stale(&e, "text_content"))?;
376
377        Ok(returns
378            .result
379            .value
380            .as_ref()
381            .and_then(|v| v.as_str())
382            .unwrap_or("")
383            .to_string())
384    }
385
386    /// Return the element's `innerHTML`.
387    ///
388    ///
389    /// # Errors
390    ///
391    /// invalidated.
392    pub async fn inner_html(&self) -> Result<String> {
393        timeout(self.cdp_timeout, self.element.inner_html())
394            .await
395            .map_err(|_| BrowserError::Timeout {
396                operation: "NodeHandle::inner_html".to_string(),
397                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
398            })?
399            .map_err(|e| self.cdp_err_or_stale(&e, "inner_html"))
400            .map(Option::unwrap_or_default)
401    }
402
403    /// Return the element's `outerHTML`.
404    ///
405    /// Backwards-compatible thin wrapper around
406    /// [`outer_html_with_strategy`][Self::outer_html_with_strategy] using the
407    /// default [`OuterHtmlStrategy::Current`] strategy. Preserves the
408    /// historical return contract: `Ok(String)` where the string may be
409    /// empty when both the primary and fallback backends return empty
410    /// payloads.
411    ///
412    /// Callers that need to distinguish an empty payload from a hard failure
413    /// — or that want the deeper `DOM.getOuterHTML` + Rust-side walk path —
414    /// should call [`outer_html_with_strategy`][Self::outer_html_with_strategy]
415    /// directly.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error when any CDP call the chosen strategy actually
420    /// invokes fails — that includes both the primary call and any fallback
421    /// call (the `XMLSerializer` JS fallback for [`OuterHtmlStrategy::Current`],
422    /// the `DOM.describeNode` walk for [`OuterHtmlStrategy::Recursive`]).
423    /// Errors surface as [`BrowserError::Timeout`] (CDP call exceeded
424    /// `cdp_timeout`), [`BrowserError::StaleNode`] (the handle was
425    /// invalidated mid-call), or [`BrowserError::CdpError`] (transport-level
426    /// failure).
427    ///
428    /// Empty or partially-empty payloads from any individual backend do
429    /// **not** error — they are flattened to an empty `String` so the
430    /// historical `Ok(String)` contract is preserved. Callers that need to
431    /// distinguish an empty payload from a hard failure should call
432    /// [`outer_html_with_strategy`][Self::outer_html_with_strategy]
433    /// directly and inspect the [`OuterHtmlResult`] variant.
434    pub async fn outer_html(&self) -> Result<String> {
435        match self
436            .outer_html_with_strategy(OuterHtmlStrategy::Current)
437            .await?
438        {
439            OuterHtmlResult::Content(s) => Ok(s),
440            OuterHtmlResult::Empty | OuterHtmlResult::Failed { .. } => Ok(String::new()),
441        }
442    }
443
444    /// Return the element's `outerHTML` using an explicit resolution strategy.
445    ///
446    /// The [`OuterHtmlStrategy::Current`] strategy matches the historical
447    /// [`outer_html`][Self::outer_html] path: a Chromium element-level JS
448    /// evaluation of `this.outerHTML`, followed by a JS
449    /// `new XMLSerializer().serializeToString(this)` fallback when the
450    /// primary call returns an empty payload.
451    ///
452    /// The [`OuterHtmlStrategy::Recursive`] strategy resolves [#66] for
453    /// sites where the JS-side `outerHTML` accessor intermittently returns
454    /// a truncated or empty payload — most notably Wix Studio / Editor X
455    /// pages and large SPAs with deeply nested shadow-DOM subtrees. It
456    /// prefers the dedicated Chromium `DevTools` Protocol command
457    /// `DOM.getOuterHTML` (a single round-trip that performs the
458    /// serialisation inside the browser, with shadow-DOM roots included by
459    /// default) and falls back to a Rust-side walk that calls
460    /// `DOM.describeNode` with `depth = -1` and serialises the resulting
461    /// `Node` tree to HTML locally. Neither path relies on Wix-specific
462    /// selectors, attributes, or heuristics — the resolution is entirely
463    /// driven by CDP commands Chromium already exposes.
464    ///
465    /// Both strategies return [`OuterHtmlResult::Empty`] (rather than
466    /// `Failed`) when every backend returns an empty payload — this is
467    /// indistinguishable from "node legitimately empty" at the CDP layer.
468    ///
469    /// [#66]: https://github.com/greysquirr3l/stygian/issues/66
470    ///
471    /// # Errors
472    ///
473    /// Returns [`BrowserError::Timeout`] if the primary CDP call exceeds
474    /// `cdp_timeout`, [`BrowserError::StaleNode`] if the handle was
475    /// invalidated, or [`BrowserError::CdpError`] on transport-level
476    /// failure.
477    ///
478    /// # Example
479    ///
480    /// ```no_run
481    /// use stygian_browser::page::OuterHtmlStrategy;
482    /// # use stygian_browser::error::Result;
483    /// # async fn run(handle: stygian_browser::NodeHandle) -> Result<()> {
484    /// // Use the deep-resolution path for SPA / Wix Studio / shadow-DOM pages.
485    /// let html = handle
486    ///     .outer_html_with_strategy(OuterHtmlStrategy::Recursive)
487    ///     .await?;
488    /// # let _ = html;
489    /// # Ok(())
490    /// # }
491    /// ```
492    pub async fn outer_html_with_strategy(
493        &self,
494        strategy: OuterHtmlStrategy,
495    ) -> Result<OuterHtmlResult> {
496        match strategy {
497            OuterHtmlStrategy::Current => self.outer_html_current().await,
498            OuterHtmlStrategy::Recursive => self.outer_html_recursive().await,
499        }
500    }
501
502    /// Strategy body for [`OuterHtmlStrategy::Current`].
503    async fn outer_html_current(&self) -> Result<OuterHtmlResult> {
504        let primary = timeout(self.cdp_timeout, self.element.outer_html())
505            .await
506            .map_err(|_| BrowserError::Timeout {
507                operation: "NodeHandle::outer_html_with_strategy(Current)".to_string(),
508                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
509            })?
510            .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_current"))?;
511
512        if let Some(html) = primary
513            && !html.trim().is_empty()
514        {
515            return Ok(OuterHtmlResult::Content(html));
516        }
517
518        let fallback_html = self.outer_html_via_js().await?;
519        if !fallback_html.trim().is_empty() {
520            return Ok(OuterHtmlResult::Content(fallback_html));
521        }
522
523        Ok(OuterHtmlResult::Empty)
524    }
525
526    /// Strategy body for [`OuterHtmlStrategy::Recursive`].
527    ///
528    /// Primary: `DOM.getOuterHTML` (single round-trip, browser-side
529    /// serialisation via stable `objectId`). Fallback: `DOM.describeNode`
530    /// with `objectId` + `depth=-1`, Rust-side `Node` → HTML serializer.
531    async fn outer_html_recursive(&self) -> Result<OuterHtmlResult> {
532        use chromiumoxide::cdp::browser_protocol::dom::{GetOuterHtmlParams, GetOuterHtmlReturns};
533        use chromiumoxide::types::CommandResponse;
534
535        let mut failed_backends: Vec<&'static str> = Vec::new();
536
537        let primary = timeout(
538            self.cdp_timeout,
539            self.page.execute(
540                GetOuterHtmlParams::builder()
541                    // Use the stable V8 RemoteObjectId instead of the
542                    // ephemeral CDP NodeId. NodeIds are invalidated whenever
543                    // the page's JavaScript mutates the DOM (e.g. React
544                    // re-renders on SPAs like Wix), causing DOM.getOuterHTML
545                    // to silently return an empty string for a valid node.
546                    // RemoteObjectId is tied to the V8 heap object reference
547                    // and survives DOM mutations.
548                    .object_id(self.element.remote_object_id.clone())
549                    .build(),
550            ),
551        )
552        .await
553        .map_err(|_| BrowserError::Timeout {
554            operation: "NodeHandle::outer_html_with_strategy(Recursive)".to_string(),
555            duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
556        })?
557        .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_recursive::DOM.getOuterHTML"));
558
559        match primary {
560            Ok(CommandResponse {
561                result: GetOuterHtmlReturns { outer_html },
562                ..
563            }) if !outer_html.trim().is_empty() => {
564                return Ok(OuterHtmlResult::Content(outer_html));
565            }
566            Ok(CommandResponse {
567                result: GetOuterHtmlReturns { outer_html },
568                ..
569            }) => {
570                debug!(
571                    selector = %self.selector,
572                    bytes = outer_html.len(),
573                    "DOM.getOuterHTML returned empty payload; falling back to DOM.describeNode walk"
574                );
575            }
576            Err(e) => {
577                failed_backends.push("DOM.getOuterHTML");
578                debug!(
579                    selector = %self.selector,
580                    error = %e,
581                    "DOM.getOuterHTML failed; falling back to DOM.describeNode walk"
582                );
583            }
584        }
585
586        match self.outer_html_via_rust_walk().await {
587            Ok(html) if !html.trim().is_empty() => Ok(OuterHtmlResult::Content(html)),
588            Ok(_) => {
589                if failed_backends.is_empty() {
590                    // Every backend returned an empty payload (no errors
591                    // raised). Surface this as `Empty` rather than `Failed`.
592                    Ok(OuterHtmlResult::Empty)
593                } else {
594                    // At least one backend errored and the other returned
595                    // empty — surface as `Failed` so callers can
596                    // distinguish "nothing to serialize" from "backends
597                    // broke".
598                    Ok(OuterHtmlResult::Failed {
599                        backends: failed_backends,
600                    })
601                }
602            }
603            Err(e) => {
604                failed_backends.push("DOM.describeNode-walk");
605                debug!(
606                    selector = %self.selector,
607                    error = %e,
608                    "Rust-side DOM.describeNode walk failed"
609                );
610                Ok(OuterHtmlResult::Failed {
611                    backends: failed_backends,
612                })
613            }
614        }
615    }
616
617    /// Rust-side fallback: `DOM.describeNode` with `depth = -1` and
618    /// `objectId` returns the entire subtree rooted at the target node;
619    /// we walk it locally and emit HTML using [`serialize_node_tree`].
620    async fn outer_html_via_rust_walk(&self) -> Result<String> {
621        use chromiumoxide::cdp::browser_protocol::dom::DescribeNodeParams;
622        use chromiumoxide::types::CommandResponse;
623
624        let described: CommandResponse<
625            chromiumoxide::cdp::browser_protocol::dom::DescribeNodeReturns,
626        > = timeout(
627            self.cdp_timeout,
628            self.page.execute(
629                DescribeNodeParams::builder()
630                    // Use stable RemoteObjectId rather than ephemeral NodeId
631                    // for the same reason as outer_html_recursive — NodeIds
632                    // become stale after SPA DOM mutations.
633                    .object_id(self.element.remote_object_id.clone())
634                    .depth(-1)
635                    .build(),
636            ),
637        )
638        .await
639        .map_err(|_| BrowserError::Timeout {
640            operation: "NodeHandle::outer_html_via_rust_walk".to_string(),
641            duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
642        })?
643        .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_via_rust_walk"))?;
644
645        Ok(serialize_node_tree(&described.node))
646    }
647
648    async fn outer_html_via_js(&self) -> Result<String> {
649        let returns = timeout(
650            self.cdp_timeout,
651            self.element.call_js_fn(
652                r"function() {
653                    if (typeof this.outerHTML === 'string' && this.outerHTML.length > 0) {
654                        return this.outerHTML;
655                    }
656                    try {
657                        return new XMLSerializer().serializeToString(this);
658                    } catch (_) {
659                        return '';
660                    }
661                }",
662                true,
663            ),
664        )
665        .await
666        .map_err(|_| BrowserError::Timeout {
667            operation: "NodeHandle::outer_html_via_js".to_string(),
668            duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
669        })?
670        .map_err(|e| self.cdp_err_or_stale(&e, "outer_html_via_js"))?;
671
672        Ok(returns
673            .result
674            .value
675            .as_ref()
676            .and_then(serde_json::Value::as_str)
677            .unwrap_or_default()
678            .to_string())
679    }
680
681    ///
682    /// Executes a single `Runtime.callFunctionOn` JavaScript function that
683    /// walks `parentElement` and collects tag names — no repeated CDP calls.
684    ///
685    /// ```text
686    /// ["p", "article", "body", "html"]
687    /// ```
688    ///
689    /// # Errors
690    ///
691    /// invalidated, or [`BrowserError::ScriptExecutionFailed`] when CDP
692    pub async fn ancestors(&self) -> Result<Vec<String>> {
693        let returns = timeout(
694            self.cdp_timeout,
695            self.element.call_js_fn(
696                r"function() {
697                    const a = [];
698                    let n = this.parentElement;
699                    while (n) { a.push(n.tagName.toLowerCase()); n = n.parentElement; }
700                    return a;
701                }",
702                true,
703            ),
704        )
705        .await
706        .map_err(|_| BrowserError::Timeout {
707            operation: "NodeHandle::ancestors".to_string(),
708            duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
709        })?
710        .map_err(|e| self.cdp_err_or_stale(&e, "ancestors"))?;
711
712        // With returnByValue=true and an array return, CDP delivers the value
713        // as a JSON array directly — no JSON.stringify/re-parse needed.
714        // A missing or wrong-type value indicates an unexpected CDP failure.
715        let arr = returns
716            .result
717            .value
718            .as_ref()
719            .and_then(|v| v.as_array())
720            .ok_or_else(|| BrowserError::ScriptExecutionFailed {
721                script: "NodeHandle::ancestors".to_string(),
722                reason: "CDP returned no value or a non-array value for ancestors()".to_string(),
723            })?;
724
725        arr.iter()
726            .map(|v| {
727                v.as_str().map(ToString::to_string).ok_or_else(|| {
728                    BrowserError::ScriptExecutionFailed {
729                        script: "NodeHandle::ancestors".to_string(),
730                        reason: format!("ancestor entry is not a string: {v}"),
731                    }
732                })
733            })
734            .collect()
735    }
736
737    ///
738    ///
739    ///
740    /// # Errors
741    ///
742    /// invalidated, or [`BrowserError::CdpError`] on transport failure.
743    pub async fn children_matching(&self, selector: &str) -> Result<Vec<Self>> {
744        let elements = timeout(self.cdp_timeout, self.element.find_elements(selector))
745            .await
746            .map_err(|_| BrowserError::Timeout {
747                operation: "NodeHandle::children_matching".to_string(),
748                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
749            })?
750            .map_err(|e| self.cdp_err_or_stale(&e, "children_matching"))?;
751
752        let selector_arc: Arc<str> = Arc::from(selector);
753        Ok(elements
754            .into_iter()
755            .map(|el| Self {
756                element: el,
757                selector: selector_arc.clone(),
758                cdp_timeout: self.cdp_timeout,
759                page: self.page.clone(),
760            })
761            .collect())
762    }
763
764    /// Return the immediate parent element, or `None` if this element has no
765    /// parent (i.e. it is the document root).
766    ///
767    /// Issues a single `Runtime.callFunctionOn` CDP call that temporarily tags
768    /// the parent element with a unique attribute, then resolves it via a
769    /// CSS attribute selector.
770    ///
771    /// # Errors
772    ///
773    /// Returns an error if the CDP call fails or the page handle is invalidated.
774    ///
775    /// # Example
776    ///
777    /// ```no_run
778    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
779    /// use std::time::Duration;
780    ///
781    /// # async fn run() -> stygian_browser::error::Result<()> {
782    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
783    /// let handle = pool.acquire().await?;
784    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
785    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
786    /// # let nodes = page.query_selector_all("a").await?;
787    /// if let Some(parent) = nodes[0].parent().await? {
788    ///     let html = parent.outer_html().await?;
789    ///     println!("parent: {}", &html[..html.len().min(80)]);
790    /// }
791    /// # Ok(())
792    /// # }
793    /// ```
794    pub async fn parent(&self) -> Result<Option<Self>> {
795        let attr = format!(
796            "data-stygian-t-{}",
797            ulid::Ulid::generate().to_string().to_lowercase()
798        );
799        let js = format!(
800            "function() {{ \
801                var t = this.parentElement; \
802                if (!t) {{ return false; }} \
803                t.setAttribute('{attr}', '1'); \
804                return true; \
805            }}"
806        );
807        self.call_traversal(&js, &attr, "parent").await
808    }
809
810    /// Return the next element sibling, or `None` if this element is the last
811    /// child of its parent.
812    ///
813    /// Uses `nextElementSibling` (skips text/comment nodes).
814    ///
815    /// # Errors
816    ///
817    /// invalidated.
818    ///
819    /// # Example
820    ///
821    /// ```no_run
822    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
823    /// use std::time::Duration;
824    ///
825    /// # async fn run() -> stygian_browser::error::Result<()> {
826    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
827    /// let handle = pool.acquire().await?;
828    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
829    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
830    /// # let nodes = page.query_selector_all("a").await?;
831    /// if let Some(next) = nodes[0].next_sibling().await? {
832    ///     println!("next sibling: {}", next.text_content().await?);
833    /// }
834    /// # Ok(())
835    /// # }
836    /// ```
837    pub async fn next_sibling(&self) -> Result<Option<Self>> {
838        let attr = format!(
839            "data-stygian-t-{}",
840            ulid::Ulid::generate().to_string().to_lowercase()
841        );
842        let js = format!(
843            "function() {{ \
844                var t = this.nextElementSibling; \
845                if (!t) {{ return false; }} \
846                t.setAttribute('{attr}', '1'); \
847                return true; \
848            }}"
849        );
850        self.call_traversal(&js, &attr, "next").await
851    }
852
853    /// Return the previous element sibling, or `None` if this element is the
854    /// first child of its parent.
855    ///
856    /// Uses `previousElementSibling` (skips text/comment nodes).
857    ///
858    /// # Errors
859    ///
860    /// invalidated.
861    ///
862    /// # Example
863    ///
864    /// ```no_run
865    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
866    /// use std::time::Duration;
867    ///
868    /// # async fn run() -> stygian_browser::error::Result<()> {
869    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
870    /// let handle = pool.acquire().await?;
871    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
872    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
873    /// # let nodes = page.query_selector_all("a").await?;
874    /// if let Some(prev) = nodes[1].previous_sibling().await? {
875    ///     println!("prev sibling: {}", prev.text_content().await?);
876    /// }
877    /// # Ok(())
878    /// # }
879    /// ```
880    pub async fn previous_sibling(&self) -> Result<Option<Self>> {
881        let attr = format!(
882            "data-stygian-t-{}",
883            ulid::Ulid::generate().to_string().to_lowercase()
884        );
885        let js = format!(
886            "function() {{ \
887                var t = this.previousElementSibling; \
888                if (!t) {{ return false; }} \
889                t.setAttribute('{attr}', '1'); \
890                return true; \
891            }}"
892        );
893        self.call_traversal(&js, &attr, "prev").await
894    }
895
896    /// Shared traversal implementation used by [`parent`], [`next_sibling`],
897    /// and [`previous_sibling`].
898    ///
899    /// The caller provides a JS function that:
900    /// 1. Computes the traversal target (for example, the parent, next
901    ///    sibling, or previous sibling) and stores it in a local variable.
902    /// 2. If the target is non-null, sets a unique attribute (`attr_name`)
903    ///    on it and returns `true`.
904    /// 3. Returns `false` when the target is null (no such neighbour).
905    ///
906    /// This helper then resolves the tagged element from the document root,
907    /// removes the temporary attribute, and wraps the result in a
908    /// `NodeHandle`.
909    ///
910    /// [`parent`]: Self::parent
911    /// [`next_sibling`]: Self::next_sibling
912    /// [`previous_sibling`]: Self::previous_sibling
913    async fn call_traversal(
914        &self,
915        js_fn: &str,
916        attr_name: &str,
917        selector_suffix: &str,
918    ) -> Result<Option<Self>> {
919        // Step 1: Run the JS that tags the target element and reports null/non-null.
920        let op_tag = format!("NodeHandle::{selector_suffix}::tag");
921        let returns = timeout(self.cdp_timeout, self.element.call_js_fn(js_fn, false))
922            .await
923            .map_err(|_| BrowserError::Timeout {
924                operation: op_tag.clone(),
925                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
926            })?
927            .map_err(|e| self.cdp_err_or_stale(&e, selector_suffix))?;
928
929        // JS returns false → no such neighbour.
930        let has_target = returns
931            .result
932            .value
933            .as_ref()
934            .and_then(serde_json::Value::as_bool)
935            .unwrap_or(false);
936        if !has_target {
937            return Ok(None);
938        }
939
940        let css = format!("[{attr_name}]");
941        let op_resolve = format!("NodeHandle::{selector_suffix}::resolve");
942        let element = timeout(self.cdp_timeout, self.page.find_element(css))
943            .await
944            .map_err(|_| BrowserError::Timeout {
945                operation: op_resolve.clone(),
946                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
947            })?
948            .map_err(|e| BrowserError::CdpError {
949                operation: op_resolve,
950                message: format!("{e:?}"),
951            })?;
952
953        // is non-fatal — it leaves a harmless stale attribute in the DOM).
954        let cleanup = format!("function() {{ this.removeAttribute('{attr_name}'); }}");
955        let _ = element.call_js_fn(cleanup, false).await;
956
957        let new_selector: Arc<str> =
958            Arc::from(format!("{}::{selector_suffix}", self.selector).as_str());
959        Ok(Some(Self {
960            element,
961            selector: new_selector,
962            cdp_timeout: self.cdp_timeout,
963            page: self.page.clone(),
964        }))
965    }
966
967    /// (when the remote object reference has been invalidated) or
968    fn cdp_err_or_stale(
969        &self,
970        err: &chromiumoxide::error::CdpError,
971        operation: &str,
972    ) -> BrowserError {
973        let msg = format!("{err:?}");
974        if msg.contains("Cannot find object with id")
975            || msg.contains("context with specified id")
976            || msg.contains("Cannot find context")
977        {
978            BrowserError::StaleNode {
979                selector: self.selector.to_string(),
980            }
981        } else {
982            BrowserError::CdpError {
983                operation: operation.to_string(),
984                message: msg,
985            }
986        }
987    }
988}
989
990// ─── PageHandle ───────────────────────────────────────────────────────────────
991
992///
993///
994/// # Example
995///
996/// ```no_run
997/// use stygian_browser::{BrowserPool, BrowserConfig};
998/// use stygian_browser::page::WaitUntil;
999/// use std::time::Duration;
1000///
1001/// # async fn run() -> stygian_browser::error::Result<()> {
1002/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1003/// let handle = pool.acquire().await?;
1004/// let mut page = handle.browser().expect("valid browser").new_page().await?;
1005/// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1006/// let html = page.content().await?;
1007/// drop(page); // closes the tab
1008/// handle.release().await;
1009/// # Ok(())
1010/// # }
1011/// ```
1012pub struct PageHandle {
1013    page: Page,
1014    cdp_timeout: Duration,
1015    /// HTTP status code of the most recent main-frame navigation, or `0` if not
1016    last_status_code: Arc<AtomicU16>,
1017    /// Background task processing `Fetch.requestPaused` events. Aborted and
1018    /// replaced each time `set_resource_filter` is called.
1019    resource_filter_task: Option<tokio::task::JoinHandle<()>>,
1020}
1021
1022impl PageHandle {
1023    /// Wrap a raw chromiumoxide [`Page`] in a handle.
1024    pub(crate) fn new(page: Page, cdp_timeout: Duration) -> Self {
1025        Self {
1026            page,
1027            cdp_timeout,
1028            last_status_code: Arc::new(AtomicU16::new(0)),
1029            resource_filter_task: None,
1030        }
1031    }
1032
1033    ///
1034    /// # Errors
1035    ///
1036    /// the CDP call fails.
1037    pub async fn navigate(
1038        &mut self,
1039        url: &str,
1040        condition: WaitUntil,
1041        nav_timeout: Duration,
1042    ) -> Result<()> {
1043        self.setup_status_capture().await;
1044        timeout(
1045            nav_timeout,
1046            self.navigate_inner(url, condition, nav_timeout),
1047        )
1048        .await
1049        .map_err(|_| BrowserError::NavigationFailed {
1050            url: url.to_string(),
1051            reason: format!("navigation timed out after {nav_timeout:?}"),
1052        })?
1053    }
1054
1055    /// Reset the last status code and wire up the `Network.responseReceived`
1056    /// so that a missing network domain never blocks navigation.
1057    async fn setup_status_capture(&self) {
1058        use chromiumoxide::cdp::browser_protocol::network::{
1059            EventResponseReceived, ResourceType as NetworkResourceType,
1060        };
1061        use futures::StreamExt;
1062
1063        // Reset so a stale code is not returned if the new navigation fails
1064        self.last_status_code.store(0, Ordering::Release);
1065
1066        let page_for_listener = self.page.clone();
1067        let status_capture = Arc::clone(&self.last_status_code);
1068        match page_for_listener
1069            .event_listener::<EventResponseReceived>()
1070            .await
1071        {
1072            Ok(mut stream) => {
1073                tokio::spawn(async move {
1074                    while let Some(event) = stream.next().await {
1075                        if event.r#type == NetworkResourceType::Document {
1076                            let code = u16::try_from(event.response.status).unwrap_or(0);
1077                            if code > 0 {
1078                                status_capture.store(code, Ordering::Release);
1079                            }
1080                            break;
1081                        }
1082                    }
1083                });
1084            }
1085            Err(e) => warn!("status-code capture unavailable: {e}"),
1086        }
1087    }
1088
1089    /// described in issue #7.
1090    async fn navigate_inner(
1091        &self,
1092        url: &str,
1093        condition: WaitUntil,
1094        nav_timeout: Duration,
1095    ) -> Result<()> {
1096        use chromiumoxide::cdp::browser_protocol::page::{
1097            EventDomContentEventFired, EventLoadEventFired,
1098        };
1099        use futures::StreamExt;
1100
1101        let url_owned = url.to_string();
1102
1103        let mut dom_events = match &condition {
1104            WaitUntil::DomContentLoaded => Some(
1105                self.page
1106                    .event_listener::<EventDomContentEventFired>()
1107                    .await
1108                    .map_err(|e| BrowserError::NavigationFailed {
1109                        url: url_owned.clone(),
1110                        reason: format!("{e:?}"),
1111                    })?,
1112            ),
1113            _ => None,
1114        };
1115
1116        let mut load_events = match &condition {
1117            WaitUntil::NetworkIdle => Some(
1118                self.page
1119                    .event_listener::<EventLoadEventFired>()
1120                    .await
1121                    .map_err(|e| BrowserError::NavigationFailed {
1122                        url: url_owned.clone(),
1123                        reason: e.to_string(),
1124                    })?,
1125            ),
1126            _ => None,
1127        };
1128
1129        let inflight = if matches!(condition, WaitUntil::NetworkIdle) {
1130            Some(self.subscribe_inflight_counter().await)
1131        } else {
1132            None
1133        };
1134
1135        self.page
1136            .goto(url)
1137            .await
1138            .map_err(|e| BrowserError::NavigationFailed {
1139                url: url_owned.clone(),
1140                reason: e.to_string(),
1141            })?;
1142
1143        match &condition {
1144            WaitUntil::DomContentLoaded => {
1145                if let Some(ref mut events) = dom_events {
1146                    let _ = events.next().await;
1147                }
1148            }
1149            WaitUntil::NetworkIdle => {
1150                if let Some(ref mut events) = load_events {
1151                    let _ = events.next().await;
1152                }
1153                if let Some(ref counter) = inflight {
1154                    Self::wait_network_idle(counter).await;
1155                }
1156            }
1157            WaitUntil::Selector(css) => {
1158                self.wait_for_selector(css, nav_timeout).await?;
1159            }
1160        }
1161        Ok(())
1162    }
1163
1164    /// Spawn three detached tasks that maintain a signed in-flight request
1165    /// counter via `Network.requestWillBeSent` (+1) and
1166    /// `Network.loadingFinished`/`Network.loadingFailed` (−1 each).
1167    async fn subscribe_inflight_counter(&self) -> Arc<std::sync::atomic::AtomicI32> {
1168        use std::sync::atomic::AtomicI32;
1169
1170        use chromiumoxide::cdp::browser_protocol::network::{
1171            EventLoadingFailed, EventLoadingFinished, EventRequestWillBeSent,
1172        };
1173        use futures::StreamExt;
1174
1175        let counter: Arc<AtomicI32> = Arc::new(AtomicI32::new(0));
1176        let pairs: [(Arc<AtomicI32>, i32); 3] = [
1177            (Arc::clone(&counter), 1),
1178            (Arc::clone(&counter), -1),
1179            (Arc::clone(&counter), -1),
1180        ];
1181        let [p1, p2, p3] = [self.page.clone(), self.page.clone(), self.page.clone()];
1182
1183        macro_rules! spawn_tracker {
1184            ($page:expr, $event:ty, $c:expr, $delta:expr) => {
1185                match $page.event_listener::<$event>().await {
1186                    Ok(mut s) => {
1187                        let c = $c;
1188                        let d = $delta;
1189                        tokio::spawn(async move {
1190                            while s.next().await.is_some() {
1191                                c.fetch_add(d, Ordering::Relaxed);
1192                            }
1193                        });
1194                    }
1195                    Err(e) => warn!("network-idle tracker unavailable: {e}"),
1196                }
1197            };
1198        }
1199
1200        let [(c1, d1), (c2, d2), (c3, d3)] = pairs;
1201        spawn_tracker!(p1, EventRequestWillBeSent, c1, d1);
1202        spawn_tracker!(p2, EventLoadingFinished, c2, d2);
1203        spawn_tracker!(p3, EventLoadingFailed, c3, d3);
1204
1205        counter
1206    }
1207
1208    async fn wait_network_idle(counter: &Arc<std::sync::atomic::AtomicI32>) {
1209        const IDLE_THRESHOLD: i32 = 2;
1210        const SETTLE: Duration = Duration::from_millis(500);
1211        loop {
1212            if counter.load(Ordering::Relaxed) <= IDLE_THRESHOLD {
1213                tokio::time::sleep(SETTLE).await;
1214                if counter.load(Ordering::Relaxed) <= IDLE_THRESHOLD {
1215                    break;
1216                }
1217            } else {
1218                tokio::time::sleep(Duration::from_millis(50)).await;
1219            }
1220        }
1221    }
1222
1223    ///
1224    /// # Errors
1225    ///
1226    /// within the given timeout.
1227    pub async fn wait_for_selector(&self, selector: &str, wait_timeout: Duration) -> Result<()> {
1228        let selector_owned = selector.to_string();
1229        let poll = async {
1230            loop {
1231                if self.page.find_element(selector_owned.clone()).await.is_ok() {
1232                    return Ok(());
1233                }
1234                tokio::time::sleep(Duration::from_millis(100)).await;
1235            }
1236        };
1237
1238        timeout(wait_timeout, poll)
1239            .await
1240            .map_err(|_| BrowserError::NavigationFailed {
1241                url: String::new(),
1242                reason: format!("selector '{selector_owned}' not found within {wait_timeout:?}"),
1243            })?
1244    }
1245
1246    ///
1247    /// Enables `Fetch` interception and spawns a background task that continues
1248    /// allowed requests and fails blocked ones with `BlockedByClient`. Any
1249    /// previously set filter task is cancelled first.
1250    ///
1251    /// # Errors
1252    ///
1253    pub async fn set_resource_filter(&mut self, filter: ResourceFilter) -> Result<()> {
1254        use chromiumoxide::cdp::browser_protocol::fetch::{
1255            ContinueRequestParams, EnableParams, EventRequestPaused, FailRequestParams,
1256            RequestPattern,
1257        };
1258        use chromiumoxide::cdp::browser_protocol::network::ErrorReason;
1259        use futures::StreamExt as _;
1260
1261        if filter.is_empty() {
1262            return Ok(());
1263        }
1264
1265        // Cancel any previously running filter task.
1266        if let Some(task) = self.resource_filter_task.take() {
1267            task.abort();
1268        }
1269
1270        let pattern = RequestPattern::builder().url_pattern("*").build();
1271        let params = EnableParams::builder()
1272            .patterns(vec![pattern])
1273            .handle_auth_requests(false)
1274            .build();
1275
1276        timeout(self.cdp_timeout, self.page.execute::<EnableParams>(params))
1277            .await
1278            .map_err(|_| BrowserError::Timeout {
1279                operation: "Fetch.enable".to_string(),
1280                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1281            })?
1282            .map_err(|e| BrowserError::CdpError {
1283                operation: "Fetch.enable".to_string(),
1284                message: e.to_string(),
1285            })?;
1286
1287        // is never blocked. Without this handler Chrome holds every intercepted
1288        // request indefinitely and the page hangs.
1289        let mut events = self
1290            .page
1291            .event_listener::<EventRequestPaused>()
1292            .await
1293            .map_err(|e| BrowserError::CdpError {
1294                operation: "Fetch.requestPaused subscribe".to_string(),
1295                message: e.to_string(),
1296            })?;
1297
1298        let page = self.page.clone();
1299        debug!("Resource filter active: {:?}", filter);
1300        let task = tokio::spawn(async move {
1301            while let Some(event) = events.next().await {
1302                let request_id = event.request_id.clone();
1303                if filter.should_block(event.resource_type.as_ref()) {
1304                    let params = FailRequestParams::new(request_id, ErrorReason::BlockedByClient);
1305                    let _ = page.execute(params).await;
1306                } else {
1307                    let _ = page.execute(ContinueRequestParams::new(request_id)).await;
1308                }
1309            }
1310        });
1311
1312        self.resource_filter_task = Some(task);
1313        Ok(())
1314    }
1315
1316    /// Return the current page URL (post-navigation, post-redirect).
1317    ///
1318    /// internally by [`save_cookies`](Self::save_cookies); no extra network
1319    /// request is made.  Returns an empty string if the URL is not yet set
1320    ///
1321    /// # Errors
1322    ///
1323    /// [`BrowserError::Timeout`] if it exceeds `cdp_timeout`.
1324    ///
1325    /// # Example
1326    ///
1327    /// ```no_run
1328    /// use stygian_browser::{BrowserPool, BrowserConfig};
1329    /// use stygian_browser::page::WaitUntil;
1330    /// use std::time::Duration;
1331    ///
1332    /// # async fn run() -> stygian_browser::error::Result<()> {
1333    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1334    /// let handle = pool.acquire().await?;
1335    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1336    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1337    /// let url = page.url().await?;
1338    /// println!("Final URL after redirects: {url}");
1339    /// # Ok(())
1340    /// # }
1341    /// ```
1342    pub async fn url(&self) -> Result<String> {
1343        timeout(self.cdp_timeout, self.page.url())
1344            .await
1345            .map_err(|_| BrowserError::Timeout {
1346                operation: "page.url".to_string(),
1347                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1348            })?
1349            .map_err(|e| BrowserError::CdpError {
1350                operation: "page.url".to_string(),
1351                message: e.to_string(),
1352            })
1353            .map(Option::unwrap_or_default)
1354    }
1355
1356    /// Return the HTTP status code of the most recent main-frame navigation.
1357    ///
1358    /// The status is captured from the `Network.responseReceived` CDP event
1359    /// wired up inside [`navigate`](Self::navigate), so it reflects the
1360    /// *final* response after any server-side redirects.
1361    ///
1362    /// navigations, when [`navigate`](Self::navigate) has not yet been called,
1363    /// or if the network event subscription failed.
1364    ///
1365    /// # Errors
1366    ///
1367    ///
1368    /// # Example
1369    ///
1370    /// ```no_run
1371    /// use stygian_browser::{BrowserPool, BrowserConfig};
1372    /// use stygian_browser::page::WaitUntil;
1373    /// use std::time::Duration;
1374    ///
1375    /// # async fn run() -> stygian_browser::error::Result<()> {
1376    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1377    /// let handle = pool.acquire().await?;
1378    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1379    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1380    /// if let Some(code) = page.status_code()? {
1381    ///     println!("HTTP {code}");
1382    /// }
1383    /// # Ok(())
1384    /// # }
1385    /// ```
1386    pub fn status_code(&self) -> Result<Option<u16>> {
1387        let code = self.last_status_code.load(Ordering::Acquire);
1388        Ok(if code == 0 { None } else { Some(code) })
1389    }
1390
1391    /// Return the page's `<title>` text.
1392    ///
1393    /// # Errors
1394    ///
1395    pub async fn title(&self) -> Result<String> {
1396        timeout(self.cdp_timeout, self.page.get_title())
1397            .await
1398            .map_err(|_| BrowserError::Timeout {
1399                operation: "get_title".to_string(),
1400                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1401            })?
1402            .map_err(|e| BrowserError::ScriptExecutionFailed {
1403                script: "document.title".to_string(),
1404                reason: e.to_string(),
1405            })
1406            .map(Option::unwrap_or_default)
1407    }
1408
1409    /// Return the page's full outer HTML.
1410    ///
1411    /// # Errors
1412    ///
1413    pub async fn content(&self) -> Result<String> {
1414        timeout(self.cdp_timeout, self.page.content())
1415            .await
1416            .map_err(|_| BrowserError::Timeout {
1417                operation: "page.content".to_string(),
1418                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1419            })?
1420            .map_err(|e| BrowserError::ScriptExecutionFailed {
1421                script: "document.documentElement.outerHTML".to_string(),
1422                reason: e.to_string(),
1423            })
1424    }
1425
1426    /// lightweight [`NodeHandle`]s backed by CDP `RemoteObjectId`s.
1427    ///
1428    /// No HTML serialisation occurs — the browser's in-memory DOM is queried
1429    /// directly over the CDP connection, eliminating the `page.content()` +
1430    /// `scraper::Html::parse_document` round-trip.
1431    ///
1432    ///
1433    /// # Errors
1434    ///
1435    /// [`BrowserError::Timeout`] if it exceeds `cdp_timeout`.
1436    ///
1437    /// # Example
1438    ///
1439    /// ```no_run
1440    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
1441    /// use std::time::Duration;
1442    ///
1443    /// # async fn run() -> stygian_browser::error::Result<()> {
1444    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1445    /// let handle = pool.acquire().await?;
1446    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1447    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
1448    /// # let nodes = page.query_selector_all("div[data-ux]").await?;
1449    /// # for node in &nodes {
1450    ///     let ux_type = node.attr("data-ux").await?;
1451    ///     let text    = node.text_content().await?;
1452    ///     println!("{ux_type:?}: {text}");
1453    /// # }
1454    /// # Ok(())
1455    /// # }
1456    /// ```
1457    pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeHandle>> {
1458        let elements = timeout(self.cdp_timeout, self.page.find_elements(selector))
1459            .await
1460            .map_err(|_| BrowserError::Timeout {
1461                operation: "PageHandle::query_selector_all".to_string(),
1462                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1463            })?
1464            .map_err(|e| BrowserError::CdpError {
1465                operation: "PageHandle::query_selector_all".to_string(),
1466                message: e.to_string(),
1467            })?;
1468
1469        let selector_arc: Arc<str> = Arc::from(selector);
1470        Ok(elements
1471            .into_iter()
1472            .map(|el| NodeHandle {
1473                element: el,
1474                selector: selector_arc.clone(),
1475                cdp_timeout: self.cdp_timeout,
1476                page: self.page.clone(),
1477            })
1478            .collect())
1479    }
1480
1481    /// Evaluate arbitrary JavaScript and return the result as `T`.
1482    ///
1483    /// # Errors
1484    ///
1485    /// deserialization error.
1486    pub async fn eval<T: serde::de::DeserializeOwned>(&self, script: &str) -> Result<T> {
1487        let script_owned = script.to_string();
1488        timeout(self.cdp_timeout, self.page.evaluate(script))
1489            .await
1490            .map_err(|_| BrowserError::Timeout {
1491                operation: "page.evaluate".to_string(),
1492                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1493            })?
1494            .map_err(|e| BrowserError::ScriptExecutionFailed {
1495                script: script_owned.clone(),
1496                reason: e.to_string(),
1497            })?
1498            .into_value::<T>()
1499            .map_err(|e| BrowserError::ScriptExecutionFailed {
1500                script: script_owned,
1501                reason: e.to_string(),
1502            })
1503    }
1504
1505    ///
1506    /// # Errors
1507    ///
1508    pub async fn save_cookies(
1509        &self,
1510    ) -> Result<Vec<chromiumoxide::cdp::browser_protocol::network::Cookie>> {
1511        use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
1512
1513        let url = self
1514            .page
1515            .url()
1516            .await
1517            .map_err(|e| BrowserError::CdpError {
1518                operation: "page.url".to_string(),
1519                message: e.to_string(),
1520            })?
1521            .unwrap_or_default();
1522
1523        timeout(
1524            self.cdp_timeout,
1525            self.page
1526                .execute(GetCookiesParams::builder().urls(vec![url]).build()),
1527        )
1528        .await
1529        .map_err(|_| BrowserError::Timeout {
1530            operation: "Network.getCookies".to_string(),
1531            duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1532        })?
1533        .map_err(|e| BrowserError::CdpError {
1534            operation: "Network.getCookies".to_string(),
1535            message: e.to_string(),
1536        })
1537        .map(|r| r.cookies.clone())
1538    }
1539
1540    ///
1541    /// [`SessionSnapshot`][crate::session::SessionSnapshot] and without
1542    /// requiring a direct `chromiumoxide` dependency in calling code.
1543    ///
1544    /// Individual cookie failures are logged as warnings and do not abort the
1545    /// remaining cookies.
1546    ///
1547    /// # Errors
1548    ///
1549    /// call exceeds `cdp_timeout`.
1550    ///
1551    /// # Example
1552    ///
1553    /// ```no_run
1554    /// use stygian_browser::{BrowserPool, BrowserConfig};
1555    /// use stygian_browser::session::SessionCookie;
1556    /// use std::time::Duration;
1557    ///
1558    /// # async fn run() -> stygian_browser::error::Result<()> {
1559    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1560    /// let handle = pool.acquire().await?;
1561    /// let page = handle.browser().expect("valid browser").new_page().await?;
1562    /// let cookies = vec![SessionCookie {
1563    ///     name: "session".to_string(),
1564    ///     value: "abc123".to_string(),
1565    ///     domain: ".example.com".to_string(),
1566    ///     path: "/".to_string(),
1567    ///     expires: -1.0,
1568    ///     http_only: true,
1569    ///     secure: true,
1570    ///     same_site: "Lax".to_string(),
1571    /// }];
1572    /// page.inject_cookies(&cookies).await?;
1573    /// # Ok(())
1574    /// # }
1575    /// ```
1576    pub async fn inject_cookies(&self, cookies: &[crate::session::SessionCookie]) -> Result<()> {
1577        use chromiumoxide::cdp::browser_protocol::network::SetCookieParams;
1578
1579        for cookie in cookies {
1580            let params = match SetCookieParams::builder()
1581                .name(cookie.name.clone())
1582                .value(cookie.value.clone())
1583                .domain(cookie.domain.clone())
1584                .path(cookie.path.clone())
1585                .http_only(cookie.http_only)
1586                .secure(cookie.secure)
1587                .build()
1588            {
1589                Ok(p) => p,
1590                Err(e) => {
1591                    warn!(cookie = %cookie.name, error = %e, "Failed to build cookie params");
1592                    continue;
1593                }
1594            };
1595
1596            match timeout(self.cdp_timeout, self.page.execute(params)).await {
1597                Err(_) => {
1598                    warn!(
1599                        cookie = %cookie.name,
1600                        timeout_ms = self.cdp_timeout.as_millis(),
1601                        "Timed out injecting cookie"
1602                    );
1603                }
1604                Ok(Err(e)) => {
1605                    warn!(cookie = %cookie.name, error = %e, "Failed to inject cookie");
1606                }
1607                Ok(Ok(_)) => {}
1608            }
1609        }
1610
1611        debug!(count = cookies.len(), "Cookies injected");
1612        Ok(())
1613    }
1614
1615    /// Capture a screenshot of the current page as PNG bytes.
1616    ///
1617    /// them in-memory.
1618    ///
1619    /// # Errors
1620    ///
1621    /// command fails, or [`BrowserError::Timeout`] if it exceeds
1622    /// `cdp_timeout`.
1623    ///
1624    /// # Example
1625    ///
1626    /// ```no_run
1627    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
1628    /// use std::{time::Duration, fs};
1629    ///
1630    /// # async fn run() -> stygian_browser::error::Result<()> {
1631    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1632    /// let handle = pool.acquire().await?;
1633    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1634    /// let png = page.screenshot().await?;
1635    /// fs::write("screenshot.png", &png).unwrap();
1636    /// # Ok(())
1637    /// # }
1638    /// ```
1639    pub async fn screenshot(&self) -> Result<Vec<u8>> {
1640        use chromiumoxide::page::ScreenshotParams;
1641
1642        let params = ScreenshotParams::builder().full_page(true).build();
1643
1644        timeout(self.cdp_timeout, self.page.screenshot(params))
1645            .await
1646            .map_err(|_| BrowserError::Timeout {
1647                operation: "Page.captureScreenshot".to_string(),
1648                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
1649            })?
1650            .map_err(|e| BrowserError::CdpError {
1651                operation: "Page.captureScreenshot".to_string(),
1652                message: e.to_string(),
1653            })
1654    }
1655
1656    /// Borrow the underlying chromiumoxide [`Page`].
1657    #[must_use]
1658    pub const fn inner(&self) -> &Page {
1659        &self.page
1660    }
1661
1662    /// Close this page (tab).
1663    ///
1664    /// # Errors
1665    ///
1666    /// Returns [`BrowserError::Timeout`] when the close call does not
1667    /// complete within the 5-second timeout, and
1668    /// [`BrowserError::CdpError`] for underlying chromiumoxide failures
1669    /// while issuing the `Page.close` CDP command.
1670    pub async fn close(self) -> Result<()> {
1671        timeout(Duration::from_secs(5), self.page.clone().close())
1672            .await
1673            .map_err(|_| BrowserError::Timeout {
1674                operation: "page.close".to_string(),
1675                duration_ms: 5000,
1676            })?
1677            .map_err(|e| BrowserError::CdpError {
1678                operation: "page.close".to_string(),
1679                message: e.to_string(),
1680            })
1681    }
1682}
1683
1684// ─── Stealth diagnostics ──────────────────────────────────────────────────────
1685
1686#[cfg(feature = "stealth")]
1687impl PageHandle {
1688    /// Run all built-in stealth detection checks against the current page.
1689    ///
1690    /// Iterates [`crate::diagnostic::all_checks`], evaluates each check's
1691    /// JavaScript via CDP `Runtime.evaluate`, and returns an aggregate
1692    /// [`crate::diagnostic::DiagnosticReport`].
1693    ///
1694    /// recorded as failing checks and do **not** abort the whole run.
1695    ///
1696    /// # Errors
1697    ///
1698    /// Individual check failures are captured in the report.
1699    ///
1700    /// # Example
1701    ///
1702    /// ```no_run
1703    /// # async fn run() -> stygian_browser::error::Result<()> {
1704    /// use stygian_browser::{BrowserPool, BrowserConfig};
1705    /// use stygian_browser::page::WaitUntil;
1706    /// use std::time::Duration;
1707    ///
1708    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1709    /// let handle = pool.acquire().await?;
1710    /// let browser = handle.browser().expect("valid browser");
1711    /// let mut page = browser.new_page().await?;
1712    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(10)).await?;
1713    ///
1714    /// let report = page.verify_stealth().await?;
1715    /// println!("Stealth: {}/{} checks passed", report.passed_count, report.checks.len());
1716    /// # for failure in report.failures() {
1717    ///     eprintln!("  FAIL  {}: {}", failure.description, failure.details);
1718    /// # }
1719    /// # Ok(())
1720    /// # }
1721    /// ```
1722    pub async fn verify_stealth(&self) -> Result<crate::diagnostic::DiagnosticReport> {
1723        use crate::diagnostic::{CheckResult, DiagnosticReport, all_checks, all_limitation_probes};
1724
1725        let mut results: Vec<CheckResult> = Vec::new();
1726        let mut known_limitations = Vec::new();
1727
1728        for check in all_checks() {
1729            let result = match self.eval::<String>(check.script).await {
1730                Ok(json) => check.parse_output(&json),
1731                Err(e) => {
1732                    tracing::warn!(
1733                        check = ?check.id,
1734                        error = %e,
1735                        "stealth check script failed during evaluation"
1736                    );
1737                    CheckResult {
1738                        id: check.id,
1739                        description: check.description.to_string(),
1740                        passed: false,
1741                        details: format!("script error: {e}"),
1742                    }
1743                }
1744            };
1745            tracing::debug!(
1746                check = ?result.id,
1747                passed = result.passed,
1748                details = %result.details,
1749                "stealth check result"
1750            );
1751            results.push(result);
1752        }
1753
1754        for probe in all_limitation_probes() {
1755            let limitation = match self.eval::<String>(probe.script).await {
1756                Ok(json) => probe.parse_output(&json),
1757                Err(error) => Some(crate::diagnostic::KnownLimitation {
1758                    id: probe.id,
1759                    description: probe.description.to_string(),
1760                    details: format!("script error: {error}"),
1761                }),
1762            };
1763            if let Some(limitation) = limitation {
1764                tracing::debug!(
1765                    limitation = ?limitation.id,
1766                    details = %limitation.details,
1767                    "stealth limitation observed"
1768                );
1769                known_limitations.push(limitation);
1770            }
1771        }
1772
1773        Ok(DiagnosticReport::new(results).with_known_limitations(known_limitations))
1774    }
1775
1776    /// Run stealth checks and attach transport diagnostics (JA3/JA4/HTTP3).
1777    ///
1778    /// # Errors
1779    ///
1780    /// Propagates any [`BrowserError`] returned by the inner
1781    /// [`Self::verify_stealth`] call (which surfaces CDP / selector /
1782    /// evaluation failures from the underlying stealth probe). The
1783    /// `navigator.userAgent` read uses `eval` and is best-effort — its
1784    /// failure is logged and downgraded to an empty string so the
1785    /// transport-diagnostic block can still be attached.
1786    pub async fn verify_stealth_with_transport(
1787        &self,
1788        observed: Option<crate::diagnostic::TransportObservations>,
1789    ) -> Result<crate::diagnostic::DiagnosticReport> {
1790        let report = self.verify_stealth().await?;
1791
1792        let user_agent = match self.eval::<String>("navigator.userAgent").await {
1793            Ok(ua) => ua,
1794            Err(e) => {
1795                tracing::warn!(error = %e, "failed to read navigator.userAgent for transport diagnostics");
1796                String::new()
1797            }
1798        };
1799
1800        let transport = crate::diagnostic::TransportDiagnostic::from_user_agent_and_observations(
1801            &user_agent,
1802            observed.as_ref(),
1803        );
1804
1805        Ok(report.with_transport(transport))
1806    }
1807}
1808
1809// ─── extract feature ─────────────────────────────────────────────────────────
1810
1811#[cfg(feature = "extract")]
1812impl PageHandle {
1813    ///
1814    ///
1815    /// All per-node extractions are driven concurrently via
1816    /// [`futures::future::try_join_all`].
1817    ///
1818    /// # Errors
1819    ///
1820    /// fails, or [`BrowserError::ExtractionFailed`] if any field extraction
1821    /// fails.
1822    ///
1823    /// # Example
1824    ///
1825    /// ```ignore
1826    /// use stygian_browser::extract::Extract;
1827    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
1828    /// use std::time::Duration;
1829    ///
1830    /// #[derive(Extract)]
1831    /// struct Link {
1832    ///     href: Option<String>,
1833    /// }
1834    ///
1835    /// # async fn run() -> stygian_browser::error::Result<()> {
1836    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
1837    /// let handle = pool.acquire().await?;
1838    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
1839    /// page.navigate(
1840    ///     "https://example.com",
1841    ///     WaitUntil::DomContentLoaded,
1842    ///     Duration::from_secs(30),
1843    /// ).await?;
1844    /// let links: Vec<Link> = page.extract_all::<Link>("nav li").await?;
1845    /// # Ok(())
1846    /// # }
1847    /// ```
1848    pub async fn extract_all<T>(&self, selector: &str) -> Result<Vec<T>>
1849    where
1850        T: crate::extract::Extractable,
1851    {
1852        use futures::future::try_join_all;
1853
1854        let nodes = self.query_selector_all(selector).await?;
1855        try_join_all(nodes.iter().map(|n| T::extract_from(n)))
1856            .await
1857            .map_err(BrowserError::ExtractionFailed)
1858    }
1859
1860    /// Try each selector in `selectors` in order and return the extracted
1861    /// results from the **first** selector that matches at least one node.
1862    ///
1863    /// This is useful when a page may use different markup across versions or
1864    /// A/B variants — supply the preferred selector first and progressively
1865    /// wider fallbacks afterwards.
1866    ///
1867    /// Returns an empty `Vec` only when *all* selectors match zero nodes
1868    /// (i.e. the element is genuinely absent from the page).  A non-empty
1869    /// intermediate selector result that then fails during extraction **will**
1870    /// return an error.
1871    ///
1872    /// # Errors
1873    ///
1874    /// Returns [`BrowserError::CdpError`] if the selector query fails, or
1875    /// [`BrowserError::ExtractionFailed`] if a matched node fails extraction.
1876    ///
1877    /// # Example
1878    ///
1879    /// ```ignore
1880    /// use stygian_browser::extract::Extract;
1881    ///
1882    /// #[derive(Extract)]
1883    /// struct Headline { title: String }
1884    ///
1885    /// # async fn run(page: &stygian_browser::PageHandle) -> stygian_browser::error::Result<()> {
1886    /// // Try modern selector first, fall back to legacy markup.
1887    /// let items = page
1888    ///     .extract_all_with_fallback::<Headline>(&["h2.headline", "h2.title", "h2"])
1889    ///     .await?;
1890    /// # Ok(())
1891    /// # }
1892    /// ```
1893    pub async fn extract_all_with_fallback<T>(&self, selectors: &[&str]) -> Result<Vec<T>>
1894    where
1895        T: crate::extract::Extractable,
1896    {
1897        use futures::future::try_join_all;
1898
1899        for &selector in selectors {
1900            let nodes = self.query_selector_all(selector).await?;
1901            if nodes.is_empty() {
1902                continue;
1903            }
1904            return try_join_all(nodes.iter().map(|n| T::extract_from(n)))
1905                .await
1906                .map_err(BrowserError::ExtractionFailed);
1907        }
1908
1909        Ok(vec![])
1910    }
1911
1912    /// Extract from every node matching `selector`, **skipping** nodes where
1913    /// a required field is absent (i.e. [`ExtractionError::Missing`]).
1914    ///
1915    /// Unlike [`extract_all`], this method is lenient about structural
1916    /// mismatches: nodes that fail with [`ExtractionError::Missing`] are
1917    /// silently dropped from the result set.  All other extraction errors
1918    /// (CDP failures, stale nodes, nested errors) still propagate as hard
1919    /// failures.
1920    ///
1921    /// This is useful when scraping heterogeneous lists where some items
1922    /// lack an optional field that your struct treats as required.
1923    ///
1924    /// [`extract_all`]: Self::extract_all
1925    /// [`ExtractionError::Missing`]: crate::extract::ExtractionError::Missing
1926    ///
1927    /// # Errors
1928    ///
1929    /// Returns [`BrowserError::CdpError`] if the selector query fails, or
1930    /// [`BrowserError::ExtractionFailed`] for non-`Missing` extraction errors.
1931    ///
1932    /// # Example
1933    ///
1934    /// ```ignore
1935    /// use stygian_browser::extract::Extract;
1936    ///
1937    /// #[derive(Extract)]
1938    /// struct Price { amount: String }
1939    ///
1940    /// # async fn run(page: &stygian_browser::PageHandle) -> stygian_browser::error::Result<()> {
1941    /// // Products without a price tag are silently skipped.
1942    /// let prices = page.extract_resilient::<Price>(".product").await?;
1943    /// # Ok(())
1944    /// # }
1945    /// ```
1946    pub async fn extract_resilient<T>(&self, selector: &str) -> Result<Vec<T>>
1947    where
1948        T: crate::extract::Extractable,
1949    {
1950        use crate::extract::ExtractionError;
1951
1952        let nodes = self.query_selector_all(selector).await?;
1953        let mut results = Vec::with_capacity(nodes.len());
1954
1955        for node in &nodes {
1956            match T::extract_from(node).await {
1957                Ok(item) => results.push(item),
1958                Err(ExtractionError::Missing { .. }) => {
1959                    tracing::debug!(
1960                        selector,
1961                        "extract_resilient: skipping node with missing required field"
1962                    );
1963                }
1964                Err(e) => return Err(BrowserError::ExtractionFailed(e)),
1965            }
1966        }
1967
1968        Ok(results)
1969    }
1970}
1971
1972// ─── similarity feature ──────────────────────────────────────────────────────
1973
1974#[cfg(feature = "similarity")]
1975impl NodeHandle {
1976    /// node.
1977    ///
1978    /// Issues a single `Runtime.callFunctionOn` JS eval that extracts the tag,
1979    /// class list, attribute names, and body-depth in one round-trip.
1980    ///
1981    /// # Errors
1982    ///
1983    /// invalidated, or [`BrowserError::ScriptExecutionFailed`] if the script
1984    /// produces unexpected output.
1985    pub async fn fingerprint(&self) -> Result<crate::similarity::ElementFingerprint> {
1986        const JS: &str = r"function() {
1987    var el = this;
1988    var tag = el.tagName.toLowerCase();
1989    var classes = Array.prototype.slice.call(el.classList).sort();
1990    var attrNames = Array.prototype.slice.call(el.attributes)
1991        .map(function(a) { return a.name; })
1992        .filter(function(n) { return n !== 'class' && n !== 'id'; })
1993        .sort();
1994    var depth = 0;
1995    var n = el.parentElement;
1996    while (n && n.tagName.toLowerCase() !== 'body') { depth++; n = n.parentElement; }
1997    return JSON.stringify({ tag: tag, classes: classes, attrNames: attrNames, depth: depth });
1998}";
1999
2000        let returns = tokio::time::timeout(self.cdp_timeout, self.element.call_js_fn(JS, true))
2001            .await
2002            .map_err(|_| BrowserError::Timeout {
2003                operation: "NodeHandle::fingerprint".to_string(),
2004                duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
2005            })?
2006            .map_err(|e| self.cdp_err_or_stale(&e, "fingerprint"))?;
2007
2008        let json_str = returns
2009            .result
2010            .value
2011            .as_ref()
2012            .and_then(|v| v.as_str())
2013            .ok_or_else(|| BrowserError::ScriptExecutionFailed {
2014                script: "NodeHandle::fingerprint".to_string(),
2015                reason: "CDP returned no string value from fingerprint script".to_string(),
2016            })?;
2017
2018        serde_json::from_str::<crate::similarity::ElementFingerprint>(json_str).map_err(|e| {
2019            BrowserError::ScriptExecutionFailed {
2020                script: "NodeHandle::fingerprint".to_string(),
2021                reason: format!("failed to deserialise fingerprint JSON: {e}"),
2022            }
2023        })
2024    }
2025}
2026
2027#[cfg(feature = "similarity")]
2028impl PageHandle {
2029    /// `reference`, scored by [`crate::similarity::SimilarityConfig`].
2030    ///
2031    /// [`NodeHandle::fingerprint`]), then fingerprints every candidate returned
2032    /// [`crate::similarity::jaccard_weighted`] score exceeds
2033    /// `config.threshold`.  Results are ordered by score descending.
2034    ///
2035    /// # Example
2036    ///
2037    /// ```no_run
2038    /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
2039    /// use stygian_browser::similarity::SimilarityConfig;
2040    /// use std::time::Duration;
2041    ///
2042    /// # async fn run() -> stygian_browser::error::Result<()> {
2043    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
2044    /// let handle = pool.acquire().await?;
2045    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
2046    /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
2047    ///
2048    /// # let nodes = page.query_selector_all("h1").await?;
2049    /// # let reference = nodes.into_iter().next().ok_or(stygian_browser::error::BrowserError::StaleNode { selector: "h1".to_string() })?;
2050    ///     let similar = page.find_similar(&reference, SimilarityConfig::default()).await?;
2051    /// # for m in &similar {
2052    ///         println!("score={:.2}", m.score);
2053    /// # }
2054    /// # Ok(())
2055    /// # }
2056    /// ```
2057    ///
2058    /// # Errors
2059    ///
2060    /// [`BrowserError::ScriptExecutionFailed`] if a scoring script fails.
2061    pub async fn find_similar(
2062        &self,
2063        reference: &NodeHandle,
2064        config: crate::similarity::SimilarityConfig,
2065    ) -> Result<Vec<crate::similarity::SimilarMatch>> {
2066        use crate::similarity::{SimilarMatch, jaccard_weighted};
2067
2068        let ref_fp = reference.fingerprint().await?;
2069        let candidates = self.query_selector_all("*").await?;
2070
2071        let mut matches: Vec<SimilarMatch> = Vec::new();
2072        for node in candidates {
2073            if let Ok(cand_fp) = node.fingerprint().await {
2074                let score = jaccard_weighted(&ref_fp, &cand_fp);
2075                if score >= config.threshold {
2076                    matches.push(SimilarMatch { node, score });
2077                }
2078            }
2079            // Stale / detached nodes are silently skipped.
2080        }
2081
2082        matches.sort_by(|a, b| {
2083            b.score
2084                .partial_cmp(&a.score)
2085                .unwrap_or(std::cmp::Ordering::Equal)
2086        });
2087
2088        if config.max_results > 0 {
2089            matches.truncate(config.max_results);
2090        }
2091
2092        Ok(matches)
2093    }
2094}
2095
2096impl Drop for PageHandle {
2097    fn drop(&mut self) {
2098        warn!("PageHandle dropped without explicit close(); spawning cleanup task");
2099        // chromiumoxide Page does not implement close on Drop, so we spawn
2100        // swap it out. We clone the Page handle (it's Arc-backed internally).
2101        let page = self.page.clone();
2102        tokio::spawn(async move {
2103            let _ = page.close().await;
2104        });
2105    }
2106}
2107
2108// ─── Session warmup & refresh ─────────────────────────────────────────────────
2109
2110/// Simplified, JSON-serializable wait strategy used in [`WarmupOptions`] and
2111/// [`RefreshOptions`].
2112///
2113/// This is a serialization-friendly analogue of [`WaitUntil`].  Use
2114/// [`WarmupWait::into_wait_until`] to convert before calling
2115/// [`PageHandle::navigate`].
2116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2117#[serde(rename_all = "snake_case")]
2118pub enum WarmupWait {
2119    /// Wait until the HTML is fully parsed (`DOMContentLoaded`).  This is the
2120    /// default and works for most pages.
2121    #[default]
2122    DomContentLoaded,
2123    /// Wait until there are no more than two in-flight network requests for at
2124    /// least 500 ms after navigation.
2125    NetworkIdle,
2126}
2127
2128impl WarmupWait {
2129    /// Convert into the lower-level [`WaitUntil`] enum.
2130    #[must_use]
2131    pub const fn into_wait_until(self) -> WaitUntil {
2132        match self {
2133            Self::DomContentLoaded => WaitUntil::DomContentLoaded,
2134            Self::NetworkIdle => WaitUntil::NetworkIdle,
2135        }
2136    }
2137}
2138
2139/// Options for [`PageHandle::warmup`].
2140///
2141/// # Example
2142///
2143/// ```
2144/// use stygian_browser::page::{WarmupOptions, WarmupWait};
2145///
2146/// let opts = WarmupOptions {
2147///     url: "https://example.com".to_string(),
2148///     wait: WarmupWait::DomContentLoaded,
2149///     timeout_ms: 30_000,
2150///     stabilize_ms: 500,
2151/// };
2152/// assert_eq!(opts.timeout_ms, 30_000);
2153/// ```
2154#[derive(Debug, Clone, Serialize, Deserialize)]
2155pub struct WarmupOptions {
2156    /// The URL to navigate to during warmup.
2157    pub url: String,
2158    /// Wait strategy applied after the navigation commit (default:
2159    /// `DomContentLoaded`).
2160    #[serde(default)]
2161    pub wait: WarmupWait,
2162    /// Navigation timeout in milliseconds.  Default: `30 000`.
2163    #[serde(default = "WarmupOptions::default_timeout_ms")]
2164    pub timeout_ms: u64,
2165    /// Additional pause after navigation to let dynamic resources (XHR,
2166    /// lazy-loaded images) settle, in milliseconds.  `0` disables the
2167    /// stabilization step (default).
2168    #[serde(default)]
2169    pub stabilize_ms: u64,
2170}
2171
2172impl WarmupOptions {
2173    /// Returns the default navigation timeout (30 000 ms).
2174    #[must_use]
2175    pub const fn default_timeout_ms() -> u64 {
2176        30_000
2177    }
2178}
2179
2180impl Default for WarmupOptions {
2181    fn default() -> Self {
2182        Self {
2183            url: String::new(),
2184            wait: WarmupWait::DomContentLoaded,
2185            timeout_ms: Self::default_timeout_ms(),
2186            stabilize_ms: 0,
2187        }
2188    }
2189}
2190
2191/// Diagnostic report produced by [`PageHandle::warmup`].
2192///
2193/// # Example
2194///
2195/// ```
2196/// use stygian_browser::page::WarmupReport;
2197/// let report = WarmupReport {
2198///     url: "https://example.com".to_string(),
2199///     elapsed_ms: 250,
2200///     status_code: Some(200),
2201///     title: "Example Domain".to_string(),
2202///     stabilized: false,
2203/// };
2204/// assert_eq!(report.status_code, Some(200));
2205/// ```
2206#[derive(Debug, Clone, Serialize, Deserialize)]
2207pub struct WarmupReport {
2208    /// The URL that was warmed.
2209    pub url: String,
2210    /// Elapsed wall-time in milliseconds.
2211    pub elapsed_ms: u64,
2212    /// HTTP status code of the warmup navigation, if captured by the
2213    /// `Network.responseReceived` listener.
2214    pub status_code: Option<u16>,
2215    /// Page title after warmup navigation.
2216    pub title: String,
2217    /// Whether a stabilization pause (`stabilize_ms > 0`) was applied after
2218    /// navigation.
2219    pub stabilized: bool,
2220}
2221
2222/// Options for [`PageHandle::refresh`].
2223///
2224/// # Example
2225///
2226/// ```
2227/// use stygian_browser::page::{RefreshOptions, WarmupWait};
2228///
2229/// let opts = RefreshOptions {
2230///     wait: WarmupWait::DomContentLoaded,
2231///     timeout_ms: 15_000,
2232///     reset_connection: true,
2233/// };
2234/// assert!(opts.reset_connection);
2235/// ```
2236#[derive(Debug, Clone, Serialize, Deserialize)]
2237pub struct RefreshOptions {
2238    /// Wait strategy applied after the reload (default: `DomContentLoaded`).
2239    #[serde(default)]
2240    pub wait: WarmupWait,
2241    /// Reload timeout in milliseconds.  Default: `30 000`.
2242    #[serde(default = "RefreshOptions::default_timeout_ms")]
2243    pub timeout_ms: u64,
2244    /// When `true`, re-navigates to the current URL rather than issuing a
2245    /// browser-level reload.  This signals to the calling code that a new TCP
2246    /// connection is desired while cookies and storage are retained in the
2247    /// browser process.  Default: `false`.
2248    #[serde(default)]
2249    pub reset_connection: bool,
2250}
2251
2252impl RefreshOptions {
2253    /// Returns the default reload timeout (30 000 ms).
2254    #[must_use]
2255    pub const fn default_timeout_ms() -> u64 {
2256        30_000
2257    }
2258}
2259
2260impl Default for RefreshOptions {
2261    fn default() -> Self {
2262        Self {
2263            wait: WarmupWait::DomContentLoaded,
2264            timeout_ms: Self::default_timeout_ms(),
2265            reset_connection: false,
2266        }
2267    }
2268}
2269
2270/// Diagnostic report produced by [`PageHandle::refresh`].
2271///
2272/// # Example
2273///
2274/// ```
2275/// use stygian_browser::page::RefreshReport;
2276/// let report = RefreshReport {
2277///     url: "https://example.com".to_string(),
2278///     elapsed_ms: 180,
2279///     status_code: Some(200),
2280/// };
2281/// assert_eq!(report.elapsed_ms, 180);
2282/// ```
2283#[derive(Debug, Clone, Serialize, Deserialize)]
2284pub struct RefreshReport {
2285    /// URL of the page after the refresh navigation.
2286    pub url: String,
2287    /// Elapsed wall-time in milliseconds.
2288    pub elapsed_ms: u64,
2289    /// HTTP status code of the refresh navigation, if captured.
2290    pub status_code: Option<u16>,
2291}
2292
2293// ─── PageHandle warmup / refresh ──────────────────────────────────────────────
2294
2295impl PageHandle {
2296    /// Warm up a browser session by navigating to `options.url` and
2297    /// optionally waiting for dynamic resources to settle.
2298    ///
2299    /// Warmup is **idempotent**: calling it repeatedly re-navigates and
2300    /// re-warms the same session without adverse side effects.
2301    ///
2302    /// # Errors
2303    ///
2304    /// Returns [`BrowserError::NavigationFailed`] if the navigation times out
2305    /// or the underlying CDP call fails.
2306    ///
2307    /// # Example
2308    ///
2309    /// ```no_run
2310    /// # async fn run() -> stygian_browser::error::Result<()> {
2311    /// use stygian_browser::{BrowserPool, BrowserConfig};
2312    /// use stygian_browser::page::{WarmupOptions, WarmupWait};
2313    ///
2314    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
2315    /// let handle = pool.acquire().await?;
2316    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
2317    ///
2318    /// let report = page.warmup(WarmupOptions {
2319    ///     url: "https://example.com".to_string(),
2320    ///     wait: WarmupWait::DomContentLoaded,
2321    ///     timeout_ms: 30_000,
2322    ///     stabilize_ms: 500,
2323    /// }).await?;
2324    /// println!("warmed in {}ms: {}", report.elapsed_ms, report.title);
2325    /// handle.release().await;
2326    /// # Ok(())
2327    /// # }
2328    /// ```
2329    pub async fn warmup(&mut self, options: WarmupOptions) -> Result<WarmupReport> {
2330        let start = std::time::Instant::now();
2331        let nav_timeout = Duration::from_millis(options.timeout_ms);
2332        self.navigate(
2333            &options.url,
2334            options.wait.clone().into_wait_until(),
2335            nav_timeout,
2336        )
2337        .await?;
2338        let status_code = self.status_code()?;
2339        let title = self.title().await.unwrap_or_default();
2340        let stabilized = options.stabilize_ms > 0;
2341        if stabilized {
2342            tokio::time::sleep(Duration::from_millis(options.stabilize_ms)).await;
2343        }
2344        let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2345        Ok(WarmupReport {
2346            url: options.url,
2347            elapsed_ms,
2348            status_code,
2349            title,
2350            stabilized,
2351        })
2352    }
2353
2354    /// Refresh the current page, retaining all in-browser session state
2355    /// (cookies, `localStorage`, `sessionStorage`).
2356    ///
2357    /// When `options.reset_connection` is `false` (default) a standard
2358    /// CDP reload is issued.  When `true`, the current URL is re-navigated,
2359    /// which expresses the caller's intent to force a new underlying TCP/TLS
2360    /// connection while keeping all browser-side state intact.
2361    ///
2362    /// Refresh is **idempotent**: repeated calls simply reload the page again.
2363    ///
2364    /// # Errors
2365    ///
2366    /// Returns [`BrowserError::NavigationFailed`] if the current URL cannot be
2367    /// determined or the reload times out.
2368    ///
2369    /// # Example
2370    ///
2371    /// ```no_run
2372    /// # async fn run() -> stygian_browser::error::Result<()> {
2373    /// use stygian_browser::{BrowserPool, BrowserConfig};
2374    /// use stygian_browser::page::{RefreshOptions, WaitUntil};
2375    ///
2376    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
2377    /// let handle = pool.acquire().await?;
2378    /// let mut page = handle.browser().expect("valid browser").new_page().await?;
2379    /// page.navigate(
2380    ///     "https://example.com",
2381    ///     WaitUntil::DomContentLoaded,
2382    ///     std::time::Duration::from_secs(30),
2383    /// ).await?;
2384    ///
2385    /// let report = page.refresh(RefreshOptions::default()).await?;
2386    /// println!("refreshed in {}ms", report.elapsed_ms);
2387    /// handle.release().await;
2388    /// # Ok(())
2389    /// # }
2390    /// ```
2391    pub async fn refresh(&mut self, options: RefreshOptions) -> Result<RefreshReport> {
2392        let start = std::time::Instant::now();
2393        let nav_timeout = Duration::from_millis(options.timeout_ms);
2394        let wait = options.wait.clone().into_wait_until();
2395        // Resolve the current URL before any navigation changes it.
2396        let current_url = self.url().await?;
2397        if current_url.is_empty() || current_url == "about:blank" {
2398            return Err(BrowserError::NavigationFailed {
2399                url: current_url,
2400                reason: "page has not been navigated yet; call warmup() or navigate() first"
2401                    .to_string(),
2402            });
2403        }
2404        // Both code paths navigate to the same URL.  `reset_connection: true`
2405        // expresses the *intent* to use a new TCP connection; the browser is free
2406        // to reuse or create a new connection as its connection pool dictates.
2407        self.navigate(&current_url, wait, nav_timeout).await?;
2408        let status_code = self.status_code()?;
2409        let url = self.url().await?;
2410        let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2411        Ok(RefreshReport {
2412            url,
2413            elapsed_ms,
2414            status_code,
2415        })
2416    }
2417}
2418
2419// ─── Rust-side CDP Node → HTML serializer (Recursive fallback) ───────────────
2420
2421/// CDP `DOM.Node.nodeType` constants (matches the WHATWG DOM spec).
2422mod node_type {
2423    /// `Element` node.
2424    pub const ELEMENT: i64 = 1;
2425    /// Text node (`Text`).
2426    pub const TEXT: i64 = 3;
2427    /// `CDATASection` node.
2428    pub const CDATA_SECTION: i64 = 4;
2429    /// `ProcessingInstruction` node.
2430    pub const PROCESSING_INSTRUCTION: i64 = 7;
2431    /// `Comment` node.
2432    pub const COMMENT: i64 = 8;
2433    /// `Document` node.
2434    pub const DOCUMENT: i64 = 9;
2435    /// `DocumentType` node.
2436    pub const DOCUMENT_TYPE: i64 = 10;
2437    /// `DocumentFragment` node.
2438    pub const DOCUMENT_FRAGMENT: i64 = 11;
2439}
2440
2441/// HTML elements that have no closing tag (per the WHATWG spec).
2442const VOID_ELEMENTS: &[&str] = &[
2443    "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
2444    "source", "track", "wbr",
2445];
2446
2447/// Serialise a CDP `Node` subtree (rooted at `node`) to an HTML string.
2448///
2449/// Used by [`NodeHandle::outer_html_via_rust_walk`] as the
2450/// [`OuterHtmlStrategy::Recursive`] fallback when `DOM.getOuterHTML`
2451/// returns an empty payload or errors out. The implementation is a
2452/// straightforward depth-first walk that mirrors what Chromium's own
2453/// `Element.outerHTML` accessor produces for the same tree:
2454/// - element nodes emit `<tag attrs>children</tag>`. [`VOID_ELEMENTS`]
2455///   emit `<tag attrs>` with no closing slash and no children, matching
2456///   Chromium's `outerHTML` byte-for-byte (which uses HTML5 syntax, not
2457///   XHTML self-closing).
2458/// - text nodes are HTML-escaped
2459/// - comment nodes emit `<!--value-->`
2460/// - `<!DOCTYPE …>` declarations are emitted for `DocumentType` roots
2461/// - `Document` / `DocumentFragment` roots emit only their children
2462///   (no outer wrapper), matching how `XMLSerializer` treats them
2463/// - `template` content (`template_content`) is inlined as additional
2464///   children of the `<template>` element, mirroring browser behaviour
2465/// - shadow roots are inlined as additional children of their host
2466///   (no `<shadowroot>` wrapper, since shadow content is what
2467///   `outerHTML` is expected to surface)
2468///
2469/// This serializer is not intended to be a perfect drop-in for
2470/// `Element.outerHTML` on every edge case (`CDATA`, `ProcessingInstruction`,
2471/// and namespace prefixes are simplified) — it is the second-line fallback
2472/// for the `Recursive` strategy and only fires when `DOM.getOuterHTML`
2473/// itself fails.
2474fn serialize_node_tree(node: &chromiumoxide::cdp::browser_protocol::dom::Node) -> String {
2475    let mut out = String::new();
2476    serialize_node_into(&mut out, node);
2477    out
2478}
2479
2480fn serialize_node_into(out: &mut String, node: &chromiumoxide::cdp::browser_protocol::dom::Node) {
2481    match node.node_type {
2482        node_type::ELEMENT => {
2483            let tag = node.local_name.as_str();
2484            out.push('<');
2485            out.push_str(tag);
2486            if let Some(attrs) = &node.attributes {
2487                for [name, value] in attrs.as_chunks::<2>().0 {
2488                    out.push(' ');
2489                    escape_attr_name(out, name);
2490                    out.push_str("=\"");
2491                    escape_attr_value(out, value);
2492                    out.push('"');
2493                }
2494            }
2495            if VOID_ELEMENTS.contains(&tag) {
2496                out.push('>');
2497                return;
2498            }
2499            out.push('>');
2500            serialize_inline_children(out, node);
2501            out.push_str("</");
2502            out.push_str(tag);
2503            out.push('>');
2504        }
2505        node_type::TEXT => {
2506            escape_text(out, &node.node_value);
2507        }
2508        node_type::COMMENT => {
2509            out.push_str("<!--");
2510            out.push_str(&node.node_value);
2511            out.push_str("-->");
2512        }
2513        node_type::DOCUMENT | node_type::DOCUMENT_FRAGMENT => {
2514            serialize_inline_children(out, node);
2515        }
2516        node_type::DOCUMENT_TYPE => {
2517            out.push_str("<!DOCTYPE ");
2518            out.push_str(&node.node_name);
2519            if let Some(public_id) = &node.public_id {
2520                out.push(' ');
2521                out.push_str(public_id);
2522            }
2523            if let Some(system_id) = &node.system_id {
2524                out.push(' ');
2525                out.push_str(system_id);
2526            }
2527            out.push('>');
2528        }
2529        node_type::CDATA_SECTION => {
2530            out.push_str("<![CDATA[");
2531            out.push_str(&node.node_value);
2532            out.push_str("]]>");
2533        }
2534        node_type::PROCESSING_INSTRUCTION => {
2535            out.push_str("<?");
2536            out.push_str(&node.node_name);
2537            if !node.node_value.is_empty() {
2538                out.push(' ');
2539                out.push_str(&node.node_value);
2540            }
2541            out.push_str("?>");
2542        }
2543        _ => {
2544            if !node.node_value.is_empty() {
2545                escape_text(out, &node.node_value);
2546            }
2547        }
2548    }
2549}
2550
2551/// Emit the inline children of a node (regular `children`, plus
2552/// `template_content`, `shadow_roots`, and `content_document`) in the order
2553/// Chromium's own `Element.outerHTML` accessor surfaces them.
2554fn serialize_inline_children(
2555    out: &mut String,
2556    node: &chromiumoxide::cdp::browser_protocol::dom::Node,
2557) {
2558    if let Some(children) = &node.children {
2559        for child in children {
2560            serialize_node_into(out, child);
2561        }
2562    }
2563    if let Some(template_content) = &node.template_content {
2564        serialize_node_into(out, template_content);
2565    }
2566    if let Some(shadow_roots) = &node.shadow_roots {
2567        for shadow in shadow_roots {
2568            serialize_node_into(out, shadow);
2569        }
2570    }
2571    if let Some(content_document) = &node.content_document {
2572        serialize_node_into(out, content_document);
2573    }
2574}
2575
2576/// Escape a text node payload for safe inclusion in HTML element content.
2577fn escape_text(out: &mut String, value: &str) {
2578    for ch in value.chars() {
2579        match ch {
2580            '&' => out.push_str("&amp;"),
2581            '<' => out.push_str("&lt;"),
2582            '>' => out.push_str("&gt;"),
2583            _ => out.push(ch),
2584        }
2585    }
2586}
2587
2588/// Escape an attribute name (same rules as text — `&` and `<` cannot appear
2589/// in well-formed attribute names but are escaped defensively).
2590fn escape_attr_name(out: &mut String, value: &str) {
2591    for ch in value.chars() {
2592        match ch {
2593            '&' => out.push_str("&amp;"),
2594            '<' => out.push_str("&lt;"),
2595            '"' => out.push_str("&quot;"),
2596            _ => out.push(ch),
2597        }
2598    }
2599}
2600
2601/// Escape an attribute value for inclusion inside `"…"` quoted form.
2602fn escape_attr_value(out: &mut String, value: &str) {
2603    for ch in value.chars() {
2604        match ch {
2605            '&' => out.push_str("&amp;"),
2606            '<' => out.push_str("&lt;"),
2607            '"' => out.push_str("&quot;"),
2608            _ => out.push(ch),
2609        }
2610    }
2611}
2612
2613// ─── Tests ────────────────────────────────────────────────────────────────────
2614
2615#[cfg(test)]
2616mod tests {
2617    use super::*;
2618
2619    #[test]
2620    fn resource_filter_block_media_blocks_image() {
2621        let filter = ResourceFilter::block_media();
2622        assert!(filter.should_block("Image"));
2623        assert!(filter.should_block("Font"));
2624        assert!(filter.should_block("Stylesheet"));
2625        assert!(filter.should_block("Media"));
2626        assert!(!filter.should_block("Script"));
2627        assert!(!filter.should_block("XHR"));
2628    }
2629
2630    #[test]
2631    fn resource_filter_case_insensitive() {
2632        let filter = ResourceFilter::block_images_and_fonts();
2633        assert!(filter.should_block("image")); // lowercase
2634        assert!(filter.should_block("IMAGE")); // uppercase
2635        assert!(!filter.should_block("Stylesheet"));
2636    }
2637
2638    #[test]
2639    fn resource_filter_builder_chain() {
2640        let filter = ResourceFilter::default()
2641            .block(ResourceType::Image)
2642            .block(ResourceType::Font);
2643        assert!(filter.should_block("Image"));
2644        assert!(filter.should_block("Font"));
2645        assert!(!filter.should_block("Stylesheet"));
2646    }
2647
2648    #[test]
2649    fn resource_filter_dedup_block() {
2650        let filter = ResourceFilter::default()
2651            .block(ResourceType::Image)
2652            .block(ResourceType::Image); // duplicate
2653        assert_eq!(filter.blocked.len(), 1);
2654    }
2655
2656    #[test]
2657    fn resource_filter_is_empty_when_default() {
2658        assert!(ResourceFilter::default().is_empty());
2659        assert!(!ResourceFilter::block_media().is_empty());
2660    }
2661
2662    #[test]
2663    fn wait_until_selector_stores_string() {
2664        let w = WaitUntil::Selector("#foo".to_string());
2665        assert!(matches!(w, WaitUntil::Selector(ref s) if s == "#foo"));
2666    }
2667
2668    #[test]
2669    fn resource_type_cdp_str() {
2670        assert_eq!(ResourceType::Image.as_cdp_str(), "Image");
2671        assert_eq!(ResourceType::Font.as_cdp_str(), "Font");
2672        assert_eq!(ResourceType::Stylesheet.as_cdp_str(), "Stylesheet");
2673        assert_eq!(ResourceType::Media.as_cdp_str(), "Media");
2674    }
2675
2676    #[test]
2677    fn page_handle_is_send_sync() {
2678        fn assert_send<T: Send>() {}
2679        fn assert_sync<T: Sync>() {}
2680        assert_send::<PageHandle>();
2681        assert_sync::<PageHandle>();
2682    }
2683
2684    /// Verify the resilient extractor correctly classifies `ExtractionError`
2685    /// variants — `Missing` must be treated as "skip", others as hard errors.
2686    #[cfg(feature = "extract")]
2687    #[test]
2688    fn extraction_error_missing_is_skippable() {
2689        use crate::extract::ExtractionError;
2690
2691        let missing = ExtractionError::Missing {
2692            field: "title",
2693            selector: "h1",
2694        };
2695        assert!(
2696            matches!(missing, ExtractionError::Missing { .. }),
2697            "ExtractionError::Missing should be the skip variant"
2698        );
2699
2700        // Non-Missing variants should NOT match the skip pattern
2701        let nested = ExtractionError::Nested {
2702            field: "link",
2703            source: Box::new(ExtractionError::Missing {
2704                field: "href",
2705                selector: "a",
2706            }),
2707        };
2708        assert!(
2709            !matches!(nested, ExtractionError::Missing { .. }),
2710            "ExtractionError::Nested must not match Missing"
2711        );
2712    }
2713
2714    /// `Option<u16>` are pure-logic invariants testable without a live browser.
2715    #[test]
2716    fn status_code_sentinel_zero_maps_to_none() {
2717        use std::sync::atomic::{AtomicU16, Ordering};
2718        let atom = AtomicU16::new(0);
2719        let code = atom.load(Ordering::Acquire);
2720        assert_eq!(if code == 0 { None } else { Some(code) }, None::<u16>);
2721    }
2722
2723    #[test]
2724    fn status_code_non_zero_maps_to_some() {
2725        use std::sync::atomic::{AtomicU16, Ordering};
2726        for &expected in &[200u16, 301, 404, 503] {
2727            let atom = AtomicU16::new(expected);
2728            let code = atom.load(Ordering::Acquire);
2729            assert_eq!(if code == 0 { None } else { Some(code) }, Some(expected));
2730        }
2731    }
2732
2733    // ── NodeHandle pure-logic tests ───────────────────────────────────────────
2734
2735    /// `attr_map` relies on `chunks_exact(2)` — verify the pairing logic is
2736    /// correct without a live browser by exercising it directly.
2737    #[test]
2738    fn attr_map_chunking_pairs_correctly() {
2739        let flat = [
2740            "id".to_string(),
2741            "main".to_string(),
2742            "data-ux".to_string(),
2743            "Section".to_string(),
2744            "class".to_string(),
2745            "container".to_string(),
2746        ];
2747        let mut map = std::collections::HashMap::with_capacity(flat.len() / 2);
2748        for pair in flat.chunks_exact(2) {
2749            if let [name, value] = pair {
2750                map.insert(name.clone(), value.clone());
2751            }
2752        }
2753        assert_eq!(map.get("id").map(String::as_str), Some("main"));
2754        assert_eq!(map.get("data-ux").map(String::as_str), Some("Section"));
2755        assert_eq!(map.get("class").map(String::as_str), Some("container"));
2756        assert_eq!(map.len(), 3);
2757    }
2758
2759    /// gracefully — the trailing element is silently ignored.
2760    #[test]
2761    fn attr_map_chunking_ignores_odd_trailing() {
2762        let flat = ["orphan".to_string()]; // no value
2763        let mut map = std::collections::HashMap::new();
2764        for pair in flat.chunks_exact(2) {
2765            if let [name, value] = pair {
2766                map.insert(name.clone(), value.clone());
2767            }
2768        }
2769        assert!(map.is_empty());
2770    }
2771
2772    /// Empty flat list → empty map.
2773    #[test]
2774    fn attr_map_chunking_empty_input() {
2775        let flat: Vec<String> = vec![];
2776        let map: std::collections::HashMap<String, String> = flat
2777            .chunks_exact(2)
2778            .filter_map(|pair| {
2779                if let [name, value] = pair {
2780                    Some((name.clone(), value.clone()))
2781                } else {
2782                    None
2783                }
2784            })
2785            .collect();
2786        assert!(map.is_empty());
2787    }
2788
2789    #[test]
2790    fn ancestors_json_parse_round_trip() -> std::result::Result<(), serde_json::Error> {
2791        let json = r#"["p","article","body","html"]"#;
2792        let result: Vec<String> = serde_json::from_str(json)?;
2793        assert_eq!(result, ["p", "article", "body", "html"]);
2794        Ok(())
2795    }
2796
2797    #[test]
2798    fn ancestors_json_parse_empty() -> std::result::Result<(), serde_json::Error> {
2799        let json = "[]";
2800        let result: Vec<String> = serde_json::from_str(json)?;
2801        assert!(result.is_empty());
2802        Ok(())
2803    }
2804
2805    /// `"div::parent"`) must surface that suffix in its `Display` output so
2806    /// callers can locate the failed traversal in logs.
2807    #[test]
2808    fn traversal_selector_suffix_in_stale_error() {
2809        let e = crate::error::BrowserError::StaleNode {
2810            selector: "div::parent".to_string(),
2811        };
2812        let msg = e.to_string();
2813        assert!(
2814            msg.contains("div::parent"),
2815            "StaleNode display must include the full selector; got: {msg}"
2816        );
2817    }
2818
2819    #[test]
2820    fn traversal_next_suffix_in_stale_error() {
2821        let e = crate::error::BrowserError::StaleNode {
2822            selector: "li.price::next".to_string(),
2823        };
2824        assert!(e.to_string().contains("li.price::next"));
2825    }
2826
2827    #[test]
2828    fn traversal_prev_suffix_in_stale_error() {
2829        let e = crate::error::BrowserError::StaleNode {
2830            selector: "td.label::prev".to_string(),
2831        };
2832        assert!(e.to_string().contains("td.label::prev"));
2833    }
2834
2835    // ── OuterHtmlStrategy / OuterHtmlResult type tests (T101) ─────────────────
2836
2837    #[test]
2838    fn outer_html_strategy_default_is_current() {
2839        assert_eq!(OuterHtmlStrategy::default(), OuterHtmlStrategy::Current);
2840    }
2841
2842    #[test]
2843    fn outer_html_strategy_as_str_matches_variant() {
2844        assert_eq!(OuterHtmlStrategy::Current.as_str(), "Current");
2845        assert_eq!(OuterHtmlStrategy::Recursive.as_str(), "Recursive");
2846    }
2847
2848    #[test]
2849    fn outer_html_strategy_display_matches_as_str() {
2850        assert_eq!(
2851            format!("{}", OuterHtmlStrategy::Current),
2852            OuterHtmlStrategy::Current.as_str()
2853        );
2854        assert_eq!(
2855            format!("{}", OuterHtmlStrategy::Recursive),
2856            OuterHtmlStrategy::Recursive.as_str()
2857        );
2858    }
2859
2860    #[test]
2861    fn outer_html_strategy_is_copy_and_eq() {
2862        let s = OuterHtmlStrategy::Recursive;
2863        let copy = s;
2864        assert_eq!(s, copy);
2865        assert_eq!(s, OuterHtmlStrategy::Recursive);
2866        assert_ne!(s, OuterHtmlStrategy::Current);
2867    }
2868
2869    #[test]
2870    fn outer_html_strategy_all_iterates_both_variants() {
2871        let all = OuterHtmlStrategy::all();
2872        assert_eq!(all.len(), 2);
2873        assert_eq!(all[0], OuterHtmlStrategy::Current);
2874        assert_eq!(all[1], OuterHtmlStrategy::Recursive);
2875    }
2876
2877    #[test]
2878    fn outer_html_strategy_serialize_round_trip()
2879    -> std::result::Result<(), Box<dyn std::error::Error>> {
2880        for variant in OuterHtmlStrategy::all() {
2881            let json = serde_json::to_string(&variant)?;
2882            let restored: OuterHtmlStrategy = serde_json::from_str(&json)?;
2883            assert_eq!(restored, variant);
2884        }
2885        Ok(())
2886    }
2887
2888    #[test]
2889    fn outer_html_result_content_returns_some_for_content() {
2890        let r = OuterHtmlResult::Content("<div/>".to_string());
2891        assert_eq!(r.content(), Some("<div/>"));
2892    }
2893
2894    #[test]
2895    fn outer_html_result_content_returns_none_for_empty() {
2896        assert_eq!(OuterHtmlResult::Empty.content(), None);
2897    }
2898
2899    #[test]
2900    fn outer_html_result_content_returns_none_for_failed() {
2901        let r = OuterHtmlResult::Failed {
2902            backends: vec!["DOM.getOuterHTML"],
2903        };
2904        assert_eq!(r.content(), None);
2905    }
2906
2907    #[test]
2908    fn outer_html_result_is_empty_variants() {
2909        assert!(OuterHtmlResult::Empty.is_empty());
2910        assert!(
2911            OuterHtmlResult::Failed {
2912                backends: vec!["a"]
2913            }
2914            .is_empty()
2915        );
2916        assert!(!OuterHtmlResult::Content("<x/>".to_string()).is_empty());
2917        assert!(OuterHtmlResult::Content(String::new()).is_empty());
2918    }
2919
2920    #[test]
2921    fn outer_html_result_display_includes_state() {
2922        assert_eq!(format!("{}", OuterHtmlResult::Empty), "Empty");
2923        assert_eq!(
2924            format!("{}", OuterHtmlResult::Content("<div/>".to_string())),
2925            "Content(6 bytes)"
2926        );
2927        let failed = OuterHtmlResult::Failed {
2928            backends: vec!["DOM.getOuterHTML", "DOM.describeNode-walk"],
2929        };
2930        let s = format!("{failed}");
2931        assert!(s.contains("DOM.getOuterHTML"));
2932        assert!(s.contains("DOM.describeNode-walk"));
2933    }
2934
2935    #[test]
2936    fn outer_html_result_serializes_each_variant()
2937    -> std::result::Result<(), Box<dyn std::error::Error>> {
2938        let empty_json = serde_json::to_string(&OuterHtmlResult::Empty)?;
2939        assert_eq!(empty_json, "\"Empty\"");
2940
2941        let content_json =
2942            serde_json::to_string(&OuterHtmlResult::Content("<p>x</p>".to_string()))?;
2943        assert_eq!(content_json, r#"{"Content":"<p>x</p>"}"#);
2944
2945        let failed_json = serde_json::to_string(&OuterHtmlResult::Failed {
2946            backends: vec!["DOM.getOuterHTML", "DOM.describeNode-walk"],
2947        })?;
2948        assert_eq!(
2949            failed_json,
2950            r#"{"Failed":{"backends":["DOM.getOuterHTML","DOM.describeNode-walk"]}}"#
2951        );
2952        Ok(())
2953    }
2954
2955    // ── Rust-side CDP Node → HTML serializer tests (T101) ─────────────────────
2956
2957    use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, Node, NodeId};
2958
2959    fn mk_node(
2960        node_type: i64,
2961        local_name: &str,
2962        node_name: &str,
2963        node_value: &str,
2964        attributes: Option<Vec<String>>,
2965        children: Option<Vec<Node>>,
2966    ) -> Node {
2967        Node {
2968            node_id: NodeId::default(),
2969            parent_id: None,
2970            backend_node_id: BackendNodeId::default(),
2971            node_type,
2972            node_name: node_name.to_string(),
2973            local_name: local_name.to_string(),
2974            node_value: node_value.to_string(),
2975            child_node_count: None,
2976            children,
2977            attributes,
2978            document_url: None,
2979            base_url: None,
2980            public_id: None,
2981            system_id: None,
2982            internal_subset: None,
2983            xml_version: None,
2984            name: None,
2985            value: None,
2986            pseudo_type: None,
2987            pseudo_identifier: None,
2988            shadow_root_type: None,
2989            frame_id: None,
2990            content_document: None,
2991            shadow_roots: None,
2992            template_content: None,
2993            pseudo_elements: None,
2994            distributed_nodes: None,
2995            is_svg: None,
2996            compatibility_mode: None,
2997            assigned_slot: None,
2998            is_scrollable: None,
2999            affected_by_starting_styles: None,
3000            adopted_style_sheets: None,
3001        }
3002    }
3003
3004    #[test]
3005    fn serialize_element_with_text_child() {
3006        let text = mk_node(node_type::TEXT, "", "", "hello", None, None);
3007        let div = mk_node(node_type::ELEMENT, "div", "DIV", "", None, Some(vec![text]));
3008        assert_eq!(serialize_node_tree(&div), "<div>hello</div>");
3009    }
3010
3011    #[test]
3012    fn serialize_element_with_attributes() {
3013        let div = mk_node(
3014            node_type::ELEMENT,
3015            "div",
3016            "DIV",
3017            "",
3018            Some(vec![
3019                "id".into(),
3020                "main".into(),
3021                "class".into(),
3022                "container wide".into(),
3023            ]),
3024            None,
3025        );
3026        assert_eq!(
3027            serialize_node_tree(&div),
3028            r#"<div id="main" class="container wide"></div>"#
3029        );
3030    }
3031
3032    #[test]
3033    fn serialize_void_element_emits_self_closing() {
3034        let img = mk_node(
3035            node_type::ELEMENT,
3036            "img",
3037            "IMG",
3038            "",
3039            Some(vec!["src".into(), "/a.png".into()]),
3040            None,
3041        );
3042        assert_eq!(serialize_node_tree(&img), r#"<img src="/a.png">"#);
3043        let br = mk_node(node_type::ELEMENT, "br", "BR", "", None, None);
3044        assert_eq!(serialize_node_tree(&br), "<br>");
3045    }
3046
3047    #[test]
3048    fn serialize_nested_elements() {
3049        let p = mk_node(
3050            node_type::ELEMENT,
3051            "p",
3052            "P",
3053            "",
3054            None,
3055            Some(vec![mk_node(
3056                node_type::TEXT,
3057                "",
3058                "",
3059                "Mesh content here",
3060                None,
3061                None,
3062            )]),
3063        );
3064        let section = mk_node(
3065            node_type::ELEMENT,
3066            "section",
3067            "SECTION",
3068            "",
3069            None,
3070            Some(vec![p]),
3071        );
3072        let html = serialize_node_tree(&section);
3073        assert_eq!(html, "<section><p>Mesh content here</p></section>");
3074    }
3075
3076    #[test]
3077    fn serialize_text_escapes_special_chars() {
3078        let n = mk_node(node_type::TEXT, "", "", "a < b && c > d", None, None);
3079        assert_eq!(serialize_node_tree(&n), "a &lt; b &amp;&amp; c &gt; d");
3080    }
3081
3082    #[test]
3083    fn serialize_attribute_value_escapes_quotes_and_amp() {
3084        let div = mk_node(
3085            node_type::ELEMENT,
3086            "div",
3087            "DIV",
3088            "",
3089            Some(vec!["title".into(), "a & b \"c\"".into()]),
3090            None,
3091        );
3092        assert_eq!(
3093            serialize_node_tree(&div),
3094            r#"<div title="a &amp; b &quot;c&quot;"></div>"#
3095        );
3096    }
3097
3098    #[test]
3099    fn serialize_attribute_name_escapes_special_chars() {
3100        let div = mk_node(
3101            node_type::ELEMENT,
3102            "div",
3103            "DIV",
3104            "",
3105            Some(vec!["weird<\"&".into(), "v".into()]),
3106            None,
3107        );
3108        assert_eq!(
3109            serialize_node_tree(&div),
3110            r#"<div weird&lt;&quot;&amp;="v"></div>"#
3111        );
3112    }
3113
3114    #[test]
3115    fn serialize_comment_node() {
3116        let n = mk_node(node_type::COMMENT, "", "", " a comment ", None, None);
3117        assert_eq!(serialize_node_tree(&n), "<!-- a comment -->");
3118    }
3119
3120    #[test]
3121    fn serialize_document_root_flattens_children() {
3122        let html = mk_node(
3123            node_type::ELEMENT,
3124            "html",
3125            "HTML",
3126            "",
3127            None,
3128            Some(vec![mk_node(
3129                node_type::ELEMENT,
3130                "body",
3131                "BODY",
3132                "",
3133                None,
3134                None,
3135            )]),
3136        );
3137        let doc = mk_node(
3138            node_type::DOCUMENT,
3139            "",
3140            "#document",
3141            "",
3142            None,
3143            Some(vec![html]),
3144        );
3145        assert_eq!(serialize_node_tree(&doc), "<html><body></body></html>");
3146    }
3147
3148    #[test]
3149    fn serialize_document_fragment_root_flattens_children() {
3150        let span = mk_node(
3151            node_type::ELEMENT,
3152            "span",
3153            "SPAN",
3154            "",
3155            None,
3156            Some(vec![mk_node(node_type::TEXT, "", "", "x", None, None)]),
3157        );
3158        let frag = mk_node(
3159            node_type::DOCUMENT_FRAGMENT,
3160            "",
3161            "#document-fragment",
3162            "",
3163            None,
3164            Some(vec![span]),
3165        );
3166        assert_eq!(serialize_node_tree(&frag), "<span>x</span>");
3167    }
3168
3169    #[test]
3170    fn serialize_doctype_node() {
3171        let dt = Node {
3172            public_id: Some("-//W3C//DTD HTML 4.01//EN".to_string()),
3173            system_id: Some("http://www.w3.org/TR/html4/strict.dtd".to_string()),
3174            ..mk_node(node_type::DOCUMENT_TYPE, "", "html", "", None, None)
3175        };
3176        assert_eq!(
3177            serialize_node_tree(&dt),
3178            "<!DOCTYPE html -//W3C//DTD HTML 4.01//EN http://www.w3.org/TR/html4/strict.dtd>"
3179        );
3180    }
3181
3182    #[test]
3183    fn serialize_doctype_node_no_ids() {
3184        let dt = mk_node(node_type::DOCUMENT_TYPE, "", "html", "", None, None);
3185        assert_eq!(serialize_node_tree(&dt), "<!DOCTYPE html>");
3186    }
3187
3188    #[test]
3189    fn serialize_cdata_section() {
3190        let n = mk_node(node_type::CDATA_SECTION, "", "", "raw & <data>", None, None);
3191        assert_eq!(serialize_node_tree(&n), "<![CDATA[raw & <data>]]>");
3192    }
3193
3194    #[test]
3195    fn serialize_processing_instruction() {
3196        let n = mk_node(
3197            node_type::PROCESSING_INSTRUCTION,
3198            "",
3199            "xml-stylesheet",
3200            "href=\"style.css\"",
3201            None,
3202            None,
3203        );
3204        assert_eq!(
3205            serialize_node_tree(&n),
3206            "<?xml-stylesheet href=\"style.css\"?>"
3207        );
3208    }
3209
3210    #[test]
3211    fn serialize_template_inlines_template_content() {
3212        let inner = mk_node(
3213            node_type::ELEMENT,
3214            "span",
3215            "SPAN",
3216            "",
3217            None,
3218            Some(vec![mk_node(node_type::TEXT, "", "", "tmpl", None, None)]),
3219        );
3220        let mut tmpl = mk_node(node_type::ELEMENT, "template", "TEMPLATE", "", None, None);
3221        tmpl.template_content = Some(Box::new(inner));
3222        assert_eq!(
3223            serialize_node_tree(&tmpl),
3224            "<template><span>tmpl</span></template>"
3225        );
3226    }
3227
3228    #[test]
3229    fn serialize_shadow_roots_inlined_into_host() {
3230        let shadow_text = mk_node(node_type::TEXT, "", "", "shadow-text", None, None);
3231        let shadow = Node {
3232            shadow_root_type: Some(chromiumoxide::cdp::browser_protocol::dom::ShadowRootType::Open),
3233            ..mk_node(
3234                node_type::DOCUMENT_FRAGMENT,
3235                "",
3236                "#document-fragment",
3237                "",
3238                None,
3239                Some(vec![mk_node(
3240                    node_type::ELEMENT,
3241                    "span",
3242                    "SPAN",
3243                    "",
3244                    None,
3245                    Some(vec![shadow_text]),
3246                )]),
3247            )
3248        };
3249        let mut host = mk_node(
3250            node_type::ELEMENT,
3251            "div",
3252            "DIV",
3253            "",
3254            None,
3255            Some(vec![mk_node(node_type::TEXT, "", "", "light", None, None)]),
3256        );
3257        host.shadow_roots = Some(vec![shadow]);
3258        assert_eq!(
3259            serialize_node_tree(&host),
3260            "<div>light<span>shadow-text</span></div>"
3261        );
3262    }
3263
3264    #[test]
3265    fn serialize_deeply_nested_subtree() {
3266        // Build a 5-level deep subtree: <a><b><c><d><e>deep</e></d></c></b></a>
3267        let tag_e = mk_node(
3268            node_type::ELEMENT,
3269            "e",
3270            "E",
3271            "",
3272            None,
3273            Some(vec![mk_node(node_type::TEXT, "", "", "deep", None, None)]),
3274        );
3275        let tag_d = mk_node(node_type::ELEMENT, "d", "D", "", None, Some(vec![tag_e]));
3276        let tag_c = mk_node(node_type::ELEMENT, "c", "C", "", None, Some(vec![tag_d]));
3277        let tag_b = mk_node(node_type::ELEMENT, "b", "B", "", None, Some(vec![tag_c]));
3278        let tag_a = mk_node(node_type::ELEMENT, "a", "A", "", None, Some(vec![tag_b]));
3279        assert_eq!(
3280            serialize_node_tree(&tag_a),
3281            "<a><b><c><d><e>deep</e></d></c></b></a>"
3282        );
3283    }
3284
3285    #[test]
3286    fn serialize_element_with_text_and_element_children() {
3287        let span = mk_node(
3288            node_type::ELEMENT,
3289            "span",
3290            "SPAN",
3291            "",
3292            None,
3293            Some(vec![mk_node(node_type::TEXT, "", "", "inline", None, None)]),
3294        );
3295        let div = mk_node(
3296            node_type::ELEMENT,
3297            "div",
3298            "DIV",
3299            "",
3300            None,
3301            Some(vec![
3302                mk_node(node_type::TEXT, "", "", "before", None, None),
3303                span,
3304                mk_node(node_type::TEXT, "", "", "after", None, None),
3305            ]),
3306        );
3307        assert_eq!(
3308            serialize_node_tree(&div),
3309            "<div>before<span>inline</span>after</div>"
3310        );
3311    }
3312
3313    #[test]
3314    fn serialize_attribute_pairs_drop_orphans() {
3315        // An odd-length attribute list (one name with no value) must not crash.
3316        let div = mk_node(
3317            node_type::ELEMENT,
3318            "div",
3319            "DIV",
3320            "",
3321            Some(vec!["orphan".into()]),
3322            None,
3323        );
3324        // The orphan name has no value so it is silently skipped (pairs of 2).
3325        assert_eq!(serialize_node_tree(&div), "<div></div>");
3326    }
3327
3328    // ── Warmup / Refresh type tests ───────────────────────────────────────────
3329
3330    #[test]
3331    fn warmup_options_defaults() {
3332        let opts = WarmupOptions::default();
3333        assert_eq!(opts.wait, WarmupWait::DomContentLoaded);
3334        assert_eq!(opts.timeout_ms, WarmupOptions::default_timeout_ms());
3335        assert_eq!(opts.stabilize_ms, 0);
3336    }
3337
3338    #[test]
3339    fn warmup_options_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>>
3340    {
3341        let opts = WarmupOptions {
3342            url: "https://example.com".to_string(),
3343            wait: WarmupWait::NetworkIdle,
3344            timeout_ms: 15_000,
3345            stabilize_ms: 250,
3346        };
3347        let json = serde_json::to_string(&opts)?;
3348        let restored: WarmupOptions = serde_json::from_str(&json)?;
3349        assert_eq!(restored.url, "https://example.com");
3350        assert_eq!(restored.wait, WarmupWait::NetworkIdle);
3351        assert_eq!(restored.timeout_ms, 15_000);
3352        assert_eq!(restored.stabilize_ms, 250);
3353        Ok(())
3354    }
3355
3356    #[test]
3357    fn warmup_wait_default_is_dom_content_loaded() {
3358        assert_eq!(WarmupWait::default(), WarmupWait::DomContentLoaded);
3359    }
3360
3361    #[test]
3362    fn warmup_wait_into_wait_until_variants() {
3363        assert!(matches!(
3364            WarmupWait::DomContentLoaded.into_wait_until(),
3365            WaitUntil::DomContentLoaded
3366        ));
3367        assert!(matches!(
3368            WarmupWait::NetworkIdle.into_wait_until(),
3369            WaitUntil::NetworkIdle
3370        ));
3371    }
3372
3373    #[test]
3374    fn refresh_options_defaults() {
3375        let opts = RefreshOptions::default();
3376        assert_eq!(opts.wait, WarmupWait::DomContentLoaded);
3377        assert_eq!(opts.timeout_ms, RefreshOptions::default_timeout_ms());
3378        assert!(!opts.reset_connection);
3379    }
3380
3381    #[test]
3382    fn refresh_options_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>>
3383    {
3384        let opts = RefreshOptions {
3385            wait: WarmupWait::NetworkIdle,
3386            timeout_ms: 10_000,
3387            reset_connection: true,
3388        };
3389        let json = serde_json::to_string(&opts)?;
3390        let restored: RefreshOptions = serde_json::from_str(&json)?;
3391        assert_eq!(restored.wait, WarmupWait::NetworkIdle);
3392        assert_eq!(restored.timeout_ms, 10_000);
3393        assert!(restored.reset_connection);
3394        Ok(())
3395    }
3396
3397    #[test]
3398    fn warmup_report_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>> {
3399        let report = WarmupReport {
3400            url: "https://example.com".to_string(),
3401            elapsed_ms: 320,
3402            status_code: Some(200),
3403            title: "Example Domain".to_string(),
3404            stabilized: true,
3405        };
3406        let json = serde_json::to_string(&report)?;
3407        let restored: WarmupReport = serde_json::from_str(&json)?;
3408        assert_eq!(restored.url, "https://example.com");
3409        assert_eq!(restored.elapsed_ms, 320);
3410        assert_eq!(restored.status_code, Some(200));
3411        assert_eq!(restored.title, "Example Domain");
3412        assert!(restored.stabilized);
3413        Ok(())
3414    }
3415
3416    #[test]
3417    fn refresh_report_serialize_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>>
3418    {
3419        let report = RefreshReport {
3420            url: "https://example.com/".to_string(),
3421            elapsed_ms: 180,
3422            status_code: Some(304),
3423        };
3424        let json = serde_json::to_string(&report)?;
3425        let restored: RefreshReport = serde_json::from_str(&json)?;
3426        assert_eq!(restored.url, "https://example.com/");
3427        assert_eq!(restored.elapsed_ms, 180);
3428        assert_eq!(restored.status_code, Some(304));
3429        Ok(())
3430    }
3431
3432    #[test]
3433    fn warmup_options_missing_stabilize_ms_defaults_to_zero()
3434    -> std::result::Result<(), Box<dyn std::error::Error>> {
3435        // stabilize_ms has `#[serde(default)]`; omitting it from JSON should
3436        // deserialize to 0 rather than erroring.
3437        let json = r#"{"url":"https://example.com","timeout_ms":30000}"#;
3438        let opts: WarmupOptions = serde_json::from_str(json)?;
3439        assert_eq!(opts.stabilize_ms, 0);
3440        Ok(())
3441    }
3442
3443    // ── Integration tests (require live Chrome — skipped in CI) ──────────────
3444
3445    /// Warm up a page then immediately extract content from the same origin.
3446    #[test]
3447    #[ignore = "requires live Chrome"]
3448    #[allow(clippy::expect_used)]
3449    fn integration_warmup_then_extraction() {
3450        let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
3451        rt.block_on(async {
3452            use crate::{BrowserConfig, BrowserPool};
3453            let pool = BrowserPool::new(BrowserConfig::default())
3454                .await
3455                .expect("pool");
3456            let handle = pool.acquire().await.expect("handle");
3457            let mut page = handle
3458                .browser()
3459                .expect("browser")
3460                .new_page()
3461                .await
3462                .expect("page");
3463
3464            let report = page
3465                .warmup(WarmupOptions {
3466                    url: "https://example.com".to_string(),
3467                    wait: WarmupWait::DomContentLoaded,
3468                    timeout_ms: 30_000,
3469                    stabilize_ms: 0,
3470                })
3471                .await
3472                .expect("warmup");
3473
3474            assert!(!report.title.is_empty(), "title populated after warmup");
3475            assert!(report.elapsed_ms > 0);
3476
3477            // Confirm the page is still usable for further queries.
3478            let html = page.content().await.expect("content");
3479            assert!(
3480                html.contains("example"),
3481                "page content available after warmup"
3482            );
3483
3484            page.close().await.expect("close");
3485            handle.release().await;
3486        });
3487    }
3488
3489    /// Refresh a page and verify session continuity (URL unchanged, page
3490    /// still navigable).
3491    #[test]
3492    #[ignore = "requires live Chrome"]
3493    #[allow(clippy::expect_used)]
3494    fn integration_refresh_keeps_session_state() {
3495        let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
3496        rt.block_on(async {
3497            use crate::{BrowserConfig, BrowserPool};
3498            let pool = BrowserPool::new(BrowserConfig::default())
3499                .await
3500                .expect("pool");
3501            let handle = pool.acquire().await.expect("handle");
3502            let mut page = handle
3503                .browser()
3504                .expect("browser")
3505                .new_page()
3506                .await
3507                .expect("page");
3508
3509            page.navigate(
3510                "https://example.com",
3511                WaitUntil::DomContentLoaded,
3512                Duration::from_secs(30),
3513            )
3514            .await
3515            .expect("initial navigate");
3516
3517            let report = page
3518                .refresh(RefreshOptions::default())
3519                .await
3520                .expect("refresh");
3521
3522            assert!(
3523                report.url.contains("example.com"),
3524                "URL retained after refresh; got: {}",
3525                report.url
3526            );
3527            assert!(report.elapsed_ms > 0);
3528
3529            page.close().await.expect("close");
3530            handle.release().await;
3531        });
3532    }
3533}