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::{Key, KeyModifiers, SpecialKey};
use crate::input::mouse::{self, MouseButton};
use crate::query::actionability::{self, ActionabilityCheck};
const DEFAULT_ACTIONABILITY_TIMEOUT: Duration = Duration::from_secs(5);
const NATIVE_VALUE_SETTER_JS: &str = "function(el, v){ \
const proto = this instanceof HTMLTextAreaElement \
? HTMLTextAreaElement.prototype \
: HTMLInputElement.prototype; \
const d = Object.getOwnPropertyDescriptor(proto, 'value'); \
if (d && d.set) { d.set.call(this, v); } else { this.value = v; } \
this.dispatchEvent(new Event('input', {bubbles: true})); \
this.dispatchEvent(new Event('change', {bubbles: true})); \
}";
const CLEAR_BY_DELETING_SLACK: usize = 2;
const CLEAR_BY_DELETING_MAX: usize = 4096;
#[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(NATIVE_VALUE_SETTER_JS, json!([{ "value": value }]))
.await?;
Ok(())
}
})
.await
}
pub async fn clear(&self) -> Result<()> {
self.with_refresh(|| async move {
let _ = self
.call_on_main(NATIVE_VALUE_SETTER_JS, json!([{ "value": "" }]))
.await?;
Ok(())
})
.await
}
pub async fn set_text(&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(v){ this.textContent = v; }",
json!([{ "value": value }]),
)
.await?;
Ok(())
}
})
.await
}
pub async fn clear_by_deleting(&self) -> Result<()> {
self.with_refresh(|| async move {
self.focus().await?;
let select_all = if cfg!(target_os = "macos") {
KeyModifiers::META
} else {
KeyModifiers::CTRL
};
self.press_with(Key::Char('a'), select_all).await?;
let len: usize = {
let res = self
.call_on_main("function(){ return (this.value || '').length; }", json!([]))
.await?;
res.get("value")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(0)
};
let presses = len
.saturating_add(CLEAR_BY_DELETING_SLACK)
.min(CLEAR_BY_DELETING_MAX);
for _ in 0..presses {
self.press(Key::Special(SpecialKey::Backspace)).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
}
pub async fn flash(&self, duration: Duration) -> Result<()> {
let ms = duration.as_millis();
self.with_refresh(|| async move {
self.scroll_into_view().await?;
let bbox = match self.bounding_box().await? {
Some(b) => b,
None => return Ok(()),
};
let cx = bbox.x + bbox.width / 2.0;
let cy = bbox.y + bbox.height / 2.0;
let js = "function(cx, cy, ms){ \
const d = document.createElement('div'); \
d.style.cssText = 'position:fixed;left:'+cx+'px;top:'+cy+'px;\
width:10px;height:10px;margin:-5px 0 0 -5px;border-radius:50%;background:red;\
z-index:2147483647;pointer-events:none;opacity:0.85;'; \
document.body.appendChild(d); \
setTimeout(() => d.remove(), ms); \
}";
let _ = self
.call_on_main(
js,
json!([{ "value": cx }, { "value": cy }, { "value": ms }]),
)
.await?;
Ok(())
})
.await
}
pub async fn highlight_overlay(&self) -> Result<()> {
self.with_refresh(|| async move {
let backend_node_id = self.backend_node_id_cloned().await?;
let _ = self.inner.tab.call("Overlay.enable", json!({})).await?;
let _ = self
.inner
.tab
.call(
"Overlay.highlightNode",
json!({
"backendNodeId": backend_node_id,
"highlightConfig": {
"showInfo": true,
"showExtensionLines": true,
"showStyles": true,
"contentColor": { "r": 111, "g": 168, "b": 220, "a": 0.66 },
"paddingColor": { "r": 147, "g": 196, "b": 125, "a": 0.55 },
"borderColor": { "r": 255, "g": 229, "b": 153, "a": 0.66 },
"marginColor": { "r": 246, "g": 178, "b": 107, "a": 0.66 },
},
}),
)
.await?;
Ok(())
})
.await
}
pub async fn mouse_drag(&self, to: (f64, f64), steps: usize) -> Result<()> {
self.with_refresh(|| async move {
self.scroll_into_view().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;
self.inner.tab.mouse_drag((cx, cy), to, steps).await
})
.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_uses_native_prototype_setter() {
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("getOwnPropertyDescriptor"));
assert!(decl.contains(".set.call"));
assert!(decl.contains("HTMLTextAreaElement"));
assert!(decl.contains("HTMLInputElement"));
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 clear_uses_native_setter_with_empty() {
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.clear().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("getOwnPropertyDescriptor"));
assert!(decl.contains(".set.call"));
assert!(decl.contains("HTMLTextAreaElement"));
assert!(decl.contains("HTMLInputElement"));
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"], "");
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn set_text_assigns_textcontent_with_value() {
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_text("New title").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("textContent"));
let args = sent["params"]["arguments"].as_array().unwrap();
assert_eq!(args.len(), 2);
assert_eq!(args[0]["objectId"], "R1");
assert_eq!(args[1]["value"], "New title");
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn clear_by_deleting_focuses_then_selects_then_backspaces() {
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.clear_by_deleting().await }
});
for _ in 0..2 {
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;
let sent = mock.last_sent();
assert!(
sent["params"]["functionDeclaration"]
.as_str()
.unwrap()
.contains("this.focus()")
);
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
for _ in 0..2 {
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": { "type": "undefined" } }))
.await;
let ctrl = i64::from(KeyModifiers::CTRL.cdp_bits());
let meta = i64::from(KeyModifiers::META.cdp_bits());
let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "keyDown");
let mod_key = sent["params"]["key"].as_str().unwrap();
assert!(
mod_key == "Meta" || mod_key == "Control",
"select-all chord must wrap with a Meta/Control keyDown, got {mod_key}"
);
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "keyDown");
assert_eq!(sent["params"]["key"], "a");
let mods = sent["params"]["modifiers"].as_i64().unwrap();
assert!(
mods == ctrl || mods == meta,
"select-all 'a' must hold Ctrl or Meta, got {mods}"
);
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
assert_eq!(mock.last_sent()["params"]["type"], "keyUp");
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
assert_eq!(mock.last_sent()["params"]["type"], "keyUp");
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
let sent = mock.last_sent();
assert!(
sent["params"]["functionDeclaration"]
.as_str()
.unwrap()
.contains(".value")
);
mock.reply(id, json!({ "result": { "value": 0, "type": "number" } }))
.await;
let mut saw_backspace = false;
for _ in 0..CLEAR_BY_DELETING_SLACK {
for _ in 0..2 {
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": { "type": "undefined" } }))
.await;
let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "rawKeyDown");
assert_eq!(sent["params"]["key"], "Backspace");
assert_ne!(sent["params"]["key"], "Delete", "must never forward-Delete");
saw_backspace = true;
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
assert_eq!(mock.last_sent()["params"]["type"], "keyUp");
mock.reply(id, json!({})).await;
}
let res = fut.await.unwrap();
res.unwrap();
assert!(saw_backspace, "expected at least one Backspace keyDown");
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();
}
#[tokio::test]
async fn flash_injects_self_removing_overlay_at_bbox_center() {
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 fut = tokio::spawn({
let e = el.clone();
async move { e.flash(Duration::from_millis(500)).await }
});
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
assert!(
mock.last_sent()["params"]["functionDeclaration"]
.as_str()
.unwrap()
.contains("scrollIntoView")
);
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.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 id = mock.expect_cmd("Runtime.callFunctionOn").await;
let sent = mock.last_sent();
let decl = sent["params"]["functionDeclaration"].as_str().unwrap();
assert!(decl.contains("createElement"), "should build a dot element");
assert!(
decl.contains("setTimeout") && decl.contains("remove"),
"should self-remove"
);
let args = sent["params"]["arguments"].as_array().unwrap();
assert_eq!(args[0]["objectId"], "R1");
assert_eq!(args[1]["value"], 60.0); assert_eq!(args[2]["value"], 45.0); assert_eq!(args[3]["value"], 500); mock.reply(id, json!({ "result": { "type": "undefined" } }))
.await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn highlight_overlay_enables_then_highlights_backend_node() {
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 fut = tokio::spawn({
let e = el.clone();
async move { e.highlight_overlay().await }
});
let id = mock.expect_cmd("Overlay.enable").await;
mock.reply(id, json!({})).await;
let id = mock.expect_cmd("Overlay.highlightNode").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["backendNodeId"], 42);
assert!(
sent["params"]["highlightConfig"].is_object(),
"should carry a HighlightConfig"
);
mock.reply(id, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn mouse_drag_resolves_bbox_then_drives_drag_from_center() {
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 fut = tokio::spawn({
let e = el.clone();
async move { e.mouse_drag((300.0, 200.0), 4).await }
});
let id = mock.expect_cmd("Runtime.callFunctionOn").await;
assert!(
mock.last_sent()["params"]["functionDeclaration"]
.as_str()
.unwrap()
.contains("scrollIntoView")
);
mock.reply(id, json!({ "result": { "type": "undefined" } }))
.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 kinds: Vec<String> = Vec::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();
if kind == "mousePressed" {
assert_eq!(sent["params"]["button"], "left");
assert_eq!(sent["params"]["x"], 60.0, "press at bbox center x");
assert_eq!(sent["params"]["y"], 45.0, "press at bbox center y");
}
if kind == "mouseReleased" {
assert_eq!(sent["params"]["button"], "left");
assert_eq!(sent["params"]["x"], 300.0, "release at destination x");
assert_eq!(sent["params"]["y"], 200.0, "release at destination y");
}
kinds.push(kind);
mock.reply(id, json!({})).await;
}
Err(_) => break,
}
}
fut.await.unwrap().unwrap();
assert_eq!(kinds.first().map(String::as_str), Some("mousePressed"));
assert_eq!(kinds.last().map(String::as_str), Some("mouseReleased"));
assert!(
kinds.iter().any(|k| k == "mouseMoved"),
"expected at least one mouseMoved between press and release"
);
conn.shutdown();
}
}