use serde_json::{Value, json};
use zendriver_transport::SessionHandle;
use crate::element::Element;
use crate::error::{Result, ZendriverError};
use crate::frame::Frame;
use crate::query::role::AriaRole;
use crate::tab::Tab;
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct RemoteRef {
pub(crate) remote_object_id: String,
pub(crate) backend_node_id: i64,
}
#[allow(dead_code)] pub(crate) enum QueryScope<'a> {
Tab(&'a Tab),
Element(&'a Element),
Frame(&'a Frame),
}
impl QueryScope<'_> {
pub(crate) fn session(&self) -> &SessionHandle {
match self {
QueryScope::Tab(t) => t.session(),
QueryScope::Element(e) => e.tab().session(),
QueryScope::Frame(f) => f.session(),
}
}
pub(crate) async fn execution_context_id(&self) -> Result<Option<i64>> {
match self {
QueryScope::Tab(_) | QueryScope::Element(_) => Ok(None),
QueryScope::Frame(f) => Ok(Some(f.ensure_isolated_world().await?)),
}
}
pub(crate) fn synthesize_tab(&self) -> Tab {
match self {
QueryScope::Tab(t) => (*t).clone(),
QueryScope::Element(e) => e.tab().clone(),
QueryScope::Frame(f) => f
.tab_for_synthesize()
.expect("Frame outlived its owning Tab while a FindBuilder query was in flight"),
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) enum SelectorKind {
Css(String),
Xpath(String),
Text { needle: String, exact: bool },
TextRegex { pattern: String, flags: String },
Role(AriaRole, Option<String>),
}
impl SelectorKind {
#[allow(dead_code)] pub(crate) async fn resolve_one(&self, scope: &QueryScope<'_>) -> Result<Option<RemoteRef>> {
self.resolve_one_inner(scope, false).await
}
pub(crate) async fn resolve_one_inner(
&self,
scope: &QueryScope<'_>,
best_match: bool,
) -> Result<Option<RemoteRef>> {
match self {
SelectorKind::Css(sel) => resolve_css_one(scope, sel).await,
SelectorKind::Xpath(expr) => resolve_xpath_one(scope, expr).await,
SelectorKind::Text { needle, exact } => {
resolve_text_one(scope, needle, *exact, best_match).await
}
SelectorKind::TextRegex { pattern, flags } => {
resolve_text_regex_one(scope, pattern, flags, best_match).await
}
SelectorKind::Role(role, name) => resolve_role_one(scope, *role, name.as_deref()).await,
}
}
#[allow(dead_code)] pub(crate) async fn resolve_many(&self, scope: &QueryScope<'_>) -> Result<Vec<RemoteRef>> {
self.resolve_many_inner(scope, false).await
}
pub(crate) async fn resolve_many_inner(
&self,
scope: &QueryScope<'_>,
best_match: bool,
) -> Result<Vec<RemoteRef>> {
match self {
SelectorKind::Css(sel) => resolve_css_many(scope, sel).await,
SelectorKind::Xpath(expr) => resolve_xpath_many(scope, expr).await,
SelectorKind::Text { needle, exact } => {
resolve_text_many(scope, needle, *exact, best_match).await
}
SelectorKind::TextRegex { pattern, flags } => {
resolve_text_regex_many(scope, pattern, flags, best_match).await
}
SelectorKind::Role(role, name) => {
resolve_role_many(scope, *role, name.as_deref()).await
}
}
}
}
#[allow(dead_code)] async fn resolve_css_one(scope: &QueryScope<'_>, selector: &str) -> Result<Option<RemoteRef>> {
let session = scope.session();
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": format!("document.querySelector({})", json!(selector)),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": "function(s){return this.querySelector(s);}",
"arguments": [{ "value": selector }],
"returnByValue": false,
}),
)
.await?
}
};
extract_node_ref(session, &result["result"]).await
}
#[allow(dead_code)] async fn resolve_css_many(scope: &QueryScope<'_>, selector: &str) -> Result<Vec<RemoteRef>> {
let session = scope.session();
let ctx = scope.execution_context_id().await?;
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
let mut params = json!({
"expression": format!(
"Array.from(document.querySelectorAll({}))",
json!(selector)
),
"returnByValue": false,
});
if let Some(id) = ctx {
params["contextId"] = json!(id);
}
session.call("Runtime.evaluate", params).await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": "function(s){return Array.from(this.querySelectorAll(s));}",
"arguments": [{ "value": selector }],
"returnByValue": false,
}),
)
.await?
}
};
extract_array_refs(session, &result["result"]).await
}
use crate::query::predicate::PredicateSet;
pub(crate) async fn resolve_predicate_many(
scope: &QueryScope<'_>,
pred: &PredicateSet,
) -> Result<Vec<RemoteRef>> {
let session = scope.session();
let ctx = scope.execution_context_id().await?;
let css = pred.to_css_selector();
let filter = pred.to_js_filter();
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
let expr = format!(
"Array.from(document.querySelectorAll({})).filter(function(el){{return {};}})",
json!(css),
filter,
);
let mut params = json!({
"expression": expr,
"returnByValue": false,
});
if let Some(id) = ctx {
params["contextId"] = json!(id);
}
session.call("Runtime.evaluate", params).await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
let func = format!(
"function(){{return Array.from(this.querySelectorAll({})).filter(function(el){{return {};}});}}",
json!(css),
filter,
);
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": func,
"returnByValue": false,
}),
)
.await?
}
};
extract_array_refs(session, &result["result"]).await
}
#[allow(dead_code)] async fn resolve_xpath_one(scope: &QueryScope<'_>, expr: &str) -> Result<Option<RemoteRef>> {
let session = scope.session();
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": format!(
"document.evaluate({}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
json!(expr)
),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration":
"function(e){return document.evaluate(e, this, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;}",
"arguments": [{ "value": expr }],
"returnByValue": false,
}),
)
.await?
}
};
extract_node_ref(session, &result["result"]).await
}
#[allow(dead_code)] async fn resolve_xpath_many(scope: &QueryScope<'_>, expr: &str) -> Result<Vec<RemoteRef>> {
let session = scope.session();
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": format!(
"(function(){{var r=document.evaluate({}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);var a=[];for(var i=0;i<r.snapshotLength;i++)a.push(r.snapshotItem(i));return a;}})()",
json!(expr)
),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration":
"function(e){var r=document.evaluate(e, this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);var a=[];for(var i=0;i<r.snapshotLength;i++)a.push(r.snapshotItem(i));return a;}",
"arguments": [{ "value": expr }],
"returnByValue": false,
}),
)
.await?
}
};
extract_array_refs(session, &result["result"]).await
}
fn best_match_sort_js(arr: &str, needle_len: usize) -> String {
format!(
"{arr}.sort(function(a,b){{\
var la=(a.innerText||a.textContent||'').length;\
var lb=(b.innerText||b.textContent||'').length;\
return Math.abs(la-{n})-Math.abs(lb-{n});\
}})",
arr = arr,
n = needle_len,
)
}
fn build_text_substring_js_tab(needle: &str, best_match: bool) -> String {
let sort = if best_match {
format!(";{}", best_match_sort_js("r", needle.chars().count()))
} else {
String::new()
};
format!(
"(function(){{\
var n={n};\
var lc=n.toLowerCase();\
var matches=Array.from(document.querySelectorAll('*')).filter(function(el){{\
var t=el.innerText||el.textContent||'';\
return t.toLowerCase().includes(lc);\
}});\
var r=matches.filter(function(el){{\
return !Array.from(el.querySelectorAll('*')).some(function(c){{\
var t=c.innerText||c.textContent||'';\
return t.toLowerCase().includes(lc);\
}});\
}}){sort};\
return r;\
}})()",
n = json!(needle),
sort = sort,
)
}
fn build_text_substring_fn_body(needle: &str, best_match: bool) -> String {
let sort = if best_match {
format!(";{}", best_match_sort_js("r", needle.chars().count()))
} else {
String::new()
};
format!(
"function(n){{\
var lc=n.toLowerCase();\
var matches=Array.from(this.querySelectorAll('*')).filter(function(el){{\
var t=el.innerText||el.textContent||'';\
return t.toLowerCase().includes(lc);\
}});\
var r=matches.filter(function(el){{\
return !Array.from(el.querySelectorAll('*')).some(function(c){{\
var t=c.innerText||c.textContent||'';\
return t.toLowerCase().includes(lc);\
}});\
}}){sort};\
return r;\
}}",
sort = sort,
)
}
fn build_text_exact_xpath_js_tab(needle: &str, snapshot: bool, best_match: bool) -> String {
let sort = if snapshot && best_match {
format!(";{}", best_match_sort_js("a", needle.chars().count()))
} else {
String::new()
};
if snapshot {
format!(
"(function(){{var n={n};var xp=\"//*[normalize-space(.)=\"+JSON.stringify(n).replace(/\"/g,\"'\")+\"]\";var r=document.evaluate(xp,document,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);var a=[];for(var i=0;i<r.snapshotLength;i++)a.push(r.snapshotItem(i));{sort};return a;}})()",
n = json!(needle),
sort = sort,
)
} else {
format!(
"(function(){{var n={n};var xp=\"//*[normalize-space(.)=\"+JSON.stringify(n).replace(/\"/g,\"'\")+\"]\";return document.evaluate(xp,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;}})()",
n = json!(needle),
)
}
}
fn build_text_exact_xpath_fn_body(needle: &str, snapshot: bool, best_match: bool) -> String {
let sort = if snapshot && best_match {
format!(";{}", best_match_sort_js("a", needle.chars().count()))
} else {
String::new()
};
if snapshot {
format!(
"function(n){{var xp=\"//*[normalize-space(.)=\"+JSON.stringify(n).replace(/\"/g,\"'\")+\"]\";var r=document.evaluate(xp,this,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);var a=[];for(var i=0;i<r.snapshotLength;i++)a.push(r.snapshotItem(i));{sort};return a;}}",
sort = sort,
)
} else {
"function(n){var xp=\"//*[normalize-space(.)=\"+JSON.stringify(n).replace(/\"/g,\"'\")+\"]\";return document.evaluate(xp,this,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;}".to_string()
}
}
#[allow(dead_code)] async fn resolve_text_one(
scope: &QueryScope<'_>,
needle: &str,
exact: bool,
best_match: bool,
) -> Result<Option<RemoteRef>> {
let session = scope.session();
if exact {
if best_match {
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": format!("({})[0] || null", build_text_exact_xpath_js_tab(needle, true, true)),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": format!(
"function(n){{return ({})[0] || null;}}",
format!("({}).call(this,n)", build_text_exact_xpath_fn_body(needle, true, true)),
),
"arguments": [{ "value": needle }],
"returnByValue": false,
}),
)
.await?
}
};
return extract_node_ref(session, &result["result"]).await;
}
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": build_text_exact_xpath_js_tab(needle, false, false),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": build_text_exact_xpath_fn_body(needle, false, false),
"arguments": [{ "value": needle }],
"returnByValue": false,
}),
)
.await?
}
};
extract_node_ref(session, &result["result"]).await
} else {
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": format!("({})[0] || null", build_text_substring_js_tab(needle, best_match)),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": format!(
"function(n){{return ({}.call(this,n))[0] || null;}}",
build_text_substring_fn_body(needle, best_match)
),
"arguments": [{ "value": needle }],
"returnByValue": false,
}),
)
.await?
}
};
extract_node_ref(session, &result["result"]).await
}
}
#[allow(dead_code)] async fn resolve_text_many(
scope: &QueryScope<'_>,
needle: &str,
exact: bool,
best_match: bool,
) -> Result<Vec<RemoteRef>> {
let session = scope.session();
let result = if exact {
match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": build_text_exact_xpath_js_tab(needle, true, best_match),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": build_text_exact_xpath_fn_body(needle, true, best_match),
"arguments": [{ "value": needle }],
"returnByValue": false,
}),
)
.await?
}
}
} else {
match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": build_text_substring_js_tab(needle, best_match),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": build_text_substring_fn_body(needle, best_match),
"arguments": [{ "value": needle }],
"returnByValue": false,
}),
)
.await?
}
}
};
extract_array_refs(session, &result["result"]).await
}
fn build_text_regex_js_tab(pattern: &str, flags: &str, best_match: bool) -> String {
let sort = if best_match {
format!(";{}", best_match_sort_js("m", pattern.chars().count()))
} else {
String::new()
};
format!(
"(function(){{var r=new RegExp({p}, {f});var m=Array.from(document.querySelectorAll('*')).filter(function(el){{var t=el.innerText||el.textContent||'';return r.test(t);}}){sort};return m;}})()",
p = json!(pattern),
f = json!(flags),
sort = sort,
)
}
fn build_text_regex_fn_body(pattern: &str, best_match: bool) -> String {
let sort = if best_match {
format!(";{}", best_match_sort_js("m", pattern.chars().count()))
} else {
String::new()
};
format!(
"function(p,f){{var r=new RegExp(p,f);var m=Array.from(this.querySelectorAll('*')).filter(function(el){{var t=el.innerText||el.textContent||'';return r.test(t);}}){sort};return m;}}",
sort = sort,
)
}
#[allow(dead_code)] async fn resolve_text_regex_one(
scope: &QueryScope<'_>,
pattern: &str,
flags: &str,
best_match: bool,
) -> Result<Option<RemoteRef>> {
let session = scope.session();
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": format!("({})[0] || null", build_text_regex_js_tab(pattern, flags, best_match)),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": format!(
"function(p,f){{return (({}).call(this,p,f))[0] || null;}}",
build_text_regex_fn_body(pattern, best_match)
),
"arguments": [{ "value": pattern }, { "value": flags }],
"returnByValue": false,
}),
)
.await?
}
};
extract_node_ref(session, &result["result"]).await
}
#[allow(dead_code)] async fn resolve_text_regex_many(
scope: &QueryScope<'_>,
pattern: &str,
flags: &str,
best_match: bool,
) -> Result<Vec<RemoteRef>> {
let session = scope.session();
let result = match scope {
QueryScope::Tab(_) | QueryScope::Frame(_) => {
session
.call(
"Runtime.evaluate",
json!({
"expression": build_text_regex_js_tab(pattern, flags, best_match),
"returnByValue": false,
}),
)
.await?
}
QueryScope::Element(el) => {
let object_id = el.remote_object_id_cloned().await?;
session
.call(
"Runtime.callFunctionOn",
json!({
"objectId": object_id,
"functionDeclaration": build_text_regex_fn_body(pattern, best_match),
"arguments": [{ "value": pattern }, { "value": flags }],
"returnByValue": false,
}),
)
.await?
}
};
extract_array_refs(session, &result["result"]).await
}
#[allow(dead_code)] async fn resolve_role_one(
scope: &QueryScope<'_>,
role: AriaRole,
name: Option<&str>,
) -> Result<Option<RemoteRef>> {
let css = role.to_css();
let candidates = resolve_css_many(scope, &css).await?;
let Some(needle) = name else {
return Ok(candidates.into_iter().next());
};
let session = scope.session();
for candidate in candidates {
if accessible_name_matches(session, &candidate, needle).await? {
return Ok(Some(candidate));
}
}
Ok(None)
}
#[allow(dead_code)] async fn resolve_role_many(
scope: &QueryScope<'_>,
role: AriaRole,
name: Option<&str>,
) -> Result<Vec<RemoteRef>> {
let css = role.to_css();
let candidates = resolve_css_many(scope, &css).await?;
let Some(needle) = name else {
return Ok(candidates);
};
let session = scope.session();
let mut out = Vec::new();
for candidate in candidates {
if accessible_name_matches(session, &candidate, needle).await? {
out.push(candidate);
}
}
Ok(out)
}
#[allow(dead_code)] async fn accessible_name_matches(
session: &SessionHandle,
node: &RemoteRef,
needle: &str,
) -> Result<bool> {
let response = session
.call(
"Accessibility.getPartialAXTree",
json!({
"backendNodeId": node.backend_node_id,
"fetchRelatives": false,
}),
)
.await?;
let needle_lower = needle.to_lowercase();
let Some(nodes) = response["nodes"].as_array() else {
return Ok(false);
};
for ax_node in nodes {
let Some(name_value) = ax_node["name"]["value"].as_str() else {
continue;
};
if name_value.to_lowercase().contains(&needle_lower) {
return Ok(true);
}
}
Ok(false)
}
#[allow(dead_code)] pub(crate) async fn extract_node_ref(
session: &SessionHandle,
result: &Value,
) -> Result<Option<RemoteRef>> {
if result["subtype"] == "null" || result["type"] == "undefined" {
return Ok(None);
}
let Some(remote_object_id) = result["objectId"].as_str().map(str::to_string) else {
return Ok(None);
};
let backend_node_id = describe_backend_id(session, &remote_object_id).await?;
Ok(Some(RemoteRef {
remote_object_id,
backend_node_id,
}))
}
#[allow(dead_code)] pub(crate) async fn extract_array_refs(
session: &SessionHandle,
result: &Value,
) -> Result<Vec<RemoteRef>> {
if result["subtype"] == "null" || result["type"] == "undefined" {
return Ok(Vec::new());
}
let Some(array_id) = result["objectId"].as_str() else {
return Ok(Vec::new());
};
let props = session
.call(
"Runtime.getProperties",
json!({
"objectId": array_id,
"ownProperties": true,
}),
)
.await?;
let entries = props["result"].as_array().cloned().unwrap_or_default();
let mut out = Vec::new();
for entry in entries {
let is_indexed = entry["name"]
.as_str()
.is_some_and(|n| n.parse::<usize>().is_ok());
if !is_indexed {
continue;
}
let value = &entry["value"];
if value["subtype"] == "null" || value["type"] == "undefined" {
continue;
}
if let Some(object_id) = value["objectId"].as_str().map(str::to_string) {
let backend_node_id = describe_backend_id(session, &object_id).await?;
out.push(RemoteRef {
remote_object_id: object_id,
backend_node_id,
});
}
}
Ok(out)
}
pub(crate) async fn text_len_of(scope: &QueryScope<'_>, node: &RemoteRef) -> Result<usize> {
let result = scope
.session()
.call(
"Runtime.callFunctionOn",
json!({
"objectId": node.remote_object_id,
"functionDeclaration":
"function(){return (this.innerText||this.textContent||'').length;}",
"returnByValue": true,
}),
)
.await?;
Ok(result["result"]["value"]
.as_u64()
.map_or(usize::MAX, |v| v as usize))
}
#[allow(dead_code)] async fn describe_backend_id(session: &SessionHandle, object_id: &str) -> Result<i64> {
let described = session
.call("DOM.describeNode", json!({ "objectId": object_id }))
.await?;
described["node"]["backendNodeId"].as_i64().ok_or_else(|| {
ZendriverError::Navigation("DOM.describeNode returned no backendNodeId".into())
})
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use zendriver_transport::SessionHandle;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn css_one_sends_query_selector_with_selector() {
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 {
let scope = QueryScope::Tab(&t);
SelectorKind::Css("#btn".into()).resolve_one(&scope).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.querySelector") && sent.contains("#btn"),
"expression should call document.querySelector with the selector, got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "objectId": "R7", "type": "object", "subtype": "node" } }),
)
.await;
let id_d = mock.expect_cmd("DOM.describeNode").await;
assert_eq!(mock.last_sent()["params"]["objectId"], "R7");
mock.reply(id_d, json!({ "node": { "backendNodeId": 99 } }))
.await;
let r = fut.await.unwrap().unwrap().unwrap();
assert_eq!(r.remote_object_id, "R7");
assert_eq!(r.backend_node_id, 99);
conn.shutdown();
}
#[tokio::test]
async fn text_substring_eval_lowercases_and_includes_needle() {
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 {
let scope = QueryScope::Tab(&t);
SelectorKind::Text {
needle: "Sign In".into(),
exact: false,
}
.resolve_one(&scope)
.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(".toLowerCase()"),
"substring path must lowercase-fold both sides; got: {sent}"
);
assert!(
sent.contains("Sign In"),
"substring path must embed the needle verbatim; got: {sent}"
);
assert!(
sent.contains(".includes("),
"substring path must call .includes; got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "type": "object", "subtype": "null" } }),
)
.await;
let r = fut.await.unwrap().unwrap();
assert!(r.is_none(), "null subtype must yield Ok(None)");
conn.shutdown();
}
#[tokio::test]
async fn text_regex_eval_constructs_new_regexp_with_pattern_and_flags() {
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 {
let scope = QueryScope::Tab(&t);
SelectorKind::TextRegex {
pattern: "hello.*world".into(),
flags: "im".into(),
}
.resolve_one(&scope)
.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("new RegExp"),
"regex path must instantiate `new RegExp`; got: {sent}"
);
assert!(
sent.contains("hello.*world"),
"regex path must embed the pattern; got: {sent}"
);
assert!(
sent.contains("im"),
"regex path must embed the flags string; got: {sent}"
);
mock.reply(
id_q,
json!({ "result": { "type": "object", "subtype": "null" } }),
)
.await;
let r = fut.await.unwrap().unwrap();
assert!(r.is_none(), "null subtype must yield Ok(None)");
conn.shutdown();
}
#[tokio::test]
async fn role_button_without_name_dispatches_attribute_selector_and_resolves_first_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 {
let scope = QueryScope::Tab(&t);
SelectorKind::Role(AriaRole::Button, None)
.resolve_one(&scope)
.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(r#"[role=\"button\"]"#),
"role path must embed the `[role=\"button\"]` attribute selector verbatim; got: {sent}"
);
assert!(
sent.contains("document.querySelectorAll"),
"role path must call querySelectorAll for the candidate enumeration; 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": "RN0", "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"], "RN0");
mock.reply(id_d, json!({ "node": { "backendNodeId": 42 } }))
.await;
let r = fut.await.unwrap().unwrap().unwrap();
assert_eq!(r.remote_object_id, "RN0");
assert_eq!(r.backend_node_id, 42);
conn.shutdown();
}
}