fix(relay): DOM-dispatch hover/dblclick/drag; deeper iframe snapshot; key-events typing (#37)

Follow-up to #36 — make the whole interaction surface reach cross-origin OOPIFs
and stop coordinate events drifting onto the user's foreground tab over the relay.

- hover/dblclick/drag now DOM-dispatch over the relay or into an iframe (like
  click already did): a coordinate Input event isn't confined to the target tab
  on a busy real Chrome and can't map an OOPIF element's box to a top-viewport
  point. drag does an HTML5 DnD in the element's frame; cross-frame drag errors
  loudly instead of drifting.
- snapshot recurses iframes to MAX_IFRAME_DEPTH (3) instead of one level, so refs
  inside nested payment/checkout widgets get a frame_id and resolve into the
  right frame.
- relay tab adoption merges several Target.getTargets snapshots — a single flaky
  relay snapshot was dropping live tabs (a driven tab vanished after restart).
- `type --key-events` (alias --keys) sends real per-character keyDown/keyUp
  instead of Input.insertText, so autocomplete/combobox widgets that ignore the
  insertText input event fire (Google address postal lookup; commits Angular
  reactive forms so Save enables).
- SKILL: hard "snapshot-first, never default to screenshot+coordinates" rule;
  snapshot -i pierces cross-origin iframes since v1.5.12; cross-origin iframe
  driving guidance (#37).
This commit is contained in:
leeguooooo
2026-06-17 00:58:12 +09:00
parent 70ab38d35f
commit 9f24e66033
10 changed files with 420 additions and 42 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrome-use" name = "chrome-use"
version = "1.5.11" version = "1.5.12"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chrome-use" name = "chrome-use"
version = "1.5.11" version = "1.5.12"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" license = "Apache-2.0"
+30 -3
View File
@@ -525,20 +525,29 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") })) Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
} }
"type" => { "type" => {
// `--key-events` (alias `--keys`): send real per-character keystrokes
// instead of Input.insertText, so autocomplete/combobox widgets that
// only react to key events fire (e.g. Google address postal lookup).
let key_events = rest.iter().any(|a| *a == "--key-events" || *a == "--keys");
let rest: Vec<&str> = rest
.iter()
.copied()
.filter(|a| *a != "--key-events" && *a != "--keys")
.collect();
// `type --focused <text>` types into whatever element currently has // `type --focused <text>` types into whatever element currently has
// focus (no selector) — for custom widgets that move focus to a hidden // focus (no selector) — for custom widgets that move focus to a hidden
// input after you open them. // input after you open them.
if rest.first() == Some(&"--focused") { if rest.first() == Some(&"--focused") {
return Ok(json!({ return Ok(json!({
"id": id, "action": "type", "focused": true, "id": id, "action": "type", "focused": true,
"text": rest[1..].join(" "), "text": rest[1..].join(" "), "keyEvents": key_events,
})); }));
} }
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments { let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "type".to_string(), context: "type".to_string(),
usage: "type <selector> <text> (or: type --focused <text>)", usage: "type <selector> <text> (or: type --focused <text>) [--key-events]",
})?; })?;
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") })) Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" "), "keyEvents": key_events }))
} }
"pick" => { "pick" => {
// pick <selector|@ref> --option "<text>" — atomic combobox select: // pick <selector|@ref> --option "<text>" — atomic combobox select:
@@ -4112,6 +4121,24 @@ mod tests {
assert_eq!(cmd["action"], "type"); assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#input"); assert_eq!(cmd["selector"], "#input");
assert_eq!(cmd["text"], "some text"); assert_eq!(cmd["text"], "some text");
assert_eq!(cmd["keyEvents"], false);
}
#[test]
fn test_type_key_events() {
// --key-events sends real keystrokes (for autocomplete/combobox) and must
// not be swallowed into the typed text.
let cmd = parse_command(&args("type #postal 201-0001 --key-events"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "type");
assert_eq!(cmd["selector"], "#postal");
assert_eq!(cmd["text"], "201-0001");
assert_eq!(cmd["keyEvents"], true);
let focused =
parse_command(&args("type --focused 201-0001 --keys"), &default_flags()).unwrap();
assert_eq!(focused["focused"], true);
assert_eq!(focused["text"], "201-0001");
assert_eq!(focused["keyEvents"], true);
} }
#[test] #[test]
+45 -3
View File
@@ -3225,6 +3225,14 @@ async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string(); let session_id = mgr.active_session_id()?.to_string();
// `--key-events`: dispatch real per-character keyDown/keyUp instead of
// Input.insertText, so autocomplete/combobox widgets that only react to key
// events fire (e.g. Google's address postal-code lookup) (issue #4/#36).
let key_events = cmd
.get("keyEvents")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// `type --focused <text>`: type into the currently-focused element without a // `type --focused <text>`: type into the currently-focused element without a
// selector (custom widgets that move focus to a hidden input on open). // selector (custom widgets that move focus to a hidden input on open).
if cmd if cmd
@@ -3236,7 +3244,8 @@ async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
.get("text") .get("text")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or("Missing 'text' parameter")?; .ok_or("Missing 'text' parameter")?;
interaction::type_text_into_active_context(&mgr.client, &session_id, text, None).await?; interaction::type_text_into_active_context(&mgr.client, &session_id, text, None, key_events)
.await?;
return Ok(json!({ "typed": text, "focused": true })); return Ok(json!({ "typed": text, "focused": true }));
} }
@@ -3260,6 +3269,7 @@ async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
clear, clear,
delay, delay,
&state.iframe_sessions, &state.iframe_sessions,
key_events,
) )
.await?; .await?;
Ok(json!({ "typed": text })) Ok(json!({ "typed": text }))
@@ -4722,8 +4732,18 @@ async fn handle_keyboard(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
.get("text") .get("text")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or("Missing 'text' parameter")?; .ok_or("Missing 'text' parameter")?;
interaction::type_text_into_active_context(&mgr.client, &session_id, text, None) let key_events = cmd
.await?; .get("keyEvents")
.and_then(|v| v.as_bool())
.unwrap_or(false);
interaction::type_text_into_active_context(
&mgr.client,
&session_id,
text,
None,
key_events,
)
.await?;
return Ok(json!({ "typed": text })); return Ok(json!({ "typed": text }));
} }
Some("insertText") => { Some("insertText") => {
@@ -7252,6 +7272,28 @@ async fn handle_drag(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or("Missing 'target' parameter")?; .ok_or("Missing 'target' parameter")?;
// Over the relay (or into an iframe) a coordinate drag drifts to the
// foreground tab and can't reach an OOPIF — DOM-dispatch an HTML5 drag in the
// element's own session instead (issues #31/#36). `coord` mode forces the
// coordinate path for pointer-driven drags (canvas/sliders) on a launched
// browser.
if std::env::var("AGENT_BROWSER_CLICK_MODE").as_deref() != Ok("coord")
&& (crate::connect::relay_url().is_some()
|| state.ref_map.ref_is_in_iframe(source)
|| state.ref_map.ref_is_in_iframe(target))
{
super::interaction::dom_drag(
&mgr.client,
&session_id,
&state.ref_map,
source,
target,
&state.iframe_sessions,
)
.await?;
return Ok(json!({ "dragged": { "source": source, "target": target }, "via": "dom" }));
}
let (sx, sy, _, _, source_session_id) = super::element::resolve_element_center( let (sx, sy, _, _, source_session_id) = super::element::resolve_element_center(
&mgr.client, &mgr.client,
&session_id, &session_id,
+55 -14
View File
@@ -725,6 +725,43 @@ impl BrowserManager {
Self::connect_cdp(&ws_url).await Self::connect_cdp(&ws_url).await
} }
/// Page targets to adopt, merging several `Target.getTargets` snapshots over
/// the extension relay. A single relay snapshot is flaky on a busy real Chrome
/// — it can omit live tabs (a different window's set, or a partial list; issue
/// #31) — so a tab the daemon should adopt would silently vanish (e.g. after a
/// daemon restart the page being driven disappeared from the tab list). Taking
/// the union of a few snapshots makes adoption resilient to a transient miss.
/// Off the relay (a browser we launched) one snapshot is authoritative.
async fn collect_page_targets(&self) -> Result<Vec<TargetInfo>, String> {
let rounds = if crate::connect::relay_url().is_some() {
3
} else {
1
};
let mut by_id: HashMap<String, TargetInfo> = HashMap::new();
let mut any_ok = false;
for i in 0..rounds {
if i > 0 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
match self
.client
.send_command_typed::<_, GetTargetsResult>("Target.getTargets", &json!({}), None)
.await
{
Ok(result) => {
any_ok = true;
for t in result.target_infos.into_iter().filter(should_track_target) {
by_id.entry(t.target_id.clone()).or_insert(t);
}
}
Err(e) if i == rounds - 1 && !any_ok => return Err(e),
Err(_) => {}
}
}
Ok(by_id.into_values().collect())
}
async fn discover_and_attach_targets(&mut self) -> Result<(), String> { async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
self.client self.client
.send_command_typed::<_, Value>( .send_command_typed::<_, Value>(
@@ -734,16 +771,7 @@ impl BrowserManager {
) )
.await?; .await?;
let result: GetTargetsResult = self let page_targets: Vec<TargetInfo> = self.collect_page_targets().await?;
.client
.send_command_typed("Target.getTargets", &json!({}), None)
.await?;
let page_targets: Vec<TargetInfo> = result
.target_infos
.into_iter()
.filter(should_track_target)
.collect();
if page_targets.is_empty() { if page_targets.is_empty() {
// Create a new tab // Create a new tab
@@ -816,10 +844,23 @@ impl BrowserManager {
}); });
} }
self.active_page_index = 0; if self.agent_group().is_some() {
self.pin_active_target(); // Relay: the adopted tabs above are the USER's, in their real
let session_id = self.pages[0].session_id.clone(); // Chrome. NEVER make one of them the agent's working tab — that is
self.enable_domains(&session_id).await?; // how commands drifted onto whatever page the user was viewing
// between steps (eval/click/get landed on the user's foreground
// tab; #35). Open our own dedicated background tab in the session's
// group and pin THAT as active. The user's tabs stay adopted (so
// `tab list` / explicit `tab switch` can reach them) but are never
// auto-selected — the agent only ever drives a tab it owns.
self.tab_new(None, None).await?;
} else {
// A browser we launched: every tab is ours, so the first is fine.
self.active_page_index = 0;
self.pin_active_target();
let session_id = self.pages[0].session_id.clone();
self.enable_domains(&session_id).await?;
}
} }
Ok(()) Ok(())
+9
View File
@@ -100,6 +100,15 @@ impl RefMap {
self.map.get(ref_id) self.map.get(ref_id)
} }
/// Whether `selector_or_ref` is a `@ref` whose snapshot entry lives inside an
/// iframe (has a `frame_id`). Pointer interactions use this to choose
/// DOM-dispatch over coordinates for OOPIF elements (issue #36).
pub fn ref_is_in_iframe(&self, selector_or_ref: &str) -> bool {
parse_ref(selector_or_ref)
.and_then(|r| self.map.get(&r).map(|e| e.frame_id.is_some()))
.unwrap_or(false)
}
pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> { pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> {
let mut entries = self let mut entries = self
.map .map
+217 -15
View File
@@ -7,6 +7,17 @@ use super::cdp::types::*;
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap}; use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
use super::humanize; use super::humanize;
/// Whether a pointer interaction should be DOM-dispatched (invoke the event on
/// the element in its own session) rather than dispatched at a viewport
/// coordinate via `Input.dispatchMouseEvent`. True when the target is inside an
/// iframe (an OOPIF element's box can't be mapped to a top-viewport point) or we
/// drive over the extension relay (a coordinate Input event isn't confined to the
/// target tab on a busy real Chrome — it drifts onto the foreground tab; issues
/// #31/#36). DOM-dispatch always hits the right element in the right tab.
fn prefer_dom_dispatch(ref_map: &RefMap, selector_or_ref: &str) -> bool {
ref_map.ref_is_in_iframe(selector_or_ref) || crate::connect::relay_url().is_some()
}
pub async fn click( pub async fn click(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
@@ -54,20 +65,15 @@ pub async fn click(
// the element's click in its own (frame) session, always hitting the right // the element's click in its own (frame) session, always hitting the right
// element in the right tab. Double/right clicks still need true pointer // element in the right tab. Double/right clicks still need true pointer
// semantics, and `coord` mode is an explicit opt-out. // semantics, and `coord` mode is an explicit opt-out.
if mode != "coord" && button == "left" && click_count == 1 { if mode != "coord" && button == "left" && click_count == 1 && prefer_dom_dispatch(ref_map, selector_or_ref) {
let in_iframe = parse_ref(selector_or_ref) return dom_click(
.and_then(|r| ref_map.get(&r).map(|e| e.frame_id.is_some())) client,
.unwrap_or(false); session_id,
if in_iframe || crate::connect::relay_url().is_some() { ref_map,
return dom_click( selector_or_ref,
client, iframe_sessions,
session_id, )
ref_map, .await;
selector_or_ref,
iframe_sessions,
)
.await;
}
} }
let resolved = resolve_element_center( let resolved = resolve_element_center(
@@ -260,6 +266,47 @@ async fn dom_click(
Ok(()) Ok(())
} }
/// DOM-dispatch a double-click on the element in its own session (no coordinates)
/// — the relay/iframe-safe counterpart to a coordinate dblclick. Fires the full
/// click,click,dblclick sequence so handlers bound to any of them respond.
async fn dom_dblclick(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const opts = { bubbles: true, cancelable: true, view: window };
this.dispatchEvent(new MouseEvent('click', opts));
this.dispatchEvent(new MouseEvent('click', { ...opts, detail: 2 }));
this.dispatchEvent(new MouseEvent('dblclick', opts));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
wait_for_paint_settled(client, &effective_session_id).await;
Ok(())
}
pub async fn dblclick( pub async fn dblclick(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
@@ -267,6 +314,13 @@ pub async fn dblclick(
selector_or_ref: &str, selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>, iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> { ) -> Result<(), String> {
// Same relay/iframe drift hazard as a single click — DOM-dispatch the
// double-click there instead of a coordinate one (issues #31/#36).
if std::env::var("AGENT_BROWSER_CLICK_MODE").as_deref() != Ok("coord")
&& prefer_dom_dispatch(ref_map, selector_or_ref)
{
return dom_dblclick(client, session_id, ref_map, selector_or_ref, iframe_sessions).await;
}
click( click(
client, client,
session_id, session_id,
@@ -279,6 +333,50 @@ pub async fn dblclick(
.await .await
} }
/// DOM-dispatch a hover (pointer/mouse enter+move) on the element in its own
/// session — reaches OOPIF elements and never drifts to the foreground tab over
/// the relay, unlike a coordinate `mouseMoved` (issues #31/#36).
async fn dom_hover(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const r = this.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const base = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy };
this.dispatchEvent(new PointerEvent('pointerover', base));
this.dispatchEvent(new PointerEvent('pointerenter', { ...base, bubbles: false }));
this.dispatchEvent(new MouseEvent('mouseover', base));
this.dispatchEvent(new MouseEvent('mouseenter', { ...base, bubbles: false }));
this.dispatchEvent(new MouseEvent('mousemove', base));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(())
}
pub async fn hover( pub async fn hover(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
@@ -286,6 +384,11 @@ pub async fn hover(
selector_or_ref: &str, selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>, iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> { ) -> Result<(), String> {
// Coordinate `mouseMoved` drifts to the foreground tab over the relay and
// can't reach an OOPIF — DOM-dispatch the hover there (issues #31/#36).
if prefer_dom_dispatch(ref_map, selector_or_ref) {
return dom_hover(client, session_id, ref_map, selector_or_ref, iframe_sessions).await;
}
let (x, y, _w, _h, effective_session_id) = resolve_element_center( let (x, y, _w, _h, effective_session_id) = resolve_element_center(
client, client,
session_id, session_id,
@@ -314,6 +417,63 @@ pub async fn hover(
Ok(()) Ok(())
} }
/// DOM-dispatch an HTML5 drag-and-drop from `source` to `target` in their shared
/// session — the relay/iframe-safe counterpart to the coordinate drag, which
/// drifts to the foreground tab over the relay and can't reach an OOPIF (issues
/// #31/#36). Covers HTML5 DnD (sortable lists, file/card boards); pointer-driven
/// drag (canvas, sliders) still needs the coordinate path. Errors if source and
/// target live in different frames — a synthetic cross-frame DnD isn't reliable.
pub async fn dom_drag(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
source: &str,
target: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (src_obj, src_session) =
resolve_element_object_id(client, session_id, ref_map, source, iframe_sessions).await?;
let (tgt_obj, tgt_session) =
resolve_element_object_id(client, session_id, ref_map, target, iframe_sessions).await?;
if src_session != tgt_session {
return Err(
"drag source and target are in different frames; cross-frame drag-and-drop over the \
relay isn't supported drag within a single frame, or use a launched browser with \
AGENT_BROWSER_CLICK_MODE=coord"
.to_string(),
);
}
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function(target) {
const dt = new DataTransfer();
const ev = (type, el) => el.dispatchEvent(
new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
ev('dragstart', this);
ev('drag', this);
ev('dragenter', target);
ev('dragover', target);
ev('drop', target);
ev('dragend', this);
}"#
.to_string(),
object_id: Some(src_obj),
arguments: Some(vec![CallArgument {
value: None,
object_id: Some(tgt_obj),
}]),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&src_session),
)
.await?;
wait_for_paint_settled(client, &src_session).await;
Ok(())
}
pub async fn fill( pub async fn fill(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
@@ -397,6 +557,7 @@ pub async fn type_text(
clear: bool, clear: bool,
delay_ms: Option<u64>, delay_ms: Option<u64>,
iframe_sessions: &HashMap<String, String>, iframe_sessions: &HashMap<String, String>,
key_events: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let (object_id, effective_session_id) = resolve_element_object_id( let (object_id, effective_session_id) = resolve_element_object_id(
client, client,
@@ -443,7 +604,7 @@ pub async fn type_text(
.await?; .await?;
} }
type_text_into_active_context(client, session_id, text, delay_ms).await type_text_into_active_context(client, session_id, text, delay_ms, key_events).await
} }
pub async fn type_text_into_active_context( pub async fn type_text_into_active_context(
@@ -451,6 +612,7 @@ pub async fn type_text_into_active_context(
session_id: &str, session_id: &str,
text: &str, text: &str,
delay_ms: Option<u64>, delay_ms: Option<u64>,
key_events: bool,
) -> Result<(), String> { ) -> Result<(), String> {
// Per-character timing: an explicit `delay_ms` wins (caller asked for a // Per-character timing: an explicit `delay_ms` wins (caller asked for a
// fixed cadence); otherwise fall back to humanize — variable, human-like // fixed cadence); otherwise fall back to humanize — variable, human-like
@@ -500,6 +662,46 @@ pub async fn type_text_into_active_context(
Some(session_id), Some(session_id),
) )
.await?; .await?;
} else if key_events {
// Real keystrokes (keyDown+keyUp carrying `text`) for autocomplete /
// combobox widgets that only react to key events and ignore the
// `input` that `Input.insertText` fires — e.g. Google's address
// postal-code → city/prefecture lookup (issue #36 / #4). The keyDown's
// `text` still inserts the character, so the field also fills.
let (key, code, key_code) = char_to_key_info(ch);
let s = ch.to_string();
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key.clone()),
code: Some(code.clone()),
text: Some(s.clone()),
unmodified_text: Some(s),
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyUp".to_string(),
key: Some(key),
code: Some(code),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
},
Some(session_id),
)
.await?;
} else { } else {
// VS Code/Electron webviews reject repeated dispatchKeyEvent calls // VS Code/Electron webviews reject repeated dispatchKeyEvent calls
// carrying printable `text`. Insert printable characters directly // carrying printable `text`. Insert printable characters directly
+27 -5
View File
@@ -330,6 +330,13 @@ impl RoleNameTracker {
} }
} }
/// Max iframe nesting depth `take_snapshot` expands. Embedded payment/checkout
/// widgets nest a few frames deep (e.g. AdSense → payments.google.com → an inner
/// form frame); expanding past the first level is what gives those inner refs a
/// `frame_id` so clicks resolve into the right frame (issue #36). Capped to keep
/// a pathological frame tree from blowing up the snapshot.
const MAX_IFRAME_DEPTH: usize = 3;
pub async fn take_snapshot( pub async fn take_snapshot(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
@@ -337,6 +344,19 @@ pub async fn take_snapshot(
ref_map: &mut RefMap, ref_map: &mut RefMap,
frame_id: Option<&str>, frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>, iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
take_snapshot_at_depth(client, session_id, options, ref_map, frame_id, iframe_sessions, 0).await
}
#[allow(clippy::too_many_arguments)]
async fn take_snapshot_at_depth(
client: &CdpClient,
session_id: &str,
options: &SnapshotOptions,
ref_map: &mut RefMap,
frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>,
depth: usize,
) -> Result<String, String> { ) -> Result<String, String> {
client client
.send_command_no_params("DOM.enable", Some(session_id)) .send_command_no_params("DOM.enable", Some(session_id))
@@ -606,10 +626,11 @@ pub async fn take_snapshot(
} }
// Recurse into child iframes: for each Iframe node with a backend_node_id, // Recurse into child iframes: for each Iframe node with a backend_node_id,
// resolve the child frame ID and take a snapshot of its content. // resolve the child frame ID and snapshot its content. Recurse to
// We only recurse from the main frame (frame_id == None) to avoid // MAX_IFRAME_DEPTH (not just the main frame) so refs inside nested
// unbounded depth; nested iframes within iframes are not expanded. // payment/checkout widgets get a `frame_id` and clicks resolve into the right
if frame_id.is_none() { // frame (issue #36); the cap bounds a pathological frame tree.
if depth < MAX_IFRAME_DEPTH {
let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); // (ref_id, child_snapshot) let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); // (ref_id, child_snapshot)
for node in tree_nodes.iter() { for node in tree_nodes.iter() {
if node.role != "Iframe" || !node.has_ref { if node.role != "Iframe" || !node.has_ref {
@@ -622,13 +643,14 @@ pub async fn take_snapshot(
if let Ok(child_fid) = resolve_iframe_frame_id(client, session_id, bid).await { if let Ok(child_fid) = resolve_iframe_frame_id(client, session_id, bid).await {
// Snapshot the child frame; errors are silently ignored // Snapshot the child frame; errors are silently ignored
// (e.g. cross-origin iframes) // (e.g. cross-origin iframes)
if let Ok(child_text) = Box::pin(take_snapshot( if let Ok(child_text) = Box::pin(take_snapshot_at_depth(
client, client,
session_id, session_id,
options, options,
ref_map, ref_map,
Some(&child_fid), Some(&child_fid),
iframe_sessions, iframe_sessions,
depth + 1,
)) ))
.await .await
{ {
+7
View File
@@ -1464,6 +1464,12 @@ Usage: chrome-use type <selector> <text>
Types text into the specified element character by character. Types text into the specified element character by character.
Unlike fill, this does not clear existing content first. Unlike fill, this does not clear existing content first.
Options:
--key-events Send real per-character keyDown/keyUp instead of
(alias --keys) Input.insertText. Use for autocomplete / combobox fields
that only react to key events e.g. a postal-code box
that auto-fills city/prefecture, or Google Places.
Global Options: Global Options:
--json Output as JSON --json Output as JSON
--session <name> Use specific session --session <name> Use specific session
@@ -1471,6 +1477,7 @@ Global Options:
Examples: Examples:
chrome-use type "#search" "hello" chrome-use type "#search" "hello"
chrome-use type @e2 "additional text" chrome-use type @e2 "additional text"
chrome-use type @e5 "201-0001" --key-events # trigger the address autocomplete
See Also: See Also:
For typing into contenteditable editors (Lexical, ProseMirror, etc.) For typing into contenteditable editors (Lexical, ProseMirror, etc.)
+28
View File
@@ -36,6 +36,18 @@ Refs (`@e1`, `@e2`, ...) are assigned fresh on every snapshot. They become
submits, dynamic re-renders, dialog opens. Always re-snapshot before your submits, dynamic re-renders, dialog opens. Always re-snapshot before your
next ref interaction. next ref interaction.
> **Snapshot-first, always. Never default to `screenshot` + coordinate clicking
> for form fields or buttons.** Run `snapshot -i` and act on `@refs`. Use
> coordinates only for canvas/WebGL, or when `snapshot` genuinely returns nothing
> for your target. This holds **even inside cross-origin embedded iframes**
> since v1.5.12 `snapshot -i` pierces out-of-process iframes (Google Payments,
> Stripe, embedded checkout/KYC) and lists their elements with refs, so
> `click @e` / `type @e` / `fill @e` work directly. A screenshot is for a genuine
> *visual* check you report to the user — not your own input. (Full-page
> screenshots of a real retina Chrome are often too large for the image reader
> anyway.) Driving off pixels on the relay also risks a coordinate event drifting
> onto the user's foreground tab — refs never do. See issue #37.
## Before you automate: pick the cheapest tool ## Before you automate: pick the cheapest tool
Driving a browser is the heavy option. chrome-use earns its keep when you Driving a browser is the heavy option. chrome-use earns its keep when you
@@ -264,6 +276,10 @@ chrome-use hover @e1 # hover
chrome-use focus @e1 # focus (useful before keyboard input) chrome-use focus @e1 # focus (useful before keyboard input)
chrome-use fill @e2 "hello" # clear then type chrome-use fill @e2 "hello" # clear then type
chrome-use type @e2 " world" # type without clearing chrome-use type @e2 " world" # type without clearing
chrome-use type @e5 "201-0001" --key-events # real keystrokes (not insertText) —
# use for autocomplete/combobox fields that
# only react to key events (e.g. a postal box
# that auto-fills city/prefecture, Google Places)
chrome-use press Enter # press a key at current focus (down+up) chrome-use press Enter # press a key at current focus (down+up)
chrome-use press Control+a # key combination chrome-use press Control+a # key combination
chrome-use keydown d # HOLD a key down (no auto-release) chrome-use keydown d # HOLD a key down (no auto-release)
@@ -295,6 +311,18 @@ chrome-use scrollintoview @e1 # scroll element into view
chrome-use drag @e1 @e2 # drag and drop chrome-use drag @e1 @e2 # drag and drop
``` ```
**Cross-origin iframes (embedded payment / checkout / KYC widgets — Google
Payments, Stripe, etc.) — drive them by ref, never by screenshot.** `snapshot -i`
pierces these out-of-process iframes and lists their elements by `@ref`
(including input values); `get text --all-frames` reads their text. Then just act
on the refs: `click @e`, `type @e`, `hover @e`, `dblclick @e`, `drag @a @b` all
work into the iframe. Over the extension relay these are dispatched through the
DOM (in the element's own frame), so they hit the right element in the right tab
— a coordinate click/scroll there can drift onto whatever tab is in the
foreground, so prefer refs. For below-the-fold content in such a frame, scroll it
with `scroll down N --at x,y` (a pixel over the frame) or `--frame n`. For a
postal/autocomplete box inside the frame, `type @e "…" --key-events`.
### When refs don't work or you don't want to snapshot ### When refs don't work or you don't want to snapshot
Use semantic locators: Use semantic locators: