pub mod actionability;
pub mod modifiers;
pub mod role;
pub mod selectors;
pub use role::AriaRole;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoundingBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
use tokio::time::Instant;
use crate::element::Element;
use crate::error::{Result, ZendriverError};
use crate::frame::Frame;
use crate::query::selectors::{QueryScope, SelectorKind};
use crate::tab::Tab;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug)]
pub struct FindBuilder<'scope> {
pub(crate) tab: Option<&'scope Tab>,
pub(crate) element: Option<&'scope Element>,
pub(crate) frame: Option<&'scope Frame>,
pub(crate) selector: Option<SelectorKind>,
pub(crate) timeout: Duration,
pub(crate) nth: Option<usize>,
pub(crate) visible_only: bool,
pub(crate) in_frame: Option<&'scope Frame>,
}
impl<'scope> FindBuilder<'scope> {
pub(crate) fn new_for_tab(tab: &'scope Tab) -> Self {
Self {
tab: Some(tab),
element: None,
frame: None,
selector: None,
timeout: DEFAULT_TIMEOUT,
nth: None,
visible_only: false,
in_frame: None,
}
}
pub(crate) fn new_for_element(element: &'scope Element) -> Self {
Self {
tab: Some(element.tab()),
element: Some(element),
frame: None,
selector: None,
timeout: DEFAULT_TIMEOUT,
nth: None,
visible_only: false,
in_frame: None,
}
}
pub(crate) fn new_for_frame(frame: &'scope Frame) -> Self {
Self {
tab: None,
element: None,
frame: Some(frame),
selector: None,
timeout: DEFAULT_TIMEOUT,
nth: None,
visible_only: false,
in_frame: None,
}
}
#[must_use]
pub fn css(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Css(selector.into()));
self
}
#[must_use]
pub fn xpath(mut self, expr: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Xpath(expr.into()));
self
}
#[must_use]
pub fn text(mut self, needle: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Text {
needle: needle.into(),
exact: false,
});
self
}
#[must_use]
pub fn text_exact(mut self, needle: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Text {
needle: needle.into(),
exact: true,
});
self
}
#[must_use]
pub fn text_regex(mut self, re: regex::Regex) -> Self {
self.selector = Some(SelectorKind::TextRegex {
pattern: re.as_str().to_string(),
flags: String::new(),
});
self
}
#[must_use]
pub fn text_regex_with_flags(
mut self,
pattern: impl Into<String>,
flags: impl Into<String>,
) -> Self {
self.selector = Some(SelectorKind::TextRegex {
pattern: pattern.into(),
flags: flags.into(),
});
self
}
#[must_use]
pub fn role(mut self, role: AriaRole) -> Self {
self.selector = Some(SelectorKind::Role(role, None));
self
}
#[must_use]
pub fn role_named(mut self, role: AriaRole, name: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Role(role, Some(name.into())));
self
}
#[must_use]
pub fn nth(mut self, idx: usize) -> Self {
self.nth = Some(idx);
self
}
#[must_use]
pub fn visible_only(mut self, on: bool) -> Self {
self.visible_only = on;
self
}
#[must_use]
pub fn in_frame<'a>(self, frame: &'a Frame) -> FindBuilder<'a>
where
'scope: 'a,
{
FindBuilder {
tab: self.tab,
element: self.element,
frame: self.frame,
selector: self.selector,
timeout: self.timeout,
nth: self.nth,
visible_only: self.visible_only,
in_frame: Some(frame),
}
}
#[must_use]
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = dur;
self
}
pub async fn one(self) -> Result<Element> {
let selector = self.selector.ok_or_else(|| {
ZendriverError::Navigation(
"FindBuilder requires a selector (.css/.xpath/.text/.role/...)".into(),
)
})?;
let deadline = Instant::now() + self.timeout;
let scope = match (self.element, self.in_frame, self.frame, self.tab) {
(Some(el), _, _, _) => QueryScope::Element(el),
(None, Some(fr), _, _) => QueryScope::Frame(fr),
(None, None, Some(fr), _) => QueryScope::Frame(fr),
(None, None, None, Some(tab)) => QueryScope::Tab(tab),
(None, None, None, None) => {
return Err(ZendriverError::Navigation(
"FindBuilder has no scope (no tab, element, or frame)".into(),
));
}
};
let want_nth = self.nth.unwrap_or(0);
loop {
let candidates = selector.resolve_many(&scope).await?;
let _ = self.visible_only;
let filtered = candidates;
if let Some(picked) = filtered.into_iter().nth(want_nth) {
return Ok(Element::synthesize_query(
picked, &scope, &selector, want_nth,
));
}
if Instant::now() >= deadline {
return Err(ZendriverError::ElementNotFound {
selector: describe_selector(&selector),
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn one_or_none(self) -> Result<Option<Element>> {
match self.one().await {
Ok(el) => Ok(Some(el)),
Err(ZendriverError::ElementNotFound { .. }) => Ok(None),
Err(e) => Err(e),
}
}
}
#[derive(Debug)]
pub struct FindAllBuilder<'scope> {
pub(crate) tab: Option<&'scope Tab>,
pub(crate) element: Option<&'scope Element>,
pub(crate) frame: Option<&'scope Frame>,
pub(crate) selector: Option<SelectorKind>,
pub(crate) timeout: Duration,
pub(crate) visible_only: bool,
pub(crate) in_frame: Option<&'scope Frame>,
}
impl<'scope> FindAllBuilder<'scope> {
pub(crate) fn new_for_tab(tab: &'scope Tab) -> Self {
Self {
tab: Some(tab),
element: None,
frame: None,
selector: None,
timeout: DEFAULT_TIMEOUT,
visible_only: false,
in_frame: None,
}
}
pub(crate) fn new_for_element(element: &'scope Element) -> Self {
Self {
tab: Some(element.tab()),
element: Some(element),
frame: None,
selector: None,
timeout: DEFAULT_TIMEOUT,
visible_only: false,
in_frame: None,
}
}
pub(crate) fn new_for_frame(frame: &'scope Frame) -> Self {
Self {
tab: None,
element: None,
frame: Some(frame),
selector: None,
timeout: DEFAULT_TIMEOUT,
visible_only: false,
in_frame: None,
}
}
#[must_use]
pub fn css(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Css(selector.into()));
self
}
#[must_use]
pub fn xpath(mut self, expr: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Xpath(expr.into()));
self
}
#[must_use]
pub fn text(mut self, needle: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Text {
needle: needle.into(),
exact: false,
});
self
}
#[must_use]
pub fn text_exact(mut self, needle: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Text {
needle: needle.into(),
exact: true,
});
self
}
#[must_use]
pub fn text_regex(mut self, re: regex::Regex) -> Self {
self.selector = Some(SelectorKind::TextRegex {
pattern: re.as_str().to_string(),
flags: String::new(),
});
self
}
#[must_use]
pub fn text_regex_with_flags(
mut self,
pattern: impl Into<String>,
flags: impl Into<String>,
) -> Self {
self.selector = Some(SelectorKind::TextRegex {
pattern: pattern.into(),
flags: flags.into(),
});
self
}
#[must_use]
pub fn role(mut self, role: AriaRole) -> Self {
self.selector = Some(SelectorKind::Role(role, None));
self
}
#[must_use]
pub fn role_named(mut self, role: AriaRole, name: impl Into<String>) -> Self {
self.selector = Some(SelectorKind::Role(role, Some(name.into())));
self
}
#[must_use]
pub fn visible_only(mut self, on: bool) -> Self {
self.visible_only = on;
self
}
#[must_use]
pub fn in_frame<'a>(self, frame: &'a Frame) -> FindAllBuilder<'a>
where
'scope: 'a,
{
FindAllBuilder {
tab: self.tab,
element: self.element,
frame: self.frame,
selector: self.selector,
timeout: self.timeout,
visible_only: self.visible_only,
in_frame: Some(frame),
}
}
#[must_use]
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = dur;
self
}
pub async fn many(self) -> Result<Vec<Element>> {
let selector = self.selector.ok_or_else(|| {
ZendriverError::Navigation(
"FindAllBuilder requires a selector (.css/.xpath/.text/.role/...)".into(),
)
})?;
let deadline = Instant::now() + self.timeout;
let scope = match (self.element, self.in_frame, self.frame, self.tab) {
(Some(el), _, _, _) => QueryScope::Element(el),
(None, Some(fr), _, _) => QueryScope::Frame(fr),
(None, None, Some(fr), _) => QueryScope::Frame(fr),
(None, None, None, Some(tab)) => QueryScope::Tab(tab),
(None, None, None, None) => {
return Err(ZendriverError::Navigation(
"FindAllBuilder has no scope (no tab, element, or frame)".into(),
));
}
};
loop {
let candidates = selector.resolve_many(&scope).await?;
let _ = self.visible_only;
let filtered = candidates;
if !filtered.is_empty() {
let elements: Vec<Element> = filtered
.into_iter()
.enumerate()
.map(|(i, r)| Element::synthesize_query(r, &scope, &selector, i))
.collect();
return Ok(elements);
}
if Instant::now() >= deadline {
return Err(ZendriverError::ElementNotFound {
selector: describe_selector(&selector),
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn many_or_empty(self) -> Result<Vec<Element>> {
match self.many().await {
Ok(els) => Ok(els),
Err(ZendriverError::ElementNotFound { .. }) => Ok(Vec::new()),
Err(e) => Err(e),
}
}
}
fn describe_selector(sel: &SelectorKind) -> String {
match sel {
SelectorKind::Css(s) => format!("css({s})"),
SelectorKind::Xpath(s) => format!("xpath({s})"),
SelectorKind::Text { needle, exact } => {
if *exact {
format!("text_exact({needle})")
} else {
format!("text({needle})")
}
}
SelectorKind::TextRegex { pattern, flags } => {
if flags.is_empty() {
format!("text_regex(/{pattern}/)")
} else {
format!("text_regex(/{pattern}/{flags})")
}
}
SelectorKind::Role(role, None) => format!("role({})", role.to_css()),
SelectorKind::Role(role, Some(name)) => {
format!("role_named({}, {name})", role.to_css())
}
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::SessionHandle;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn one_returns_element_when_query_selector_matches() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move { t.find().css("#b").one().await }
});
let id_q = mock.expect_cmd("Runtime.evaluate").await;
let sent = mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.to_string();
assert!(
sent.contains("document.querySelectorAll") && sent.contains("#b"),
"expected querySelectorAll with selector, got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "objectId": "RArr", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "RArr");
mock.reply(
id_p,
json!({
"result": [
{
"name": "0",
"value": { "objectId": "R1", "type": "object", "subtype": "node" }
},
{
"name": "length",
"value": { "value": 1, "type": "number" }
}
]
}),
)
.await;
let id_d = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "R1");
mock.reply(id_d, json!({ "node": { "backendNodeId": 42 } }))
.await;
let el = fut.await.unwrap().unwrap();
assert_eq!(*el.inner.backend_node_id.lock().await, Some(42));
assert_eq!(
el.inner.remote_object_id.lock().await.as_deref(),
Some("R1")
);
conn.shutdown();
}
#[tokio::test]
async fn one_returns_element_not_found_when_query_returns_empty() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move {
t.find()
.css("#missing")
.timeout(Duration::from_millis(150))
.one()
.await
}
});
loop {
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(220)) => break,
cmd = mock.expect_cmd("Runtime.evaluate") => {
mock.reply(
cmd,
json!({ "result": { "objectId": "RArrEmpty", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
mock.reply(id_p, json!({ "result": [
{ "name": "length", "value": { "value": 0, "type": "number" } }
] })).await;
}
}
}
let res = fut.await.unwrap();
match res {
Err(ZendriverError::ElementNotFound { selector }) => {
assert!(selector.contains("#missing"), "got: {selector}");
}
Err(e) => panic!("unexpected error: {e:?}"),
Ok(_) => panic!("unexpected ok"),
}
conn.shutdown();
}
#[tokio::test]
async fn one_or_none_returns_none_on_timeout() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move {
t.find()
.css("#missing")
.timeout(Duration::from_millis(120))
.one_or_none()
.await
}
});
loop {
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(200)) => break,
cmd = mock.expect_cmd("Runtime.evaluate") => {
mock.reply(
cmd,
json!({ "result": { "objectId": "RArrEmpty", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
mock.reply(id_p, json!({ "result": [
{ "name": "length", "value": { "value": 0, "type": "number" } }
] })).await;
}
}
}
let res = fut.await.unwrap().unwrap();
assert!(res.is_none());
conn.shutdown();
}
#[test]
fn describe_selector_renders_each_kind() {
assert_eq!(
describe_selector(&SelectorKind::Css("#b".into())),
"css(#b)"
);
assert_eq!(
describe_selector(&SelectorKind::Xpath("//a".into())),
"xpath(//a)"
);
assert_eq!(
describe_selector(&SelectorKind::Text {
needle: "Hi".into(),
exact: false,
}),
"text(Hi)"
);
assert_eq!(
describe_selector(&SelectorKind::Text {
needle: "Hi".into(),
exact: true,
}),
"text_exact(Hi)"
);
assert_eq!(
describe_selector(&SelectorKind::TextRegex {
pattern: "a.*b".into(),
flags: String::new(),
}),
"text_regex(/a.*b/)"
);
assert_eq!(
describe_selector(&SelectorKind::TextRegex {
pattern: "a.*b".into(),
flags: "i".into(),
}),
"text_regex(/a.*b/i)"
);
assert_eq!(
describe_selector(&SelectorKind::Role(AriaRole::Button, None)),
r#"role([role="button"])"#
);
assert_eq!(
describe_selector(&SelectorKind::Role(AriaRole::Button, Some("Save".into()))),
r#"role_named([role="button"], Save)"#
);
}
#[tokio::test]
async fn text_regex_wraps_regex_pattern_and_empty_flags() {
let re = regex::Regex::new("hello.*world").unwrap();
let (_mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fb = tab.find().text_regex(re);
let Some(SelectorKind::TextRegex { pattern, flags }) = fb.selector else {
panic!("expected TextRegex selector kind");
};
assert_eq!(pattern, "hello.*world");
assert_eq!(flags, "");
conn.shutdown();
}
#[tokio::test]
async fn text_regex_with_flags_passes_pattern_and_flags_through() {
let (_mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fb = tab.find().text_regex_with_flags("h.*w", "im");
let Some(SelectorKind::TextRegex { pattern, flags }) = fb.selector else {
panic!("expected TextRegex selector kind");
};
assert_eq!(pattern, "h.*w");
assert_eq!(flags, "im");
conn.shutdown();
}
#[tokio::test]
async fn later_selector_overrides_earlier() {
let (_mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fb = tab.find().css("#a").xpath("//b");
let Some(SelectorKind::Xpath(expr)) = fb.selector else {
panic!("expected Xpath selector kind after .xpath() override");
};
assert_eq!(expr, "//b");
conn.shutdown();
}
#[tokio::test]
async fn modifiers_chain_and_persist_on_builder() {
let (_mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess.clone());
let frame = Frame::new(
"F2".into(),
None,
String::new(),
None,
sess,
std::sync::Weak::new(),
);
let fb = tab
.find()
.css(".item")
.nth(3)
.visible_only(true)
.in_frame(&frame)
.timeout(Duration::from_secs(5));
assert_eq!(fb.nth, Some(3));
assert!(fb.visible_only);
assert!(fb.in_frame.is_some(), "in_frame must hold Frame ref");
assert_eq!(fb.in_frame.unwrap().id(), "F2");
assert_eq!(fb.timeout, Duration::from_secs(5));
conn.shutdown();
}
#[tokio::test]
async fn in_frame_override_routes_dispatch_to_frame_session() {
let (mut mock, conn) = MockConnection::pair();
let tab_sess = SessionHandle::new(conn.clone(), "S_TAB");
let frame_sess = SessionHandle::new(conn.clone(), "S_FRAME");
let tab = Tab::new_for_test(tab_sess);
let frame = Frame::new(
"F_OOPIF".into(),
None,
String::new(),
None,
frame_sess,
std::sync::Arc::downgrade(&tab.inner),
);
let fut = tokio::spawn({
let t = tab.clone();
let f = frame.clone();
async move { t.find().in_frame(&f).css("button").one().await }
});
let id_iso = mock.expect_cmd("Page.createIsolatedWorld").await;
assert_eq!(
mock.last_sent()["sessionId"],
"S_FRAME",
"in_frame override must route Page.createIsolatedWorld through the Frame's session"
);
assert_eq!(mock.last_sent()["params"]["frameId"], "F_OOPIF");
mock.reply(id_iso, json!({ "executionContextId": 9001 }))
.await;
let id_q = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(
mock.last_sent()["sessionId"],
"S_FRAME",
"in_frame override must route Runtime.evaluate through the Frame's session, not the Tab's"
);
assert_eq!(
mock.last_sent()["params"]["contextId"],
9001,
"Runtime.evaluate must be pinned to the frame's isolated-world contextId"
);
let sent = mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.to_string();
assert!(
sent.contains("document.querySelectorAll") && sent.contains("button"),
"expected querySelectorAll with selector, got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "objectId": "RArr", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
assert_eq!(
mock.last_sent()["sessionId"],
"S_FRAME",
"follow-up Runtime.getProperties must also dispatch on the Frame's session"
);
mock.reply(
id_p,
json!({
"result": [
{ "name": "0", "value": { "objectId": "R0", "type": "object", "subtype": "node" } },
{ "name": "length", "value": { "value": 1, "type": "number" } }
]
}),
)
.await;
let id_d = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(
mock.last_sent()["sessionId"],
"S_FRAME",
"DOM.describeNode must also dispatch on the Frame's session"
);
mock.reply(id_d, json!({ "node": { "backendNodeId": 7 } }))
.await;
let el = fut.await.unwrap().unwrap();
assert_eq!(*el.inner.backend_node_id.lock().await, Some(7));
conn.shutdown();
}
#[tokio::test]
async fn many_returns_all_matches() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move { t.find_all().css(".item").many().await }
});
let id_q = mock.expect_cmd("Runtime.evaluate").await;
let sent = mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.to_string();
assert!(
sent.contains("document.querySelectorAll") && sent.contains(".item"),
"expected querySelectorAll with selector, got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "objectId": "RArr", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "RArr");
mock.reply(
id_p,
json!({
"result": [
{ "name": "0", "value": { "objectId": "R0", "type": "object", "subtype": "node" } },
{ "name": "1", "value": { "objectId": "R1", "type": "object", "subtype": "node" } },
{ "name": "length", "value": { "value": 2, "type": "number" } }
]
}),
)
.await;
let id_d0 = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "R0");
mock.reply(id_d0, json!({ "node": { "backendNodeId": 20 } }))
.await;
let id_d1 = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "R1");
mock.reply(id_d1, json!({ "node": { "backendNodeId": 21 } }))
.await;
let els = fut.await.unwrap().unwrap();
assert_eq!(els.len(), 2);
assert_eq!(
els[0].inner.remote_object_id.lock().await.as_deref(),
Some("R0")
);
assert_eq!(*els[0].inner.backend_node_id.lock().await, Some(20));
assert_eq!(
els[1].inner.remote_object_id.lock().await.as_deref(),
Some("R1")
);
assert_eq!(*els[1].inner.backend_node_id.lock().await, Some(21));
conn.shutdown();
}
#[tokio::test]
async fn many_or_empty_returns_empty_vec_on_timeout() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move {
t.find_all()
.css(".missing")
.timeout(Duration::from_millis(120))
.many_or_empty()
.await
}
});
loop {
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(200)) => break,
cmd = mock.expect_cmd("Runtime.evaluate") => {
mock.reply(
cmd,
json!({ "result": { "objectId": "RArrEmpty", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
mock.reply(id_p, json!({ "result": [
{ "name": "length", "value": { "value": 0, "type": "number" } }
] })).await;
}
}
}
let res = fut.await.unwrap().unwrap();
assert!(
res.is_empty(),
"expected empty Vec on timeout, got len={}",
res.len()
);
conn.shutdown();
}
#[tokio::test]
async fn one_with_nth_picks_indexed_match() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move { t.find().css(".item").nth(1).one().await }
});
let id_q = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(
id_q,
json!({ "result": { "objectId": "RArr", "type": "object", "subtype": "array" } }),
)
.await;
let id_p = mock.expect_cmd("Runtime.getProperties").await;
mock.reply(
id_p,
json!({
"result": [
{ "name": "0", "value": { "objectId": "R0", "type": "object", "subtype": "node" } },
{ "name": "1", "value": { "objectId": "R1", "type": "object", "subtype": "node" } },
{ "name": "length", "value": { "value": 2, "type": "number" } }
]
}),
)
.await;
let id_d0 = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "R0");
mock.reply(id_d0, json!({ "node": { "backendNodeId": 10 } }))
.await;
let id_d1 = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "R1");
mock.reply(id_d1, json!({ "node": { "backendNodeId": 11 } }))
.await;
let el = fut.await.unwrap().unwrap();
assert_eq!(
el.inner.remote_object_id.lock().await.as_deref(),
Some("R1")
);
assert_eq!(*el.inner.backend_node_id.lock().await, Some(11));
conn.shutdown();
}
}