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:
Mike Mooring committed 2026-04-24 23:27:18 -07:00
1 parent b00a1f20e7
commit 9e3e1e4ef7
6 files changed
+77 -10

No files matched your search

+30 -3
View File
@@ -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(),