fix(cdp): five Playwright drop-in compatibility patches
Brings Obscura's CDP server in line with what Playwright's `chromium.connectOverCDP()` expects. Each fix is small and surgical; together they make `connect → newContext → newPage → goto → page.title()` work end-to-end against `https://example.com`. 1. **server.rs — emit pending events before command response.** Chromium CDP semantics require side-effect events (e.g. `Target.targetCreated` + `Target.attachedToTarget` from `Target.createTarget`) to arrive *before* the originating command's response. Playwright's wire reader assumes this ordering — it resolves the response promise on the response and then synchronously looks up the freshly-created session. Reversing the order makes that lookup miss and the page hang at `newPage()`. 2. **domains/browser.rs — accept Browser.setWindowBounds as a no-op.** Playwright calls `Browser.setWindowBounds` during teardown. Returning "Unknown Browser method" causes Playwright to treat the target as broken and tear it down mid-test, surfacing as "Frame has been detached" on the next `page.title()`. 3. **obscura-browser/page.rs — frame_id == target_id for main frame.** Was `format!("{id}.1", id)`. Playwright's frame manager indexes frames by frame id and looks up the main frame by target id, so the `.1` suffix made every main-frame lookup miss. 4. **domains/page.rs + dispatch.rs — track isolated worlds and re-emit them post-navigation.** Playwright registers a utility world via `Page.createIsolatedWorld` and expects `Runtime.executionContextCreated` for each registered world after every navigation. Without re-emission, the utility-world handle goes stale post-`goto` and `page.title()` hangs waiting for the world's execution context. Falls back to the legacy `__puppeteer_utility_world__24.40.0` name when no worlds have been registered, preserving prior Puppeteer support. 5. **obscura-js/runtime.rs — strip trailing semicolons before wrapping.** Playwright's utility-script expression is an IIFE ending in `})();`. Both `wrap_expression` (CDP `Runtime.evaluate`) and `evaluate_for_cdp` (the `meta_code` IIFE) embed the user expression inside `({EXPR})` / `return ({EXPR});`. With the trailing `;` left in, this produces `({...;})` — a SyntaxError. Parse errors are not catchable, so the surrounding try/catch never fires and the call silently returns `undefined`, surfacing as `page.title() === ""`. Verified end-to-end against `https://example.com`: [probe] connect OK [probe] newContext OK [probe] newPage OK [probe] goto OK [probe] title = Example Domain
This commit is contained in:
1 parent
b00a1f20e7
commit
9e3e1e4ef7
6 files changed
+77
-10
No files matched your search
@@ -46,7 +46,12 @@ pub struct Page {
|
||||
impl Page {
|
||||
pub fn new(id: String, context: Arc<BrowserContext>) -> 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())))
|
||||
|
||||
@@ -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<String>,
|
||||
pub fetch_intercept: FetchInterceptState,
|
||||
pub intercept_tx: Option<tokio::sync::mpsc::UnboundedSender<InterceptedRequest>>,
|
||||
}
|
||||
@@ -38,6 +46,7 @@ impl CdpContext {
|
||||
preload_counter: 0,
|
||||
fetch_intercept: FetchInterceptState::new(),
|
||||
intercept_tx: None,
|
||||
isolated_worlds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ pub async fn handle(method: &str, _params: &Value) -> Result<Value, String> {
|
||||
"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)),
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user