Add Target.targetInfoChanged and DOMSnapshot.captureSnapshot for DOM agents (#282)

CDP DOM-agent frameworks (browser-use and similar) could connect but saw a
blank page: navigation never refreshed their cached target url/title, and their
DOM build aborted with "Unknown domain: DOMSnapshot".

- Emit Target.targetInfoChanged after navigation (both CDP Page.navigate and
  JS-initiated) carrying the post-nav url/title. Strict clients refresh a target
  only on this event; it also fixes Puppeteer/Playwright page.url() tracking.
- Implement DOMSnapshot.captureSnapshot, built from the DOM with synthetic
  geometry (no paint engine) and plausible computed styles, keyed by the same
  backendNodeId as DOM.getDocument. Enough for tag/ARIA/accessibility element
  detection; it runs only when a client requests it, so the fetch path is
  unaffected. Coordinate clicking still requires a real layout engine.
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-09 21:02:04 +00:00
1 parent 5225f6d985
commit f4cbb78f0b
4 files changed
+481

No files matched your search

+74
View File
@@ -130,6 +130,30 @@ pub fn emit_navigation_events(
}
phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es });
ctx.pending_events.extend(phase3);
// Target.targetInfoChanged: strict CDP clients (browser-use, and
// Puppeteer/Playwright `page.url()` tracking) cache the TargetInfo from
// attachedToTarget and only refresh it on this event. Without it they keep
// reporting the pre-navigation url/title (about:blank) and never see the
// loaded page. Emit it browser-level (no sessionId) with the new url/title.
let (tic_title, tic_ctx) = ctx
.get_page(page_id)
.map(|p| (p.title.clone(), p.context.id.clone()))
.unwrap_or_default();
ctx.pending_events.push(CdpEvent::new(
"Target.targetInfoChanged",
json!({
"targetInfo": {
"targetId": page_id,
"type": "page",
"title": tic_title,
"url": page_url,
"attached": true,
"canAccessOpener": false,
"browserContextId": tic_ctx,
}
}),
));
}
/// Parse the `waitUntil` argument that Puppeteer/Playwright pass on
@@ -584,4 +608,54 @@ mod tests {
"captureSnapshot must NOT fall through: {err2}"
);
}
#[tokio::test]
async fn navigation_emits_target_info_changed_with_url_and_title() {
// Strict CDP clients (browser-use, Puppeteer/Playwright `page.url()`)
// refresh a target's url/title only on Target.targetInfoChanged. A
// navigation must emit it with the post-nav url/title, otherwise those
// clients stay stuck on the pre-nav about:blank.
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = format!("{}-session", page_id);
ctx.sessions.insert(session_id.clone(), page_id.clone());
let params = json!({
"url": "data:text/html,<title>Hello</title><button>B</button>",
"waitUntil": "load",
});
handle("navigate", &params, &mut ctx, &Some(session_id.clone()))
.await
.expect("navigate should succeed");
let evt = ctx
.pending_events
.iter()
.find(|e| e.method == "Target.targetInfoChanged")
.expect("navigation must emit Target.targetInfoChanged");
// Browser-level event (no sessionId) so the root connection's
// targetInfoChanged handler receives it.
assert!(
evt.session_id.is_none(),
"targetInfoChanged must be browser-level (no sessionId)"
);
let info = evt.params["targetInfo"].clone();
// The payload must carry the live post-navigation url/title and the
// canAccessOpener field strict clients require on every TargetInfo.
let (exp_url, exp_title) = {
let page = ctx.get_page(&page_id).expect("page exists");
(page.url_string(), page.title.clone())
};
assert_eq!(info["targetId"], json!(page_id));
assert_eq!(info["type"], "page");
assert_eq!(info["url"], json!(exp_url));
assert_eq!(info["title"], json!(exp_title));
assert!(
info["url"].as_str().unwrap_or_default().starts_with("data:"),
"url should reflect the navigated page, got {}",
info["url"]
);
assert_eq!(info["canAccessOpener"], json!(false));
}
}