use std::path::Path;
use std::time::Duration;
use serde_json::json;
use crate::element::Element;
use crate::error::{Result, ZendriverError};
use crate::input::keyboard::KeyModifiers;
use crate::input::mouse::{self, MouseButton};
use crate::query::actionability::{self, ActionabilityCheck};
const DEFAULT_ACTIONABILITY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy)]
pub struct ClickOptions {
pub button: MouseButton,
pub modifiers: KeyModifiers,
pub click_count: u32,
pub force: bool,
pub realistic: bool,
pub position: Option<(f64, f64)>,
}
impl Default for ClickOptions {
fn default() -> Self {
Self {
button: MouseButton::Left,
modifiers: KeyModifiers::empty(),
click_count: 1,
force: false,
realistic: true,
position: None,
}
}
}
impl Element {
pub async fn click(&self) -> Result<()> {
self.click_with(ClickOptions::default()).await
}
pub async fn click_fast(&self) -> Result<()> {
self.click_with(ClickOptions {
realistic: false,
force: true,
..Default::default()
})
.await
}
pub async fn click_with(&self, opts: ClickOptions) -> Result<()> {
self.with_refresh(|| async move {
self.scroll_into_view().await?;
if !opts.force {
actionability::wait_actionable(
self,
ActionabilityCheck::FULL,
DEFAULT_ACTIONABILITY_TIMEOUT,
)
.await?;
}
let bbox = self
.bounding_box()
.await?
.ok_or_else(|| ZendriverError::Navigation("element has no bounding box".into()))?;
let (tx, ty) = match opts.position {
Some((dx, dy)) => (bbox.x + dx, bbox.y + dy),
None => (bbox.x + bbox.width / 2.0, bbox.y + bbox.height / 2.0),
};
let input = self.inner.tab.input().clone();
mouse::click_at(
&input,
&self.inner.tab,
tx,
ty,
opts.button,
opts.click_count,
opts.realistic,
)
.await
})
.await
}
pub async fn hover(&self) -> Result<()> {
self.with_refresh(|| async move {
self.scroll_into_view().await?;
actionability::wait_actionable(
self,
ActionabilityCheck {
visible: true,
stable: true,
enabled: false,
receives_pointer: true,
},
DEFAULT_ACTIONABILITY_TIMEOUT,
)
.await?;
let bbox = self
.bounding_box()
.await?
.ok_or_else(|| ZendriverError::Navigation("element has no bounding box".into()))?;
let cx = bbox.x + bbox.width / 2.0;
let cy = bbox.y + bbox.height / 2.0;
let input = self.inner.tab.input().clone();
mouse::move_realistic(&input, &self.inner.tab, cx, cy).await
})
.await
}
pub async fn hover_fast(&self) -> Result<()> {
self.with_refresh(|| async move {
self.scroll_into_view().await?;
actionability::wait_actionable(
self,
ActionabilityCheck {
visible: true,
stable: true,
enabled: false,
receives_pointer: true,
},
DEFAULT_ACTIONABILITY_TIMEOUT,
)
.await?;
let bbox = self
.bounding_box()
.await?
.ok_or_else(|| ZendriverError::Navigation("element has no bounding box".into()))?;
let cx = bbox.x + bbox.width / 2.0;
let cy = bbox.y + bbox.height / 2.0;
let input = self.inner.tab.input().clone();
mouse::move_raw(&input, &self.inner.tab, cx, cy).await
})
.await
}
pub async fn focus(&self) -> Result<()> {
self.with_refresh(|| async move {
actionability::wait_actionable(
self,
ActionabilityCheck::TEXT_INPUT,
DEFAULT_ACTIONABILITY_TIMEOUT,
)
.await?;
let _ = self
.call_on_main("function(){ this.focus(); }", json!([]))
.await?;
Ok(())
})
.await
}
pub async fn scroll_into_view(&self) -> Result<()> {
self.with_refresh(|| async move {
let _ = self
.call_on_main(
"function(){ this.scrollIntoView({block:'center',behavior:'instant'}); }",
json!([]),
)
.await?;
Ok(())
})
.await
}
pub async fn set_value(&self, value: impl AsRef<str>) -> Result<()> {
let value = value.as_ref().to_string();
self.with_refresh(|| {
let value = value.clone();
async move {
let _ = self
.call_on_main(
"function(el, v){ \
this.value = v; \
this.dispatchEvent(new Event('input', {bubbles: true})); \
this.dispatchEvent(new Event('change', {bubbles: true})); \
}",
json!([{ "value": value }]),
)
.await?;
Ok(())
}
})
.await
}
pub async fn clear(&self) -> Result<()> {
self.with_refresh(|| async move {
let _ = self
.call_on_main(
"function(){ \
this.value = ''; \
this.dispatchEvent(new Event('input', {bubbles: true})); \
}",
json!([]),
)
.await?;
Ok(())
})
.await
}
pub async fn upload_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
let files: Vec<String> = paths
.iter()
.map(|p| p.as_ref().to_string_lossy().into_owned())
.collect();
self.with_refresh(|| {
let files = files.clone();
async move {
let backend_node_id = self.backend_node_id_cloned().await?;
let _ = self
.inner
.tab
.call(
"DOM.setFileInputFiles",
json!({
"files": files,
"backendNodeId": backend_node_id,
}),
)
.await?;
Ok(())
}
})
.await
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::tab::Tab;
use zendriver_transport::SessionHandle;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn hover_dispatches_input_dispatchmouseevent_with_type_mousemoved() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let el = Element::from_jsret(tab.clone(), 99, "R1".to_string());
let fut = tokio::spawn({
let e = el.clone();
async move { e.hover().await }
});
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
let sent = mock.last_sent();
assert!(
sent["params"]["functionDeclaration"]
.as_str()
.unwrap()
.contains("scrollIntoView")
);
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
mock.reply(
id,
json!({ "result": { "value": true, "type": "boolean" } }),
)
.await;
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
mock.reply(
id,
json!({ "result": { "value": true, "type": "boolean" } }),
)
.await;
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
mock.reply(
id,
json!({ "result": { "value": true, "type": "boolean" } }),
)
.await;
let id = mock.expect_cmd("DOM.getBoxModel").await;
mock.reply(
id,
json!({
"model": {
"content": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"padding": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"border": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"margin": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"width": 100,
"height": 50
}
}),
)
.await;
let mut saw_mouse_moved = false;
loop {
let next = tokio::time::timeout(
Duration::from_millis(500),
mock.expect_cmd("Input.dispatchMouseEvent"),
)
.await;
match next {
Ok(id) => {
let sent = mock.last_sent();
let kind = sent["params"]["type"].as_str().unwrap_or("");
assert_eq!(
kind, "mouseMoved",
"hover should only emit mouseMoved events"
);
saw_mouse_moved = true;
mock.reply(id, json!({})).await;
}
Err(_) => break,
}
}
let res = fut.await.unwrap();
res.unwrap();
assert!(
saw_mouse_moved,
"expected at least one Input.dispatchMouseEvent with type=mouseMoved"
);
conn.shutdown();
}
#[tokio::test]
async fn click_dispatches_mousemoved_then_mousepressed_then_mousereleased() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let el = Element::from_jsret(tab.clone(), 99, "R1".to_string());
let fut = tokio::spawn({
let e = el.clone();
async move { e.click().await }
});
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
for _ in 0..4 {
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
mock.reply(
id,
json!({ "result": { "value": true, "type": "boolean" } }),
)
.await;
}
let id = mock.expect_cmd("DOM.getBoxModel").await;
mock.reply(
id,
json!({
"model": {
"content": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"padding": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"border": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"margin": [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0],
"width": 100,
"height": 50
}
}),
)
.await;
let mut saw_pressed = false;
let mut saw_released = false;
let mut last_kind = String::new();
loop {
let next = tokio::time::timeout(
Duration::from_millis(500),
mock.expect_cmd("Input.dispatchMouseEvent"),
)
.await;
match next {
Ok(id) => {
let sent = mock.last_sent();
let kind = sent["params"]["type"].as_str().unwrap_or("").to_string();
match kind.as_str() {
"mouseMoved" => {
assert!(
!saw_pressed && !saw_released,
"mouseMoved arrived after mousePressed/Released"
);
}
"mousePressed" => {
assert!(!saw_pressed, "duplicate mousePressed");
assert!(!saw_released, "mousePressed after mouseReleased");
saw_pressed = true;
}
"mouseReleased" => {
assert!(saw_pressed, "mouseReleased before mousePressed");
assert!(!saw_released, "duplicate mouseReleased");
saw_released = true;
}
other => panic!("unexpected dispatch type: {other}"),
}
last_kind = kind;
mock.reply(id, json!({})).await;
}
Err(_) => break,
}
}
let res = fut.await.unwrap();
res.unwrap();
assert!(saw_pressed, "expected a mousePressed dispatch");
assert!(saw_released, "expected a mouseReleased dispatch");
assert_eq!(
last_kind, "mouseReleased",
"final dispatch should be mouseReleased"
);
conn.shutdown();
}
#[tokio::test]
async fn set_value_dispatches_call_function_on_with_value_argument() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let el = Element::from_jsret(tab, 7, "R1".to_string());
let fut = tokio::spawn({
let e = el.clone();
async move { e.set_value("hello world").await }
});
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
let sent = mock.last_sent();
let decl = sent["params"]["functionDeclaration"].as_str().unwrap();
assert!(decl.contains("this.value = v"));
assert!(decl.contains("'input'"));
assert!(decl.contains("'change'"));
let args = sent["params"]["arguments"].as_array().unwrap();
assert_eq!(args.len(), 2);
assert_eq!(args[0]["objectId"], "R1");
assert_eq!(args[1]["value"], "hello world");
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn upload_files_dispatches_dom_set_file_input_files_with_paths() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let el = Element::from_jsret(tab, 42, "R1".to_string());
let paths: &[&std::path::Path] = &[
std::path::Path::new("/tmp/a.txt"),
std::path::Path::new("/tmp/b.pdf"),
];
let fut = tokio::spawn({
let e = el.clone();
let paths: Vec<std::path::PathBuf> = paths.iter().map(|p| p.to_path_buf()).collect();
async move { e.upload_files(&paths).await }
});
let id = mock.expect_cmd("DOM.setFileInputFiles").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["backendNodeId"], 42);
let files = sent["params"]["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0], "/tmp/a.txt");
assert_eq!(files[1], "/tmp/b.pdf");
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
}