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,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageBox {
pub viewport: BoundingBox,
pub scroll_x: f64,
pub scroll_y: f64,
}
impl PageBox {
#[must_use]
pub fn abs_origin(&self) -> (f64, f64) {
(
self.viewport.x + self.scroll_x,
self.viewport.y + self.scroll_y,
)
}
#[must_use]
pub fn abs_center(&self) -> (f64, f64) {
(
self.viewport.x + self.viewport.width / 2.0 + self.scroll_x,
self.viewport.y + self.viewport.height / 2.0 + self.scroll_y,
)
}
}
use tokio::time::Instant;
use crate::element::Element;
use crate::error::{Result, ZendriverError};
use crate::frame::Frame;
use crate::query::selectors::{QueryScope, RemoteRef, SelectorKind, text_len_of};
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>,
pub(crate) include_frames: bool,
pub(crate) best_match: bool,
}
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,
include_frames: false,
best_match: false,
}
}
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,
include_frames: false,
best_match: false,
}
}
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,
include_frames: false,
best_match: false,
}
}
#[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),
include_frames: self.include_frames,
best_match: self.best_match,
}
}
#[must_use]
pub fn include_frames(mut self) -> Self {
self.include_frames = true;
self
}
#[must_use]
pub fn best_match(mut self) -> Self {
self.best_match = true;
self
}
#[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 best_match = effective_best_match(self.best_match, &selector);
let want_nth = self.nth.unwrap_or(0);
let fan_frames = self.include_frames
&& self.element.is_none()
&& self.in_frame.is_none()
&& self.frame.is_none();
if let (true, Some(tab)) = (fan_frames, self.tab) {
return one_across_frames(tab, &selector, best_match, want_nth, deadline).await;
}
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(),
));
}
};
loop {
let candidates = selector.resolve_many_inner(&scope, best_match).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>,
pub(crate) include_frames: bool,
pub(crate) best_match: bool,
}
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,
include_frames: false,
best_match: false,
}
}
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,
include_frames: false,
best_match: false,
}
}
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,
include_frames: false,
best_match: false,
}
}
#[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),
include_frames: self.include_frames,
best_match: self.best_match,
}
}
#[must_use]
pub fn include_frames(mut self) -> Self {
self.include_frames = true;
self
}
#[must_use]
pub fn best_match(mut self) -> Self {
self.best_match = true;
self
}
#[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 best_match = effective_best_match(self.best_match, &selector);
let fan_frames = self.include_frames
&& self.element.is_none()
&& self.in_frame.is_none()
&& self.frame.is_none();
if let (true, Some(tab)) = (fan_frames, self.tab) {
return many_across_frames(tab, &selector, best_match, deadline).await;
}
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_inner(&scope, best_match).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 selector_is_text(sel: &SelectorKind) -> bool {
matches!(
sel,
SelectorKind::Text { .. } | SelectorKind::TextRegex { .. }
)
}
fn needle_len_of(sel: &SelectorKind) -> Option<usize> {
match sel {
SelectorKind::Text { needle, .. } => Some(needle.chars().count()),
SelectorKind::TextRegex { pattern, .. } => Some(pattern.chars().count()),
_ => None,
}
}
fn effective_best_match(requested: bool, sel: &SelectorKind) -> bool {
if requested && !selector_is_text(sel) {
tracing::debug!("best_match ignored for non-text selector");
return false;
}
requested && selector_is_text(sel)
}
async fn one_across_frames(
tab: &Tab,
selector: &SelectorKind,
best_match: bool,
want_nth: usize,
deadline: Instant,
) -> Result<Element> {
loop {
let frames = tab.frames().await?;
if best_match {
let needle_len = needle_len_of(selector).unwrap_or(0);
let mut best: Option<(usize, RemoteRef, ScopeTag)> = None;
let main_scope = QueryScope::Tab(tab);
consider_scope_best(
&main_scope,
selector,
want_nth,
needle_len,
ScopeTag::Main,
&mut best,
)
.await?;
for (i, frame) in frames.iter().enumerate() {
let scope = QueryScope::Frame(frame);
consider_scope_best(
&scope,
selector,
want_nth,
needle_len,
ScopeTag::Frame(i),
&mut best,
)
.await?;
}
if let Some((_, picked, tag)) = best {
let scope = match tag {
ScopeTag::Main => QueryScope::Tab(tab),
ScopeTag::Frame(i) => QueryScope::Frame(&frames[i]),
};
return Ok(Element::synthesize_query(
picked, &scope, selector, want_nth,
));
}
} else {
let main_scope = QueryScope::Tab(tab);
let main_hits = selector.resolve_many_inner(&main_scope, false).await?;
if let Some(picked) = main_hits.into_iter().nth(want_nth) {
return Ok(Element::synthesize_query(
picked,
&main_scope,
selector,
want_nth,
));
}
for frame in &frames {
let scope = QueryScope::Frame(frame);
let hits = selector.resolve_many_inner(&scope, false).await?;
if let Some(picked) = hits.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;
}
}
async fn many_across_frames(
tab: &Tab,
selector: &SelectorKind,
best_match: bool,
deadline: Instant,
) -> Result<Vec<Element>> {
loop {
let frames = tab.frames().await?;
let mut elements: Vec<Element> = Vec::new();
let main_scope = QueryScope::Tab(tab);
for (i, r) in selector
.resolve_many_inner(&main_scope, best_match)
.await?
.into_iter()
.enumerate()
{
elements.push(Element::synthesize_query(r, &main_scope, selector, i));
}
for frame in &frames {
let scope = QueryScope::Frame(frame);
for (i, r) in selector
.resolve_many_inner(&scope, best_match)
.await?
.into_iter()
.enumerate()
{
elements.push(Element::synthesize_query(r, &scope, selector, i));
}
}
if !elements.is_empty() {
return Ok(elements);
}
if Instant::now() >= deadline {
return Err(ZendriverError::ElementNotFound {
selector: describe_selector(selector),
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[derive(Clone, Copy)]
enum ScopeTag {
Main,
Frame(usize),
}
async fn consider_scope_best(
scope: &QueryScope<'_>,
selector: &SelectorKind,
want_nth: usize,
needle_len: usize,
tag: ScopeTag,
best: &mut Option<(usize, RemoteRef, ScopeTag)>,
) -> Result<()> {
let hits = selector.resolve_many_inner(scope, true).await?;
let Some(candidate) = hits.into_iter().nth(want_nth) else {
return Ok(());
};
let len = text_len_of(scope, &candidate).await?;
let dist = len.abs_diff(needle_len);
if best
.as_ref()
.is_none_or(|(best_dist, _, _)| dist < *best_dist)
{
*best = Some((dist, candidate, tag));
}
Ok(())
}
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();
}
#[tokio::test]
async fn best_match_one_picks_closest_length() {
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().text("accept all").best_match().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("Math.abs"),
"best_match collector must score by abs distance; got: {sent}"
);
assert!(
sent.contains(".length"),
"best_match collector must compare text length; got: {sent}"
);
assert!(
sent.contains(".sort"),
"best_match collector must sort candidates; got: {sent}"
);
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": "RBest", "type": "object", "subtype": "node" } },
{ "name": "1", "value": { "objectId": "RFar", "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"], "RBest");
mock.reply(id_d0, json!({ "node": { "backendNodeId": 1 } }))
.await;
let id_d1 = mock.expect_cmd("DOM.describeNode").await;
mock.reply(id_d1, json!({ "node": { "backendNodeId": 2 } }))
.await;
let el = fut.await.unwrap().unwrap();
assert_eq!(
el.inner.remote_object_id.lock().await.as_deref(),
Some("RBest"),
"best_match .one() must take the first (closest-length) candidate"
);
conn.shutdown();
}
#[tokio::test]
async fn best_match_noop_on_css_logs() {
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").best_match().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"),
"css path must dispatch plain querySelectorAll; got: {sent}"
);
assert!(
!sent.contains("Math.abs"),
"best_match must NOT inject a distance sort on css selectors; got: {sent}"
);
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": "R1", "type": "object", "subtype": "node" } },
{ "name": "length", "value": { "value": 1, "type": "number" } }
]
}),
)
.await;
let id_d = mock.expect_cmd("DOM.describeNode").await;
mock.reply(id_d, json!({ "node": { "backendNodeId": 7 } }))
.await;
let el = fut.await.unwrap().unwrap();
assert_eq!(
el.inner.remote_object_id.lock().await.as_deref(),
Some("R1")
);
conn.shutdown();
}
#[tokio::test]
async fn include_frames_one_falls_through_to_frame() {
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_CHILD".into(),
Some("F_ROOT".into()),
String::new(),
None,
frame_sess,
std::sync::Arc::downgrade(&tab.inner),
);
tab.inner
.frames
.write()
.await
.insert("F_CHILD".into(), frame);
let fut = tokio::spawn({
let t = tab.clone();
async move { t.find().css("button").include_frames().one().await }
});
let id_q = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(
mock.last_sent()["sessionId"],
"S_TAB",
"main scope must resolve on the Tab's session first"
);
mock.reply(
id_q,
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 id_iso = mock.expect_cmd("Page.createIsolatedWorld").await;
assert_eq!(
mock.last_sent()["sessionId"],
"S_FRAME",
"frame fall-through must dispatch on the Frame's session"
);
assert_eq!(mock.last_sent()["params"]["frameId"], "F_CHILD");
mock.reply(id_iso, json!({ "executionContextId": 4242 }))
.await;
let id_qf = mock.expect_cmd("Runtime.evaluate").await;
assert_eq!(mock.last_sent()["sessionId"], "S_FRAME");
assert_eq!(mock.last_sent()["params"]["contextId"], 4242);
mock.reply(
id_qf,
json!({ "result": { "objectId": "RArrF", "type": "object", "subtype": "array" } }),
)
.await;
let id_pf = mock.expect_cmd("Runtime.getProperties").await;
mock.reply(
id_pf,
json!({ "result": [
{ "name": "0", "value": { "objectId": "RInFrame", "type": "object", "subtype": "node" } },
{ "name": "length", "value": { "value": 1, "type": "number" } }
] }),
)
.await;
let id_df = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(
mock.last_sent()["objectId"]
.as_str()
.or_else(|| mock.last_sent()["params"]["objectId"].as_str()),
Some("RInFrame")
);
mock.reply(id_df, json!({ "node": { "backendNodeId": 88 } }))
.await;
let el = fut.await.unwrap().unwrap();
assert_eq!(
el.inner.remote_object_id.lock().await.as_deref(),
Some("RInFrame"),
"include_frames .one() must return the in-frame hit on main miss"
);
assert_eq!(*el.inner.backend_node_id.lock().await, Some(88));
conn.shutdown();
}
}