diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 14557ce..94fb566 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -46,7 +46,12 @@ pub struct Page { impl Page { pub fn new(id: String, context: Arc) -> Self { let http_client = context.http_client.clone(); - let frame_id = format!("{}.1", id); + // Chromium convention: the main frame's frameId == the targetId. + // Playwright's frame manager looks up the main frame by targetId + // (via target._targetInfo.targetId), so any divergence here makes + // Page.getFrameTree return a frame the client cannot match, + // triggering a Target.closeTarget and "Frame has been detached". + let frame_id = id.clone(); #[cfg(feature = "stealth")] let stealth_client = if context.stealth { Some(Arc::new(StealthHttpClient::new(context.cookie_jar.clone()))) diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index c2cee0d..ee2a345 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -17,6 +17,14 @@ pub struct CdpContext { page_counter: u32, pub preload_scripts: Vec<(String, String)>, // (identifier, source) pub preload_counter: u32, + // World names registered via Page.createIsolatedWorld. After every + // navigation Obscura clears execution contexts (via + // Runtime.executionContextsCleared) and must re-emit a + // Runtime.executionContextCreated for each registered world, otherwise + // Playwright/Puppeteer hang waiting for their utility world to come + // back. Stored as plain Strings (not by-page) — for now we only model + // a single page in CdpContext anyway. + pub isolated_worlds: Vec, pub fetch_intercept: FetchInterceptState, pub intercept_tx: Option>, } @@ -38,6 +46,7 @@ impl CdpContext { preload_counter: 0, fetch_intercept: FetchInterceptState::new(), intercept_tx: None, + isolated_worlds: Vec::new(), } } diff --git a/crates/obscura-cdp/src/domains/browser.rs b/crates/obscura-cdp/src/domains/browser.rs index c96d5cf..bb4e68d 100644 --- a/crates/obscura-cdp/src/domains/browser.rs +++ b/crates/obscura-cdp/src/domains/browser.rs @@ -26,6 +26,11 @@ pub async fn handle(method: &str, _params: &Value) -> Result { "getWindowBounds" => Ok(json!({ "bounds": { "left": 0, "top": 0, "width": 1280, "height": 720, "windowState": "normal" } })), + // No-op acks for window-management methods Playwright sends during + // page setup. We don't model real OS windows, but answering with {} + // lets the client's setup sequence complete instead of tearing down + // the page on an unknown-method error. + "setWindowBounds" => Ok(json!({})), _ => Err(format!("Unknown Browser method: {}", method)), } } diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index e33bb05..0052af9 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -67,14 +67,33 @@ pub async fn handle( let es = session_id.clone(); let ts = timestamp(); - let phase1 = vec![ + let mut phase1 = vec![ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, - CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 100, "origin": page_url, "name": "__puppeteer_utility_world__24.40.0", "uniqueId": format!("ctx-isolated-nav-{}", page_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}), session_id: es.clone() }, - CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() }, ]; + // Re-emit each isolated world the client previously registered + // via Page.createIsolatedWorld. Without this, Playwright's + // utility-world handle becomes stale after navigation and + // every subsequent evaluate() (including page.title()) hangs. + // Fallback to the legacy hardcoded Puppeteer name so older + // Puppeteer clients that don't call createIsolatedWorld + // continue to work. + let world_names: Vec = if ctx.isolated_worlds.is_empty() { + vec!["__puppeteer_utility_world__24.40.0".to_string()] + } else { + ctx.isolated_worlds.clone() + }; + for (idx, world_name) in world_names.iter().enumerate() { + let world_ctx_id = 100 + idx as u32; + phase1.push(CdpEvent { + method: "Runtime.executionContextCreated".into(), + params: json!({"context": {"id": world_ctx_id, "origin": page_url, "name": world_name, "uniqueId": format!("ctx-isolated-nav-{}-{}", page_id, idx), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}), + session_id: es.clone(), + }); + } + phase1.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() }); ctx.pending_events.extend(phase1); if ctx.fetch_intercept.enabled { @@ -159,6 +178,14 @@ pub async fn handle( let page_url = page.url_string(); let page_id = page.id.clone(); let context_id = 100; + // Track this world so Page.navigate can re-emit a context for it + // post-navigation. Without this, Playwright (and Puppeteer) + // hang in any operation that uses the utility world — including + // page.title() — because their utility world is gone after + // Runtime.executionContextsCleared and never re-created. + if !world_name.is_empty() && !ctx.isolated_worlds.contains(&world_name) { + ctx.isolated_worlds.push(world_name.clone()); + } ctx.pending_events.push(CdpEvent { method: "Runtime.executionContextCreated".to_string(), diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index 31e0ec4..9b6806b 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -400,16 +400,22 @@ async fn process_cdp_message( let response = dispatch::dispatch(&req, ctx).await; - if let Ok(json) = serde_json::to_string(&response) { - let _ = reply_tx.send(json); - } - + // Chromium CDP semantics: events emitted as a side-effect of a command + // (e.g. Target.targetCreated + Target.attachedToTarget from + // Target.createTarget) MUST arrive BEFORE the command's response. + // Playwright awaits the response and immediately reads state wired up + // by those events; if the response lands first, accessing + // Target._page errors with "Cannot read properties of undefined". for event in ctx.pending_events.drain(..) { if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); } } + if let Ok(json) = serde_json::to_string(&response) { + let _ = reply_tx.send(json); + } + if let Some((nav_url, nav_method, nav_body)) = check_pending_navigation(ctx, &req.session_id) { tracing::info!("JS-triggered nav: {} {} (body: {} bytes)", nav_method, nav_url, nav_body.len()); let nav_req = CdpRequest { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 4ffb33d..e95d1d9 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -127,6 +127,13 @@ impl ObscuraJsRuntime { 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 = format!( "(function() {{\n\ var __result;\n\ @@ -134,7 +141,7 @@ impl ObscuraJsRuntime { globalThis.__obscura_objects['{oid}'] = __result;\n\ return {meta_fn};\n\ }})()", - expr = expression, + expr = cleaned_expr, oid = oid, meta_fn = Self::meta_extract_js("__result"), ); @@ -559,9 +566,17 @@ impl ObscuraJsRuntime { expression ) } else { + // Strip trailing semicolons + whitespace before wrapping in + // `return (...);`. Playwright's utility-script expression is + // an IIFE that ends with `})();` — leaving the `;` in place + // produces `return (...;);`, a SyntaxError. The script fails + // to parse, the catch never fires (parse errors are not + // catchable), and the function silently returns `undefined`. + // Stripping makes the wrapped expression syntactically valid. + let cleaned = trimmed.trim_end_matches(|c: char| c == ';' || c.is_whitespace()); format!( "(function() {{ try {{ return ({}); }} catch(e) {{ return null; }} }})()", - expression + cleaned ) } }