use serde_json::json;
use crate::cdp::client::CdpClient;
fn first_line(text: &str) -> String {
text.lines().next().unwrap_or(text).trim_start_matches("Error: ").to_string()
}
use crate::element::{click_at_coords, dblclick_at_coords, wait_for_stabilization, ElementError};
pub async fn selector_uid(client: &CdpClient, selector: &str) -> Option<String> {
let expression = format!(
"document.querySelector({})",
serde_json::to_string(selector).unwrap_or_default()
);
let handle: serde_json::Value = client
.call("Runtime.evaluate", json!({ "expression": expression }))
.await
.ok()?;
let object_id = handle.get("result")?.get("objectId")?.as_str()?.to_string();
let described: serde_json::Value = client
.call("DOM.describeNode", json!({ "objectId": object_id }))
.await
.ok()?;
let backend_id = described.get("node")?.get("backendNodeId")?.as_i64()?;
Some(format!("n{backend_id}"))
}
pub async fn click_selector(client: &CdpClient, selector: &str) -> Result<(), ElementError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r"(() => {{
const el = document.querySelector({sel});
if (!el) throw new Error('No element matches selector: ' + {sel});
el.scrollIntoView({{block: 'center', inline: 'center'}});
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return null;
return [r.left + r.width / 2, r.top + r.height / 2];
}})()"
);
let result: serde_json::Value = client
.call("Runtime.evaluate", json!({ "expression": js, "returnByValue": true }))
.await
.map_err(|e| ElementError::Action(format!("click_selector 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::NotFound(text.to_string()));
}
let center = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(serde_json::Value::as_array)
.filter(|a| a.len() == 2)
.and_then(|a| Some((a[0].as_f64()?, a[1].as_f64()?)));
if let Some((cx, cy)) = center {
return click_at_coords(client, cx, cy).await;
}
let fallback = format!(
r"(() => {{
const el = document.querySelector({sel});
if (!el) throw new Error('No element matches selector: ' + {sel});
el.click();
}})()"
);
let nav_events = client.events();
client
.call::<_, serde_json::Value>(
"Runtime.evaluate",
json!({ "expression": fallback, "returnByValue": true }),
)
.await
.map_err(|e| ElementError::Action(format!("click_selector fallback failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
pub async fn dblclick_selector(client: &CdpClient, selector: &str) -> Result<(), ElementError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r"(() => {{
const el = document.querySelector({sel});
if (!el) throw new Error('No element matches selector: ' + {sel});
el.scrollIntoView({{block: 'center', inline: 'center'}});
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return null;
return [r.left + r.width / 2, r.top + r.height / 2];
}})()"
);
let result: serde_json::Value = client
.call("Runtime.evaluate", json!({ "expression": js, "returnByValue": true }))
.await
.map_err(|e| ElementError::Action(format!("dblclick_selector 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::NotFound(text.to_string()));
}
let center = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(serde_json::Value::as_array)
.filter(|a| a.len() == 2)
.and_then(|a| Some((a[0].as_f64()?, a[1].as_f64()?)));
if let Some((cx, cy)) = center {
return dblclick_at_coords(client, cx, cy).await;
}
let fallback = format!(
r"(() => {{
const el = document.querySelector({sel});
if (!el) throw new Error('No element matches selector: ' + {sel});
el.dispatchEvent(new MouseEvent('dblclick', {{bubbles: true, cancelable: true}}));
}})()"
);
let nav_events = client.events();
client
.call::<_, serde_json::Value>(
"Runtime.evaluate",
json!({ "expression": fallback, "returnByValue": true }),
)
.await
.map_err(|e| ElementError::Action(format!("dblclick_selector fallback failed: {e}")))?;
wait_for_stabilization(nav_events).await;
Ok(())
}
pub async fn fill_selector(
client: &CdpClient,
selector: &str,
value: &str,
) -> Result<crate::element::FillOutcome, ElementError> {
let js = format!(
r"(() => {{
const el = document.querySelector({sel});
if (!el) throw new Error('No element matches selector: ' + {sel});
if (el.matches(':disabled')) throw new Error('Element is disabled and cannot be filled: ' + {sel});
if (el.readOnly) throw new Error('Element is readonly and cannot be filled: ' + {sel});
el.focus();
const proto = el instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) {{
setter.set.call(el, {val});
}} else {{
el.value = {val};
}}
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
// Read after the window rather than on the next line: a controlled component
// that reverts in a promise callback has not run yet when the write returns.
return new Promise(resolve => setTimeout(() => resolve({{
value: el.value === undefined ? null : String(el.value),
maxLength: typeof el.maxLength === 'number' ? el.maxLength : null,
sensitive: el.type === 'password' ||
/password|cc-number|cc-csc|one-time-code/i.test(el.autocomplete || '')
}}), {window}));
}})()",
sel = serde_json::to_string(selector).unwrap_or_default(),
val = serde_json::to_string(value).unwrap_or_default(),
window = crate::element::READ_BACK_MS
);
let nav_events = client.events();
let result: serde_json::Value = client
.call(
"Runtime.evaluate",
json!({ "expression": js, "returnByValue": true, "awaitPromise": true }),
)
.await
.map_err(|e| ElementError::Action(format!("fill_selector 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(first_line(text)));
}
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(crate::element::FillOutcome::new(value, actual)
.with_max_length(max_length)
.secret(sensitive))
}
pub async fn focus_selector(client: &CdpClient, selector: &str) -> Result<(), ElementError> {
let js = format!(
r"(() => {{
const el = document.querySelector({sel});
if (!el) throw new Error('No element matches selector: ' + {sel});
el.focus();
}})()",
sel = serde_json::to_string(selector).unwrap_or_default()
);
let result: serde_json::Value = client
.call("Runtime.evaluate", json!({ "expression": js, "returnByValue": true }))
.await
.map_err(|e| ElementError::Action(format!("focus_selector 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::NotFound(text.to_string()));
}
Ok(())
}