use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use deno_core::{JsRuntime, RuntimeOptions}; use obscura_dom::DomTree; /// Re-exported so other crates (obscura-browser, obscura-cdp) can name the V8 /// isolate handle without taking a direct dependency on deno_core. pub use deno_core::v8::IsolateHandle; use crate::module_loader::ObscuraModuleLoader; use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody}; static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); /// Serializes V8 isolate construction across OS threads. The thread-per- /// connection server (issue #430) builds isolates on many threads. The main /// thread already warms up V8 once before any connection thread starts (see the /// `ObscuraJsRuntime::new` warmup in `obscura-cdp` server startup), which is /// what actually prevents the `InitializeBuiltinJSDispatchTable` segfault of a /// first isolate built off the main thread. This lock is defense-in-depth: it /// keeps two connections from running V8's isolate setup concurrently in case /// any residual first-time process init races. Construction is rare and fast, so /// serializing it costs nothing measurable; isolate *execution* stays fully /// parallel, each isolate on its own thread with no shared lock. static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[derive(Debug, Clone)] pub struct RemoteObjectInfo { pub js_type: String, pub subtype: Option, pub class_name: String, pub description: String, pub object_id: Option, pub value: Option, } pub struct ObscuraJsRuntime { runtime: JsRuntime, state: Rc>, object_store: HashMap, object_counter: u64, /// Thread-safe handle to this runtime's V8 isolate, captured at /// construction. Lets a watchdog be armed from `&self` (the CDP dispatcher /// only holds `&Page` on the hot path) and is stable for the isolate's life. isolate_handle: IsolateHandle, } /// Handle to an armed V8 execution watchdog (see [`ObscuraJsRuntime::arm_watchdog`]). /// Holds the cancel channel and the watchdog thread; pass it back to /// `disarm_watchdog` to stop the watchdog and learn whether it fired. pub struct WatchdogToken { pair: std::sync::Arc<(std::sync::Mutex, std::sync::Condvar)>, join: Option>, fired: std::sync::Arc, } /// Arm a V8 termination watchdog directly from an isolate handle, with no /// runtime borrow. The CDP dispatcher uses this to bound every command so a /// hung page cannot hold this connection's V8 lock forever. Pair with /// [`WatchdogToken::stop`]; if `stop` returns true, clear the termination flag /// via [`ObscuraJsRuntime::cancel_termination`] before reusing the isolate. pub fn spawn_watchdog(handle: IsolateHandle, budget: std::time::Duration) -> WatchdogToken { let pair = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); let fired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let pair_c = pair.clone(); let fired_c = fired.clone(); let join = std::thread::spawn(move || { let (lock, cvar) = &*pair_c; let mut cancelled = lock.lock().unwrap(); let deadline = std::time::Instant::now() + budget; loop { // Check first: stop() may have set this (and notified into the void) // before this thread even started, which happens constantly for fast // CDP commands where stop() is called right after spawn. Without this // top check the lost notify means we wait the full budget before // noticing, and stop()'s join() blocks for that whole time. if *cancelled { return; } let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { fired_c.store(true, std::sync::atomic::Ordering::SeqCst); handle.terminate_execution(); return; } let (guard, _) = cvar.wait_timeout(cancelled, remaining).unwrap(); cancelled = guard; if *cancelled { return; } } }); WatchdogToken { pair, join: Some(join), fired } } impl WatchdogToken { /// Stop the watchdog. Returns true if it had already fired (terminated the /// isolate). The caller must then clear the termination flag via /// [`ObscuraJsRuntime::cancel_termination`] before the next eval. pub fn stop(mut self) -> bool { { let (lock, cvar) = &*self.pair; *lock.lock().unwrap() = true; cvar.notify_one(); } if let Some(j) = self.join.take() { let _ = j.join(); } self.fired.load(std::sync::atomic::Ordering::SeqCst) } } impl ObscuraJsRuntime { pub fn new() -> Self { Self::with_base_url("about:blank") } pub fn with_base_url(base_url: &str) -> Self { Self::with_base_url_and_proxy(base_url, None) } /// Construct a runtime whose ES-module loader routes dynamic imports /// through `proxy_url` (#139). `None` is equivalent to `with_base_url` /// (direct connection). pub fn with_base_url_and_proxy(base_url: &str, proxy_url: Option) -> Self { let state = Rc::new(RefCell::new(ObscuraState::new())); let state_clone = state.clone(); let module_loader = Rc::new(ObscuraModuleLoader::with_proxy(base_url, proxy_url)); // Build the isolate under the process-wide creation lock so two // connection threads never construct isolates concurrently (#430). let (runtime, isolate_handle) = { let _create_guard = ISOLATE_CREATE_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let mut runtime = JsRuntime::new(RuntimeOptions { extensions: vec![build_extension()], module_loader: Some(module_loader), startup_snapshot: Some(SNAPSHOT), ..Default::default() }); runtime.op_state().borrow_mut().put(state_clone); runtime .execute_script( "", "globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0;".to_string(), ) .expect("init should not fail"); let isolate_handle = runtime.v8_isolate().thread_safe_handle(); (runtime, isolate_handle) }; ObscuraJsRuntime { runtime, state, object_store: HashMap::new(), object_counter: 0, isolate_handle, } } pub fn set_cookie_jar(&self, jar: std::sync::Arc) { self.state.borrow_mut().cookie_jar = Some(jar); } pub fn set_http_client(&self, client: std::sync::Arc) { self.state.borrow_mut().http_client = Some(client); } /// Install the owning page's passive on_request/on_response callback /// registry so scripted fetch()/XHR observation is page-scoped (issue #408). pub fn set_callbacks(&self, callbacks: std::sync::Arc) { self.state.borrow_mut().callbacks = Some(callbacks); } /// Install the stealth (wreq) HTTP client so scripted fetch()/XHR is routed /// through it in stealth mode (see op_fetch_url / stealth_fetch_all). #[cfg(feature = "stealth")] pub fn set_stealth_client(&self, client: std::sync::Arc) { self.state.borrow_mut().stealth_client = Some(client); } pub fn set_dom(&self, dom: DomTree) { self.state.borrow_mut().dom = Some(dom); } pub fn set_url(&self, url: &str) { self.state.borrow_mut().url = url.to_string(); } /// Set the document's character encoding (WHATWG canonical name). Backs /// `document.characterSet` and the ``/`` URL query encoding /// override for legacy-charset documents. pub fn set_encoding(&self, encoding: &str) { self.state.borrow_mut().encoding = encoding.to_string(); } pub fn set_title(&self, title: &str) { self.state.borrow_mut().title = title.to_string(); } pub fn set_blocked_urls(&self, patterns: Vec) { self.state.borrow_mut().blocked_urls = patterns; } pub fn take_pending_navigation(&self) -> Option<(String, String, String)> { self.state.borrow_mut().pending_navigation.take() } pub fn take_pending_binding_calls(&self) -> Vec<(String, String)> { std::mem::take(&mut self.state.borrow_mut().pending_binding_calls) } pub fn get_network_response_body(&self, request_id: &str) -> Option { self.state.borrow().network_response_bodies.get(request_id).cloned() } pub fn clear_network_response_bodies(&self) { let mut state = self.state.borrow_mut(); state.network_response_bodies.clear(); state.network_response_body_order.clear(); } /// Wire up the interception channel without enabling interception. /// Use set_intercept_enabled separately. The two were entangled before /// and every navigation auto-enabled interception, which made /// `fetch()` from page JS hang forever waiting for a CDP client to /// answer Fetch.requestPaused events that the client never asked for. pub fn set_intercept_tx(&self, tx: tokio::sync::mpsc::UnboundedSender) { let mut state = self.state.borrow_mut(); state.intercept_tx = Some(tx); } pub fn set_intercept_enabled(&self, enabled: bool) { let mut state = self.state.borrow_mut(); state.intercept_enabled = enabled; } pub fn set_user_agent(&mut self, ua: &str) { let escaped = ua.replace('\\', "\\\\").replace('\'', "\\'"); let _ = self.runtime.execute_script( "", format!("globalThis.__obscura_ua = '{}';", escaped), ); } pub fn set_platform(&mut self, platform: &str, ua_platform: &str, ua_platform_version: &str) { let p = platform.replace('\'', "\\'"); let uap = ua_platform.replace('\'', "\\'"); let uapv = ua_platform_version.replace('\'', "\\'"); let _ = self.runtime.execute_script( "", format!( "globalThis.__obscura_platform='{}';globalThis.__obscura_ua_platform='{}';globalThis.__obscura_ua_platform_version='{}';", p, uap, uapv ), ); } pub fn set_stealth(&mut self, enabled: bool) { let _ = self.runtime.execute_script( "", format!("globalThis.__obscura_stealth = {};", enabled), ); } /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.) /// have been set. Must be called once per page setup, after all set_* methods. pub fn run_page_init(&mut self) { let _ = self.runtime.execute_script( "", "globalThis.__obscura_init();".to_string(), ); } /// Override the coordinates the navigator.geolocation shim reports. The /// values are injected as numeric globals the bootstrap reads; when unset it /// keeps the built-in default. Callers validate the range before calling. pub fn set_geolocation(&mut self, latitude: f64, longitude: f64) { let _ = self.runtime.execute_script( "", format!( "globalThis.__obscura_geo_lat={};globalThis.__obscura_geo_lon={};", latitude, longitude ), ); } pub fn evaluate(&mut self, expression: &str) -> Result { let wrapped = Self::wrap_expression(expression); let result = self .runtime .execute_script("", wrapped) .map_err(|e| format!("JS error: {}", e))?; self.v8_to_json(result) } pub async fn evaluate_for_cdp( &mut self, expression: &str, return_by_value: bool, await_promise: bool, ) -> Result { if !await_promise && return_by_value { let val = self.evaluate(expression)?; return Ok(Self::info_from_json(&val)); } self.object_counter += 1; let oid = self.make_oid(self.object_counter); // Same trailing-semicolon trim as wrap_expression — Playwright's // utility-script eval ends with `})();`, and `({expr})` would // otherwise become `(...;)` which is a parse-time SyntaxError. let cleaned_expr = expression .trim() .trim_end_matches(|c: char| c == ';' || c.is_whitespace()); // Puppeteer / Playwright bundles end with a `//# sourceURL=...` // line comment. If we put `{expr})` on a single line the comment // swallows the closing paren and our wrapper breaks. A newline // before the `)` terminates any trailing line comment so the // parens close on their own line. let done_counter = self.object_counter; let meta_code = if await_promise { format!( "(async function() {{\n\ try {{\n\ var __result = await (\n{expr}\n);\n\ globalThis.__obscura_objects['{oid}'] = __result;\n\ globalThis.__obscura_await_meta = {meta_fn};\n\ globalThis.__obscura_await_rejected = false;\n\ }} catch(e) {{\n\ globalThis.__obscura_objects['{oid}'] = e;\n\ globalThis.__obscura_await_meta = {err_meta_fn};\n\ globalThis.__obscura_await_rejected = true;\n\ }}\n\ globalThis.__obscura_done_{done_counter} = true;\n\ }})()", expr = cleaned_expr, oid = oid, meta_fn = Self::meta_extract_js("__result"), err_meta_fn = Self::meta_extract_js("e"), done_counter = done_counter, ) } else { format!( "(function() {{\n\ var __result;\n\ try {{ __result = (\n{expr}\n); }} catch(e) {{ __result = undefined; }}\n\ globalThis.__obscura_objects['{oid}'] = __result;\n\ return {meta_fn};\n\ }})()", expr = cleaned_expr, oid = oid, meta_fn = Self::meta_extract_js("__result"), ) }; let result = self .runtime .execute_script("", meta_code) .map_err(|e| format!("JS error: {}", e))?; let meta_str = if await_promise { let __t0 = std::time::Instant::now(); let sentinel = format!("globalThis.__obscura_done_{done_counter} === true"); self.resolve_promises_until( |rt| rt.runtime.execute_script("", sentinel.clone()) .ok() .and_then(|v| rt.v8_to_json(v).ok()) .and_then(|j| j.as_bool()) .unwrap_or(false), 5000, ).await; let __dt = __t0.elapsed(); if __dt > std::time::Duration::from_secs(1) { let preview: String = expression .chars() .take(200) .map(|c| if c == '\n' || c == '\t' { ' ' } else { c }) .collect(); tracing::debug!( "Runtime.evaluate awaitPromise took {}ms; expr={}", __dt.as_millis(), preview, ); } let rejected = self.runtime.execute_script("", "globalThis.__obscura_await_rejected".to_string()) .map_err(|e| format!("JS error: {}", e))?; if self.v8_to_json(rejected)?.as_bool().unwrap_or(false) { let err = self.runtime.execute_script("", format!("String(globalThis.__obscura_objects['{0}'] && (globalThis.__obscura_objects['{0}'].message || globalThis.__obscura_objects['{0}']))", oid)) .map_err(|e| format!("JS error: {}", e))?; return Err(format!("Promise rejected: {}", self.v8_to_json(err)?.as_str().unwrap_or(""))); } self.runtime.execute_script("", "globalThis.__obscura_await_meta".to_string()) .map_err(|e| format!("JS error: {}", e))? } else { result }; let meta_str = self.v8_to_json(meta_str)?; let meta_json = if let serde_json::Value::String(s) = &meta_str { serde_json::from_str(s).unwrap_or(meta_str) } else { meta_str }; self.object_store.insert( oid.clone(), format!("globalThis.__obscura_objects['{}']", oid), ); if await_promise && return_by_value { let read = self.runtime.execute_script("", format!("globalThis.__obscura_objects['{}']", oid)) .map_err(|e| format!("JS error: {}", e))?; let json_val = self.v8_to_json(read)?; return Ok(Self::info_from_json(&json_val)); } Ok(Self::info_from_meta(&meta_json, Some(oid))) } pub async fn call_function_on_for_cdp( &mut self, function_declaration: &str, object_id: Option<&str>, arguments: &[serde_json::Value], return_by_value: bool, await_promise: bool, ) -> Result { let this_expr = self.resolve_this(object_id); let (setup, args_list) = self.build_args(arguments); self.object_counter += 1; let oid = self.make_oid(self.object_counter); if await_promise { let done_counter = self.object_counter; let err_meta_fn = Self::meta_extract_js("__result"); let code = format!( "(async function() {{\n\ {setup}\n\ var __fn = ({fn_decl});\n\ var __this = ({this_expr});\n\ var __result;\n\ try {{\n\ __result = await __fn.call(__this, {args});\n\ globalThis.__obscura_objects['{oid}'] = __result;\n\ globalThis.__obscura_await_meta = {meta_fn};\n\ }} catch(e) {{\n\ __result = e;\n\ globalThis.__obscura_objects['{oid}'] = e;\n\ globalThis.__obscura_await_meta = {err_meta_fn};\n\ }} finally {{\n\ globalThis.__obscura_done_{done_counter} = true;\n\ }}\n\ }})()", setup = setup, fn_decl = function_declaration, this_expr = this_expr, args = args_list, oid = oid, meta_fn = Self::meta_extract_js("__result"), err_meta_fn = err_meta_fn, done_counter = done_counter, ); self.runtime .execute_script("", code) .map_err(|e| format!("JS error: {}", e))?; let __t0 = std::time::Instant::now(); let sentinel = format!("globalThis.__obscura_done_{done_counter} === true"); self.resolve_promises_until( |rt| rt.runtime.execute_script("", sentinel.clone()) .ok() .and_then(|v| rt.v8_to_json(v).ok()) .and_then(|j| j.as_bool()) .unwrap_or(false), 5000, ).await; let __dt = __t0.elapsed(); if __dt > std::time::Duration::from_secs(1) { let preview: String = function_declaration .chars() .take(300) .map(|c| if c == '\n' || c == '\t' { ' ' } else { c }) .collect(); tracing::debug!( "Runtime.callFunctionOn awaitPromise took {}ms; fn={}", __dt.as_millis(), preview, ); } if return_by_value { let read = self.runtime.execute_script( "", format!("globalThis.__obscura_objects['{}']", oid), ).map_err(|e| format!("JS error: {}", e))?; let json_val = self.v8_to_json(read)?; return Ok(Self::info_from_json(&json_val)); } let meta_result = self.runtime.execute_script( "", "globalThis.__obscura_await_meta".to_string(), ).map_err(|e| format!("JS error: {}", e))?; let meta_str = self.v8_to_json(meta_result)?; let meta_json = if let serde_json::Value::String(s) = &meta_str { serde_json::from_str(s).unwrap_or(meta_str.clone()) } else { meta_str }; self.object_store.insert( oid.clone(), format!("globalThis.__obscura_objects['{}']", oid), ); return Ok(Self::info_from_meta(&meta_json, Some(oid))); } if return_by_value { let code = format!( "(function() {{\n\ {setup}\n\ var __fn = ({fn_decl});\n\ var __this = ({this_expr});\n\ return __fn.call(__this, {args});\n\ }})()", setup = setup, fn_decl = function_declaration, this_expr = this_expr, args = args_list, ); let result = self.runtime .execute_script("", code) .map_err(|e| format!("JS error: {}", e))?; let json_val = self.v8_to_json(result)?; return Ok(Self::info_from_json(&json_val)); } let code = format!( "(function() {{\n\ {setup}\n\ var __fn = ({fn_decl});\n\ var __this = ({this_expr});\n\ var __result = __fn.call(__this, {args});\n\ globalThis.__obscura_objects['{oid}'] = __result;\n\ return {meta_fn};\n\ }})()", setup = setup, fn_decl = function_declaration, this_expr = this_expr, args = args_list, oid = oid, meta_fn = Self::meta_extract_js("__result"), ); let result = self.runtime .execute_script("", code) .map_err(|e| format!("JS error: {}", e))?; let meta_str = self.v8_to_json(result)?; let meta_json = if let serde_json::Value::String(s) = &meta_str { serde_json::from_str(s).unwrap_or(meta_str.clone()) } else { meta_str }; self.object_store.insert( oid.clone(), format!("globalThis.__obscura_objects['{}']", oid), ); Ok(Self::info_from_meta(&meta_json, Some(oid))) } pub async fn call_function_on( &mut self, function_declaration: &str, object_id: Option<&str>, arguments: &[serde_json::Value], return_by_value: bool, ) -> Result { self.call_function_on_for_cdp(function_declaration, object_id, arguments, return_by_value, false).await } pub fn store_object(&mut self, js_expression: &str) -> Result { self.object_counter += 1; let oid = self.make_oid(self.object_counter); let code = format!( "globalThis.__obscura_objects['{}'] = ({});", oid, js_expression, ); self.runtime .execute_script("", code) .map_err(|e| format!("Store error: {}", e))?; self.object_store.insert( oid.clone(), format!("globalThis.__obscura_objects['{}']", oid), ); Ok(oid) } pub fn store_object_with_meta( &mut self, js_expression: &str, ) -> Result { self.object_counter += 1; let oid = self.make_oid(self.object_counter); let code = format!( "(function() {{\n\ var __result = (\n{expr}\n);\n\ globalThis.__obscura_objects['{oid}'] = __result;\n\ return {meta_fn};\n\ }})()", expr = js_expression, oid = oid, meta_fn = Self::meta_extract_js("__result"), ); let result = self .runtime .execute_script("", code) .map_err(|e| format!("Store error: {}", e))?; let meta_str = self.v8_to_json(result)?; let meta_json = if let serde_json::Value::String(s) = &meta_str { serde_json::from_str(s).unwrap_or(meta_str.clone()) } else { meta_str }; self.object_store.insert( oid.clone(), format!("globalThis.__obscura_objects['{}']", oid), ); Ok(Self::info_from_meta(&meta_json, Some(oid))) } pub fn release_object(&mut self, object_id: &str) { if self.object_store.remove(object_id).is_some() { let code = format!( "delete globalThis.__obscura_objects['{}'];", object_id, ); let _ = self.runtime.execute_script("", code); } } pub fn release_object_group(&mut self) { let _ = self.runtime.execute_script( "", "globalThis.__obscura_objects = {};".to_string(), ); self.object_store.clear(); } pub async fn load_module(&mut self, url: &str, budget_ms: u64) -> Result<(), String> { let budget = tokio::time::Duration::from_millis(budget_ms); let specifier = deno_core::ModuleSpecifier::parse(url) .map_err(|e| format!("Invalid module URL {}: {}", url, e))?; // Fetch the module source. The old impl registered an empty string // and called it loaded, so every Vite / Next module bundle "loaded" // in 1ms with zero code and the SPA never mounted (issue #205). let (client, callbacks) = { let st = self.state.borrow(); (st.http_client.clone(), st.callbacks.clone()) }; let source_code = match client { Some(c) => match c.fetch_with_callbacks(&specifier, callbacks.as_deref()).await { Ok(resp) => obscura_net::decode_non_html(&resp.body, resp.content_type()), Err(e) => { tracing::warn!("Module fetch failed ({}): {}", url, e); String::new() } }, None => { tracing::warn!("No http_client wired to runtime; module {} will be empty", url); String::new() } }; // Bound the recursive import-graph fetch. deno_core fetches the graph // concurrently, but a module whose top-level eval idle-waits forever (no // CPU, no network) otherwise blocks here until the phase watchdog fires. // The caller sizes the budget: short for enhancement modules on an // already-rendered page, full for an unmounted SPA shell (#205). let module_id = match tokio::time::timeout( budget, self.runtime.load_side_es_module_from_code(&specifier, deno_core::ModuleCodeString::from(source_code)), ).await { Ok(Ok(id)) => id, Ok(Err(e)) => return Err(format!("Module load error: {}", e)), Err(_) => { tracing::warn!("Module graph load timed out after {}ms: {}", budget_ms, url); return Ok(()); } }; // Return as soon as the module finishes evaluating rather than waiting // for the loop to go fully idle: a page timer (setInterval) keeps the // loop busy forever and would otherwise burn the whole budget (#374). self.drive_module_eval(module_id, budget_ms, &format!("Module {}", url)) .await; Ok(()) } /// Drive a just-started module evaluation to completion, or up to /// `budget_ms`. Returns as soon as the module finishes rather than waiting /// for the event loop to go idle: a page timer (setInterval) keeps the loop /// busy forever and would otherwise burn the whole budget, abandoning a /// module that had already evaluated (issue #374). /// /// A module eval error or a timeout is logged under `what` and swallowed: /// neither is fatal to rendering the rest of the page. An event-loop error /// is propagated out of the select and handled the same way -- it must not /// be discarded, or a module stalled on a top-level await spins here for the /// whole budget with nothing logged. async fn drive_module_eval(&mut self, module_id: deno_core::ModuleId, budget_ms: u64, what: &str) { let budget = tokio::time::Duration::from_millis(budget_ms); let result = self.runtime.mod_evaluate(module_id); tokio::pin!(result); let outcome = tokio::time::timeout(budget, async { let event_loop = self .runtime .run_event_loop(deno_core::PollEventLoopOptions::default()); tokio::pin!(event_loop); tokio::select! { biased; r = &mut result => r, e = &mut event_loop => { e?; (&mut result).await } } }) .await; match outcome { Ok(Ok(())) => {} Ok(Err(e)) => tracing::warn!("{} eval error: {}", what, e), Err(_) => tracing::warn!("{} evaluation timed out after {}ms", what, budget_ms), } } pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> { let budget = tokio::time::Duration::from_millis(budget_ms); let specifier = deno_core::ModuleSpecifier::parse( &format!("{}#inline-module-{}", base_url, self.object_counter), ) .unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap()); self.object_counter += 1; let module_id = match tokio::time::timeout( budget, self.runtime.load_side_es_module_from_code( &specifier, deno_core::ModuleCodeString::from(code.to_string()), ), ).await { Ok(Ok(id)) => id, Ok(Err(e)) => return Err(format!("Inline module load error: {}", e)), Err(_) => { tracing::warn!("Inline module graph load timed out after {}ms", budget_ms); return Ok(()); } }; // Return as soon as the module finishes evaluating rather than waiting // for idle: Vite's HMR / React-Refresh client installs a setInterval that // keeps the loop busy forever, and waiting for idle burned the whole // budget on this preamble module and starved the module that mounts the // app, leaving #root empty (issue #374). self.drive_module_eval(module_id, budget_ms, "Inline module").await; Ok(()) } pub fn execute_script(&mut self, _name: &str, source: &str) -> Result<(), String> { self.runtime .execute_script("", ); let md = rt .evaluate(crate::HTML_TO_MARKDOWN_JS) .unwrap() .as_str() .unwrap() .to_string(); assert!(md.contains("Text"), "missing visible text: {}", md); assert!(!md.contains("alert"), "leaked script content: {}", md); assert!(!md.contains("color:red"), "leaked style content: {}", md); } #[test] fn test_page_content_puppeteer_pattern() { let mut rt = setup_runtime("

