use std::collections::HashMap;
use std::time::Duration;
use serde_json::json;
use tokio::sync::broadcast;
use crate::cdp::client::CdpClient;
use crate::cdp::types::{
CdpEvent, DispatchMouseEventParams, GetBoxModelResult, MouseButton, MouseEventType, ResolveNodeParams,
ResolveNodeResult,
};
use crate::element_ref::ElementRef;
pub async fn resolve_uid(
client: &CdpClient,
uid_map: &HashMap<String, ElementRef>,
uid: &str,
) -> Result<ResolvedElement, ElementError> {
let element_ref = uid_map.get(uid).ok_or_else(|| {
ElementError::NotFound(format!(
"Element uid={uid} not found. Run 'chrome-agent inspect' to get fresh uids."
))
})?;
let backend_node_id = element_ref.backend_node_id().ok_or_else(|| {
ElementError::NotFound(format!("Element uid={uid} has no resolvable backend node."))
})?;
let result: ResolveNodeResult = client
.call("DOM.resolveNode", ResolveNodeParams {
node_id: None,
backend_node_id: Some(backend_node_id),
object_group: Some("dev-browser".into()),
execution_context_id: None,
})
.await
.map_err(|e| {
ElementError::Detached(format!(
"Element uid={uid} no longer exists. The page may have changed. \
Run 'chrome-agent inspect' to get fresh uids. ({e})"
))
})?;
let object_id = result.object.object_id.ok_or_else(|| {
ElementError::Detached(format!(
"Element uid={uid} could not be resolved to a JS object."
))
})?;
let box_result: Result<GetBoxModelResult, _> = client
.call(
"DOM.getBoxModel",
json!({ "backendNodeId": backend_node_id }),
)
.await;
let center = box_result.ok().map(|r| r.model.content_center());
Ok(ResolvedElement {
object_id,
center,
backend_node_id,
})
}
pub struct ResolvedElement {
pub object_id: String,
pub center: Option<(f64, f64)>,
pub backend_node_id: i64,
}
pub async fn click(
client: &CdpClient,
uid_map: &HashMap<String, ElementRef>,
uid: &str,
) -> Result<(), ElementError> {
let resolved = resolve_uid(client, uid_map, uid).await?;
if resolved.center.is_none() {
return js_click(client, &resolved.object_id).await;
}
let _ = client
.call::<_, serde_json::Value>(
"Runtime.callFunctionOn",
json!({
"objectId": resolved.object_id,
"functionDeclaration": "function() { this.scrollIntoViewIfNeeded(); }",
"returnByValue": true,
}),
)
.await;
let box_result: Result<GetBoxModelResult, _> = client
.call(
"DOM.getBoxModel",
json!({ "backendNodeId": resolved.backend_node_id }),
)
.await;
let Some((cx, cy)) = box_result.ok().map(|r| r.model.content_center()) else {
return js_click(client, &resolved.object_id).await;
};
let nav_events = client.events();
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MousePressed,
x: cx, y: cy,
button: Some(MouseButton::Left), buttons: Some(1), click_count: Some(1),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mousePressed failed: {e}")))?;
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MouseReleased,
x: cx, y: cy,
button: Some(MouseButton::Left), buttons: Some(0), click_count: Some(1),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mouseReleased failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
async fn js_click(client: &CdpClient, object_id: &str) -> Result<(), ElementError> {
let nav_events = client.events();
let result: serde_json::Value = client
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": "function() { this.click(); }",
"returnByValue": true,
}),
)
.await
.map_err(|e| ElementError::Action(format!("JS click fallback failed: {e}")))?;
if let Some(exception) = result.get("exceptionDetails") {
return Err(ElementError::Action(format!(
"JS click threw: {}",
exception.get("text").and_then(|t| t.as_str()).unwrap_or("unknown")
)));
}
wait_for_stabilization(nav_events).await;
Ok(())
}
pub struct FillOutcome {
pub requested: String,
pub actual: Option<String>,
pub sensitive: bool,
pub caveat: Option<String>,
}
impl FillOutcome {
pub fn new(requested: &str, actual: Option<String>) -> Self {
Self { requested: requested.to_string(), actual, caveat: None, sensitive: false }
}
pub const fn secret(mut self, sensitive: bool) -> Self {
self.sensitive = sensitive;
self
}
pub fn with_max_length(mut self, max_length: Option<i64>) -> Self {
if let (Some(max), Some(actual)) = (max_length, self.actual.as_deref())
&& let Ok(cap) = usize::try_from(max)
&& actual.chars().count() > cap
{
{
self.caveat = Some(format!(
"exceeds maxlength={max}; a person typing could not have produced this, \
and the form is likely to reject it"
));
}
}
self
}
pub fn verbatim(&self) -> bool {
self.actual.as_deref() == Some(self.requested.as_str())
}
}
pub async fn fill(
client: &CdpClient,
uid_map: &HashMap<String, ElementRef>,
uid: &str,
value: &str,
) -> Result<FillOutcome, ElementError> {
let resolved = resolve_uid(client, uid_map, uid).await?;
let js = r"function(v) {
if (this.matches(':disabled')) throw new Error('Element is disabled and cannot be filled');
if (this.readOnly) throw new Error('Element is readonly and cannot be filled');
this.focus();
var proto = this instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) {
setter.set.call(this, v);
} else {
this.value = v;
}
this.dispatchEvent(new Event('input', {bubbles: true}));
this.dispatchEvent(new Event('change', {bubbles: true}));
return {
value: this.value === undefined ? null : String(this.value),
maxLength: typeof this.maxLength === 'number' ? this.maxLength : null,
sensitive: this.type === 'password' ||
/password|cc-number|cc-csc|one-time-code/i.test(this.autocomplete || '')
};
}".to_string();
let nav_events = client.events();
let result: serde_json::Value = client
.call(
"Runtime.callFunctionOn",
json!({
"objectId": resolved.object_id,
"functionDeclaration": js,
"arguments": [{"value": value}],
"returnByValue": true,
}),
)
.await
.map_err(|e| ElementError::Action(format!("fill failed: {e}")))?;
if let Some(exception) = result.get("exceptionDetails") {
let text = exception
.get("exception")
.and_then(|ex| ex.get("description"))
.and_then(|d| d.as_str())
.or_else(|| exception.get("text").and_then(|t| t.as_str()))
.unwrap_or("unknown error");
return Err(ElementError::Action(
text.lines().next().unwrap_or(text).trim_start_matches("Error: ").to_string(),
));
}
let payload = result.get("result").and_then(|r| r.get("value")).cloned().unwrap_or_default();
let actual = payload.get("value").and_then(serde_json::Value::as_str).map(str::to_string);
let max_length = payload.get("maxLength").and_then(serde_json::Value::as_i64);
let sensitive = payload.get("sensitive").and_then(serde_json::Value::as_bool).unwrap_or(false);
wait_for_stabilization(nav_events).await;
Ok(FillOutcome::new(value, actual).with_max_length(max_length).secret(sensitive))
}
pub async fn require_editable_focus(client: &CdpClient) -> Result<(), ElementError> {
let probe = r"(() => {
const a = document.activeElement;
if (!a || a === document.body || a === document.documentElement) return 'none';
const tag = a.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || a.isContentEditable) return 'ok';
return tag.toLowerCase();
})()";
let result: serde_json::Value = client
.call("Runtime.evaluate", json!({"expression": probe, "returnByValue": true}))
.await
.map_err(|e| ElementError::Action(format!("focus check failed: {e}")))?;
let state = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(serde_json::Value::as_str)
.unwrap_or("none");
match state {
"ok" => Ok(()),
"none" => Err(ElementError::Action(
"Nothing editable has focus, so there is nowhere to type. Focus a field first: \
click its uid, or use `fill --selector` to set a value directly."
.into(),
)),
other => Err(ElementError::Action(format!(
"Focus is on a <{other}>, which does not accept typing. Focus an input, a \
textarea or a contenteditable element first."
))),
}
}
pub async fn type_text(
client: &CdpClient,
text: &str,
) -> Result<(), ElementError> {
let nav_events = client.events();
client
.send("Input.insertText", json!({ "text": text }))
.await
.map_err(|e| ElementError::Action(format!("insertText failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
pub async fn press_key(
client: &CdpClient,
key: &str,
) -> Result<(), ElementError> {
let (vk_code, text) = match key {
"Enter" | "Return" => (13, Some("\r")),
"Tab" => (9, None),
"Escape" => (27, None),
"Backspace" => (8, None),
"Delete" => (46, None),
"ArrowUp" => (38, None),
"ArrowDown" => (40, None),
"ArrowLeft" => (37, None),
"ArrowRight" => (39, None),
"Space" | " " => (32, Some(" ")),
"Home" => (36, None),
"End" => (35, None),
"PageUp" => (33, None),
"PageDown" => (34, None),
"Insert" => (45, None),
"F1" => (112, None),
"F2" => (113, None),
"F3" => (114, None),
"F4" => (115, None),
"F5" => (116, None),
"F6" => (117, None),
"F7" => (118, None),
"F8" => (119, None),
"F9" => (120, None),
"F10" => (121, None),
"F11" => (122, None),
"F12" => (123, None),
_ if key.chars().count() == 1 => {
let ch = key.chars().next().unwrap_or(' ');
let vk = if ch.is_ascii_alphanumeric() {
u32::from(ch.to_ascii_uppercase() as u8)
} else {
0
};
(vk, Some(key))
}
other => {
return Err(ElementError::Action(format!(
"Unknown key '{other}'. Use a single character, or one of: Enter, Tab, Escape, \
Backspace, Delete, Space, Home, End, PageUp, PageDown, Insert, \
ArrowUp/Down/Left/Right, F1-F12."
)));
}
};
let mut key_down = json!({
"type": "keyDown",
"key": key,
});
if vk_code > 0 {
key_down["windowsVirtualKeyCode"] = json!(vk_code);
key_down["nativeVirtualKeyCode"] = json!(vk_code);
}
if let Some(t) = text {
key_down["text"] = json!(t);
}
let nav_events = client.events();
client
.send("Input.dispatchKeyEvent", key_down)
.await
.map_err(|e| ElementError::Action(format!("keyDown failed: {e}")))?;
client
.send(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"key": key,
}),
)
.await
.map_err(|e| ElementError::Action(format!("keyUp failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
pub async fn hover(
client: &CdpClient,
uid_map: &HashMap<String, ElementRef>,
uid: &str,
) -> Result<(), ElementError> {
let resolved = resolve_uid(client, uid_map, uid).await?;
let (x, y) = resolved.center.ok_or_else(|| {
ElementError::NotInteractable(format!(
"Element uid={uid} has no visible box model."
))
})?;
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MouseMoved,
x, y,
button: None, buttons: None, click_count: None,
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("hover failed: {e}")))?;
Ok(())
}
async fn recv_event(rx: &mut broadcast::Receiver<CdpEvent>, method: &str, timeout: Duration) -> bool {
tokio::time::timeout(timeout, async {
loop {
match rx.recv().await {
Ok(event) if event.method == method => return true,
Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return false,
}
}
})
.await
.unwrap_or(false)
}
pub async fn wait_for_stabilization(mut nav_events: broadcast::Receiver<CdpEvent>) {
if recv_event(&mut nav_events, "Page.frameNavigated", Duration::from_millis(50)).await {
let _ = recv_event(&mut nav_events, "Page.loadEventFired", Duration::from_secs(10)).await;
}
}
#[derive(Debug, thiserror::Error)]
pub enum ElementError {
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Detached(String),
#[error("{0}")]
NotInteractable(String),
#[error("{0}")]
Action(String),
}
pub async fn click_at_coords(
client: &CdpClient,
x: f64,
y: f64,
) -> Result<(), ElementError> {
let nav_events = client.events();
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MousePressed,
x, y,
button: Some(MouseButton::Left), buttons: Some(1), click_count: Some(1),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mousePressed failed: {e}")))?;
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MouseReleased,
x, y,
button: Some(MouseButton::Left), buttons: Some(0), click_count: Some(1),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mouseReleased failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
pub use crate::element_selector::{click_selector, dblclick_selector, fill_selector, focus_selector};
pub use crate::element_controls::{
drag, select_option, select_option_selector, set_checked, set_checked_selector,
set_file_input, set_file_input_selector,
};
pub async fn dblclick(
client: &CdpClient,
uid_map: &HashMap<String, ElementRef>,
uid: &str,
) -> Result<(), ElementError> {
let resolved = resolve_uid(client, uid_map, uid).await?;
if resolved.center.is_none() {
return js_dblclick(client, &resolved.object_id).await;
}
let _ = client
.call::<_, serde_json::Value>(
"Runtime.callFunctionOn",
json!({
"objectId": resolved.object_id,
"functionDeclaration": "function() { this.scrollIntoViewIfNeeded(); }",
"returnByValue": true,
}),
)
.await;
let box_result: Result<GetBoxModelResult, _> = client
.call("DOM.getBoxModel", json!({ "backendNodeId": resolved.backend_node_id }))
.await;
let Some((cx, cy)) = box_result.ok().map(|r| r.model.content_center()) else {
return js_dblclick(client, &resolved.object_id).await;
};
let nav_events = client.events();
for click_count in [1, 2] {
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MousePressed,
x: cx, y: cy,
button: Some(MouseButton::Left), buttons: Some(1),
click_count: Some(click_count),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mousePressed failed: {e}")))?;
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MouseReleased,
x: cx, y: cy,
button: Some(MouseButton::Left), buttons: Some(0),
click_count: Some(click_count),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mouseReleased failed: {e}")))?;
}
wait_for_stabilization(nav_events).await;
Ok(())
}
async fn js_dblclick(client: &CdpClient, object_id: &str) -> Result<(), ElementError> {
let nav_events = client.events();
client
.call::<_, serde_json::Value>(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": "function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles:true, cancelable:true})); }",
"returnByValue": true,
}),
)
.await
.map_err(|e| ElementError::Action(format!("JS dblclick failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
pub async fn dblclick_at_coords(client: &CdpClient, x: f64, y: f64) -> Result<(), ElementError> {
let nav_events = client.events();
for click_count in [1, 2] {
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MousePressed, x, y,
button: Some(MouseButton::Left), buttons: Some(1),
click_count: Some(click_count),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mousePressed failed: {e}")))?;
client
.send("Input.dispatchMouseEvent", DispatchMouseEventParams {
event_type: MouseEventType::MouseReleased, x, y,
button: Some(MouseButton::Left), buttons: Some(0),
click_count: Some(click_count),
modifiers: None, timestamp: None, delta_x: None, delta_y: None,
pointer_type: Some("mouse".into()),
})
.await
.map_err(|e| ElementError::Action(format!("mouseReleased failed: {e}")))?;
}
wait_for_stabilization(nav_events).await;
Ok(())
}
pub fn check_js_exception(result: &serde_json::Value) -> Result<(), ElementError> {
if let Some(exception) = result.get("exceptionDetails") {
let text = exception
.get("exception")
.and_then(|ex| ex.get("description"))
.and_then(|d| d.as_str())
.or_else(|| exception.get("text").and_then(|t| t.as_str()))
.unwrap_or("unknown error");
return Err(ElementError::Action(
text.lines().next().unwrap_or(text).trim_start_matches("Error: ").to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(method: &str) -> CdpEvent {
CdpEvent { method: method.to_string(), params: serde_json::Value::Null, session_id: None }
}
#[test]
fn check_js_exception_none() {
let val = serde_json::json!({"result": {"value": true}});
assert!(check_js_exception(&val).is_ok());
}
#[test]
fn check_js_exception_present() {
let val = serde_json::json!({"exceptionDetails": {"text": "boom"}});
let err = check_js_exception(&val).unwrap_err();
assert!(err.to_string().contains("boom"));
}
#[tokio::test]
async fn recv_event_times_out_without_match() {
let (tx, _) = broadcast::channel::<CdpEvent>(16);
let mut rx = tx.subscribe();
tx.send(ev("Runtime.consoleAPICalled")).unwrap();
assert!(!recv_event(&mut rx, "Page.frameNavigated", Duration::from_millis(20)).await);
}
#[tokio::test]
async fn stabilization_sees_navigation_buffered_before_wait() {
let (tx, _) = broadcast::channel::<CdpEvent>(16);
let rx = tx.subscribe(); tx.send(ev("Page.frameNavigated")).unwrap();
tx.send(ev("Page.loadEventFired")).unwrap();
tokio::time::timeout(Duration::from_secs(1), wait_for_stabilization(rx))
.await
.expect("should not hang when nav events are already buffered");
}
}