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},
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 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 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 pub async fn navigate(&mut self, url: &str) -> Result<(), PageError> {
146 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 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 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 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 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, ¤t_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(¤t_html, ¤t_url);
230
231 let challenge = engine_classify(¤t_html);
232
233 if !challenge.verdict.is_challenge() {
235 self.load_subresources().await?;
236 return Ok(());
237 }
238
239 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 let resp = client
257 .request("GET", ¤t_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, ¤t_url).await;
263 continue;
264 }
265
266 if iter + 1 < iterations {
269 let cookies_after = cookie_snapshot(client, ¤t_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", ¤t_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, ¤t_url).await;
279 continue;
280 }
281 }
282
283 break;
285 }
286
287 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 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 #[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 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 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 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 pub fn title(&self) -> String {
430 self.title.clone()
431 }
432
433 pub fn url(&self) -> &str {
435 &self.url
436 }
437
438 pub fn stealth(&self) -> bool {
440 self.stealth
441 }
442
443 #[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 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 pub fn has_element(&self, _selector: &str) -> bool {
476 false
477 }
478
479 pub fn challenge_verdict(&self) -> ChallengeVerdict {
481 self.challenge_class.verdict
482 }
483
484 pub fn engine_class(&self) -> &EngineClass {
486 &self.challenge_class
487 }
488
489 pub fn dom(&self) -> &Dom {
490 &self.dom
491 }
492
493 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 pub fn set_subresource_block_types(&mut self, types: HashSet<ResourceType>) {
530 self.subresource_block_types = types;
531 }
532
533 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 self.html = self.dom.serialize_html(crate::dom::NodeId::DOCUMENT);
559
560 Ok(())
561 }
562}
563
564fn 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
588async 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
597fn 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 #[tokio::test]
633 async fn bdd_navigate_to_clean_page() {
634 let mut body = String::from("Hello World. ");
635 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 #[tokio::test]
656 async fn bdd_navigate_with_challenge_detection() {
657 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 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}