use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use deno_core::{JsRuntime, RuntimeOptions}; use obscura_dom::{DomTree, NodeId}; /// 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::import_map::ImportMap; use crate::module_loader::{ModuleLoadActivity, ObscuraModuleLoader}; #[cfg(all(test, feature = "render"))] use crate::ops::ensure_prepared_render; use crate::ops::{build_extension, node_is_script, ObscuraState, StoredNetworkResponseBody}; #[cfg(feature = "render")] use crate::ops::{ begin_animation_task, clamp_scroll_offset, document_base_url, ensure_resolved_scroll, }; 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(()); #[cfg(feature = "render")] fn with_sync_render_loading_disabled( state: &mut ObscuraState, capture: impl FnOnce(&mut ObscuraState) -> R, ) -> R { let previous = state .render_resources .set_sync_loading_enabled(false); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| capture(state))); state .render_resources .set_sync_loading_enabled(previous); match result { Ok(value) => value, Err(payload) => std::panic::resume_unwind(payload), } } #[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, import_map: Rc>, /// Loader-owned signal for pending dynamic-import graph fetches. This is /// intentionally separate from page fetch/XHR activity so analytics does /// not hold screenshot readiness open. module_load_activity: std::sync::Arc, /// 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, } /// A fetched and instantiated module graph whose evaluation is intentionally /// delayed until the HTML script scheduler reaches its post-parse turn. pub struct PreparedModule { module_id: deno_core::ModuleId, description: String, } fn remaining_deadline_ms(deadline: tokio::time::Instant) -> Option { let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?; if remaining.is_zero() { return None; } // Round up so a positive sub-millisecond remainder still gets one bounded // event-loop turn. The watchdog supplies the hard wall-clock boundary. let millis = remaining .as_millis() .saturating_add(u128::from(remaining.subsec_nanos() % 1_000_000 != 0)); Some(millis.min(u128::from(u64::MAX)) as u64) } /// 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 { /// Freeze the document timeline for one JavaScript task. Browser timelines /// update at task/rendering boundaries, not on each forced style or layout /// read. Keeping one sample across the task also lets repeated CSSOM reads /// share the retained layout on pages with running animations. fn begin_javascript_task(&mut self) { #[cfg(feature = "render")] begin_animation_task(&mut self.state.borrow_mut()); } 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 import_map = state.borrow().import_map.clone(); let module_loader = ObscuraModuleLoader::with_page_state( base_url, proxy_url, &state, import_map.clone(), ); let module_load_activity = module_loader.activity(); let module_loader = Rc::new(module_loader); // 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, import_map, module_load_activity, isolate_handle, } } /// Parse and merge an inline document import map. Rules which would alter /// already-observed module resolutions are discarded while unrelated new /// rules remain available, matching Chromium's multiple-map model. pub fn add_import_map(&self, source: &str, base_url: &str) -> Result<(), String> { let map = ImportMap::parse(source, base_url)?; self.import_map .try_borrow_mut() .map_err(|_| "Import map is already borrowed".to_string())? .merge(map); Ok(()) } 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) { let mut gs = self.state.borrow_mut(); gs.dom = Some(dom); gs.document_generation = gs.document_generation.wrapping_add(1); gs.activity_generation = 0; gs.page_in_flight = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); gs.already_started_scripts.borrow_mut().clear(); // A new document owns a fresh retained scene and resource cache. #[cfg(feature = "render")] { gs.prepared_render = None; gs.animation_sample = obscura_render::AnimationSample::default(); gs.animation_timeline = obscura_render::AnimationTimelineState::default(); gs.animation_timeline_origin = std::time::Instant::now(); gs.animation_task_generation = 0; gs.animation_sampled_task_generation = 0; gs.pending_style_mutations.clear(); gs.render_resources = obscura_render::RenderResourceCache::default(); gs.render_image_in_flight.clear(); gs.stylesheet_cache = obscura_render::StylesheetCache::default(); gs.dynamic_fonts.clear(); gs.scroll_offset = (0.0, 0.0); gs.element_scroll_offsets.clear(); gs.scroll_generation = 0; gs.resolved_scroll = None; } } pub fn set_url(&self, url: &str) { let mut state = self.state.borrow_mut(); if state.url != url { state.url = url.to_string(); #[cfg(feature = "render")] { // Relative resources use the document URL when no is // present. Keep already-fetched absolute bytes, but rebuild // candidate selection/layout against the new base. state.prepared_render = None; state.pending_style_mutations.clear(); state.resolved_scroll = None; } } } /// 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(); } /// Set the source document URL exposed as `document.referrer`. Navigation /// owns this value; it is not derived from the current URL because direct /// navigations and document-initiated navigations have different /// referrer semantics. pub fn set_referrer(&self, referrer: &str) { self.state.borrow_mut().referrer = referrer.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), ); } /// Set the CSS viewport exposed to page JavaScript. This must run before /// `run_page_init` for navigation-time responsive code; it may also be /// called later by CDP emulation to update the live window surfaces. pub fn set_viewport(&mut self, width: f64, height: f64) { if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { return; } #[cfg(feature = "render")] { let mut state = self.state.borrow_mut(); let viewport = (width as f32, height as f32); if state.viewport != viewport { state.viewport = viewport; state.prepared_render = None; state.pending_style_mutations.clear(); state.resolved_scroll = None; } } let _ = self.runtime.execute_script( "", format!( "globalThis.__obscura_viewport_w={width};\ globalThis.__obscura_viewport_h={height};\ globalThis.innerWidth={width};globalThis.innerHeight={height};\ if(globalThis.visualViewport){{\ globalThis.visualViewport.width={width};\ globalThis.visualViewport.height={height};\ }}\ if(typeof globalThis.__obscura_recompute_intersections==='function'){{\ globalThis.__obscura_recompute_intersections();\ }}\ if(typeof globalThis.__obscura_recompute_resizes==='function'){{\ globalThis.__obscura_recompute_resizes();\ }}", ), ); } /// Current clamped root scroll offset shared by CSSOM geometry and paint. #[cfg(feature = "render")] pub fn scroll_offset(&self) -> (f32, f32) { let mut state = self.state.borrow_mut(); let requested = state.scroll_offset; clamp_scroll_offset(&mut state, requested) } /// Select the document-timeline instant used by the next render flush. /// Returns false for invalid times and preserves the current sample. #[cfg(feature = "render")] pub fn set_animation_sample_time( &self, sample: obscura_render::AnimationSampleTime, ) -> bool { self.set_animation_sample(obscura_render::AnimationSample::document( sample.milliseconds, )) } #[cfg(feature = "render")] pub fn set_animation_sample(&self, sample: obscura_render::AnimationSample) -> bool { if !sample.time.milliseconds.is_finite() || sample.time.milliseconds < 0.0 { return false; } let mut state = self.state.borrow_mut(); if state.animation_sample != sample { if sample.mode == obscura_render::AnimationSampleMode::DocumentTime && state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime && sample.time.milliseconds > state.animation_sample.time.milliseconds && state.prepared_render.as_mut().is_some_and(|prepared| { prepared.advance_inactive_animation_sample_time(sample.time) }) { state.animation_sample = sample; return true; } state.animation_sample = sample; state.prepared_render = None; state.pending_style_mutations.clear(); state.resolved_scroll = None; } true } #[cfg(feature = "render")] pub fn animation_sample_time(&self) -> obscura_render::AnimationSampleTime { self.state.borrow().animation_sample.time } #[cfg(feature = "render")] pub fn live_animation_sample(&self) -> obscura_render::AnimationSample { let state = self.state.borrow(); obscura_render::AnimationSample::document( (state.animation_timeline_origin.elapsed().as_secs_f64() * 1_000.0) .min(f64::from(f32::MAX)) as f32, ) } #[cfg(feature = "render")] pub fn reset_animation_timeline(&self) { let mut state = self.state.borrow_mut(); state.animation_timeline_origin = std::time::Instant::now(); state.animation_timeline = obscura_render::AnimationTimelineState::default(); state.animation_sample = obscura_render::AnimationSample::default(); state.prepared_render = None; state.pending_style_mutations.clear(); state.resolved_scroll = None; } /// Read animation damage from the last prepared frame without causing a /// style/layout flush. Screencast scheduling uses this to avoid rasterizing /// static pages on every compositor tick. #[cfg(feature = "render")] pub fn prepared_has_active_css_animations(&self) -> bool { self.state .borrow() .prepared_render .as_ref() .is_some_and(|prepared| prepared.has_active_css_animations()) } /// Capture the live render viewport from the same prepared layout used by /// CSSOM geometry. A mismatched ad-hoc viewport/base returns `None` so the /// browser layer can retain its compatibility one-shot path. #[cfg(feature = "render")] pub fn screenshot_prepared( &self, viewport: (f32, f32), base_url: Option<&str>, ) -> Option> { let mut state = self.state.borrow_mut(); let effective_base = document_base_url(&state); if viewport != state.viewport || base_url != effective_base.as_deref() { return None; } with_sync_render_loading_disabled(&mut state, |state| { ensure_resolved_scroll(state)?; let ObscuraState { dom, prepared_render, render_resources, resolved_scroll, .. } = state; let (_, scroll) = resolved_scroll.as_ref()?; obscura_render::screenshot_prepared_with_scroll( dom.as_ref()?, prepared_render.as_mut()?, render_resources, scroll, ) }) } /// Capture a document-space rectangle without changing the live viewport, /// root scroll, element scroll offsets, or retained layout. The current /// resolved scroll snapshot supplies fixed/sticky and nested-scroll state. #[cfg(feature = "render")] pub fn screenshot_prepared_region( &self, region: obscura_render::CaptureRegion, ) -> Result, obscura_render::CaptureError> { self.screenshot_prepared_region_with_backgrounds(region, true) } /// Capture a document-space rectangle with the PDF print-background /// policy without mutating the page DOM or retained geometry. #[cfg(feature = "render")] pub fn screenshot_prepared_region_with_backgrounds( &self, region: obscura_render::CaptureRegion, paint_backgrounds: bool, ) -> Result, obscura_render::CaptureError> { let mut state = self.state.borrow_mut(); with_sync_render_loading_disabled(&mut state, |state| { ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?; let ObscuraState { dom, prepared_render, render_resources, resolved_scroll, .. } = state; let (_, scroll) = resolved_scroll .as_ref() .ok_or(obscura_render::CaptureError::PaintFailed)?; obscura_render::screenshot_prepared_region_with_scroll_and_backgrounds( dom.as_ref() .ok_or(obscura_render::CaptureError::PaintFailed)?, prepared_render .as_mut() .ok_or(obscura_render::CaptureError::PaintFailed)?, render_resources, scroll, region, paint_backgrounds, ) }) } /// Capture one immutable document slice as if its origin were the root /// scroll position of a virtual viewport. This leaves the live page scroll /// untouched while giving fixed and sticky descendants page-local paint /// geometry, which paginated raster PDF export requires. #[cfg(feature = "render")] pub fn screenshot_prepared_region_at_scroll_with_backgrounds( &self, region: obscura_render::CaptureRegion, root_scroll: (f32, f32), paint_backgrounds: bool, ) -> Result, obscura_render::CaptureError> { let mut state = self.state.borrow_mut(); with_sync_render_loading_disabled(&mut state, |state| { ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?; let ObscuraState { dom, prepared_render, render_resources, element_scroll_offsets, .. } = state; let dom = dom .as_ref() .ok_or(obscura_render::CaptureError::PaintFailed)?; let scroll = prepared_render .as_ref() .ok_or(obscura_render::CaptureError::PaintFailed)? .resolve_scroll_state_for_viewport( dom, root_scroll, element_scroll_offsets, (region.width, region.height), ); obscura_render::screenshot_prepared_region_with_scroll_and_backgrounds( dom, prepared_render .as_mut() .ok_or(obscura_render::CaptureError::PaintFailed)?, render_resources, &scroll, region, paint_backgrounds, ) }) } /// Return the retained layout's scrollable document size without changing /// the live viewport or scroll position. PDF/full-document consumers use /// this to paginate document-space captures from the same geometry. #[cfg(feature = "render")] pub fn prepared_content_size(&self) -> Option<(f32, f32)> { let mut state = self.state.borrow_mut(); with_sync_render_loading_disabled(&mut state, |state| { ensure_resolved_scroll(state)?; state .prepared_render .as_ref() .map(|render| render.content_size()) }) } /// Return the exact responsive candidates selected for live `` /// elements without loading them. The browser layer can then fetch them /// concurrently through the page-owned transport before synchronous layout /// or paint observes the cache. #[cfg(feature = "render")] pub fn pending_render_image_urls(&self) -> Vec<(String, crate::ops::ImageRequestProfile)> { let state = self.state.borrow(); let base_url = document_base_url(&state); let Some(dom) = state.dom.as_ref() else { return Vec::new(); }; let mut urls = Vec::new(); for id in dom.descendants(dom.document()) { let Some(node) = dom.get_node(id) else { continue; }; if node .as_element() .is_none_or(|element| element.local.as_ref() != "img") { continue; } let Some((url, _, known, _)) = state.render_resources.cached_image_element_metadata( dom, id, state.viewport, base_url.as_deref(), ) else { continue; }; if !known && !url.starts_with("data:") { let profile = match node .get_attribute("crossorigin") .map(|value| value.trim().to_ascii_lowercase()) .as_deref() { Some("use-credentials") => crate::ops::ImageRequestProfile::CorsInclude, Some(_) => crate::ops::ImageRequestProfile::CorsSameOrigin, None => crate::ops::ImageRequestProfile::NoCorsInclude, }; urls.push((url, profile)); } } urls.sort(); urls.dedup(); urls } /// Insert one page-transport resource outcome into the retained renderer /// cache. A new successful byte body can change intrinsic geometry, so any /// previously prepared layout is invalidated. A negative outcome cannot /// change geometry and therefore preserves the retained layout/scroll. #[cfg(feature = "render")] pub fn seed_render_resource(&mut self, url: String, bytes: Option>) { let mut state = self.state.borrow_mut(); match bytes { Some(bytes) => { state.render_resources.seed(url, bytes); state.prepared_render = None; state.pending_style_mutations.clear(); state.resolved_scroll = None; } None => state.render_resources.seed_missing(url), } } #[cfg(feature = "render")] pub fn seed_render_image_resource( &mut self, url: String, profile: crate::ops::ImageRequestProfile, bytes: Option>, ) { let mut state = self.state.borrow_mut(); match bytes { Some(bytes) if obscura_render::image_intrinsic_dimensions(&bytes).is_some() => { state.render_resources.seed_image(url, profile, bytes); state.prepared_render = None; state.pending_style_mutations.clear(); state.resolved_scroll = None; } _ => state.render_resources.seed_image_missing(url, profile), } } #[cfg(feature = "render")] pub fn render_resource_is_known(&self, url: &str) -> bool { self.state.borrow().render_resources.has_live_outcome(url) } #[cfg(feature = "render")] pub fn render_image_resource_is_known( &self, url: &str, profile: crate::ops::ImageRequestProfile, ) -> bool { self.state .borrow() .render_resources .has_live_image_outcome(url, profile) } /// 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 { self.begin_javascript_task(); 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.begin_javascript_task(); 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 { self.begin_javascript_task(); 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.begin_javascript_task(); 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.begin_javascript_task(); 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 deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(budget_ms); let prepared = self.prepare_module(url, budget_ms).await?; let remaining_ms = remaining_deadline_ms(deadline).ok_or_else(|| { format!( "Module {} exhausted its {}ms load+evaluation budget", url, budget_ms ) })?; self.evaluate_prepared_module(prepared, remaining_ms).await } pub async fn prepare_module( &mut self, url: &str, budget_ms: u64, ) -> Result { 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))?; // Bound the recursive import-graph fetch. deno_core fetches the graph // concurrently through the one page-scoped module loader. Loading the // entry from that loader too is important: cookies, configured request // headers, redirects, interception, and callbacks must not change at // the first import edge. // 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(&specifier), ) .await { Ok(Ok(id)) => id, Ok(Err(e)) => return Err(format!("Module load error: {}", e)), Err(_) => { return Err(format!( "Module graph load timed out after {}ms: {}", budget_ms, url )); } }; // 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). Ok(PreparedModule { module_id, description: format!("Module {}", url), }) } /// 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 timeout is returned to the page lifecycle. The /// caller may continue rendering, but must not report a failed module as /// successfully loaded. An event-loop error is propagated out of the /// select and handled the same way. async fn drive_module_eval( &mut self, module_id: deno_core::ModuleId, budget_ms: u64, what: &str, ) -> Result<(), String> { self.begin_javascript_task(); 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; e = &mut event_loop => { e?; (&mut result).await } r = &mut result => r, } }) .await; match outcome { Ok(Ok(())) => Ok(()), Ok(Err(e)) => Err(format!("{} eval error: {}", what, e)), Err(_) => Err(format!( "{} 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 deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(budget_ms); let prepared = self .prepare_inline_module(code, base_url, budget_ms) .await?; let remaining_ms = remaining_deadline_ms(deadline).ok_or_else(|| { format!( "Inline module exhausted its {}ms load+evaluation budget", budget_ms ) })?; self.evaluate_prepared_module(prepared, remaining_ms).await } pub async fn prepare_inline_module( &mut self, code: &str, base_url: &str, budget_ms: u64, ) -> Result { let budget = tokio::time::Duration::from_millis(budget_ms); // Inline modules use the document base URL as their module URL. This is // observable through import.meta.url and is also the referrer used for // relative imports and import-map scope matching. deno_core permits // multiple side modules with this name; the returned ModuleId keeps // each prepared module distinct until its scheduled evaluation. let specifier = deno_core::ModuleSpecifier::parse(base_url) .unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap()); 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(_) => { return Err(format!( "Inline module graph load timed out after {}ms", budget_ms )); } }; // 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). Ok(PreparedModule { module_id, description: "Inline module".to_string(), }) } pub async fn evaluate_prepared_module( &mut self, prepared: PreparedModule, budget_ms: u64, ) -> Result<(), String> { let PreparedModule { module_id, description, } = prepared; // Tokio timeouts cannot run while synchronous top-level module work // pins the runtime thread in V8. Pair the async timeout with a hard V8 // watchdog so this budget is a real wall-clock ceiling for both forms // of evaluation. let watchdog = self.arm_watchdog(std::time::Duration::from_millis(budget_ms)); let result = self .drive_module_eval(module_id, budget_ms, &description) .await; let watchdog_fired = self.disarm_watchdog(watchdog); if watchdog_fired { Err(format!( "{} evaluation timed out after {}ms", description, budget_ms )) } else { result } } fn execute_classic_script(&mut self, name: &str, source: &str) -> Result<(), String> { self.begin_javascript_task(); // JsRuntime::execute_script in deno_core 0.350 restricts `name` to a // &'static str. Browser script URLs are runtime data, and V8 uses this // origin as import()'s referrer, so compile in the runtime's main // context directly instead of substituting the fixed "", ); 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); } fn spawn_one_response_server(status: &str, body: &str) -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let status = status.to_string(); let body = body.to_string(); std::thread::spawn(move || { use std::io::{Read as _, Write as _}; let (mut stream, _) = listener.accept().unwrap(); let mut request = [0u8; 2048]; let _ = stream.read(&mut request); let response = format!( "HTTP/1.1 {}\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", status, body.len(), body ); stream.write_all(response.as_bytes()).unwrap(); }); format!("http://{}", address) } #[derive(Clone, Copy)] enum ModuleGraphFixture { CookieProtected, RedirectedChild, } fn spawn_module_graph_server( fixture: ModuleGraphFixture, ) -> (String, std::sync::mpsc::Receiver) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (requests_tx, requests_rx) = std::sync::mpsc::channel(); let request_count = match fixture { ModuleGraphFixture::CookieProtected => 2, ModuleGraphFixture::RedirectedChild => 3, }; std::thread::spawn(move || { use std::io::{Read as _, Write as _}; for _ in 0..request_count { let (mut stream, _) = listener.accept().unwrap(); let mut request = vec![0u8; 8192]; let length = stream.read(&mut request).unwrap(); let request = String::from_utf8_lossy(&request[..length]).to_string(); let lower_request = request.to_ascii_lowercase(); let path = request .lines() .next() .and_then(|line| line.split_ascii_whitespace().nth(1)) .unwrap_or("/") .to_string(); requests_tx.send(request.clone()).unwrap(); let (status, extra_headers, body) = match (fixture, path.as_str()) { (ModuleGraphFixture::CookieProtected, "/entry.js") => ( "200 OK", "", "import { value } from './child.js'; \ globalThis.__module_graph_value = value;", ), (ModuleGraphFixture::CookieProtected, "/child.js") if lower_request.contains("\r\ncookie: session=ok\r\n") && lower_request .contains("\r\nuser-agent: modulegraphtest/1.0\r\n") && lower_request.contains("\r\nx-module-test: shared\r\n") => { ("200 OK", "", "export const value = 'cookie-child';") } (ModuleGraphFixture::CookieProtected, "/child.js") => ( "401 Unauthorized", "", "throw new Error('page request context missing');", ), (ModuleGraphFixture::RedirectedChild, "/entry.js") => ( "200 OK", "", "import { value } from './redirect.js'; \ globalThis.__module_graph_value = value;", ), (ModuleGraphFixture::RedirectedChild, "/redirect.js") => { ("302 Found", "Location: /child.js\r\n", "") } (ModuleGraphFixture::RedirectedChild, "/child.js") => { ("200 OK", "", "export const value = 'redirect-child';") } _ => ("404 Not Found", "", "not found"), }; let response = format!( "HTTP/1.1 {status}\r\n\ Content-Type: application/javascript\r\n\ {extra_headers}Content-Length: {}\r\n\ Connection: close\r\n\r\n{body}", body.len(), ); stream.write_all(response.as_bytes()).unwrap(); } }); (format!("http://{}", address), requests_rx) } fn spawn_import_map_server() -> (String, std::sync::mpsc::Receiver) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (requests_tx, requests_rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { use std::io::{Read as _, Write as _}; for _ in 0..2 { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0u8; 2048]; let length = stream.read(&mut request).unwrap(); let request = String::from_utf8_lossy(&request[..length]); let path = request .lines() .next() .and_then(|line| line.split_ascii_whitespace().nth(1)) .unwrap_or("/") .to_string(); requests_tx.send(path.clone()).unwrap(); let (status, body) = match path.as_str() { "/vendor/pkg/feature.js" => ("200 OK", "export const value = 'prefix-static';"), "/vendor/dynamic.js" => ("200 OK", "export const value = 'exact-dynamic';"), _ => ("404 Not Found", "not found"), }; let response = format!( "HTTP/1.1 {status}\r\n\ Content-Type: application/javascript\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\r\n{body}", body.len(), ); stream.write_all(response.as_bytes()).unwrap(); } }); (format!("http://{}", address), requests_rx) } fn spawn_root_module_import_map_server() -> (String, std::sync::mpsc::Receiver) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (requests_tx, requests_rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { use std::io::{Read as _, Write as _}; let (mut stream, _) = listener.accept().unwrap(); let mut request = [0u8; 2048]; let length = stream.read(&mut request).unwrap(); let request = String::from_utf8_lossy(&request[..length]); let path = request .lines() .next() .and_then(|line| line.split_ascii_whitespace().nth(1)) .unwrap_or("/") .to_string(); requests_tx.send(path.clone()).unwrap(); let body = if path == "/entry.js" { "globalThis.__root_module_identity = 'entry';" } else { "globalThis.__root_module_identity = 'remapped';" }; let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: application/javascript\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\r\n{body}", body.len(), ); stream.write_all(response.as_bytes()).unwrap(); }); (format!("http://{}", address), requests_rx) } #[tokio::test(flavor = "current_thread")] async fn entry_module_http_failure_is_not_evaluated_as_empty_source() { let base = spawn_one_response_server("404 Not Found", "not found"); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/", base)); rt.set_http_client(client); let error = rt .load_module(&format!("{}/entry.js", base), 1_000) .await .unwrap_err(); assert!( error.contains("HTTP 404"), "expected entry fetch status in error, got: {}", error ); } #[tokio::test(flavor = "current_thread")] async fn descendant_module_uses_page_cookie_identity_and_headers() { let (base, requests) = spawn_module_graph_server(ModuleGraphFixture::CookieProtected); let page_url = url::Url::parse(&format!("{}/", base)).unwrap(); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); jar.set_cookie("session=ok; Path=/", &page_url); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( jar.clone(), None, true, )); client.set_user_agent("ModuleGraphTest/1.0").await; client .set_extra_headers(std::collections::HashMap::from([( "x-module-test".to_string(), "shared".to_string(), )])) .await; let callback_urls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let callbacks = std::sync::Arc::new(obscura_net::CallbackRegistry::new()); let callback_urls_capture = callback_urls.clone(); callbacks.add_request(std::sync::Arc::new(move |request| { callback_urls_capture .lock() .unwrap() .push(request.url.path().to_string()); })); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/", base)); rt.set_cookie_jar(jar); rt.set_http_client(client); rt.set_callbacks(callbacks); rt.load_module(&format!("{}/entry.js", base), 1_000) .await .unwrap(); assert_eq!( rt.evaluate("globalThis.__module_graph_value").unwrap(), serde_json::json!("cookie-child"), ); let requests = (0..2) .map(|_| { requests .recv_timeout(std::time::Duration::from_secs(1)) .unwrap() }) .collect::>(); assert!(requests .iter() .any(|request| request.starts_with("GET /entry.js "))); let child = requests .iter() .find(|request| request.starts_with("GET /child.js ")) .expect("descendant request"); let child_lower = child.to_ascii_lowercase(); assert!( child_lower.contains("\r\ncookie: session=ok\r\n"), "{child}" ); assert!( child_lower.contains("\r\nuser-agent: modulegraphtest/1.0\r\n"), "{child}" ); assert!( child_lower.contains("\r\nx-module-test: shared\r\n"), "{child}" ); assert_eq!( *callback_urls.lock().unwrap(), vec!["/entry.js", "/child.js"], ); } #[tokio::test(flavor = "current_thread")] async fn descendant_module_follows_page_client_redirects() { let (base, requests) = spawn_module_graph_server(ModuleGraphFixture::RedirectedChild); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/", base)); rt.set_http_client(client); rt.load_module(&format!("{}/entry.js", base), 1_000) .await .unwrap(); assert_eq!( rt.evaluate("globalThis.__module_graph_value").unwrap(), serde_json::json!("redirect-child"), ); let paths = (0..3) .map(|_| { requests .recv_timeout(std::time::Duration::from_secs(1)) .unwrap() .lines() .next() .unwrap() .to_string() }) .collect::>(); assert_eq!( paths, vec![ "GET /entry.js HTTP/1.1", "GET /redirect.js HTTP/1.1", "GET /child.js HTTP/1.1", ], ); } #[tokio::test(flavor = "current_thread")] async fn import_map_resolves_prefix_static_and_exact_dynamic_imports() { let (base, requests) = spawn_import_map_server(); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/app/index.html", base)); rt.set_http_client(client); rt.add_import_map( r#"{ "imports": { "pkg/": "../vendor/pkg/", "dynamic-pkg": "../vendor/dynamic.js" } }"#, &format!("{}/config/import-map.json", base), ) .unwrap(); rt.load_inline_module( "import { value as prefix } from 'pkg/feature.js'; \ const dynamic = (await import('dynamic-pkg')).value; \ globalThis.__import_map_values = [prefix, dynamic];", &format!("{}/app/index.html", base), 1_000, ) .await .unwrap(); assert_eq!( rt.evaluate("globalThis.__import_map_values").unwrap(), serde_json::json!(["prefix-static", "exact-dynamic"]), ); let paths = (0..2) .map(|_| { requests .recv_timeout(std::time::Duration::from_secs(1)) .unwrap() }) .collect::>(); assert!( paths.contains(&"/vendor/pkg/feature.js".to_string()), "{paths:?}" ); assert!( paths.contains(&"/vendor/dynamic.js".to_string()), "{paths:?}" ); } #[tokio::test(flavor = "current_thread")] async fn import_map_does_not_remap_external_root_module_url() { let (base, requests) = spawn_root_module_import_map_server(); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/index.html", base)); rt.set_http_client(client); rt.add_import_map( &format!(r#"{{"imports":{{"{base}/entry.js":"{base}/remapped.js"}}}}"#), &format!("{}/index.html", base), ) .unwrap(); rt.load_module(&format!("{}/entry.js", base), 1_000) .await .unwrap(); assert_eq!( rt.evaluate("globalThis.__root_module_identity").unwrap(), serde_json::json!("entry"), ); assert_eq!( requests .recv_timeout(std::time::Duration::from_secs(1)) .unwrap(), "/entry.js", ); } #[tokio::test(flavor = "current_thread")] async fn inline_modules_expose_document_base_as_import_meta_url() { let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/page/index.html"); rt.load_inline_module( "globalThis.__first_inline_url = import.meta.url;", "https://example.com/base/", 1_000, ) .await .unwrap(); rt.load_inline_module( "globalThis.__second_inline_url = import.meta.url;", "https://example.com/base/", 1_000, ) .await .unwrap(); assert_eq!( rt.evaluate("[globalThis.__first_inline_url, globalThis.__second_inline_url]") .unwrap(), serde_json::json!(["https://example.com/base/", "https://example.com/base/"]) ); } #[tokio::test(flavor = "current_thread")] async fn classic_script_url_is_dynamic_import_referrer() { use std::io::{Read as _, Write as _}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let request_thread = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0u8; 2048]; let length = stream.read(&mut request).unwrap(); let path = String::from_utf8_lossy(&request[..length]) .lines() .next() .and_then(|line| line.split_ascii_whitespace().nth(1)) .unwrap_or("/") .to_string(); let body = "export const value = 'scoped-classic';"; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len(), ); stream.write_all(response.as_bytes()).unwrap(); path }); let base = format!("http://{address}"); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{base}/page/index.html")); rt.set_http_client(client); rt.set_dom(parse_html("")); rt.run_page_init(); rt.add_import_map( &format!(r#"{{"scopes":{{"{base}/classic/":{{"pkg":"{base}/scoped.js"}}}}}}"#), &format!("{base}/page/index.html"), ) .unwrap(); rt.execute_script( &format!("{base}/classic/entry.js"), "document.documentElement.setAttribute('data-classic-op', 'ran'); \ import('pkg').then(module => { globalThis.__classic_import = module.value; });", ) .unwrap(); rt.run_event_loop().await.unwrap(); assert_eq!( rt.evaluate("globalThis.__classic_import").unwrap(), serde_json::json!("scoped-classic") ); assert_eq!( rt.evaluate("document.documentElement.getAttribute('data-classic-op')") .unwrap(), serde_json::json!("ran") ); assert_eq!(request_thread.join().unwrap(), "/scoped.js"); } #[test] fn timed_out_classic_script_leaves_runtime_reusable() { let mut rt = ObscuraJsRuntime::new(); rt.execute_script_with_timeout( "https://example.test/hang.js", "while (true) {}", std::time::Duration::from_millis(20), ) .unwrap(); rt.execute_script( "https://example.test/after-timeout.js", "globalThis.__after_timeout = true;", ) .unwrap(); assert_eq!( rt.evaluate("globalThis.__after_timeout").unwrap(), serde_json::json!(true) ); } #[tokio::test(flavor = "current_thread")] async fn inline_module_graph_error_propagates() { let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/"); let error = rt .load_inline_module("import 'bare-specifier';", "https://example.com/", 1_000) .await .unwrap_err(); assert!( error.contains("Inline module load error"), "expected graph load error, got: {}", error ); } #[tokio::test(flavor = "current_thread")] async fn inline_module_evaluation_error_propagates() { let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/"); let error = rt .load_inline_module( "throw new Error('module-evaluation-boom');", "https://example.com/", 1_000, ) .await .unwrap_err(); assert!( error.contains("Inline module eval error") && error.contains("module-evaluation-boom"), "expected evaluation error, got: {}", error ); } #[tokio::test(flavor = "current_thread")] async fn inline_module_evaluation_timeout_propagates() { let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/"); let error = rt .load_inline_module( "await new Promise(resolve => setTimeout(resolve, 10000));", "https://example.com/", 20, ) .await .unwrap_err(); assert!( error.contains("Inline module evaluation timed out after"), "expected evaluation timeout, got: {}", error ); } #[tokio::test(flavor = "current_thread")] async fn successful_inline_module_does_not_wait_for_interval_idle() { let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/"); rt.load_inline_module( "globalThis.__module_loaded = true; setInterval(() => {}, 10000);", "https://example.com/", 500, ) .await .unwrap(); assert_eq!( rt.evaluate("globalThis.__module_loaded").unwrap(), serde_json::json!(true) ); } // 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]) ); } #[test] fn text_codec_streams_expose_browser_shape() { let mut rt = setup_runtime("
"); let result = rt .evaluate( r#" const encoder = new TextEncoderStream(); const decoder = new TextDecoderStream(); return { encoder: encoder.encoding, encoderReadable: typeof encoder.readable.getReader, encoderWritable: typeof encoder.writable.getWriter, decoder: decoder.encoding, decoderReadable: typeof decoder.readable.getReader, decoderWritable: typeof decoder.writable.getWriter, }; "#, ) .unwrap(); assert_eq!( result, serde_json::json!({ "encoder": "utf-8", "encoderReadable": "function", "encoderWritable": "function", "decoder": "utf-8", "decoderReadable": "function", "decoderWritable": "function", }) ); } #[tokio::test(flavor = "current_thread")] async fn text_encoder_stream_pipe_through_delivers_hydration_data() { let mut rt = setup_runtime("
"); let result = rt .evaluate_for_cdp( r#" (async () => { let sourceController; const source = new ReadableStream({ start(controller) { sourceController = controller; }, }); const encoded = source.pipeThrough(new TextEncoderStream()); sourceController.enqueue('["server",{"hydrated":true}]\n'); sourceController.close(); const decoder = new TextDecoder(); let tail = ""; const lines = encoded.pipeThrough(new TransformStream({ transform(chunk, controller) { const complete = (tail + decoder.decode(chunk, {stream: true})).split("\n"); tail = complete.pop() || ""; for (const line of complete) controller.enqueue(line); }, flush(controller) { if (tail) controller.enqueue(tail); }, })); const first = await lines.getReader().read(); return JSON.parse(first.value); })() "#, true, true, ) .await .unwrap(); assert_eq!( result.value.unwrap(), serde_json::json!(["server", {"hydrated": 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"])); } /// Framework schedulers commonly subclass EventTarget for their own /// lifecycle events. These targets have no backing DOM node, but must /// still deliver callbacks (including object, once, and signal listeners). #[test] fn standalone_event_target_delivers_framework_lifecycle_events() { let mut rt = setup_runtime("
"); let result = rt .evaluate( r#" const TypedEventTarget = class extends EventTarget {}; const target = new TypedEventTarget(); const calls = []; const removed = () => calls.push("removed"); const controller = new AbortController(); const node = { id: "canvas-ref" }; target.addEventListener("insert", (event) => calls.push(event.node.id)); target.addEventListener("insert", { handleEvent() { calls.push("object"); } }); target.addEventListener("insert", () => calls.push("once"), { once: true }); target.addEventListener("insert", removed); target.removeEventListener("insert", removed); target.addEventListener("insert", () => calls.push("aborted"), { signal: controller.signal, }); controller.abort(); const first = new Event("insert", { cancelable: true }); first.node = node; const firstResult = target.dispatchEvent(first); const second = new Event("insert"); second.node = node; const secondResult = target.dispatchEvent(second); return [ target instanceof EventTarget, calls, firstResult, secondResult, first.target === target, first.currentTarget === null, ]; "#, ) .unwrap(); assert_eq!( result, serde_json::json!([ true, ["canvas-ref", "object", "once", "canvas-ref", "object"], true, true, true, true ]) ); } #[test] fn media_text_tracks_expose_loaded_webvtt_cues() { let mut rt = setup_runtime( r#""#, ); let result = rt .evaluate( r#" const element = document.getElementById("captions"); const video = document.querySelector("video"); const cue = element.track.cues[0]; const added = video.addTextTrack("metadata", "Data", "en"); cue.line = -2; cue.size = 80; return [ element instanceof HTMLTrackElement, element.readyState === HTMLTrackElement.LOADED, element.track instanceof TextTrack, element.track.cues.length, cue.startTime, cue.endTime, cue.text, cue.line, cue.size, video.textTracks.length, video.textTracks.getTrackById("captions") === element.track, added.kind, ]; "#, ) .unwrap(); assert_eq!( result, serde_json::json!([true, true, true, 1, 1, 3, "Hello", -2, 80, 1, true, "metadata"]) ); } #[test] fn html_string_scripts_remain_inert_when_connected() { let mut rt = setup_runtime("
"); let result = rt .evaluate( r#" var scriptTestSetup = true; globalThis.__fragmentScriptRuns = 0; const direct = document.createElement("div"); direct.innerHTML = "", ); let result = rt .evaluate( r#" var scriptTestSetup = true; globalThis.__cloneScriptRuns = 0; const parser = document.getElementById("parser"); globalThis.__markParserScripts([parser._nid]); document.head.appendChild(parser); document.body.appendChild(parser.cloneNode(true)); const dynamic = document.createElement("script"); dynamic.textContent = "globalThis.__cloneScriptRuns++"; document.body.appendChild(dynamic); document.body.appendChild(dynamic.cloneNode(true)); const holder = document.createElement("div"); holder.innerHTML = "