fix: support xpath= selector prefix in element resolution (#908)

* fix: support xpath= selector prefix in element resolution

Resolves #907. When a selector starts with "xpath=", use
document.evaluate() instead of document.querySelector() so that
XPath expressions like "xpath=//button" work correctly.

* test: replace overlapping test with edge case tests

Replace test_build_selector_js_xpath_strips_prefix (which overlapped
with the xpath test) with two edge case tests: empty xpath and
selector starting with "xpath" without "=" delimiter.

* fix: support xpath= selector in resolve_element_object_id

Apply the same xpath= handling to resolve_element_object_id, which is
used by type, fill, focus, hover, check, select, screenshot, drag,
and all other selector-based commands beyond basic click.

* refactor: extract build_find_element_js to deduplicate xpath/css logic

The xpath= vs querySelector branching was duplicated in both
build_selector_js and resolve_element_object_id. Extract the shared
logic into build_find_element_js and reuse it in both places.

* fix: support xpath= selector in get_element_count

Use ORDERED_NODE_SNAPSHOT_TYPE with snapshotLength for XPath counting,
matching the querySelectorAll().length behavior for CSS selectors.

* refactor: rename find to find_expr for clarity

* refactor: extract build_count_elements_js and add regression tests

Extract element counting JS generation into build_count_elements_js
helper (matching the pattern of build_find_element_js) and add tests
for both CSS and XPath counting paths.

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
This commit is contained in:
jin.2
2026-03-19 19:27:03 -05:00
committed by GitHub
co-authored by hyunjinee
parent 421f8fab82
commit af800f8403
+87 -18
View File
@@ -263,11 +263,8 @@ pub async fn resolve_element_object_id(
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
}
// CSS selector fallback
let js = format!(
"document.querySelector({})",
serde_json::to_string(selector_or_ref).unwrap_or_default()
);
// Selector fallback (CSS or XPath)
let js = build_find_element_js(selector_or_ref);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
@@ -347,20 +344,54 @@ fn extract_ax_string(value: &Option<AXValue>) -> String {
}
}
/// Build a JS expression that finds a DOM element by CSS selector or XPath.
fn build_find_element_js(selector: &str) -> String {
if let Some(xpath) = selector.strip_prefix("xpath=") {
format!(
"document.evaluate({}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
serde_json::to_string(xpath).unwrap_or_default()
)
} else {
format!(
"document.querySelector({})",
serde_json::to_string(selector).unwrap_or_default()
)
}
}
/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
fn build_count_elements_js(selector: &str) -> String {
if let Some(xpath) = selector.strip_prefix("xpath=") {
format!(
"document.evaluate({}, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength",
serde_json::to_string(xpath).unwrap_or_default()
)
} else {
format!(
"document.querySelectorAll({}).length",
serde_json::to_string(selector).unwrap_or_default()
)
}
}
fn build_selector_js(selector: &str) -> String {
let find_expr = build_find_element_js(selector);
format!(
r#"(() => {{
const el = {find_expr};
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
}})()"#,
)
}
async fn resolve_by_selector(
client: &CdpClient,
session_id: &str,
selector: &str,
) -> Result<(f64, f64), String> {
let js = format!(
r#"(() => {{
const el = document.querySelector({sel});
if (!el) return null;
const rect = el.getBoundingClientRect();
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
}})()"#,
sel = serde_json::to_string(selector).unwrap_or_default(),
);
let js = build_selector_js(selector);
let result: EvaluateResult = client
.send_command_typed(
@@ -739,10 +770,7 @@ pub async fn get_element_count(
session_id: &str,
selector: &str,
) -> Result<i64, String> {
let js = format!(
"document.querySelectorAll({}).length",
serde_json::to_string(selector).unwrap_or_default()
);
let js = build_count_elements_js(selector);
let result: EvaluateResult = client
.send_command_typed(
@@ -849,6 +877,47 @@ mod tests {
assert!(map.get("e2").is_none());
}
#[test]
fn test_build_selector_js_css() {
let js = build_selector_js("#submit-btn");
assert!(js.contains("document.querySelector(\"#submit-btn\")"));
assert!(!js.contains("document.evaluate"));
}
#[test]
fn test_build_selector_js_xpath() {
let js = build_selector_js("xpath=//button[@id='ok']");
assert!(js.contains("document.evaluate(\"//button[@id='ok']\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)"));
assert!(!js.contains("document.querySelector"));
}
#[test]
fn test_build_selector_js_xpath_empty() {
let js = build_selector_js("xpath=");
assert!(js.contains("document.evaluate"));
}
#[test]
fn test_build_selector_js_not_xpath_prefix() {
// "xpath" without "=" should be treated as CSS selector
let js = build_selector_js("xpath//div");
assert!(js.contains("document.querySelector"));
}
#[test]
fn test_build_count_elements_js_css() {
let js = build_count_elements_js(".item");
assert!(js.contains("document.querySelectorAll(\".item\").length"));
assert!(!js.contains("document.evaluate"));
}
#[test]
fn test_build_count_elements_js_xpath() {
let js = build_count_elements_js("xpath=//li");
assert!(js.contains("document.evaluate(\"//li\", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength"));
assert!(!js.contains("querySelectorAll"));
}
#[test]
fn test_box_model_center() {
let model = BoxModel {