Test

"); let result = rt.evaluate( "(function() { let retVal = ''; if (document.doctype) retVal = new XMLSerializer().serializeToString(document.doctype); if (document.documentElement) retVal += document.documentElement.outerHTML; return retVal; })()" ).unwrap(); let html = result.as_str().unwrap(); assert!(html.starts_with("")); assert!(html.contains("")); assert!(html.contains("

Test

")); } #[test] fn test_element_from_point_is_function() { let mut rt = setup_runtime(""); let kind = rt.evaluate("typeof document.elementFromPoint").unwrap(); assert_eq!(kind, serde_json::json!("function")); let kind2 = rt.evaluate("typeof document.elementsFromPoint").unwrap(); assert_eq!(kind2, serde_json::json!("function")); } #[test] fn test_element_from_point_in_viewport_returns_body() { let mut rt = setup_runtime("

Hi

"); let tag = rt.evaluate("document.elementFromPoint(10, 10)?.tagName").unwrap(); assert_eq!(tag, serde_json::json!("BODY")); } #[test] fn test_element_from_point_out_of_viewport_returns_null() { let mut rt = setup_runtime(""); let neg_x = rt.evaluate("document.elementFromPoint(-1, 10)").unwrap(); assert_eq!(neg_x, serde_json::Value::Null); let neg_y = rt.evaluate("document.elementFromPoint(10, -1)").unwrap(); assert_eq!(neg_y, serde_json::Value::Null); let huge = rt.evaluate("document.elementFromPoint(99999, 99999)").unwrap(); assert_eq!(huge, serde_json::Value::Null); } #[test] fn test_elements_from_point_returns_array() { let mut rt = setup_runtime(""); let len_in = rt.evaluate("document.elementsFromPoint(10, 10).length").unwrap(); assert_eq!(len_in.as_f64().unwrap() as i64, 1); let len_out = rt.evaluate("document.elementsFromPoint(-1, -1).length").unwrap(); assert_eq!(len_out.as_f64().unwrap() as i64, 0); } #[test] fn test_element_from_point_non_numeric_returns_null() { let mut rt = setup_runtime(""); let nan = rt.evaluate("document.elementFromPoint(NaN, 10)").unwrap(); assert_eq!(nan, serde_json::Value::Null); let inf = rt.evaluate("document.elementFromPoint(Infinity, 10)").unwrap(); assert_eq!(inf, serde_json::Value::Null); } // Issue #139 — proxy_url must thread through to both the ES-module // loader (module_loader.rs) and op_fetch_url's reqwest client // (ops.rs::build_request_client). Pre-fix both built clients with // `Client::builder().build()` — no proxy — so JS fetch/XHR and // dynamic imports silently bypassed BrowserContext.proxy_url. // // Phase 5.5 RED check: each test references a symbol that does NOT // exist on main (proxy_url() accessor, with_proxy ctor, // with_base_url_and_proxy ctor), so the tests fail to compile without // the prod fix. #[test] fn http_client_round_trips_proxy_url() { use obscura_net::{CookieJar, ObscuraHttpClient}; let jar = std::sync::Arc::new(CookieJar::new()); let configured = ObscuraHttpClient::with_options(jar.clone(), Some("http://proxy.test:8080")); assert_eq!( configured.proxy_url(), Some("http://proxy.test:8080"), "proxy_url() must expose the value passed to with_options" ); let direct = ObscuraHttpClient::with_options(jar, None); assert_eq!( direct.proxy_url(), None, "proxy_url() must return None when no proxy was configured" ); } #[test] fn module_loader_stores_proxy_for_dynamic_imports() { use crate::module_loader::ObscuraModuleLoader; let loader = ObscuraModuleLoader::with_proxy( "https://example.com/", Some("http://proxy.test:8080".to_string()), ); assert_eq!(loader.proxy_url.as_deref(), Some("http://proxy.test:8080")); assert_eq!(loader.base_url, "https://example.com/"); // Default constructor must keep the historical "no proxy" behaviour. let direct = ObscuraModuleLoader::new("https://example.com/"); assert_eq!(direct.proxy_url, None); } #[test] fn runtime_with_base_url_and_proxy_constructs_successfully() { // Sanity-check the public ctor that page.rs uses to thread proxy // through to the module loader. Direct (None) and proxied paths // must both initialise the JS environment. let _direct = ObscuraJsRuntime::with_base_url_and_proxy("https://example.com/", None); let _proxied = ObscuraJsRuntime::with_base_url_and_proxy( "https://example.com/", Some("http://proxy.test:8080".to_string()), ); } // ── Issue #45 (Playwright actionability) regression tests ──────────────── // Kept at the end of the module so they don't share textual context with // unrelated test additions in other branches (avoids spurious merge // conflicts when both this branch and an unrelated bootstrap.js change // add tests near the start of `mod tests`). /// Playwright >= 1.25 calls `element.checkVisibility(...)` before every /// input event. If the method isn't defined Playwright retries until its /// action timeout fires. Without a layout engine we can't compute it /// properly, so the stub always returns true — still strictly better /// than the undefined path. #[test] fn element_check_visibility_is_callable() { let mut rt = setup_runtime(r#"
x
"#); let result = rt .evaluate("document.getElementById('x').checkVisibility({checkOpacity: true})") .unwrap(); assert_eq!(result, serde_json::json!(true)); let typeof_method = rt .evaluate("typeof document.getElementById('x').checkVisibility") .unwrap(); assert_eq!(typeof_method, serde_json::json!("function")); } /// Playwright's `getByRole` / `getByLabel` locators resolve via ARIA /// reflection properties. Without the getters those locators always /// fail. Reflect the underlying aria-* attributes. #[test] fn element_aria_reflection_properties_read_aria_attrs() { let mut rt = setup_runtime( r#""#, ); let result = rt .evaluate( r#" const el = document.getElementById('b'); return [el.role, el.ariaLabel, el.ariaSelected]; "#, ) .unwrap(); assert_eq!(result, serde_json::json!(["tab", "Settings", "true"])); } /// Setting an ARIA reflection property must write through to the /// underlying attribute so frameworks that toggle state via /// `el.ariaExpanded = 'true'` actually update the DOM. /// Regression: React 18 / mobile SPAs (e.g. goofish.com) call /// addEventListener on navigator.connection (NetworkInformation) and /// navigator.serviceWorker (ServiceWorkerContainer). Both are EventTargets /// in real browsers; missing the method crashed the app bundle with /// "addEventListener is not a function". #[test] fn navigator_eventtarget_stubs_expose_add_event_listener() { let mut rt = setup_runtime("
"); let result = rt .evaluate( r#" const connection = navigator.connection; let calls = 0; let receiverMatches = false; function listener(event) { calls += 1; receiverMatches = this === connection && event.type === 'change'; } connection.addEventListener('change', listener); const dispatchResult = connection.dispatchEvent(new Event('change')); connection.removeEventListener('change', listener); connection.dispatchEvent(new Event('change')); return [ typeof connection.addEventListener, typeof connection.removeEventListener, typeof connection.dispatchEvent, typeof navigator.serviceWorker.addEventListener, dispatchResult, calls, receiverMatches, ]; "#, ) .unwrap(); assert_eq!( result, serde_json::json!([ "function", "function", "function", "function", true, 1, true ]) ); } /// Regression test for #285: DDoS-Guard's challenge calls /// `t.insertAdjacentText(...)` and dies with `TypeError: ... is not a /// function` because `Element.prototype.insertAdjacentText` was missing. /// Verify all four positions place a Text node (NOT parsed HTML) at the /// right spot. Tests `insertAdjacentText` exists, is callable, and that /// inserted content remains literal text — angle brackets must not be /// parsed as markup, which is the whole point of the API. #[test] fn element_insert_adjacent_text_polyfill() { let mut rt = setup_runtime(r#"
X
"#); let result = rt .evaluate( r#" const t = document.getElementById('t'); t.insertAdjacentText('afterbegin', 'AB'); t.insertAdjacentText('beforeend', 'BE'); t.insertAdjacentText('beforebegin', 'BB'); t.insertAdjacentText('afterend', 'AE'); t.insertAdjacentText('beforeend', 'raw'); return [ typeof Element.prototype.insertAdjacentText, document.getElementById('p').textContent, t.getElementsByTagName('b').length, ]; "#, ) .unwrap(); assert_eq!( result, serde_json::json!(["function", "BBABXBErawAE", 0]) ); } /// Regression test for #285: `Element.prototype.insertAdjacentElement` /// was missing alongside `insertAdjacentText`. Verify all four positions /// place the given element correctly and that the inserted element is /// returned (per spec — that's the contract callers rely on for chaining). #[test] fn element_insert_adjacent_element_polyfill() { let mut rt = setup_runtime(r#"
X
"#); let result = rt .evaluate( r#" const t = document.getElementById('t'); const before = document.createElement('b'); before.id = 'before'; const after = document.createElement('i'); after.id = 'after'; const inside = document.createElement('em'); inside.id = 'inside'; const last = document.createElement('u'); last.id = 'last'; const r1 = t.insertAdjacentElement('beforebegin', before); const r2 = t.insertAdjacentElement('afterend', after); const r3 = t.insertAdjacentElement('afterbegin', inside); const r4 = t.insertAdjacentElement('beforeend', last); const siblings = Array.from(document.getElementById('p').children).map(c => c.id); const inT = Array.from(t.children).map(c => c.id); return [ typeof Element.prototype.insertAdjacentElement, r1 === before && r2 === after && r3 === inside && r4 === last, siblings, inT, ]; "#, ) .unwrap(); assert_eq!( result, serde_json::json!([ "function", true, ["before", "t", "after"], ["inside", "last"] ]) ); } #[test] fn console_log_error_does_not_trigger_prepare_stack_trace() { let mut rt = setup_runtime("
"); let result = rt.evaluate(r#" let called = false; const saved = Error.prepareStackTrace; Error.prepareStackTrace = function() { called = true; return saved; }; const e = new Error("test"); console.log(e); Error.prepareStackTrace = saved; return called; "#).unwrap(); assert_eq!(result, serde_json::json!(false)); } #[test] fn element_aria_reflection_setters_write_through() { let mut rt = setup_runtime(r#"
"#); let result = rt .evaluate( r#" const el = document.getElementById('d'); el.role = 'menu'; el.ariaExpanded = 'true'; return [el.getAttribute('role'), el.getAttribute('aria-expanded')]; "#, ) .unwrap(); assert_eq!(result, serde_json::json!(["menu", "true"])); } }