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,
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
21const DEFAULT_NAV_BUDGET: Duration = Duration::from_secs(15);
23const DEFAULT_MAX_ITERATIONS: u8 = 3;
25
26pub 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 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 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 #[cfg_attr(feature = "hotpath", hotpath::measure)]
120 pub fn reload_html(&mut self, html: &str, url: &str) {
121 let lowered_html = html.to_lowercase();
124 self.dom = crate::html_parser::parse_html(html);
125 self.url = url.to_string();
126 self.html = html.to_string();
127 self.title = extract_title_lower(&lowered_html, html);
128 self.challenge_class = engine_classify_lower(&lowered_html);
129 #[cfg(feature = "v8")]
130 {
131 if self.js_runtime.is_some() {
132 let rt_dom = crate::html_parser::parse_html(html);
135 if let Some(ref mut rt) = self.js_runtime {
136 rt.update_dom(rt_dom);
137 }
138 } else {
139 let rt_dom = crate::html_parser::parse_html(html);
140 self.js_runtime = Some(BrowserJsRuntime::new(rt_dom));
141 }
142 }
143 }
144
145 pub async fn navigate(&mut self, url: &str) -> Result<(), PageError> {
150 let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
152 self.navigate_inner(url, &client, DEFAULT_MAX_ITERATIONS, DEFAULT_NAV_BUDGET)
153 .await
154 }
155
156 pub async fn navigate_with_solvers(
160 &mut self,
161 url: &str,
162 solvers: &[&dyn crate::challenge::ChallengeSolver],
163 ) -> Result<(), PageError> {
164 let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
165 self.navigate_with_solvers_inner(
166 url,
167 &client,
168 solvers,
169 DEFAULT_MAX_ITERATIONS,
170 DEFAULT_NAV_BUDGET,
171 )
172 .await
173 }
174
175 pub async fn navigate_warm(&mut self, url: &str) -> Result<(), PageError> {
179 let client = HttpClient::new(hpx::BrowserProfile::Chrome).map_err(PageError::Net)?;
180 let resp = client
181 .request("GET", url, None, &[], RedirectPolicy::Follow(10))
182 .await
183 .map_err(PageError::Net)?;
184 let html = resp.text();
185 let resp_url = resp.url.clone();
186
187 self.reload_html(&html, &resp_url);
188 Ok(())
189 }
190
191 async fn navigate_inner(
193 &mut self,
194 url: &str,
195 client: &HttpClient,
196 max_iterations: u8,
197 budget: Duration,
198 ) -> Result<(), PageError> {
199 self.navigate_with_solvers_inner(url, client, &[], max_iterations, budget)
200 .await
201 }
202
203 async fn navigate_with_solvers_inner(
205 &mut self,
206 url: &str,
207 client: &HttpClient,
208 solvers: &[&dyn crate::challenge::ChallengeSolver],
209 max_iterations: u8,
210 budget: Duration,
211 ) -> Result<(), PageError> {
212 let t0 = Instant::now();
213 let iterations = max_iterations.max(1);
214
215 let resp = client
216 .request("GET", url, None, &[], RedirectPolicy::Follow(10))
217 .await
218 .map_err(PageError::Net)?;
219 let mut current_html = resp.text();
220 let mut current_url = resp.url.clone();
221 let mut cookies_before = cookie_snapshot(client, ¤t_url).await;
222
223 for iter in 0..iterations {
224 if t0.elapsed() >= budget {
225 tracing::warn!(
226 iter,
227 elapsed_ms = t0.elapsed().as_millis(),
228 "navigate budget exhausted"
229 );
230 break;
231 }
232
233 self.reload_html(¤t_html, ¤t_url);
234
235 let challenge = engine_classify(¤t_html);
236
237 if !challenge.verdict.is_challenge() {
239 self.load_subresources().await?;
240 return Ok(());
241 }
242
243 let kind = tag_to_kind(challenge.tag);
245 let mut any_solved = false;
246 for solver in solvers {
247 if !solver.can_handle(&kind) {
248 continue;
249 }
250 if matches!(
251 solver.solve(&kind, self).await,
252 crate::challenge::SolveOutcome::Solved
253 ) {
254 any_solved = true;
255 }
256 }
257
258 if any_solved {
259 let resp = client
261 .request("GET", ¤t_url, None, &[], RedirectPolicy::Follow(10))
262 .await
263 .map_err(PageError::Net)?;
264 current_html = resp.text();
265 current_url = resp.url.clone();
266 cookies_before = cookie_snapshot(client, ¤t_url).await;
267 continue;
268 }
269
270 if iter + 1 < iterations {
273 let cookies_after = cookie_snapshot(client, ¤t_url).await;
274 if cookies_after != cookies_before && !cookies_after.is_empty() {
275 tracing::info!(iter, "cookie delta detected — retrying navigation");
276 let resp = client
277 .request("GET", ¤t_url, None, &[], RedirectPolicy::Follow(10))
278 .await
279 .map_err(PageError::Net)?;
280 current_html = resp.text();
281 current_url = resp.url.clone();
282 cookies_before = cookie_snapshot(client, ¤t_url).await;
283 continue;
284 }
285 }
286
287 break;
289 }
290
291 self.load_subresources().await?;
293
294 Ok(())
295 }
296
297 pub async fn evaluate_async(&mut self, script: &str) -> Result<serde_json::Value, PageError> {
298 #[cfg(feature = "v8")]
299 {
300 self.ensure_js_runtime();
301 if let Some(ref mut rt) = self.js_runtime {
302 let result = rt
303 .execute_script(script)
304 .map_err(|e| PageError::Evaluation(e.to_string()))?;
305 return Ok(serde_json::Value::String(result));
306 }
307 }
308 let _ = script;
309 Err(PageError::Evaluation(
310 "evaluate_async requires v8 feature".into(),
311 ))
312 }
313
314 pub fn evaluate(&mut self, script: &str) -> Result<String, PageError> {
316 #[cfg(feature = "v8")]
317 {
318 self.ensure_js_runtime();
319 if let Some(ref mut rt) = self.js_runtime {
320 return rt
321 .execute_script(script)
322 .map_err(|e| PageError::Evaluation(e.to_string()));
323 }
324 }
325 let _ = script;
326 Ok("undefined".to_string())
327 }
328
329 #[cfg(feature = "v8")]
331 fn ensure_js_runtime(&mut self) {
332 if self.js_runtime.is_some() {
333 return;
334 }
335 let rt_dom = crate::html_parser::parse_html(&self.html);
336 self.js_runtime = Some(BrowserJsRuntime::new(rt_dom));
337 }
338
339 pub fn execute_inline_scripts(&mut self) -> Result<(), PageError> {
344 let scripts = self.collect_inline_scripts();
345 if scripts.is_empty() {
346 return Ok(());
347 }
348
349 #[cfg(feature = "v8")]
350 {
351 self.ensure_js_runtime();
352 if let Some(ref mut rt) = self.js_runtime {
353 for script in &scripts {
354 if let Err(e) = rt.execute_script(script) {
355 tracing::warn!(error = %e, "inline script execution failed");
356 }
357 }
358 }
359 }
360
361 #[cfg(not(feature = "v8"))]
362 {
363 for script in &scripts {
364 tracing::warn!(len = script.len(), "inline script skipped (no v8)");
365 }
366 }
367
368 Ok(())
369 }
370
371 pub fn execute_scripts(
377 &mut self,
378 external_scripts: &[crate::resource_loader::LoadedResource],
379 ) -> Result<(), PageError> {
380 use crate::resource_loader::ResourceType;
381
382 self.execute_inline_scripts()?;
383
384 for script in external_scripts {
385 if script.resource_type != ResourceType::Script {
386 continue;
387 }
388 #[cfg(feature = "v8")]
389 {
390 self.ensure_js_runtime();
391 if let Some(ref mut rt) = self.js_runtime {
392 if let Err(e) = rt.execute_script(&script.content) {
393 tracing::warn!(url = %script.url, error = %e, "external script execution failed");
394 }
395 }
396 }
397 #[cfg(not(feature = "v8"))]
398 {
399 tracing::debug!(url = %script.url, "external script skipped (no v8)");
400 }
401 }
402
403 Ok(())
404 }
405
406 pub fn collect_inline_scripts(&self) -> Vec<String> {
409 use crate::dom::{DomElement, NodeId};
410
411 let mut scripts = Vec::new();
412 for script_id in self
413 .dom
414 .get_elements_by_tag_name(NodeId::DOCUMENT, "script")
415 {
416 if let Some(el) = DomElement::new(&self.dom, script_id) {
417 if el.attr("src").is_none() {
418 let content = self.dom.text_content(script_id);
419 if !content.is_empty() {
420 scripts.push(content);
421 }
422 }
423 }
424 }
425 scripts
426 }
427
428 pub async fn title_async(&self) -> Result<String, PageError> {
429 Ok(self.title.clone())
430 }
431
432 pub fn title(&self) -> String {
434 self.title.clone()
435 }
436
437 pub fn url(&self) -> &str {
439 &self.url
440 }
441
442 pub fn stealth(&self) -> bool {
444 self.stealth
445 }
446
447 #[cfg(feature = "v8")]
454 pub fn set_profile(&mut self, profile: StealthProfile) {
455 self.ensure_js_runtime();
456 if let Some(ref mut rt) = self.js_runtime {
457 rt.set_user_agent(&profile.user_agent);
458 rt.set_platform(&profile.platform, &profile.os_name, &profile.os_version);
459 rt.set_stealth(true);
460 rt.run_page_init();
461 }
462 self.profile = Some(profile);
463 }
464
465 pub fn content(&self) -> String {
467 self.html.clone()
468 }
469
470 pub async fn text_content(&self) -> Result<String, PageError> {
471 Ok(self.dom.text_content(crate::dom::NodeId::DOCUMENT))
472 }
473
474 pub async fn text_of(&self, _selector: &str) -> Result<String, PageError> {
475 Ok(String::new())
476 }
477
478 pub fn has_element(&self, _selector: &str) -> bool {
480 false
481 }
482
483 pub fn challenge_verdict(&self) -> ChallengeVerdict {
485 self.challenge_class.verdict
486 }
487
488 pub fn engine_class(&self) -> &EngineClass {
490 &self.challenge_class
491 }
492
493 pub fn dom(&self) -> &Dom {
494 &self.dom
495 }
496
497 pub fn apply_stylesheets(&mut self, styles: &[crate::resource_loader::LoadedResource]) {
499 use crate::{dom::NodeId, resource_loader::ResourceType};
500
501 let html_el = self
502 .dom
503 .child_elements(NodeId::DOCUMENT)
504 .into_iter()
505 .find(|&id| {
506 self.dom
507 .get(id)
508 .map(|n| n.is_element_with_tag("html"))
509 .unwrap_or(false)
510 });
511 let head = html_el.and_then(|html| {
512 self.dom
513 .get_elements_by_tag_name(html, "head")
514 .into_iter()
515 .next()
516 });
517
518 if let Some(head) = head {
519 for style in styles {
520 if style.resource_type == ResourceType::Stylesheet {
521 let style_el = self
522 .dom
523 .create_element(crate::dom::QualName::new("style"), Vec::new());
524 let text = self.dom.create_text(style.content.clone());
525 self.dom.append_child(head, style_el);
526 self.dom.append_child(style_el, text);
527 }
528 }
529 }
530 }
531
532 pub fn set_subresource_block_types(&mut self, types: HashSet<ResourceType>) {
534 self.subresource_block_types = types;
535 }
536
537 pub async fn load_subresources(&mut self) -> Result<(), PageError> {
540 let resources = extract_resource_urls(&self.dom);
541 let filtered = filter_by_block_types(resources, &self.subresource_block_types);
542 if filtered.is_empty() {
543 return Ok(());
544 }
545 let loaded = fetch_resources(filtered, &self.subresource_block_types, 6).await;
546
547 let styles: Vec<_> = loaded
548 .iter()
549 .filter(|r| r.resource_type == ResourceType::Stylesheet)
550 .cloned()
551 .collect();
552 let scripts: Vec<_> = loaded
553 .iter()
554 .filter(|r| r.resource_type == ResourceType::Script)
555 .cloned()
556 .collect();
557
558 self.apply_stylesheets(&styles);
559 self.execute_scripts(&scripts)?;
560
561 self.html = self.dom.serialize_html(crate::dom::NodeId::DOCUMENT);
563
564 Ok(())
565 }
566}
567
568fn tag_to_kind(tag: &'static str) -> crate::challenge::ChallengeKind {
570 let (vendor, sub_kind): (&'static str, &'static str) = if tag.starts_with("cf-") {
571 ("cloudflare", tag)
572 } else if tag.starts_with("AWS-WAF") {
573 ("aws-waf", tag)
574 } else if tag.eq_ignore_ascii_case("datadome") {
575 ("datadome", tag)
576 } else if tag.starts_with("akamai") {
577 ("akamai", tag)
578 } else if tag.starts_with("px-") || tag.starts_with("PXC") {
579 ("perimeterx", tag)
580 } else if tag.starts_with("kasada") {
581 ("kasada", tag)
582 } else if tag.starts_with("sec-cpt") {
583 ("sec-cpt", tag)
584 } else if tag.starts_with("hcaptcha") {
585 ("hcaptcha", tag)
586 } else {
587 ("unknown", tag)
588 };
589 crate::challenge::ChallengeKind::new(vendor, sub_kind)
590}
591
592async fn cookie_snapshot(client: &HttpClient, url: &str) -> String {
594 if let Ok(parsed) = url::Url::parse(url) {
595 client.cookies_for_url(&parsed).await.unwrap_or_default()
596 } else {
597 String::new()
598 }
599}
600
601fn extract_title(html: &str) -> String {
603 let lower = html.to_lowercase();
604 extract_title_lower(&lower, html)
605}
606
607fn extract_title_lower(lowered_html: &str, original_html: &str) -> String {
613 if let Some(start) = lowered_html.find("<title") {
614 let after_tag = &original_html[start..];
615 if let Some(gt) = after_tag.find('>') {
616 let content = &after_tag[gt + 1..];
617 if let Some(end) = content.to_lowercase().find("</title>") {
618 return content[..end].trim().to_string();
619 }
620 }
621 }
622 String::new()
623}
624
625#[derive(Debug, thiserror::Error)]
626pub enum PageError {
627 #[error("navigation failed: {0}")]
628 Navigation(String),
629 #[error("evaluation failed: {0}")]
630 Evaluation(String),
631 #[error("element not found")]
632 ElementNotFound,
633 #[error("page not loaded")]
634 NotLoaded,
635 #[error("network error: {0}")]
636 Net(#[from] crate::net::NetError),
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 #[tokio::test]
646 async fn bdd_navigate_to_clean_page() {
647 let mut body = String::from("Hello World. ");
648 for _ in 0..500 {
650 body.push_str("This is real rendered content for the test page. ");
651 }
652 let html = format!(
653 r#"<!DOCTYPE html>
654<html>
655<head><title>Test Page</title></head>
656<body>{body}</body>
657</html>"#
658 );
659 let page = Page::from_html(&html, false).await.unwrap();
660
661 assert_eq!(page.title(), "Test Page");
662 assert!(page.content().contains("Hello World"));
663 assert_eq!(page.challenge_verdict(), ChallengeVerdict::Pass);
664 }
665
666 #[tokio::test]
669 async fn bdd_navigate_with_challenge_detection() {
670 let html = r#"<!DOCTYPE html>
672<html>
673<head><title>Just a moment...</title></head>
674<body>
675<script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>
676Checking your browser before accessing the site...
677</body>
678</html>"#;
679 let page = Page::from_html(html, false).await.unwrap();
680
681 assert_eq!(page.challenge_verdict(), ChallengeVerdict::EdgeBlock);
682 assert!(page.challenge_verdict().is_challenge());
683 }
684
685 #[tokio::test]
688 async fn bdd_challenge_incomplete_verdict() {
689 let mut html = String::from(
690 r#"<html><head><title>Just a moment...</title></head><body>
691 <script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>"#,
692 );
693 for _ in 0..2000 {
694 html.push_str("<div>cf challenge orchestrator shell padding</div>");
695 }
696 html.push_str("</body></html>");
697 assert!(html.len() >= 50_000);
698
699 let page = Page::from_html(&html, false).await.unwrap();
700 assert_eq!(
701 page.challenge_verdict(),
702 ChallengeVerdict::ChallengeIncomplete
703 );
704 assert!(page.challenge_verdict().is_challenge());
705 }
706
707 #[tokio::test]
710 async fn bdd_clean_page_passes() {
711 let mut html = String::from("<html><body>");
712 for _ in 0..400 {
713 html.push_str("<p>Normal rendered content paragraph with enough text.</p>");
714 }
715 html.push_str("</body></html>");
716 assert!(html.len() >= 15_000);
717
718 let page = Page::from_html(&html, false).await.unwrap();
719 assert_eq!(page.challenge_verdict(), ChallengeVerdict::Pass);
720 assert!(!page.challenge_verdict().is_challenge());
721 }
722
723 #[tokio::test]
726 async fn bdd_warm_reuse_reloads_html() {
727 let html1 =
728 r#"<!DOCTYPE html><html><head><title>First</title></head><body>Page One</body></html>"#;
729 let html2 = r#"<!DOCTYPE html><html><head><title>Second</title></head><body>Page Two</body></html>"#;
730
731 let mut page = Page::from_html(html1, false).await.unwrap();
732 assert_eq!(page.title(), "First");
733 assert!(page.content().contains("Page One"));
734
735 page.reload_html(html2, "https://example.com/second");
737 assert_eq!(page.title(), "Second");
738 assert!(page.content().contains("Page Two"));
739 assert_eq!(page.url(), "https://example.com/second");
740 }
741
742 #[tokio::test]
745 async fn bdd_thin_body_render_incomplete() {
746 let html = "<html><body>tiny</body></html>";
747 let page = Page::from_html(html, false).await.unwrap();
748 assert_eq!(page.challenge_verdict(), ChallengeVerdict::RenderIncomplete);
749 assert!(!page.challenge_verdict().is_challenge());
750 }
751
752 #[tokio::test]
755 async fn bdd_datadome_interstitial() {
756 let html = r#"<script src="https://geo.captcha-delivery.com/captcha.js"></script>
757<div id="ddcaptchaencoded">encoded_payload</div>"#;
758 let page = Page::from_html(html, false).await.unwrap();
759 assert!(page.challenge_verdict().is_challenge());
760 }
761
762 #[tokio::test]
765 async fn bdd_awswaf_challenge() {
766 let html = r#"<html><body>
767<script>window.gokuProps={key:'a',context:'b',iv:'c'};</script>
768<script>window.awsWafCookieDomainList=["example.com"];</script>
769<script src="https://x.token.awswaf.com/challenge.js"></script>
770<script>AwsWafIntegration.checkForceRefresh();</script>
771</body></html>"#;
772 let page = Page::from_html(html, false).await.unwrap();
773 assert!(page.challenge_verdict().is_challenge());
774 }
775
776 #[test]
779 fn extract_title_basic() {
780 assert_eq!(
781 extract_title("<html><head><title>Hello</title></head></html>"),
782 "Hello"
783 );
784 }
785
786 #[test]
787 fn extract_title_empty() {
788 assert_eq!(extract_title("<html><body></body></html>"), "");
789 }
790
791 #[test]
792 fn extract_title_case_insensitive() {
793 assert_eq!(
794 extract_title("<HTML><HEAD><TITLE>Test</TITLE></HEAD></HTML>"),
795 "Test"
796 );
797 }
798
799 #[tokio::test]
800 async fn apply_stylesheets_injects_style_tags() {
801 use crate::{
802 dom::NodeId,
803 resource_loader::{LoadedResource, ResourceType},
804 };
805
806 let mut page = Page::from_html("<html><head></head><body></body></html>", false)
807 .await
808 .unwrap();
809
810 let styles = vec![LoadedResource {
811 url: "http://example.com/style.css".to_string(),
812 resource_type: ResourceType::Stylesheet,
813 content: "body { color: red; }".to_string(),
814 content_type: Some("text/css".to_string()),
815 }];
816
817 page.apply_stylesheets(&styles);
818
819 let html = page.dom().child_elements(NodeId::DOCUMENT)[0];
820 let head = page.dom().get_elements_by_tag_name(html, "head")[0];
821 let style_tags = page.dom().get_elements_by_tag_name(head, "style");
822 assert_eq!(style_tags.len(), 1);
823
824 let text = page.dom().text_content(style_tags[0]);
826 assert_eq!(text, "body { color: red; }");
827 }
828
829 #[tokio::test]
830 async fn apply_stylesheets_skips_non_stylesheet_resources() {
831 use crate::{
832 dom::NodeId,
833 resource_loader::{LoadedResource, ResourceType},
834 };
835
836 let mut page = Page::from_html("<html><head></head><body></body></html>", false)
837 .await
838 .unwrap();
839
840 let styles = vec![
841 LoadedResource {
842 url: "http://example.com/script.js".to_string(),
843 resource_type: ResourceType::Script,
844 content: "alert(1)".to_string(),
845 content_type: Some("application/javascript".to_string()),
846 },
847 LoadedResource {
848 url: "http://example.com/style.css".to_string(),
849 resource_type: ResourceType::Stylesheet,
850 content: "h1 { font-size: 2em; }".to_string(),
851 content_type: Some("text/css".to_string()),
852 },
853 ];
854
855 page.apply_stylesheets(&styles);
856
857 let html = page.dom().child_elements(NodeId::DOCUMENT)[0];
858 let head = page.dom().get_elements_by_tag_name(html, "head")[0];
859 let style_tags = page.dom().get_elements_by_tag_name(head, "style");
860 assert_eq!(style_tags.len(), 1);
861 }
862
863 #[tokio::test]
864 async fn collect_inline_scripts_excludes_external() {
865 let html = r#"<!DOCTYPE html>
866<html><head></head><body>
867<script>window.a = 1;</script>
868<script src="/app.js"></script>
869<script>window.b = 2;</script>
870</body></html>"#;
871 let page = Page::from_html(html, false).await.unwrap();
872 let scripts = page.collect_inline_scripts();
873 assert_eq!(scripts.len(), 2);
874 assert_eq!(scripts[0], "window.a = 1;");
875 assert_eq!(scripts[1], "window.b = 2;");
876 }
877
878 #[tokio::test]
879 async fn collect_inline_scripts_empty_when_none() {
880 let html = r#"<!DOCTYPE html>
881<html><head></head><body>
882<script src="/app.js"></script>
883<p>No inline scripts here</p>
884</body></html>"#;
885 let page = Page::from_html(html, false).await.unwrap();
886 let scripts = page.collect_inline_scripts();
887 assert!(scripts.is_empty());
888 }
889
890 #[cfg(feature = "v8")]
891 #[tokio::test]
892 async fn execute_inline_scripts_sets_globals() {
893 let html = r#"<!DOCTYPE html>
894<html><head></head><body>
895<script>window.x = 42;</script>
896</body></html>"#;
897 let mut page = Page::from_html(html, false).await.unwrap();
898 page.execute_inline_scripts().unwrap();
899
900 let mut rt = BrowserJsRuntime::new(crate::dom::Dom::new());
901 let result = rt.execute_script("1 + 1").unwrap();
905 assert_eq!(result, "2");
906 }
907
908 #[cfg(feature = "v8")]
909 #[tokio::test]
910 async fn execute_inline_scripts_continues_on_error() {
911 let html = r#"<!DOCTYPE html>
912<html><head></head><body>
913<script>throw new Error("boom");</script>
914<script>window.ok = true;</script>
915</body></html>"#;
916 let mut page = Page::from_html(html, false).await.unwrap();
917 page.execute_inline_scripts().unwrap();
919 }
920
921 #[tokio::test]
922 async fn execute_scripts_processes_external_scripts() {
923 use crate::resource_loader::{LoadedResource, ResourceType};
924
925 let mut page = Page::from_html("<html><body></body></html>", false)
926 .await
927 .unwrap();
928
929 let scripts = vec![LoadedResource {
930 url: "http://example.com/app.js".to_string(),
931 resource_type: ResourceType::Script,
932 content: "var x = 1;".to_string(),
933 content_type: Some("application/javascript".to_string()),
934 }];
935 page.execute_scripts(&scripts).unwrap();
936 }
937
938 #[tokio::test]
939 async fn execute_scripts_mixed_inline_and_external() {
940 use crate::resource_loader::{LoadedResource, ResourceType};
941
942 let html = r#"<!DOCTYPE html>
943<html><head></head><body>
944<script>window.a = 1;</script>
945<script src="/app.js"></script>
946<script>window.b = 2;</script>
947</body></html>"#;
948 let mut page = Page::from_html(html, false).await.unwrap();
949
950 let scripts = vec![LoadedResource {
951 url: "http://example.com/app.js".to_string(),
952 resource_type: ResourceType::Script,
953 content: "window.c = 3;".to_string(),
954 content_type: Some("application/javascript".to_string()),
955 }];
956 page.execute_scripts(&scripts).unwrap();
957 }
958
959 #[tokio::test]
960 async fn execute_scripts_skips_non_script_resources() {
961 use crate::resource_loader::{LoadedResource, ResourceType};
962
963 let mut page = Page::from_html("<html><body></body></html>", false)
964 .await
965 .unwrap();
966
967 let resources = vec![LoadedResource {
968 url: "http://example.com/style.css".to_string(),
969 resource_type: ResourceType::Stylesheet,
970 content: "body { color: red; }".to_string(),
971 content_type: Some("text/css".to_string()),
972 }];
973 page.execute_scripts(&resources).unwrap();
974 }
975}