fix(cli): text-selector click by visible label + get text→body + tab --activate (#24)
Three CLI gaps surfaced driving a Mercari signup→checkout flow: - #24-B (correctness): a bare label like 'click 購入手続きへ' was fed straight to document.querySelector as CSS and failed as an invalid selector, even though snapshot listed the button by that exact name. build_find_element_js now tries CSS first, then falls back to matching an interactive element by visible text (exact then contains) — nested and non-ASCII labels resolve. 'text=<label>' forces the text path. CSS still wins when it matches. - #24-D: 'get text' with no selector now returns the whole page (body). - #24-C: 'tab <ref> --activate' (alias --front) switches to the tab AND raises it to the foreground — to surface a specific tab for the human. Tests cover the text fallback / text= / xpath builder, body default, activate flag. The core stale-sessionId-after-cross-process-nav bug is the #20/#23 class, already fixed in ext 0.4.8 — needs that extension deployed.
This commit is contained in:
+38
-15
@@ -1575,11 +1575,16 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
Some(tab_ref) => Ok(json!({
|
||||
"id": id,
|
||||
"action": "tab_switch",
|
||||
"tabId": tab_ref,
|
||||
})),
|
||||
Some(tab_ref) => {
|
||||
// `tab <ref> --activate` (alias `--front`) switches to the tab
|
||||
// AND raises it to the foreground — for handing a specific tab
|
||||
// to the human (SMS code, captcha) (issue #24-C).
|
||||
let mut cmd = json!({ "id": id, "action": "tab_switch", "tabId": tab_ref });
|
||||
if rest.iter().any(|a| *a == "--activate" || *a == "--front") {
|
||||
cmd["activate"] = json!(true);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
None => {
|
||||
let mut cmd = json!({ "id": id, "action": "tab_list" });
|
||||
if full {
|
||||
@@ -2361,10 +2366,10 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
|
||||
match rest.first().copied() {
|
||||
Some("text") => {
|
||||
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "get text".to_string(),
|
||||
usage: "get text <selector>",
|
||||
})?;
|
||||
// `get text` with no selector returns the whole page's text (body) —
|
||||
// a common convenience; previously it errored without a selector
|
||||
// (issue #24-D).
|
||||
let sel = rest.get(1).copied().unwrap_or("body");
|
||||
Ok(json!({ "id": id, "action": "gettext", "selector": sel }))
|
||||
}
|
||||
Some("html") => {
|
||||
@@ -4686,12 +4691,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_text_missing_selector() {
|
||||
let result = parse_command(&args("get text"), &default_flags());
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, ParseError::MissingArguments { .. }));
|
||||
assert!(err.format().contains("get text"));
|
||||
fn test_get_text_defaults_to_body() {
|
||||
// `get text` with no selector now returns the whole page (body) instead
|
||||
// of erroring (issue #24-D).
|
||||
let cmd = parse_command(&args("get text"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "gettext");
|
||||
assert_eq!(cmd["selector"], "body");
|
||||
// An explicit selector still wins.
|
||||
let cmd2 = parse_command(&args("get text h1"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd2["selector"], "h1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_activate_flag() {
|
||||
let plain = parse_command(&args("tab t3"), &default_flags()).unwrap();
|
||||
assert_eq!(plain["action"], "tab_switch");
|
||||
assert!(plain.get("activate").is_none());
|
||||
|
||||
let act = parse_command(&args("tab t3 --activate"), &default_flags()).unwrap();
|
||||
assert_eq!(act["action"], "tab_switch");
|
||||
assert_eq!(act["tabId"], "t3");
|
||||
assert_eq!(act["activate"], true);
|
||||
// `--front` alias.
|
||||
let front = parse_command(&args("tab t3 --front"), &default_flags()).unwrap();
|
||||
assert_eq!(front["activate"], true);
|
||||
}
|
||||
|
||||
// === Protocol alignment tests ===
|
||||
|
||||
@@ -4466,6 +4466,17 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
state.active_frame_id = None;
|
||||
let result = mgr.tab_switch_by_id(tab_id).await?;
|
||||
|
||||
// `--activate`: raise this tab to the foreground (the switch made it active;
|
||||
// bring_to_front acts on the active tab) — for handing a specific tab to the
|
||||
// human (issue #24-C). Best-effort; don't fail the switch if it can't.
|
||||
if cmd
|
||||
.get("activate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = mgr.bring_to_front().await;
|
||||
}
|
||||
|
||||
if let Some(ref server) = state.stream_server {
|
||||
if let Ok(dims) = mgr
|
||||
.evaluate(
|
||||
|
||||
@@ -796,16 +796,43 @@ pub(super) 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!(
|
||||
return 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()
|
||||
)
|
||||
);
|
||||
}
|
||||
// Bare string (or explicit `text=`): try CSS first, then fall back to
|
||||
// matching an interactive element by its VISIBLE TEXT. snapshot exposes
|
||||
// buttons/links by their name, so `click "購入手続きへ"` should resolve by
|
||||
// that label — previously it was fed straight to `querySelector` as CSS and
|
||||
// failed as an invalid selector even though the button was right there
|
||||
// (issue #24-B). CSS still wins when it matches, so existing selectors are
|
||||
// unaffected; nested/non-ASCII labels now resolve too.
|
||||
let text_only = selector.strip_prefix("text=");
|
||||
let force_text = text_only.is_some();
|
||||
let sel_json = serde_json::to_string(selector).unwrap_or_default();
|
||||
let want_json = serde_json::to_string(text_only.unwrap_or(selector)).unwrap_or_default();
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const sel = {sel};
|
||||
const css = {force_text} ? null : (() => {{ try {{ return document.querySelector(sel); }} catch (_e) {{ return null; }} }})();
|
||||
if (css) return css;
|
||||
const norm = s => (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
|
||||
const w = norm({want}); if (!w) return null;
|
||||
const wl = w.toLowerCase();
|
||||
const interactive = Array.from(document.querySelectorAll(
|
||||
'button,a,[role=button],[role=link],[role=menuitem],[role=tab],[role=option],input[type=submit],input[type=button],input[type=reset],summary,label,[onclick]'));
|
||||
const textOf = e => norm(e.innerText || e.textContent) || norm(e.value) ||
|
||||
norm(e.getAttribute && e.getAttribute('aria-label')) || norm(e.getAttribute && e.getAttribute('title'));
|
||||
let hit = interactive.find(e => textOf(e) === w) || interactive.find(e => textOf(e).toLowerCase().includes(wl));
|
||||
if (hit) return hit;
|
||||
const leaves = Array.from(document.querySelectorAll('*')).filter(e => !e.children.length);
|
||||
return leaves.find(e => norm(e.textContent) === w) || leaves.find(e => norm(e.textContent).toLowerCase().includes(wl)) || null;
|
||||
}})()"#,
|
||||
sel = sel_json,
|
||||
want = want_json,
|
||||
force_text = force_text
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
|
||||
@@ -1452,10 +1479,30 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_selector_js_css() {
|
||||
let js = build_selector_js("#submit-btn");
|
||||
assert!(js.contains("document.querySelector(\"#submit-btn\")"));
|
||||
// CSS is now tried via a `sel` variable, with a visible-text fallback
|
||||
// appended (issue #24-B). It must still use querySelector (not xpath).
|
||||
assert!(js.contains("const sel = \"#submit-btn\""));
|
||||
assert!(js.contains("document.querySelector(sel)"));
|
||||
assert!(!js.contains("document.evaluate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_find_element_js_text_fallback() {
|
||||
// A bare label gets a text-matching fallback so `click "購入手続きへ"`
|
||||
// resolves by visible text, not just CSS (issue #24-B).
|
||||
let js = build_find_element_js("購入手続きへ");
|
||||
assert!(js.contains("購入手続きへ"));
|
||||
assert!(js.contains("interactive")); // the text-match branch
|
||||
assert!(js.contains("textOf"));
|
||||
// `text=` forces the text path (skips CSS).
|
||||
let forced = build_find_element_js("text=Buy now");
|
||||
assert!(forced.contains("true ? null")); // force_text => css skipped
|
||||
// xpath is unchanged.
|
||||
let xp = build_find_element_js("xpath=//button");
|
||||
assert!(xp.contains("document.evaluate"));
|
||||
assert!(!xp.contains("interactive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selector_js_xpath() {
|
||||
let js = build_selector_js("xpath=//button[@id='ok']");
|
||||
|
||||
Reference in New Issue
Block a user