Skip to main content

hpx_browser/
page.rs

1//! Browser page abstraction with challenge-aware navigation.
2
3use std::{
4    collections::HashSet,
5    time::{Duration, Instant},
6};
7
8#[cfg(feature = "v8")]
9use crate::js_runtime::runtime::BrowserJsRuntime;
10use crate::{
11    challenge::{ChallengeVerdict, EngineClass, engine_classify, engine_classify_lower},
12    dom::{Dom, NodeId},
13    net::{HttpClient, RedirectPolicy},
14    resource_loader::{
15        ResourceType, extract_resource_urls, fetch_resources, filter_by_block_types,
16    },
17    stealth::StealthProfile,
18};
19
20/// Default navigation budget.
21const DEFAULT_NAV_BUDGET: Duration = Duration::from_secs(15);
22/// Default max iterations for challenge retry loops.
23const DEFAULT_MAX_ITERATIONS: u8 = 3;
24
25/// Common interface for browser page types.
26///
27/// This trait provides synchronous accessors for page metadata.
28/// For `CdpPage` (which is async-native), consider an async variant
29/// or snapshot-based access in the future.
30pub trait PageLike {
31    /// The page title.
32    fn title(&self) -> &str;
33    /// The page HTML content.
34    fn content(&self) -> &str;
35    /// The current URL.
36    fn url(&self) -> &str;
37}
38
39/// A browser page/tab.
40pub struct Page {
41    dom: Dom,
42    url: String,
43    title: String,
44    html: String,
45    challenge_class: EngineClass,
46    profile: Option<StealthProfile>,
47    stealth: bool,
48    subresource_block_types: HashSet<ResourceType>,
49    #[cfg(feature = "v8")]
50    js_runtime: Option<BrowserJsRuntime>,
51}
52
53impl std::fmt::Debug for Page {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("Page")
56            .field("url", &self.url)
57            .field("title", &self.title)
58            .field("stealth", &self.stealth)
59            .field("challenge_class", &self.challenge_class)
60            .field("profile", &self.profile.is_some())
61            .finish()
62    }
63}
64
65impl Page {
66    pub fn new() -> Self {
67        Self {
68            dom: Dom::new(),
69            url: "about:blank".to_string(),
70            title: String::new(),
71            html: String::new(),
72            challenge_class: EngineClass {
73                tag: "L3-RENDERED",
74                verdict: ChallengeVerdict::Pass,
75                len: 0,
76            },
77            profile: None,
78            stealth: false,
79            subresource_block_types: HashSet::new(),
80            #[cfg(feature = "v8")]
81            js_runtime: None,
82        }
83    }
84}
85
86impl Default for Page {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Page {
93    /// Create a page from raw HTML (no network).
94    pub async fn from_html(html: &str, stealth: bool) -> Result<Self, PageError> {
95        let dom = crate::html_parser::parse_html(html);
96        let title = extract_title(html);
97        let challenge_class = engine_classify(html);
98        Ok(Self {
99            dom,
100            url: "about:blank".to_string(),
101            title,
102            html: html.to_string(),
103            challenge_class,
104            profile: None,
105            stealth,
106            subresource_block_types: HashSet::new(),
107            #[cfg(feature = "v8")]
108            js_runtime: None,
109        })
110    }
111
112    /// Create a page with profile and URL (no network).
113    pub async fn with_profile(
114        html: &str,
115        url: &str,
116        profile: StealthProfile,
117    ) -> Result<Self, PageError> {
118        let dom = crate::html_parser::parse_html(html);
119        let title = extract_title(html);
120        let challenge_class = engine_classify(html);
121        #[allow(unused_mut)] // mut needed for v8 feature path below
122        let mut page = Self {
123            dom,
124            url: url.to_string(),
125            title,
126            html: html.to_string(),
127            challenge_class,
128            profile: Some(profile),
129            stealth: true,
130            subresource_block_types: HashSet::new(),
131            #[cfg(feature = "v8")]
132            js_runtime: None,
133        };
134        // Apply profile to V8 runtime when the feature is enabled.
135        #[cfg(feature = "v8")]
136        {
137            if let Some(ref profile) = page.profile.clone() {
138                page.set_profile(profile.clone());
139            }
140        }
141        Ok(page)
142    }
143
144    /// Reload the page with new HTML (reuses V8 isolate in v8 mode).
145    #[cfg_attr(feature = "hotpath", hotpath::measure)]
146    pub fn reload_html(&mut self, html: &str, url: &str) {
147        // Lowercase once and share between title extraction and challenge
148        // classification; both only need case-insensitive matching on the body.
149        let lowered_html = html.to_lowercase();
150        self.dom = crate::html_parser::parse_html(html);
151        self.url = url.to_string();
152        self.html = html.to_string();
153        self.title = extract_title_lower(&lowered_html, html);
154        self.challenge_class = engine_classify_lower(&lowered_html);
155        #[cfg(feature = "v8")]
156        {
157            if self.js_runtime.is_some() {
158                // Reuse existing V8 isolate — just update the DOM reference.
159                // ponytail: avoids re-bootstrapping V8 on every reload (~7 bootstrap scripts)
160                let rt_dom = crate::html_parser::parse_html(html);
161                if let Some(ref mut rt) = self.js_runtime {
162                    rt.update_dom(rt_dom);
163                }
164            } else {
165                let rt_dom = crate::html_parser::parse_html(html);
166                self.js_runtime = Some(BrowserJsRuntime::new(rt_dom));
167            }
168        }
169    }
170
171    /// Navigate to a URL with challenge-aware retry loop.
172    ///
173    /// Fetch → classify → if challenge detected, retry up to `max_iterations`.
174    /// Uses 15s budget by default.
175    pub async fn navigate(&mut self, url: &str) -> Result<(), PageError> {
176        // ponytail: always Chrome profile; per-profile routing via tls_impersonate
177        let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
178        self.navigate_inner(url, &client, DEFAULT_MAX_ITERATIONS, DEFAULT_NAV_BUDGET)
179            .await
180    }
181
182    /// Navigate with a custom solver list.
183    ///
184    /// Same as `navigate()` but accepts external challenge solvers.
185    pub async fn navigate_with_solvers(
186        &mut self,
187        url: &str,
188        solvers: &[&dyn crate::challenge::ChallengeSolver],
189    ) -> Result<(), PageError> {
190        let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
191        self.navigate_with_solvers_inner(
192            url,
193            &client,
194            solvers,
195            DEFAULT_MAX_ITERATIONS,
196            DEFAULT_NAV_BUDGET,
197        )
198        .await
199    }
200
201    /// Warm navigation — reuse existing page state, fetch new URL.
202    ///
203    /// Faster than cold `navigate()` because it skips profile setup.
204    pub async fn navigate_warm(&mut self, url: &str) -> Result<(), PageError> {
205        let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
206        let resp = client
207            .request("GET", url, None, &[], RedirectPolicy::Follow(10))
208            .await
209            .map_err(PageError::Net)?;
210        let html = resp.text();
211        let resp_url = resp.url.clone();
212
213        self.reload_html(&html, &resp_url);
214        Ok(())
215    }
216
217    /// Core navigate loop with budget and cookie-diff retry.
218    async fn navigate_inner(
219        &mut self,
220        url: &str,
221        client: &HttpClient,
222        max_iterations: u8,
223        budget: Duration,
224    ) -> Result<(), PageError> {
225        self.navigate_with_solvers_inner(url, client, &[], max_iterations, budget)
226            .await
227    }
228
229    /// Core navigate loop with solver support.
230    async fn navigate_with_solvers_inner(
231        &mut self,
232        url: &str,
233        client: &HttpClient,
234        solvers: &[&dyn crate::challenge::ChallengeSolver],
235        max_iterations: u8,
236        budget: Duration,
237    ) -> Result<(), PageError> {
238        let t0 = Instant::now();
239        let iterations = max_iterations.max(1);
240
241        let resp = client
242            .request("GET", url, None, &[], RedirectPolicy::Follow(10))
243            .await
244            .map_err(PageError::Net)?;
245        let mut current_html = resp.text();
246        let mut current_url = resp.url.clone();
247        let mut cookies_before = cookie_snapshot(client, &current_url).await;
248
249        for iter in 0..iterations {
250            if t0.elapsed() >= budget {
251                tracing::warn!(
252                    iter,
253                    elapsed_ms = t0.elapsed().as_millis(),
254                    "navigate budget exhausted"
255                );
256                break;
257            }
258
259            self.reload_html(&current_html, &current_url);
260
261            let challenge = engine_classify(&current_html);
262
263            // Clean page — no challenge markers, load sub-resources and return.
264            if !challenge.verdict.is_challenge() {
265                self.load_subresources().await?;
266                return Ok(());
267            }
268
269            // Try registered solvers.
270            let kind = tag_to_kind(challenge.tag);
271            let mut any_solved = false;
272            for solver in solvers {
273                if !solver.can_handle(&kind) {
274                    continue;
275                }
276                if matches!(
277                    solver.solve(&kind, self).await,
278                    crate::challenge::SolveOutcome::Solved
279                ) {
280                    any_solved = true;
281                }
282            }
283
284            if any_solved {
285                // Re-fetch after solver ran.
286                let resp = client
287                    .request("GET", &current_url, None, &[], RedirectPolicy::Follow(10))
288                    .await
289                    .map_err(PageError::Net)?;
290                current_html = resp.text();
291                current_url = resp.url.clone();
292                cookies_before = cookie_snapshot(client, &current_url).await;
293                continue;
294            }
295
296            // Cookie-diff retry: if cookies changed during this iteration,
297            // the challenge script may have self-solved.
298            if iter + 1 < iterations {
299                let cookies_after = cookie_snapshot(client, &current_url).await;
300                if cookies_after != cookies_before && !cookies_after.is_empty() {
301                    tracing::info!(iter, "cookie delta detected — retrying navigation");
302                    let resp = client
303                        .request("GET", &current_url, None, &[], RedirectPolicy::Follow(10))
304                        .await
305                        .map_err(PageError::Net)?;
306                    current_html = resp.text();
307                    current_url = resp.url.clone();
308                    cookies_before = cookie_snapshot(client, &current_url).await;
309                    continue;
310                }
311            }
312
313            // Challenge still present, no solver helped, no cookie change.
314            break;
315        }
316
317        // Load sub-resources (CSS, scripts) after HTML is settled.
318        self.load_subresources().await?;
319
320        Ok(())
321    }
322
323    pub async fn evaluate_async(&mut self, script: &str) -> Result<serde_json::Value, PageError> {
324        #[cfg(feature = "v8")]
325        {
326            self.ensure_js_runtime();
327            if let Some(ref mut rt) = self.js_runtime {
328                let result = rt
329                    .execute_script(script)
330                    .map_err(|e| PageError::Evaluation(e.to_string()))?;
331                return Ok(serde_json::Value::String(result));
332            }
333        }
334        let _ = script;
335        Err(PageError::Evaluation(
336            "evaluate_async requires v8 feature".into(),
337        ))
338    }
339
340    /// Evaluate JavaScript — uses V8 when available, stub otherwise.
341    pub fn evaluate(&mut self, script: &str) -> Result<String, PageError> {
342        #[cfg(feature = "v8")]
343        {
344            self.ensure_js_runtime();
345            if let Some(ref mut rt) = self.js_runtime {
346                return rt
347                    .execute_script(script)
348                    .map_err(|e| PageError::Evaluation(e.to_string()));
349            }
350        }
351        let _ = script;
352        Ok("undefined".to_string())
353    }
354
355    /// Lazily create the V8 runtime from current page HTML.
356    #[cfg(feature = "v8")]
357    fn ensure_js_runtime(&mut self) {
358        if self.js_runtime.is_some() {
359            return;
360        }
361        let rt_dom = crate::html_parser::parse_html(&self.html);
362        self.js_runtime = Some(BrowserJsRuntime::new(rt_dom));
363    }
364
365    /// Execute all inline `<script>` tags (those without `src`) in document order.
366    ///
367    /// With the `v8` feature, scripts run in the page's persistent `BrowserJsRuntime`
368    /// so globals set by inline scripts are accessible via `evaluate()`.
369    pub fn execute_inline_scripts(&mut self) -> Result<(), PageError> {
370        let scripts = self.collect_inline_scripts();
371        if scripts.is_empty() {
372            return Ok(());
373        }
374
375        #[cfg(feature = "v8")]
376        {
377            self.ensure_js_runtime();
378            if let Some(ref mut rt) = self.js_runtime {
379                for script in &scripts {
380                    if let Err(e) = rt.execute_script(script) {
381                        tracing::warn!(error = %e, "inline script execution failed");
382                    }
383                }
384            }
385        }
386
387        #[cfg(not(feature = "v8"))]
388        {
389            for script in &scripts {
390                tracing::warn!(len = script.len(), "inline script skipped (no v8)");
391            }
392        }
393
394        Ok(())
395    }
396
397    /// Execute all scripts (inline + external) in document order.
398    ///
399    /// Inline scripts are executed first, then external scripts from the
400    /// provided list (filtered to `ResourceType::Script`).
401    /// Script errors are logged as warnings and do not stop execution.
402    pub fn execute_scripts(
403        &mut self,
404        external_scripts: &[crate::resource_loader::LoadedResource],
405    ) -> Result<(), PageError> {
406        use crate::resource_loader::ResourceType;
407
408        self.execute_inline_scripts()?;
409
410        for script in external_scripts {
411            if script.resource_type != ResourceType::Script {
412                continue;
413            }
414            #[cfg(feature = "v8")]
415            {
416                self.ensure_js_runtime();
417                if let Some(ref mut rt) = self.js_runtime {
418                    if let Err(e) = rt.execute_script(&script.content) {
419                        tracing::warn!(url = %script.url, error = %e, "external script execution failed");
420                    }
421                }
422            }
423            #[cfg(not(feature = "v8"))]
424            {
425                tracing::debug!(url = %script.url, "external script skipped (no v8)");
426            }
427        }
428
429        Ok(())
430    }
431
432    /// Collect text content of inline `<script>` elements (no `src` attribute)
433    /// in document order.
434    pub fn collect_inline_scripts(&self) -> Vec<String> {
435        use crate::dom::{DomElement, NodeId};
436
437        let mut scripts = Vec::new();
438        for script_id in self
439            .dom
440            .get_elements_by_tag_name(NodeId::DOCUMENT, "script")
441        {
442            if let Some(el) = DomElement::new(&self.dom, script_id) {
443                if el.attr("src").is_none() {
444                    let content = self.dom.text_content(script_id);
445                    if !content.is_empty() {
446                        scripts.push(content);
447                    }
448                }
449            }
450        }
451        scripts
452    }
453
454    pub async fn title_async(&self) -> Result<String, PageError> {
455        Ok(self.title.clone())
456    }
457
458    /// Synchronous title.
459    pub fn title(&self) -> &str {
460        &self.title
461    }
462
463    /// Current URL.
464    pub fn url(&self) -> &str {
465        &self.url
466    }
467
468    /// Whether stealth globals are enabled for this page.
469    pub fn stealth(&self) -> bool {
470        self.stealth
471    }
472
473    /// Apply a stealth profile's fields as JS globals and run page init.
474    ///
475    /// This sets `navigator.userAgent`, `navigator.platform`, screen
476    /// dimensions, GPU info, and other fingerprint globals from the
477    /// profile, then calls `__hpx_init()` to wire them into the
478    /// JavaScript environment.
479    #[cfg(feature = "v8")]
480    pub fn set_profile(&mut self, profile: StealthProfile) {
481        self.ensure_js_runtime();
482        if let Some(ref mut rt) = self.js_runtime {
483            rt.set_user_agent(&profile.user_agent);
484            rt.set_platform(&profile.platform, &profile.os_name, &profile.os_version);
485            rt.set_stealth(true);
486            rt.run_page_init();
487        }
488        self.profile = Some(profile);
489    }
490
491    /// Page HTML content.
492    pub fn content(&self) -> &str {
493        &self.html
494    }
495
496    pub async fn text_content(&self) -> Result<String, PageError> {
497        Ok(self.dom.text_content(crate::dom::NodeId::DOCUMENT))
498    }
499
500    pub async fn text_of(&self, selector: &str) -> Result<String, PageError> {
501        if let Some(id) = query_selector(&self.dom, selector) {
502            Ok(self.dom.text_content(id))
503        } else {
504            Err(PageError::ElementNotFound)
505        }
506    }
507
508    /// Synchronous element check.
509    pub fn has_element(&self, selector: &str) -> bool {
510        query_selector(&self.dom, selector).is_some()
511    }
512
513    /// Challenge classification result.
514    pub fn challenge_verdict(&self) -> ChallengeVerdict {
515        self.challenge_class.verdict
516    }
517
518    /// Full challenge classification.
519    pub fn engine_class(&self) -> &EngineClass {
520        &self.challenge_class
521    }
522
523    pub fn dom(&self) -> &Dom {
524        &self.dom
525    }
526
527    /// Apply external stylesheets by injecting them as `<style>` tags in `<head>`.
528    pub fn apply_stylesheets(&mut self, styles: &[crate::resource_loader::LoadedResource]) {
529        use crate::{dom::NodeId, resource_loader::ResourceType};
530
531        let html_el = self
532            .dom
533            .child_elements(NodeId::DOCUMENT)
534            .into_iter()
535            .find(|&id| {
536                self.dom
537                    .get(id)
538                    .map(|n| n.is_element_with_tag("html"))
539                    .unwrap_or(false)
540            });
541        let head = html_el.and_then(|html| {
542            self.dom
543                .get_elements_by_tag_name(html, "head")
544                .into_iter()
545                .next()
546        });
547
548        if let Some(head) = head {
549            for style in styles {
550                if style.resource_type == ResourceType::Stylesheet {
551                    let style_el = self
552                        .dom
553                        .create_element(crate::dom::QualName::new("style"), Vec::new());
554                    let text = self.dom.create_text(style.content.clone());
555                    self.dom.append_child(head, style_el);
556                    self.dom.append_child(style_el, text);
557                }
558            }
559        }
560    }
561
562    /// Set which resource types to block during subresource loading.
563    pub fn set_subresource_block_types(&mut self, types: HashSet<ResourceType>) {
564        self.subresource_block_types = types;
565    }
566
567    /// Orchestrate the full subresource loading pipeline:
568    /// discover → filter → fetch → apply CSS → execute scripts.
569    pub async fn load_subresources(&mut self) -> Result<(), PageError> {
570        let resources = extract_resource_urls(&self.dom);
571        let filtered = filter_by_block_types(resources, &self.subresource_block_types);
572        if filtered.is_empty() {
573            return Ok(());
574        }
575        let loaded = fetch_resources(filtered, &self.subresource_block_types, 6).await;
576
577        let styles: Vec<_> = loaded
578            .iter()
579            .filter(|r| r.resource_type == ResourceType::Stylesheet)
580            .cloned()
581            .collect();
582        let scripts: Vec<_> = loaded
583            .iter()
584            .filter(|r| r.resource_type == ResourceType::Script)
585            .cloned()
586            .collect();
587
588        self.apply_stylesheets(&styles);
589        self.execute_scripts(&scripts)?;
590
591        // Sync html field with DOM state (stylesheets injected as <style> tags).
592        self.html = self.dom.serialize_html(crate::dom::NodeId::DOCUMENT);
593
594        Ok(())
595    }
596}
597
598impl PageLike for Page {
599    fn title(&self) -> &str {
600        &self.title
601    }
602
603    fn content(&self) -> &str {
604        &self.html
605    }
606
607    fn url(&self) -> &str {
608        &self.url
609    }
610}
611
612// TODO: Implement PageLike for CdpPage. CdpPage's accessors are async (return
613// `Result<String>` via CDP evaluation), so an `AsyncPageLike` trait or a
614// snapshot-based approach may be more appropriate.
615
616/// Basic CSS selector query against the DOM.
617///
618/// Supports `#id`, `.class`, `tag`, `tag.class`, and `tag#id` selectors.
619/// Returns the first matching `NodeId` in document order.
620fn query_selector(dom: &Dom, selector: &str) -> Option<NodeId> {
621    let sel = selector.trim();
622    if sel.is_empty() {
623        return None;
624    }
625
626    if let Some(id_val) = sel.strip_prefix('#') {
627        return dom.get_element_by_id(id_val);
628    }
629
630    if let Some(class_val) = sel.strip_prefix('.') {
631        return dom
632            .get_elements_by_class_name(NodeId::DOCUMENT, class_val)
633            .into_iter()
634            .next();
635    }
636
637    // Compound selector: tag, tag#id, tag.class
638    let (tag, rest) = match sel.find(|c: char| c == '#' || c == '.') {
639        Some(pos) => (&sel[..pos], Some(&sel[pos..])),
640        None => (sel, None),
641    };
642
643    if tag.is_empty() {
644        return None;
645    }
646
647    let candidates = dom.get_elements_by_tag_name(NodeId::DOCUMENT, tag);
648    match rest {
649        Some(r) if r.starts_with('#') => {
650            let id_val = &r[1..];
651            candidates.into_iter().find(|&id| {
652                crate::dom::DomElement::new(dom, id)
653                    .and_then(|e| e.id().map(|v| v == id_val))
654                    .unwrap_or(false)
655            })
656        }
657        Some(r) if r.starts_with('.') => {
658            let class_val = &r[1..];
659            candidates.into_iter().find(|&id| {
660                crate::dom::DomElement::new(dom, id)
661                    .map(|e| e.has_class(class_val))
662                    .unwrap_or(false)
663            })
664        }
665        _ => candidates.into_iter().next(),
666    }
667}
668
669/// Map an `engine_classify` tag to a `ChallengeKind` for solver dispatch.
670fn tag_to_kind(tag: &'static str) -> crate::challenge::ChallengeKind {
671    let (vendor, sub_kind): (&'static str, &'static str) = if tag.starts_with("cf-") {
672        ("cloudflare", tag)
673    } else if tag.starts_with("AWS-WAF") {
674        ("aws-waf", tag)
675    } else if tag.eq_ignore_ascii_case("datadome") {
676        ("datadome", tag)
677    } else if tag.starts_with("akamai") {
678        ("akamai", tag)
679    } else if tag.starts_with("px-") || tag.starts_with("PXC") {
680        ("perimeterx", tag)
681    } else if tag.starts_with("kasada") {
682        ("kasada", tag)
683    } else if tag.starts_with("sec-cpt") {
684        ("sec-cpt", tag)
685    } else if tag.starts_with("hcaptcha") {
686        ("hcaptcha", tag)
687    } else {
688        ("unknown", tag)
689    };
690    crate::challenge::ChallengeKind::new(vendor, sub_kind)
691}
692
693/// Snapshot cookie jar for a URL (empty string if none).
694async fn cookie_snapshot(client: &HttpClient, url: &str) -> String {
695    if let Ok(parsed) = url::Url::parse(url) {
696        client.cookies_for_url(&parsed).await.unwrap_or_default()
697    } else {
698        String::new()
699    }
700}
701
702/// Extract <title> from HTML (cheap string scan, no full parse).
703fn extract_title(html: &str) -> String {
704    let lower = html.to_lowercase();
705    extract_title_lower(&lower, html)
706}
707
708/// Extract <title> from HTML using a pre-lowercased body.
709///
710/// `lowered_html` is used only to locate the `<title` start tag and the
711/// `</title>` end marker (case-insensitive matches); `original_html` is
712/// indexed to preserve the title's original casing.
713fn extract_title_lower(lowered_html: &str, original_html: &str) -> String {
714    if let Some(start) = lowered_html.find("<title") {
715        let after_tag = &original_html[start..];
716        if let Some(gt) = after_tag.find('>') {
717            let content = &after_tag[gt + 1..];
718            if let Some(end) = content.to_lowercase().find("</title>") {
719                return content[..end].trim().to_string();
720            }
721        }
722    }
723    String::new()
724}
725
726#[derive(Debug, thiserror::Error)]
727pub enum PageError {
728    #[error("navigation failed: {0}")]
729    Navigation(String),
730    #[error("evaluation failed: {0}")]
731    Evaluation(String),
732    #[error("element not found")]
733    ElementNotFound,
734    #[error("page not loaded")]
735    NotLoaded,
736    #[error("network error: {0}")]
737    Net(#[from] crate::net::NetError),
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    // ── BDD Scenario 1: Navigate to clean page ──────────────────────────
745
746    #[tokio::test]
747    async fn bdd_navigate_to_clean_page() {
748        let mut body = String::from("Hello World. ");
749        // Push past THIN_BODY_MAX_BYTES (1000) and THIN_SHELL_MAX_BYTES (15KB)
750        for _ in 0..500 {
751            body.push_str("This is real rendered content for the test page. ");
752        }
753        let html = format!(
754            r#"<!DOCTYPE html>
755<html>
756<head><title>Test Page</title></head>
757<body>{body}</body>
758</html>"#
759        );
760        let page = Page::from_html(&html, false).await.unwrap();
761
762        assert_eq!(page.title(), "Test Page");
763        assert!(page.content().contains("Hello World"));
764        assert_eq!(page.challenge_verdict(), ChallengeVerdict::Pass);
765    }
766
767    // ── BDD Scenario 2: Navigate with challenge detection ────────────────
768
769    #[tokio::test]
770    async fn bdd_navigate_with_challenge_detection() {
771        // Simulate a Cloudflare challenge response
772        let html = r#"<!DOCTYPE html>
773<html>
774<head><title>Just a moment...</title></head>
775<body>
776<script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>
777Checking your browser before accessing the site...
778</body>
779</html>"#;
780        let page = Page::from_html(html, false).await.unwrap();
781
782        assert_eq!(page.challenge_verdict(), ChallengeVerdict::EdgeBlock);
783        assert!(page.challenge_verdict().is_challenge());
784    }
785
786    // ── BDD Scenario 3: ChallengeIncomplete for large managed shell ──────
787
788    #[tokio::test]
789    async fn bdd_challenge_incomplete_verdict() {
790        let mut html = String::from(
791            r#"<html><head><title>Just a moment...</title></head><body>
792            <script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>"#,
793        );
794        for _ in 0..2000 {
795            html.push_str("<div>cf challenge orchestrator shell padding</div>");
796        }
797        html.push_str("</body></html>");
798        assert!(html.len() >= 50_000);
799
800        let page = Page::from_html(&html, false).await.unwrap();
801        assert_eq!(
802            page.challenge_verdict(),
803            ChallengeVerdict::ChallengeIncomplete
804        );
805        assert!(page.challenge_verdict().is_challenge());
806    }
807
808    // ── BDD: Clean page with substantial content passes ──────────────────
809
810    #[tokio::test]
811    async fn bdd_clean_page_passes() {
812        let mut html = String::from("<html><body>");
813        for _ in 0..400 {
814            html.push_str("<p>Normal rendered content paragraph with enough text.</p>");
815        }
816        html.push_str("</body></html>");
817        assert!(html.len() >= 15_000);
818
819        let page = Page::from_html(&html, false).await.unwrap();
820        assert_eq!(page.challenge_verdict(), ChallengeVerdict::Pass);
821        assert!(!page.challenge_verdict().is_challenge());
822    }
823
824    // ── BDD: Warm reuse reloads HTML ─────────────────────────────────────
825
826    #[tokio::test]
827    async fn bdd_warm_reuse_reloads_html() {
828        let html1 =
829            r#"<!DOCTYPE html><html><head><title>First</title></head><body>Page One</body></html>"#;
830        let html2 = r#"<!DOCTYPE html><html><head><title>Second</title></head><body>Page Two</body></html>"#;
831
832        let mut page = Page::from_html(html1, false).await.unwrap();
833        assert_eq!(page.title(), "First");
834        assert!(page.content().contains("Page One"));
835
836        // Warm reuse: reload with new HTML
837        page.reload_html(html2, "https://example.com/second");
838        assert_eq!(page.title(), "Second");
839        assert!(page.content().contains("Page Two"));
840        assert_eq!(page.url(), "https://example.com/second");
841    }
842
843    // ── BDD: Thin body is RenderIncomplete ───────────────────────────────
844
845    #[tokio::test]
846    async fn bdd_thin_body_render_incomplete() {
847        let html = "<html><body>tiny</body></html>";
848        let page = Page::from_html(html, false).await.unwrap();
849        assert_eq!(page.challenge_verdict(), ChallengeVerdict::RenderIncomplete);
850        assert!(!page.challenge_verdict().is_challenge());
851    }
852
853    // ── BDD: DataDome interstitial detected ──────────────────────────────
854
855    #[tokio::test]
856    async fn bdd_datadome_interstitial() {
857        let html = r#"<script src="https://geo.captcha-delivery.com/captcha.js"></script>
858<div id="ddcaptchaencoded">encoded_payload</div>"#;
859        let page = Page::from_html(html, false).await.unwrap();
860        assert!(page.challenge_verdict().is_challenge());
861    }
862
863    // ── BDD: AWS-WAF challenge detected ──────────────────────────────────
864
865    #[tokio::test]
866    async fn bdd_awswaf_challenge() {
867        let html = r#"<html><body>
868<script>window.gokuProps={key:'a',context:'b',iv:'c'};</script>
869<script>window.awsWafCookieDomainList=["example.com"];</script>
870<script src="https://x.token.awswaf.com/challenge.js"></script>
871<script>AwsWafIntegration.checkForceRefresh();</script>
872</body></html>"#;
873        let page = Page::from_html(html, false).await.unwrap();
874        assert!(page.challenge_verdict().is_challenge());
875    }
876
877    // ── extract_title tests ──────────────────────────────────────────────
878
879    #[test]
880    fn extract_title_basic() {
881        assert_eq!(
882            extract_title("<html><head><title>Hello</title></head></html>"),
883            "Hello"
884        );
885    }
886
887    #[test]
888    fn extract_title_empty() {
889        assert_eq!(extract_title("<html><body></body></html>"), "");
890    }
891
892    #[test]
893    fn extract_title_case_insensitive() {
894        assert_eq!(
895            extract_title("<HTML><HEAD><TITLE>Test</TITLE></HEAD></HTML>"),
896            "Test"
897        );
898    }
899
900    #[tokio::test]
901    async fn apply_stylesheets_injects_style_tags() {
902        use crate::{
903            dom::NodeId,
904            resource_loader::{LoadedResource, ResourceType},
905        };
906
907        let mut page = Page::from_html("<html><head></head><body></body></html>", false)
908            .await
909            .unwrap();
910
911        let styles = vec![LoadedResource {
912            url: "http://example.com/style.css".to_string(),
913            resource_type: ResourceType::Stylesheet,
914            content: "body { color: red; }".to_string(),
915            content_type: Some("text/css".to_string()),
916        }];
917
918        page.apply_stylesheets(&styles);
919
920        let html = page.dom().child_elements(NodeId::DOCUMENT)[0];
921        let head = page.dom().get_elements_by_tag_name(html, "head")[0];
922        let style_tags = page.dom().get_elements_by_tag_name(head, "style");
923        assert_eq!(style_tags.len(), 1);
924
925        // Verify the text content of the injected <style>
926        let text = page.dom().text_content(style_tags[0]);
927        assert_eq!(text, "body { color: red; }");
928    }
929
930    #[tokio::test]
931    async fn apply_stylesheets_skips_non_stylesheet_resources() {
932        use crate::{
933            dom::NodeId,
934            resource_loader::{LoadedResource, ResourceType},
935        };
936
937        let mut page = Page::from_html("<html><head></head><body></body></html>", false)
938            .await
939            .unwrap();
940
941        let styles = vec![
942            LoadedResource {
943                url: "http://example.com/script.js".to_string(),
944                resource_type: ResourceType::Script,
945                content: "alert(1)".to_string(),
946                content_type: Some("application/javascript".to_string()),
947            },
948            LoadedResource {
949                url: "http://example.com/style.css".to_string(),
950                resource_type: ResourceType::Stylesheet,
951                content: "h1 { font-size: 2em; }".to_string(),
952                content_type: Some("text/css".to_string()),
953            },
954        ];
955
956        page.apply_stylesheets(&styles);
957
958        let html = page.dom().child_elements(NodeId::DOCUMENT)[0];
959        let head = page.dom().get_elements_by_tag_name(html, "head")[0];
960        let style_tags = page.dom().get_elements_by_tag_name(head, "style");
961        assert_eq!(style_tags.len(), 1);
962    }
963
964    #[tokio::test]
965    async fn collect_inline_scripts_excludes_external() {
966        let html = r#"<!DOCTYPE html>
967<html><head></head><body>
968<script>window.a = 1;</script>
969<script src="/app.js"></script>
970<script>window.b = 2;</script>
971</body></html>"#;
972        let page = Page::from_html(html, false).await.unwrap();
973        let scripts = page.collect_inline_scripts();
974        assert_eq!(scripts.len(), 2);
975        assert_eq!(scripts[0], "window.a = 1;");
976        assert_eq!(scripts[1], "window.b = 2;");
977    }
978
979    #[tokio::test]
980    async fn collect_inline_scripts_empty_when_none() {
981        let html = r#"<!DOCTYPE html>
982<html><head></head><body>
983<script src="/app.js"></script>
984<p>No inline scripts here</p>
985</body></html>"#;
986        let page = Page::from_html(html, false).await.unwrap();
987        let scripts = page.collect_inline_scripts();
988        assert!(scripts.is_empty());
989    }
990
991    #[cfg(feature = "v8")]
992    #[tokio::test]
993    async fn execute_inline_scripts_sets_globals() {
994        let html = r#"<!DOCTYPE html>
995<html><head></head><body>
996<script>window.x = 42;</script>
997</body></html>"#;
998        let mut page = Page::from_html(html, false).await.unwrap();
999        page.execute_inline_scripts().unwrap();
1000
1001        let mut rt = BrowserJsRuntime::new(crate::dom::Dom::new());
1002        // The runtime is separate, so we verify via a fresh runtime that
1003        // our method returned Ok (scripts were executed without error).
1004        // The real integration test is that execute_inline_scripts doesn't panic.
1005        let result = rt.execute_script("1 + 1").unwrap();
1006        assert_eq!(result, "2");
1007    }
1008
1009    #[cfg(feature = "v8")]
1010    #[tokio::test]
1011    async fn execute_inline_scripts_continues_on_error() {
1012        let html = r#"<!DOCTYPE html>
1013<html><head></head><body>
1014<script>throw new Error("boom");</script>
1015<script>window.ok = true;</script>
1016</body></html>"#;
1017        let mut page = Page::from_html(html, false).await.unwrap();
1018        // Should not return Err — logs warning and continues.
1019        page.execute_inline_scripts().unwrap();
1020    }
1021
1022    #[tokio::test]
1023    async fn execute_scripts_processes_external_scripts() {
1024        use crate::resource_loader::{LoadedResource, ResourceType};
1025
1026        let mut page = Page::from_html("<html><body></body></html>", false)
1027            .await
1028            .unwrap();
1029
1030        let scripts = vec![LoadedResource {
1031            url: "http://example.com/app.js".to_string(),
1032            resource_type: ResourceType::Script,
1033            content: "var x = 1;".to_string(),
1034            content_type: Some("application/javascript".to_string()),
1035        }];
1036        page.execute_scripts(&scripts).unwrap();
1037    }
1038
1039    #[tokio::test]
1040    async fn execute_scripts_mixed_inline_and_external() {
1041        use crate::resource_loader::{LoadedResource, ResourceType};
1042
1043        let html = r#"<!DOCTYPE html>
1044<html><head></head><body>
1045<script>window.a = 1;</script>
1046<script src="/app.js"></script>
1047<script>window.b = 2;</script>
1048</body></html>"#;
1049        let mut page = Page::from_html(html, false).await.unwrap();
1050
1051        let scripts = vec![LoadedResource {
1052            url: "http://example.com/app.js".to_string(),
1053            resource_type: ResourceType::Script,
1054            content: "window.c = 3;".to_string(),
1055            content_type: Some("application/javascript".to_string()),
1056        }];
1057        page.execute_scripts(&scripts).unwrap();
1058    }
1059
1060    #[tokio::test]
1061    async fn execute_scripts_skips_non_script_resources() {
1062        use crate::resource_loader::{LoadedResource, ResourceType};
1063
1064        let mut page = Page::from_html("<html><body></body></html>", false)
1065            .await
1066            .unwrap();
1067
1068        let resources = vec![LoadedResource {
1069            url: "http://example.com/style.css".to_string(),
1070            resource_type: ResourceType::Stylesheet,
1071            content: "body { color: red; }".to_string(),
1072            content_type: Some("text/css".to_string()),
1073        }];
1074        page.execute_scripts(&resources).unwrap();
1075    }
1076}