#![allow(missing_docs)]
use std::collections::HashMap;
use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
#[derive(Default)]
pub struct ClickResult {
pub dialog_opened: bool,
pub pending_release: Option<PendingRelease>,
}
pub struct PendingRelease {
pub session_id: String,
pub x: f64,
pub y: f64,
pub button: String,
}
pub async fn click(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
button: &str,
click_count: i32,
iframe_sessions: &HashMap<String, String>,
) -> Result<ClickResult, String> {
let (x, y, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
dispatch_click(
client,
&effective_session_id,
&[effective_session_id.as_str(), session_id],
x,
y,
button,
click_count,
)
.await
}
pub async fn dblclick(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<ClickResult, String> {
click(
client,
session_id,
ref_map,
selector_or_ref,
"left",
2,
iframe_sessions,
)
.await
}
pub async fn hover(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseMoved".to_string(),
x,
y,
button: None,
buttons: None,
click_count: None,
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(&effective_session_id),
)
.await?;
Ok(())
}
pub async fn drag(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
from: &str,
to: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x1, y1, sid1) =
resolve_element_center(client, session_id, ref_map, from, iframe_sessions).await?;
let (x2, y2, sid2) =
resolve_element_center(client, session_id, ref_map, to, iframe_sessions).await?;
if sid1 != sid2 {
return Err("drag endpoints must share the same frame/session".to_string());
}
let sid = sid1;
for (event_type, x, y, button, buttons, click_count) in [
("mouseMoved", x1, y1, None, None, None),
(
"mousePressed",
x1,
y1,
Some("left".to_string()),
Some(1),
Some(1),
),
(
"mouseMoved",
x2,
y2,
Some("left".to_string()),
Some(1),
None,
),
(
"mouseReleased",
x2,
y2,
Some("left".to_string()),
Some(0),
Some(1),
),
] {
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: event_type.to_string(),
x,
y,
button,
buttons,
click_count,
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(&sid),
)
.await?;
}
Ok(())
}
pub async fn fill(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
value: &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: "function() { this.focus(); }".to_string(),
object_id: Some(object_id.clone()),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.select && this.select();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.insertText",
&InsertTextParams {
text: value.to_string(),
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn fill_smart(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
value: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let kind = detect_fill_kind(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
match kind.as_str() {
"select" => {
select_option(
client,
session_id,
ref_map,
selector_or_ref,
&[value.to_string()],
iframe_sessions,
)
.await
}
"checkbox" => {
let want = parse_boolish(value);
if want {
check(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await
} else {
uncheck(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await
}
}
"radio" => {
let want = parse_boolish(value);
if want {
check(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await
} else {
Err("radio cannot be set to false via fill; select another radio option".into())
}
}
_ => {
fill(
client,
session_id,
ref_map,
selector_or_ref,
value,
iframe_sessions,
)
.await
}
}
}
fn parse_boolish(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "on" | "yes" | "checked"
)
}
async fn detect_fill_kind(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
let (object_id, effective_session_id) = resolve_element_object_id(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
let result = client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
const tag = (this.tagName || '').toUpperCase();
if (tag === 'SELECT') return 'select';
if (tag === 'INPUT') {
const t = (this.type || 'text').toLowerCase();
if (t === 'checkbox') return 'checkbox';
if (t === 'radio') return 'radio';
}
if (this.getAttribute && this.getAttribute('role') === 'checkbox') return 'checkbox';
if (this.getAttribute && this.getAttribute('role') === 'radio') return 'radio';
return 'text';
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
Ok(result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("text")
.to_string())
}
pub async fn click_at(
client: &CdpClient,
session_id: &str,
x: f64,
y: f64,
dblclick: bool,
) -> Result<ClickResult, String> {
let click_count = if dblclick { 2 } else { 1 };
dispatch_click(client, session_id, &[session_id], x, y, "left", click_count).await
}
#[allow(clippy::too_many_arguments)]
pub async fn type_text(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
text: &str,
clear: bool,
delay_ms: Option<u64>,
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: "function() { this.focus(); }".to_string(),
object_id: Some(object_id.clone()),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
if clear {
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: r#"function() {
this.select && this.select();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
}"#
.to_string(),
object_id: Some(object_id),
arguments: None,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
}
type_text_into_active_context(client, session_id, text, delay_ms).await
}
pub async fn type_text_into_active_context(
client: &CdpClient,
session_id: &str,
text: &str,
delay_ms: Option<u64>,
) -> Result<(), String> {
let delay = delay_ms.unwrap_or(0);
for ch in text.chars() {
if matches!(ch, '\n' | '\r' | '\t') {
let (key, code, key_code) = char_to_key_info(ch);
let text_str = key_text(&key);
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key.clone()),
code: Some(code.clone()),
text: text_str.clone(),
unmodified_text: text_str,
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 {
client
.send_command_typed::<_, Value>(
"Input.insertText",
&InsertTextParams {
text: ch.to_string(),
},
Some(session_id),
)
.await?;
}
if delay > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
}
}
Ok(())
}
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
press_key_with_modifiers(client, session_id, key, None).await
}
pub async fn press_key_with_modifiers(
client: &CdpClient,
session_id: &str,
key: &str,
modifiers: Option<i32>,
) -> Result<(), String> {
let (key_name, code, key_code) = named_key_info(key);
let has_command_modifier = modifiers.is_some_and(|m| m & (2 | 4) != 0);
let text = if has_command_modifier {
None
} else {
key_text(&key_name)
};
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyDown".to_string(),
key: Some(key_name.clone()),
code: Some(code.clone()),
text: text.clone(),
unmodified_text: text.clone(),
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers,
},
Some(session_id),
)
.await?;
client
.send_command_typed::<_, Value>(
"Input.dispatchKeyEvent",
&DispatchKeyEventParams {
event_type: "keyUp".to_string(),
key: Some(key_name),
code: Some(code),
text: None,
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers,
},
Some(session_id),
)
.await?;
Ok(())
}
pub async fn scroll(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: Option<&str>,
delta_x: f64,
delta_y: f64,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
if let Some(sel) = selector_or_ref {
let (object_id, effective_session_id) =
resolve_element_object_id(client, session_id, ref_map, sel, iframe_sessions).await?;
let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: Some(vec![
CallArgument {
value: Some(serde_json::json!(delta_x)),
object_id: None,
},
CallArgument {
value: Some(serde_json::json!(delta_y)),
object_id: None,
},
]),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
} else {
let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
client
.send_command_typed::<_, Value>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
}
Ok(())
}
pub async fn select_option(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
values: &[String],
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?;
let js = r#"function(vals) {
const options = Array.from(this.options);
let matched = 0;
for (const opt of options) {
opt.selected = vals.includes(opt.value) || vals.includes(opt.textContent.trim());
if (opt.selected) matched += 1;
}
if (matched === 0) {
const available = options.map(o => o.value + ' ("' + o.textContent.trim() + '")').join(', ');
return { error: 'No option matched ' + JSON.stringify(vals) + '. Available options: ' + available };
}
this.dispatchEvent(new Event('change', { bubbles: true }));
return { matched };
}"#
.to_string();
let result = client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
object_id: Some(object_id),
arguments: Some(vec![CallArgument {
value: Some(serde_json::json!(values)),
object_id: None,
}]),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(&effective_session_id),
)
.await?;
if let Some(error) = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.get("error"))
.and_then(|e| e.as_str())
{
return Err(error.to_string());
}
Ok(())
}
pub async fn check(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let is_checked = super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
if !is_checked {
click(
client,
session_id,
ref_map,
selector_or_ref,
"left",
1,
iframe_sessions,
)
.await?;
if !super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?
{
js_click_checkbox(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
}
}
Ok(())
}
pub async fn uncheck(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let is_checked = super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
if is_checked {
click(
client,
session_id,
ref_map,
selector_or_ref,
"left",
1,
iframe_sessions,
)
.await?;
if super::element::is_element_checked(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?
{
js_click_checkbox(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
}
}
Ok(())
}
async fn js_click_checkbox(
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?;
let js = r#"function() {
var el = this;
var tag = el.tagName && el.tagName.toUpperCase();
// 1. Native input — click it directly
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
el.click();
return;
}
// 2. Follow label → control association
var label = tag === 'LABEL' ? el : (el.closest && el.closest('label'));
if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
label.control.click();
return;
}
// 3. Nested native input
var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
if (input) {
input.click();
return;
}
// 4. ARIA role control — click the element itself
el.click();
}"#;
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js.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 focus(
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: "function() { this.focus(); }".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 clear(
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() {
this.focus();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}"#
.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 select_all(
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() {
this.focus();
if (typeof this.select === 'function') {
this.select();
} else {
const range = document.createRange();
range.selectNodeContents(this);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}"#
.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 scroll_into_view(
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:
"function() { this.scrollIntoView({ block: 'center', inline: 'center' }); }"
.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 dispatch_event(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
event_type: &str,
event_init: Option<&Value>,
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?;
let init_json = event_init
.map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
.unwrap_or_else(|| "{ bubbles: true }".to_string());
let js = format!(
"function() {{ this.dispatchEvent(new Event({}, {})); }}",
serde_json::to_string(event_type).unwrap_or_default(),
init_json
);
client
.send_command_typed::<_, Value>(
"Runtime.callFunctionOn",
&CallFunctionOnParams {
function_declaration: js,
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 highlight(
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() {
this.style.outline = '2px solid red';
this.style.outlineOffset = '2px';
const el = this;
setTimeout(() => {
el.style.outline = '';
el.style.outlineOffset = '';
}, 3000);
}"#
.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 tap_touch(
client: &CdpClient,
session_id: &str,
ref_map: &RefMap,
selector_or_ref: &str,
iframe_sessions: &HashMap<String, String>,
) -> Result<(), String> {
let (x, y, effective_session_id) = resolve_element_center(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await?;
client
.send_command(
"Input.dispatchTouchEvent",
Some(serde_json::json!({
"type": "touchStart",
"touchPoints": [{ "x": x, "y": y }],
})),
Some(&effective_session_id),
)
.await?;
client
.send_command(
"Input.dispatchTouchEvent",
Some(serde_json::json!({
"type": "touchEnd",
"touchPoints": [],
})),
Some(&effective_session_id),
)
.await?;
Ok(())
}
async fn dispatch_mouse_or_dialog(
client: &CdpClient,
session_id: &str,
accept_sessions: &[&str],
params: &DispatchMouseEventParams,
) -> Result<bool, String> {
use tokio::sync::broadcast::error::RecvError;
let mut events = client.subscribe();
let send =
client.send_command_typed::<_, Value>("Input.dispatchMouseEvent", params, Some(session_id));
tokio::pin!(send);
loop {
tokio::select! {
res = &mut send => {
res?;
return Ok(false);
}
event = events.recv() => {
match event {
Ok(e) if e.method == "Page.javascriptDialogOpening" => {
let ours = match e.session_id.as_deref() {
Some(sid) => accept_sessions.contains(&sid),
None => true,
};
if ours {
return Ok(true);
}
continue;
}
Ok(_) => continue,
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => {
(&mut send).await?;
return Ok(false);
}
}
}
}
}
}
async fn dispatch_click(
client: &CdpClient,
session_id: &str,
accept_sessions: &[&str],
x: f64,
y: f64,
button: &str,
click_count: i32,
) -> Result<ClickResult, String> {
if dispatch_mouse_or_dialog(
client,
session_id,
accept_sessions,
&DispatchMouseEventParams {
event_type: "mouseMoved".to_string(),
x,
y,
button: None,
buttons: None,
click_count: None,
delta_x: None,
delta_y: None,
modifiers: None,
},
)
.await?
{
return Ok(ClickResult {
dialog_opened: true,
pending_release: None,
});
}
let button_value = match button {
"right" => 2,
"middle" => 4,
_ => 1,
};
if dispatch_mouse_or_dialog(
client,
session_id,
accept_sessions,
&DispatchMouseEventParams {
event_type: "mousePressed".to_string(),
x,
y,
button: Some(button.to_string()),
buttons: Some(button_value),
click_count: Some(click_count),
delta_x: None,
delta_y: None,
modifiers: None,
},
)
.await?
{
return Ok(ClickResult {
dialog_opened: true,
pending_release: Some(PendingRelease {
session_id: session_id.to_string(),
x,
y,
button: button.to_string(),
}),
});
}
let dialog_opened = dispatch_mouse_or_dialog(
client,
session_id,
accept_sessions,
&DispatchMouseEventParams {
event_type: "mouseReleased".to_string(),
x,
y,
button: Some(button.to_string()),
buttons: Some(0),
click_count: Some(click_count),
delta_x: None,
delta_y: None,
modifiers: None,
},
)
.await?;
Ok(ClickResult {
dialog_opened,
pending_release: None,
})
}
pub async fn dispatch_pending_release(
client: &CdpClient,
release: &PendingRelease,
) -> Result<(), String> {
client
.send_command_typed::<_, Value>(
"Input.dispatchMouseEvent",
&DispatchMouseEventParams {
event_type: "mouseReleased".to_string(),
x: release.x,
y: release.y,
button: Some(release.button.clone()),
buttons: Some(0),
click_count: Some(1),
delta_x: None,
delta_y: None,
modifiers: None,
},
Some(&release.session_id),
)
.await?;
Ok(())
}
fn char_to_key_info(ch: char) -> (String, String, i32) {
match ch {
'\n' | '\r' => ("Enter".to_string(), "Enter".to_string(), 13),
'\t' => ("Tab".to_string(), "Tab".to_string(), 9),
' ' => (" ".to_string(), "Space".to_string(), 32),
_ => {
let key = ch.to_string();
if ch.is_ascii_alphabetic() {
let upper = ch.to_ascii_uppercase();
let code = format!("Key{}", upper);
let key_code = upper as i32;
(key, code, key_code)
} else if ch.is_ascii_digit() {
let code = format!("Digit{}", ch);
let key_code = ch as i32;
(key, code, key_code)
} else {
let (code, key_code) = punctuation_key_info(ch);
(key, code.to_string(), key_code)
}
}
}
}
fn punctuation_key_info(ch: char) -> (&'static str, i32) {
match ch {
';' | ':' => ("Semicolon", 186),
'=' | '+' => ("Equal", 187),
',' | '<' => ("Comma", 188),
'-' | '_' => ("Minus", 189),
'.' | '>' => ("Period", 190),
'/' | '?' => ("Slash", 191),
'`' | '~' => ("Backquote", 192),
'[' | '{' => ("BracketLeft", 219),
'\\' | '|' => ("Backslash", 220),
']' | '}' => ("BracketRight", 221),
'\'' | '"' => ("Quote", 222),
_ => ("", 0),
}
}
fn key_text(key_name: &str) -> Option<String> {
match key_name {
"Enter" => Some("\r".to_string()),
"Tab" => Some("\t".to_string()),
" " => Some(" ".to_string()),
_ => {
if key_name.len() == 1 {
Some(key_name.to_string())
} else {
None
}
}
}
}
fn named_key_info(key: &str) -> (String, String, i32) {
match key.to_lowercase().as_str() {
"enter" | "return" => ("Enter".to_string(), "Enter".to_string(), 13),
"tab" => ("Tab".to_string(), "Tab".to_string(), 9),
"escape" | "esc" => ("Escape".to_string(), "Escape".to_string(), 27),
"backspace" => ("Backspace".to_string(), "Backspace".to_string(), 8),
"delete" => ("Delete".to_string(), "Delete".to_string(), 46),
"arrowup" | "up" => ("ArrowUp".to_string(), "ArrowUp".to_string(), 38),
"arrowdown" | "down" => ("ArrowDown".to_string(), "ArrowDown".to_string(), 40),
"arrowleft" | "left" => ("ArrowLeft".to_string(), "ArrowLeft".to_string(), 37),
"arrowright" | "right" => ("ArrowRight".to_string(), "ArrowRight".to_string(), 39),
"home" => ("Home".to_string(), "Home".to_string(), 36),
"end" => ("End".to_string(), "End".to_string(), 35),
"pageup" => ("PageUp".to_string(), "PageUp".to_string(), 33),
"pagedown" => ("PageDown".to_string(), "PageDown".to_string(), 34),
"space" | " " => (" ".to_string(), "Space".to_string(), 32),
_ => {
if key.len() == 1 {
if let Some(ch) = key.chars().next() {
char_to_key_info(ch)
} else {
(key.to_string(), key.to_string(), 0)
}
} else {
(key.to_string(), key.to_string(), 0)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_char_to_key_info_matches_playwright_layout() {
let cases: &[(char, &str, i32)] = &[
('a', "KeyA", 65),
('z', "KeyZ", 90),
('A', "KeyA", 65),
('0', "Digit0", 48),
('9', "Digit9", 57),
('.', "Period", 190),
(',', "Comma", 188),
('/', "Slash", 191),
(';', "Semicolon", 186),
('\'', "Quote", 222),
('[', "BracketLeft", 219),
(']', "BracketRight", 221),
('\\', "Backslash", 220),
('`', "Backquote", 192),
('-', "Minus", 189),
('=', "Equal", 187),
('>', "Period", 190),
('<', "Comma", 188),
('?', "Slash", 191),
(':', "Semicolon", 186),
('"', "Quote", 222),
('{', "BracketLeft", 219),
('}', "BracketRight", 221),
('|', "Backslash", 220),
('~', "Backquote", 192),
('_', "Minus", 189),
('+', "Equal", 187),
(' ', "Space", 32),
('\n', "Enter", 13),
('\t', "Tab", 9),
];
for &(ch, expected_code, expected_vk) in cases {
let (key, code, vk) = char_to_key_info(ch);
assert_eq!(
code, expected_code,
"char {:?}: expected code {:?}, got {:?}",
ch, expected_code, code
);
assert_eq!(
vk, expected_vk,
"char {:?}: expected VK {}, got {} (ASCII would be {})",
ch, expected_vk, vk, ch as i32
);
if !ch.is_control() {
assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch);
}
}
}
#[test]
fn test_period_is_not_vk_delete() {
let (_, _, vk) = char_to_key_info('.');
assert_ne!(
vk, 46,
"Period must not use VK code 46 (VK_DELETE); expected 190 (VK_OEM_PERIOD)"
);
assert_eq!(vk, 190);
}
#[test]
fn test_unmapped_chars_return_zero_keycode() {
for ch in ['@', '#', '$', '%', '^', '&', '*', '(', ')', '€', '£', '你'] {
let (key, code, vk) = char_to_key_info(ch);
assert_eq!(
code, "",
"char {:?}: unmapped char should have empty code, got {:?}",
ch, code
);
assert_eq!(
vk, 0,
"char {:?}: unmapped char should have VK 0, got {}",
ch, vk
);
assert_eq!(key, ch.to_string());
}
}
#[test]
fn test_key_text_returns_correct_text_for_special_keys() {
assert_eq!(key_text("Enter"), Some("\r".to_string()));
assert_eq!(key_text("Tab"), Some("\t".to_string()));
assert_eq!(key_text(" "), Some(" ".to_string()));
assert_eq!(key_text("a"), Some("a".to_string()));
assert_eq!(key_text("Z"), Some("Z".to_string()));
assert_eq!(key_text("Escape"), None);
assert_eq!(key_text("ArrowUp"), None);
assert_eq!(key_text("Backspace"), None);
assert_eq!(key_text("Delete"), None);
}
#[test]
fn fill_smart_mode_tokens_documented() {
for mode in [
"select",
"checkbox",
"radio",
"text",
"textarea",
"contenteditable",
] {
assert!(!mode.is_empty());
}
assert!("true".parse::<bool>().unwrap());
assert!(!"false".parse::<bool>().unwrap());
}
}