diff --git a/crates/obscura-browser/src/lib.rs b/crates/obscura-browser/src/lib.rs index 376b816..c715344 100644 --- a/crates/obscura-browser/src/lib.rs +++ b/crates/obscura-browser/src/lib.rs @@ -7,3 +7,6 @@ pub use page::{NetworkEvent, Page, PageError}; pub use context::BrowserContext; pub use lifecycle::{LifecycleState, WaitUntil}; pub use obscura_js::HTML_TO_MARKDOWN_JS; +// Re-exported so the embeddable `obscura` crate (which depends on obscura-browser, +// not obscura-js) can surface the interception channel types. +pub use obscura_js::ops::{InterceptResolution, InterceptedRequest}; diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 393ca9c..9e0079f 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use obscura_dom::{parse_html, DomTree}; use obscura_js::runtime::ObscuraJsRuntime; -use obscura_net::{ObscuraHttpClient, ObscuraNetError, Response}; +use obscura_net::{ObscuraHttpClient, ObscuraNetError, RequestCallback, Response, ResponseCallback}; use url::Url; use crate::context::BrowserContext; @@ -1526,6 +1526,46 @@ impl Page { self.preload_scripts = scripts; } + /// Append a script that runs in the page before any of the page's own + /// `".to_string()) + }; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}", + ct, + body.len(), + body + ); + let _ = s.write_all(resp.as_bytes()); + let _ = s.shutdown(std::net::Shutdown::Both); + } + }); + format!("http://{}", addr) +} + +#[tokio::test] +async fn page_intercepts_and_observes_js_fetch() { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let base = spawn_echo_server(); + + let browser = Browser::new().unwrap(); + let mut page = browser.new_page().await.unwrap(); + + // Passive on_request counter (fires for navigation + the JS fetch). + let req_count = Arc::new(AtomicU32::new(0)); + let rc = req_count.clone(); + page.on_request(Arc::new(move |_info| { + rc.fetch_add(1, Ordering::SeqCst); + })); + + // Passive on_response: capture the /api (Fetch) body. + let captured = Arc::new(Mutex::new(String::new())); + let cap = captured.clone(); + page.on_response(Arc::new(move |info, resp| { + if info.resource_type == ResourceType::Fetch { + *cap.lock().unwrap() = String::from_utf8_lossy(&resp.body).into_owned(); + } + })); + + // Channel-based interception: resolve every request Continue so the fetch + // is not blocked. + let mut rx = page.enable_interception(); + tokio::spawn(async move { + while let Some(req) = rx.recv().await { + if req + .resolver + .send(InterceptResolution::Continue { + url: None, + method: None, + headers: None, + body: None, + }) + .is_err() + { + break; + } + } + }); + + page.goto(&base).await.unwrap(); + // Pump the event loop so the inline fetch('/api') resolves. + for _ in 0..20 { + page.settle(500).await; + if captured.lock().unwrap().contains("hello") { + break; + } + } + + assert!( + req_count.load(Ordering::SeqCst) >= 1, + "on_request never fired for navigation or fetch" + ); + let body = captured.lock().unwrap().clone(); + assert!( + body.contains("hello"), + "on_response did not capture the fetch response body: {:?}", + body + ); +}