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