1use 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
20const DEFAULT_NAV_BUDGET: Duration = Duration::from_secs(15);
22const DEFAULT_MAX_ITERATIONS: u8 = 3;
24
25pub trait PageLike {
31 fn title(&self) -> &str;
33 fn content(&self) -> &str;
35 fn url(&self) -> &str;
37}
38
39pub 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 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 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)] 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 #[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 #[cfg_attr(feature = "hotpath", hotpath::measure)]
146 pub fn reload_html(&mut self, html: &str, url: &str) {
147 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 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 pub async fn navigate(&mut self, url: &str) -> Result<(), PageError> {
176 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 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 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 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 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, ¤t_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(¤t_html, ¤t_url);
260
261 let challenge = engine_classify(¤t_html);
262
263 if !challenge.verdict.is_challenge() {
265 self.load_subresources().await?;
266 return Ok(());
267 }
268
269 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 let resp = client
287 .request("GET", ¤t_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, ¤t_url).await;
293 continue;
294 }
295
296 if iter + 1 < iterations {
299 let cookies_after = cookie_snapshot(client, ¤t_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", ¤t_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, ¤t_url).await;
309 continue;
310 }
311 }
312
313 break;
315 }
316
317 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 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 #[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 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 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 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 pub fn title(&self) -> &str {
460 &self.title
461 }
462
463 pub fn url(&self) -> &str {
465 &self.url
466 }
467
468 pub fn stealth(&self) -> bool {
470 self.stealth
471 }
472
473 #[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 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 pub fn has_element(&self, selector: &str) -> bool {
510 query_selector(&self.dom, selector).is_some()
511 }
512
513 pub fn challenge_verdict(&self) -> ChallengeVerdict {
515 self.challenge_class.verdict
516 }
517
518 pub fn engine_class(&self) -> &EngineClass {
520 &self.challenge_class
521 }
522
523 pub fn dom(&self) -> &Dom {
524 &self.dom
525 }
526
527 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 pub fn set_subresource_block_types(&mut self, types: HashSet<ResourceType>) {
564 self.subresource_block_types = types;
565 }
566
567 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 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
612fn 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 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
669fn 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
693async 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
702fn extract_title(html: &str) -> String {
704 let lower = html.to_lowercase();
705 extract_title_lower(&lower, html)
706}
707
708fn 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 #[tokio::test]
747 async fn bdd_navigate_to_clean_page() {
748 let mut body = String::from("Hello World. ");
749 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 #[tokio::test]
770 async fn bdd_navigate_with_challenge_detection() {
771 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 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}