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:
Generated
+1
-1
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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(())
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user