use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use deno_core::{JsRuntime, RuntimeOptions}; use obscura_dom::DomTree; use crate::module_loader::ObscuraModuleLoader; use crate::ops::{build_extension, ObscuraState}; static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); #[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, } 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)); 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; globalThis.__obscura_init();".to_string(), ) .expect("init should not fail"); ObscuraJsRuntime { runtime, state, object_store: HashMap::new(), object_counter: 0, } } 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); } 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(); } 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 set_intercept_tx(&self, tx: tokio::sync::mpsc::UnboundedSender) { let mut state = self.state.borrow_mut(); state.intercept_tx = Some(tx); state.intercept_enabled = true; } 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 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()); let meta_code = if await_promise { format!( "(async function() {{\n\ try {{\n\ var __result = await ({expr});\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\ }})()", expr = cleaned_expr, oid = oid, meta_fn = Self::meta_extract_js("__result"), err_meta_fn = Self::meta_extract_js("e"), ) } else { format!( "(function() {{\n\ var __result;\n\ try {{ __result = ({expr}); }} 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 { self.resolve_promises().await; 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 code = format!( "(async function() {{\n\ {setup}\n\ var __fn = ({fn_decl});\n\ var __this = ({this_expr});\n\ var __result = await __fn.call(__this, {args});\n\ globalThis.__obscura_objects['{oid}'] = __result;\n\ globalThis.__obscura_await_meta = {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"), ); self.runtime .execute_script("", code) .map_err(|e| format!("JS error: {}", e))?; self.resolve_promises().await; 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 = ({expr});\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) -> Result<(), String> { let specifier = deno_core::ModuleSpecifier::parse(url) .map_err(|e| format!("Invalid module URL {}: {}", url, e))?; let module_id = self .runtime .load_side_es_module_from_code(&specifier, deno_core::ModuleCodeString::from_static("")) .await .map_err(|e| format!("Module load error: {}", e))?; let result = self.runtime.mod_evaluate(module_id); let timeout = tokio::time::timeout( tokio::time::Duration::from_secs(10), self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()), ).await; match timeout { Ok(Ok(())) => {} Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)), Err(_) => { tracing::warn!("Module evaluation timed out after 10s: {}", url); return Ok(()); } } match result.await { Ok(()) => Ok(()), Err(e) => { tracing::warn!("Module eval error: {}", e); Ok(()) } } } pub async fn load_inline_module(&mut self, code: &str, base_url: &str) -> Result<(), String> { 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 = self .runtime .load_side_es_module_from_code( &specifier, deno_core::ModuleCodeString::from(code.to_string()), ) .await .map_err(|e| format!("Inline module load error: {}", e))?; let result = self.runtime.mod_evaluate(module_id); let timeout = tokio::time::timeout( tokio::time::Duration::from_secs(10), self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()), ).await; match timeout { Ok(Ok(())) => {} Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)), Err(_) => { tracing::warn!("Inline module timed out after 10s"); return Ok(()); } } match result.await { Ok(()) => Ok(()), Err(e) => { tracing::warn!("Inline module eval error: {}", e); 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. #[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"])); } }