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 match BrowserJsRuntime::new(rt_dom) {
167 Ok(rt) => self.js_runtime = Some(rt),
168 Err(e) => tracing::error!("BrowserJsRuntime init failed in reload_html: {e}"),
169 }
170 }
171 }
172 }
173
174 pub async fn navigate(&mut self, url: &str) -> Result<(), PageError> {
179 let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
181 self.navigate_inner(url, &client, DEFAULT_MAX_ITERATIONS, DEFAULT_NAV_BUDGET)
182 .await
183 }
184
185 pub async fn navigate_with_solvers(
189 &mut self,
190 url: &str,
191 solvers: &[&dyn crate::challenge::ChallengeSolver],
192 ) -> Result<(), PageError> {
193 let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
194 self.navigate_with_solvers_inner(
195 url,
196 &client,
197 solvers,
198 DEFAULT_MAX_ITERATIONS,
199 DEFAULT_NAV_BUDGET,
200 )
201 .await
202 }
203
204 pub async fn navigate_warm(&mut self, url: &str) -> Result<(), PageError> {
208 let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
209 let resp = client
210 .request("GET", url, None, &[], RedirectPolicy::Follow(10))
211 .await
212 .map_err(PageError::Net)?;
213 let html = resp.text();
214 let resp_url = resp.url.clone();
215
216 self.reload_html(&html, &resp_url);
217 Ok(())
218 }
219
220 async fn navigate_inner(
222 &mut self,
223 url: &str,
224 client: &HttpClient,
225 max_iterations: u8,
226 budget: Duration,
227 ) -> Result<(), PageError> {
228 self.navigate_with_solvers_inner(url, client, &[], max_iterations, budget)
229 .await
230 }
231
232 async fn navigate_with_solvers_inner(
234 &mut self,
235 url: &str,
236 client: &HttpClient,
237 solvers: &[&dyn crate::challenge::ChallengeSolver],
238 max_iterations: u8,
239 budget: Duration,
240 ) -> Result<(), PageError> {
241 let t0 = Instant::now();
242 let iterations = max_iterations.max(1);
243
244 let resp = client
245 .request("GET", url, None, &[], RedirectPolicy::Follow(10))
246 .await
247 .map_err(PageError::Net)?;
248 let mut current_html = resp.text();
249 let mut current_url = resp.url.clone();
250 let mut cookies_before = cookie_snapshot(client, ¤t_url).await;
251
252 for iter in 0..iterations {
253 if t0.elapsed() >= budget {
254 tracing::warn!(
255 iter,
256 elapsed_ms = t0.elapsed().as_millis(),
257 "navigate budget exhausted"
258 );
259 break;
260 }
261
262 self.reload_html(¤t_html, ¤t_url);
263
264 let challenge = engine_classify(¤t_html);
265
266 if !challenge.verdict.is_challenge() {
268 self.load_subresources().await?;
269 return Ok(());
270 }
271
272 let kind = tag_to_kind(challenge.tag);
274 let mut any_solved = false;
275 for solver in solvers {
276 if !solver.can_handle(&kind) {
277 continue;
278 }
279 if matches!(
280 solver.solve(&kind, self).await,
281 crate::challenge::SolveOutcome::Solved
282 ) {
283 any_solved = true;
284 }
285 }
286
287 if any_solved {
288 let resp = client
290 .request("GET", ¤t_url, None, &[], RedirectPolicy::Follow(10))
291 .await
292 .map_err(PageError::Net)?;
293 current_html = resp.text();
294 current_url = resp.url.clone();
295 cookies_before = cookie_snapshot(client, ¤t_url).await;
296 continue;
297 }
298
299 if iter + 1 < iterations {
302 let cookies_after = cookie_snapshot(client, ¤t_url).await;
303 if cookies_after != cookies_before && !cookies_after.is_empty() {
304 tracing::info!(iter, "cookie delta detected — retrying navigation");
305 let resp = client
306 .request("GET", ¤t_url, None, &[], RedirectPolicy::Follow(10))
307 .await
308 .map_err(PageError::Net)?;
309 current_html = resp.text();
310 current_url = resp.url.clone();
311 cookies_before = cookie_snapshot(client, ¤t_url).await;
312 continue;
313 }
314 }
315
316 break;
318 }
319
320 self.load_subresources().await?;
322
323 Ok(())
324 }
325
326 pub async fn evaluate_async(&mut self, script: &str) -> Result<serde_json::Value, PageError> {
327 #[cfg(feature = "v8")]
328 {
329 self.ensure_js_runtime();
330 if let Some(ref mut rt) = self.js_runtime {
331 let result = rt
332 .execute_script(script)
333 .map_err(|e| PageError::Evaluation(e.to_string()))?;
334 return Ok(serde_json::Value::String(result));
335 }
336 }
337 let _ = script;
338 Err(PageError::Evaluation(
339 "evaluate_async requires v8 feature".into(),
340 ))
341 }
342
343 pub fn evaluate(&mut self, script: &str) -> Result<String, PageError> {
345 #[cfg(feature = "v8")]
346 {
347 self.ensure_js_runtime();
348 if let Some(ref mut rt) = self.js_runtime {
349 return rt
350 .execute_script(script)
351 .map_err(|e| PageError::Evaluation(e.to_string()));
352 }
353 }
354 let _ = script;
355 Ok("undefined".to_string())
356 }
357
358 #[cfg(feature = "v8")]
360 fn ensure_js_runtime(&mut self) {
361 if self.js_runtime.is_some() {
362 return;
363 }
364 let rt_dom = crate::html_parser::parse_html(&self.html);
365 match BrowserJsRuntime::new(rt_dom) {
366 Ok(rt) => self.js_runtime = Some(rt),
367 Err(e) => tracing::error!("BrowserJsRuntime init failed in ensure_js_runtime: {e}"),
368 }
369 }
370
371 pub fn execute_inline_scripts(&mut self) -> Result<(), PageError> {
376 let scripts = self.collect_inline_scripts();
377 if scripts.is_empty() {
378 return Ok(());
379 }
380
381 #[cfg(feature = "v8")]
382 {
383 self.ensure_js_runtime();
384 if let Some(ref mut rt) = self.js_runtime {
385 for script in &scripts {
386 if let Err(e) = rt.execute_script(script) {
387 tracing::warn!(error = %e, "inline script execution failed");
388 }
389 }
390 }
391 }
392
393 #[cfg(not(feature = "v8"))]
394 {
395 for script in &scripts {
396 tracing::warn!(len = script.len(), "inline script skipped (no v8)");
397 }
398 }
399
400 Ok(())
401 }
402
403 pub fn execute_scripts(
409 &mut self,
410 external_scripts: &[crate::resource_loader::LoadedResource],
411 ) -> Result<(), PageError> {
412 use crate::resource_loader::ResourceType;
413
414 self.execute_inline_scripts()?;
415
416 for script in external_scripts {
417 if script.resource_type != ResourceType::Script {
418 continue;
419 }
420 #[cfg(feature = "v8")]
421 {
422 self.ensure_js_runtime();
423 if let Some(ref mut rt) = self.js_runtime {
424 if let Err(e) = rt.execute_script(&script.content) {
425 tracing::warn!(url = %script.url, error = %e, "external script execution failed");
426 }
427 }
428 }
429 #[cfg(not(feature = "v8"))]
430 {
431 tracing::debug!(url = %script.url, "external script skipped (no v8)");
432 }
433 }
434
435 Ok(())
436 }
437
438 pub fn collect_inline_scripts(&self) -> Vec<String> {
441 use crate::dom::DomElement;
442
443 let mut scripts = Vec::new();
444 for script_id in self
445 .dom
446 .get_elements_by_tag_name(self.dom.document(), "script")
447 {
448 if let Some(el) = DomElement::new(&self.dom, script_id) {
449 if el.attr("src").is_none() {
450 let content = self.dom.text_content(script_id);
451 if !content.is_empty() {
452 scripts.push(content);
453 }
454 }
455 }
456 }
457 scripts
458 }
459
460 pub async fn title_async(&self) -> Result<String, PageError> {
461 Ok(self.title.clone())
462 }
463
464 pub fn title(&self) -> &str {
466 &self.title
467 }
468
469 pub fn url(&self) -> &str {
471 &self.url
472 }
473
474 pub fn stealth(&self) -> bool {
476 self.stealth
477 }
478
479 #[cfg(feature = "v8")]
486 pub fn set_profile(&mut self, profile: StealthProfile) {
487 self.ensure_js_runtime();
488 if let Some(ref mut rt) = self.js_runtime {
489 rt.set_user_agent(&profile.user_agent);
490 rt.set_platform(&profile.platform, &profile.os_name, &profile.os_version);
491 rt.set_stealth(true);
492 rt.run_page_init();
493 }
494 self.profile = Some(profile);
495 }
496
497 pub fn content(&self) -> &str {
499 &self.html
500 }
501
502 pub async fn text_content(&self) -> Result<String, PageError> {
503 Ok(self.dom.text_content(self.dom.document()))
504 }
505
506 pub async fn text_of(&self, selector: &str) -> Result<String, PageError> {
507 if let Some(id) = query_selector(&self.dom, selector) {
508 Ok(self.dom.text_content(id))
509 } else {
510 Err(PageError::ElementNotFound)
511 }
512 }
513
514 pub fn has_element(&self, selector: &str) -> bool {
516 query_selector(&self.dom, selector).is_some()
517 }
518
519 pub fn challenge_verdict(&self) -> ChallengeVerdict {
521 self.challenge_class.verdict
522 }
523
524 pub fn engine_class(&self) -> &EngineClass {
526 &self.challenge_class
527 }
528
529 pub fn dom(&self) -> &Dom {
530 &self.dom
531 }
532
533 pub fn apply_stylesheets(&mut self, styles: &[crate::resource_loader::LoadedResource]) {
535 use crate::resource_loader::ResourceType;
536
537 let html_el = self
538 .dom
539 .child_elements(self.dom.document())
540 .into_iter()
541 .find(|&id| {
542 self.dom
543 .get(id)
544 .map(|n| n.is_element_with_tag("html"))
545 .unwrap_or(false)
546 });
547 let head = html_el.and_then(|html| {
548 self.dom
549 .get_elements_by_tag_name(html, "head")
550 .into_iter()
551 .next()
552 });
553
554 if let Some(head) = head {
555 for style in styles {
556 if style.resource_type == ResourceType::Stylesheet {
557 let style_el = self
558 .dom
559 .create_element(crate::dom::QualName::new("style"), Vec::new());
560 let text = self.dom.create_text(style.content.clone());
561 self.dom.append_child(head, style_el);
562 self.dom.append_child(style_el, text);
563 }
564 }
565 }
566 }
567
568 pub fn set_subresource_block_types(&mut self, types: HashSet<ResourceType>) {
570 self.subresource_block_types = types;
571 }
572
573 pub async fn load_subresources(&mut self) -> Result<(), PageError> {
576 let resources = extract_resource_urls(&self.dom);
577 let filtered = filter_by_block_types(resources, &self.subresource_block_types);
578 if filtered.is_empty() {
579 return Ok(());
580 }
581 let loaded = fetch_resources(filtered, &self.subresource_block_types, 6).await;
582
583 let styles: Vec<_> = loaded
584 .iter()
585 .filter(|r| r.resource_type == ResourceType::Stylesheet)
586 .cloned()
587 .collect();
588 let scripts: Vec<_> = loaded
589 .iter()
590 .filter(|r| r.resource_type == ResourceType::Script)
591 .cloned()
592 .collect();
593
594 self.apply_stylesheets(&styles);
595 self.execute_scripts(&scripts)?;
596
597 self.html = self.dom.serialize_html(self.dom.document());
599
600 Ok(())
601 }
602}
603
604impl PageLike for Page {
605 fn title(&self) -> &str {
606 &self.title
607 }
608
609 fn content(&self) -> &str {
610 &self.html
611 }
612
613 fn url(&self) -> &str {
614 &self.url
615 }
616}
617
618fn query_selector(dom: &Dom, selector: &str) -> Option<NodeId> {
627 let sel = selector.trim();
628 if sel.is_empty() {
629 return None;
630 }
631
632 if let Some(id_val) = sel.strip_prefix('#') {
633 return dom.get_element_by_id(id_val);
634 }
635
636 if let Some(class_val) = sel.strip_prefix('.') {
637 return dom
638 .get_elements_by_class_name(dom.document(), class_val)
639 .into_iter()
640 .next();
641 }
642
643 let (tag, rest) = match sel.find(|c: char| c == '#' || c == '.') {
645 Some(pos) => (&sel[..pos], Some(&sel[pos..])),
646 None => (sel, None),
647 };
648
649 if tag.is_empty() {
650 return None;
651 }
652
653 let candidates = dom.get_elements_by_tag_name(dom.document(), tag);
654 match rest {
655 Some(r) if r.starts_with('#') => {
656 let id_val = &r[1..];
657 candidates.into_iter().find(|&id| {
658 crate::dom::DomElement::new(dom, id)
659 .and_then(|e| e.id().map(|v| v == id_val))
660 .unwrap_or(false)
661 })
662 }
663 Some(r) if r.starts_with('.') => {
664 let class_val = &r[1..];
665 candidates.into_iter().find(|&id| {
666 crate::dom::DomElement::new(dom, id)
667 .map(|e| e.has_class(class_val))
668 .unwrap_or(false)
669 })
670 }
671 _ => candidates.into_iter().next(),
672 }
673}
674
675fn tag_to_kind(tag: &'static str) -> crate::challenge::ChallengeKind {
677 let (vendor, sub_kind): (&'static str, &'static str) = if tag.starts_with("cf-") {
678 ("cloudflare", tag)
679 } else if tag.starts_with("AWS-WAF") {
680 ("aws-waf", tag)
681 } else if tag.eq_ignore_ascii_case("datadome") {
682 ("datadome", tag)
683 } else if tag.starts_with("akamai") {
684 ("akamai", tag)
685 } else if tag.starts_with("px-") || tag.starts_with("PXC") {
686 ("perimeterx", tag)
687 } else if tag.starts_with("kasada") {
688 ("kasada", tag)
689 } else if tag.starts_with("sec-cpt") {
690 ("sec-cpt", tag)
691 } else if tag.starts_with("hcaptcha") {
692 ("hcaptcha", tag)
693 } else {
694 ("unknown", tag)
695 };
696 crate::challenge::ChallengeKind::new(vendor, sub_kind)
697}
698
699async fn cookie_snapshot(client: &HttpClient, url: &str) -> String {
701 if let Ok(parsed) = url::Url::parse(url) {
702 client.cookies_for_url(&parsed).await.unwrap_or_default()
703 } else {
704 String::new()
705 }
706}
707
708fn extract_title(html: &str) -> String {
710 let lower = html.to_lowercase();
711 extract_title_lower(&lower, html)
712}
713
714fn extract_title_lower(lowered_html: &str, original_html: &str) -> String {
720 if let Some(start) = lowered_html.find("<title") {
721 let after_tag = &original_html[start..];
722 if let Some(gt) = after_tag.find('>') {
723 let content = &after_tag[gt + 1..];
724 if let Some(end) = content.to_lowercase().find("</title>") {
725 return content[..end].trim().to_string();
726 }
727 }
728 }
729 String::new()
730}
731
732#[derive(Debug, thiserror::Error)]
733pub enum PageError {
734 #[error("navigation failed: {0}")]
735 Navigation(String),
736 #[error("evaluation failed: {0}")]
737 Evaluation(String),
738 #[error("element not found")]
739 ElementNotFound,
740 #[error("page not loaded")]
741 NotLoaded,
742 #[error("network error: {0}")]
743 Net(#[from] crate::net::NetError),
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[tokio::test]
753 async fn bdd_navigate_to_clean_page() {
754 let mut body = String::from("Hello World. ");
755 for _ in 0..500 {
757 body.push_str("This is real rendered content for the test page. ");
758 }
759 let html = format!(
760 r#"<!DOCTYPE html>
761<html>
762<head><title>Test Page</title></head>
763<body>{body}</body>
764</html>"#
765 );
766 let page = Page::from_html(&html, false).await.unwrap();
767
768 assert_eq!(page.title(), "Test Page");
769 assert!(page.content().contains("Hello World"));
770 assert_eq!(page.challenge_verdict(), ChallengeVerdict::Pass);
771 }
772
773 #[tokio::test]
776 async fn bdd_navigate_with_challenge_detection() {
777 let html = r#"<!DOCTYPE html>
779<html>
780<head><title>Just a moment...</title></head>
781<body>
782<script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>
783Checking your browser before accessing the site...
784</body>
785</html>"#;
786 let page = Page::from_html(html, false).await.unwrap();
787
788 assert_eq!(page.challenge_verdict(), ChallengeVerdict::EdgeBlock);
789 assert!(page.challenge_verdict().is_challenge());
790 }
791
792 #[tokio::test]
795 async fn bdd_challenge_incomplete_verdict() {
796 let mut html = String::from(
797 r#"<html><head><title>Just a moment...</title></head><body>
798 <script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>"#,
799 );
800 for _ in 0..2000 {
801 html.push_str("<div>cf challenge orchestrator shell padding</div>");
802 }
803 html.push_str("</body></html>");
804 assert!(html.len() >= 50_000);
805
806 let page = Page::from_html(&html, false).await.unwrap();
807 assert_eq!(
808 page.challenge_verdict(),
809 ChallengeVerdict::ChallengeIncomplete
810 );
811 assert!(page.challenge_verdict().is_challenge());
812 }
813
814 #[tokio::test]
817 async fn bdd_clean_page_passes() {
818 let mut html = String::from("<html><body>");
819 for _ in 0..400 {
820 html.push_str("<p>Normal rendered content paragraph with enough text.</p>");
821 }
822 html.push_str("</body></html>");
823 assert!(html.len() >= 15_000);
824
825 let page = Page::from_html(&html, false).await.unwrap();
826 assert_eq!(page.challenge_verdict(), ChallengeVerdict::Pass);
827 assert!(!page.challenge_verdict().is_challenge());
828 }
829
830 #[tokio::test]
833 async fn bdd_warm_reuse_reloads_html() {
834 let html1 =
835 r#"<!DOCTYPE html><html><head><title>First</title></head><body>Page One</body></html>"#;
836 let html2 = r#"<!DOCTYPE html><html><head><title>Second</title></head><body>Page Two</body></html>"#;
837
838 let mut page = Page::from_html(html1, false).await.unwrap();
839 assert_eq!(page.title(), "First");
840 assert!(page.content().contains("Page One"));
841
842 page.reload_html(html2, "https://example.com/second");
844 assert_eq!(page.title(), "Second");
845 assert!(page.content().contains("Page Two"));
846 assert_eq!(page.url(), "https://example.com/second");
847 }
848
849 #[tokio::test]
852 async fn bdd_thin_body_render_incomplete() {
853 let html = "<html><body>tiny</body></html>";
854 let page = Page::from_html(html, false).await.unwrap();
855 assert_eq!(page.challenge_verdict(), ChallengeVerdict::RenderIncomplete);
856 assert!(!page.challenge_verdict().is_challenge());
857 }
858
859 #[tokio::test]
862 async fn bdd_datadome_interstitial() {
863 let html = r#"<script src="https://geo.captcha-delivery.com/captcha.js"></script>
864<div id="ddcaptchaencoded">encoded_payload</div>"#;
865 let page = Page::from_html(html, false).await.unwrap();
866 assert!(page.challenge_verdict().is_challenge());
867 }
868
869 #[tokio::test]
872 async fn bdd_awswaf_challenge() {
873 let html = r#"<html><body>
874<script>window.gokuProps={key:'a',context:'b',iv:'c'};</script>
875<script>window.awsWafCookieDomainList=["example.com"];</script>
876<script src="https://x.token.awswaf.com/challenge.js"></script>
877<script>AwsWafIntegration.checkForceRefresh();</script>
878</body></html>"#;
879 let page = Page::from_html(html, false).await.unwrap();
880 assert!(page.challenge_verdict().is_challenge());
881 }
882
883 #[test]
886 fn extract_title_basic() {
887 assert_eq!(
888 extract_title("<html><head><title>Hello</title></head></html>"),
889 "Hello"
890 );
891 }
892
893 #[test]
894 fn extract_title_empty() {
895 assert_eq!(extract_title("<html><body></body></html>"), "");
896 }
897
898 #[test]
899 fn extract_title_case_insensitive() {
900 assert_eq!(
901 extract_title("<HTML><HEAD><TITLE>Test</TITLE></HEAD></HTML>"),
902 "Test"
903 );
904 }
905
906 #[tokio::test]
907 async fn apply_stylesheets_injects_style_tags() {
908 use crate::resource_loader::{LoadedResource, ResourceType};
909
910 let mut page = Page::from_html("<html><head></head><body></body></html>", false)
911 .await
912 .unwrap();
913
914 let styles = vec![LoadedResource {
915 url: "http://example.com/style.css".to_string(),
916 resource_type: ResourceType::Stylesheet,
917 content: "body { color: red; }".to_string(),
918 content_type: Some("text/css".to_string()),
919 }];
920
921 page.apply_stylesheets(&styles);
922
923 let html = page.dom().child_elements(page.dom().document())[0];
924 let head = page.dom().get_elements_by_tag_name(html, "head")[0];
925 let style_tags = page.dom().get_elements_by_tag_name(head, "style");
926 assert_eq!(style_tags.len(), 1);
927
928 let text = page.dom().text_content(style_tags[0]);
930 assert_eq!(text, "body { color: red; }");
931 }
932
933 #[tokio::test]
934 async fn apply_stylesheets_skips_non_stylesheet_resources() {
935 use crate::resource_loader::{LoadedResource, ResourceType};
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(page.dom().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()).unwrap();
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}