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:
+87
-18
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user