use smix_driver::{AndroidDriver, Driver, Platform, SimctlDriver};
use smix_input::{KeyName, SwipeDirection};
use smix_runner_client::HttpRunnerClient;
use smix_selector::{Modifiers, Pattern, Selector};
use std::time::Duration;
pub const DEFAULT_RUNNER_PORT: u16 = 22087;
pub fn runner_port_from_env_opt() -> Option<u16> {
std::env::var("SMIX_RUNNER_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
}
pub fn parse_selector(s: &str) -> Result<Selector, String> {
let Some((kind, value)) = s.split_once(':') else {
return Err(format!(
"`{s}` has no kind — write `<kind>:<value>`, one of \
id / text / label / role / ocrText / point"
));
};
let modifiers = Modifiers::default();
match kind {
"id" => Ok(Selector::Id {
id: value.to_string(),
modifiers,
}),
"text" => Ok(Selector::Text {
text: Pattern::text(value),
modifiers,
}),
"label" => Ok(Selector::Label {
label: value.to_string(),
modifiers,
}),
"role" => {
let role = smix_selector::role_from_name(value).ok_or_else(|| {
format!(
"unknown role `{value}`; accepted: {}",
smix_selector::ROLE_NAMES
)
})?;
Ok(Selector::Role {
role,
name: None,
modifiers,
})
}
"ocrText" => Ok(Selector::OcrText {
ocr_text: value.to_string(),
locales: Vec::new(),
modifiers,
}),
"point" => {
let (nx, ny) = smix_selector::point_from_str(value)?;
Ok(Selector::Point { nx, ny })
}
_ => Err(format!(
"unknown selector kind `{kind}` — one of id / text / label / role / ocrText / point"
)),
}
}
async fn ocr_frame(
d: &dyn Driver,
needle: &str,
locales: &[String],
) -> Result<Option<smix_driver::OcrFrame>, ActError> {
d.find_text_by_ocr(needle, locales, smix_driver::OCR_RECOGNITION_LEVEL)
.await
.map_err(|e| ActError::Transport(e.to_prompt()))
}
fn ocr_needle(sel: &Selector) -> Option<&str> {
match sel {
Selector::OcrText { ocr_text, .. } => Some(ocr_text),
_ => None,
}
}
#[derive(Debug, thiserror::Error)]
pub enum ActError {
#[error("invalid selector `{0}`: {1}")]
BadSelector(String, String),
#[error("runner transport: {0}")]
Transport(String),
#[error("wait_for timeout after {timeout_ms}ms: {selector}")]
Timeout { selector: String, timeout_ms: u64 },
}
fn driver_for(platform: Platform, port: u16) -> Box<dyn Driver> {
let client = HttpRunnerClient::new(port);
match platform {
Platform::Android => Box::new(AndroidDriver::new(client)),
Platform::Ios => Box::new(SimctlDriver::new(client)),
}
}
#[cfg(test)]
mod driver_for {
use super::*;
#[test]
fn dispatches_android_to_the_android_driver() {
assert_eq!(
driver_for(Platform::Android, 22088).platform(),
Platform::Android
);
}
#[test]
fn dispatches_ios_to_the_ios_driver() {
assert_eq!(driver_for(Platform::Ios, 22087).platform(), Platform::Ios);
}
}
pub fn parse_key_name(s: &str) -> Option<KeyName> {
match s {
"return" | "enter" => Some(KeyName::Return),
"delete" | "backspace" => Some(KeyName::Delete),
"tab" => Some(KeyName::Tab),
"space" => Some(KeyName::Space),
"escape" | "esc" => Some(KeyName::Escape),
"arrowUp" | "up" => Some(KeyName::ArrowUp),
"arrowDown" | "down" => Some(KeyName::ArrowDown),
"arrowLeft" | "left" => Some(KeyName::ArrowLeft),
"arrowRight" | "right" => Some(KeyName::ArrowRight),
"home" => Some(KeyName::Home),
"lock" => Some(KeyName::Lock),
"volumeUp" | "volume-up" => Some(KeyName::VolumeUp),
"volumeDown" | "volume-down" => Some(KeyName::VolumeDown),
_ => None,
}
}
pub fn parse_direction(s: &str) -> Option<SwipeDirection> {
match s {
"up" => Some(SwipeDirection::Up),
"down" => Some(SwipeDirection::Down),
"left" => Some(SwipeDirection::Left),
"right" => Some(SwipeDirection::Right),
_ => None,
}
}
pub async fn cmd_tap_then_screenshot(
selector_str: String,
port: u16,
platform: Platform,
out: &std::path::Path,
) -> Result<(), ActError> {
let selector = parse_selector(&selector_str)
.map_err(|why| ActError::BadSelector(selector_str.clone(), why))?;
if ocr_needle(&selector).is_some() {
return Err(ActError::BadSelector(
selector_str,
"--then-screenshot needs a selector the tree can resolve. An \
ocrText hit is a text frame rather than a resolved element, \
so there would be nothing to say about where the touch \
landed — which is why this command takes no --ocr-locale"
.to_string(),
));
}
let d = driver_for(platform, port);
let raw = HttpRunnerClient::new(port);
let (outcome, captured) = smix_sdk::tap_then_capture_with(d.as_ref(), Some(&raw), &selector)
.await
.map_err(|e| ActError::Transport(e.to_prompt()))?;
std::fs::write(out, &captured.png)
.map_err(|e| ActError::Transport(format!("write {}: {e}", out.display())))?;
println!(
"tapped: {selector_str} — frame via {} {} ms later, {} bytes to {}",
captured.via,
captured.gap_ms,
captured.png.len(),
out.display()
);
if let smix_driver::ActVerdict::Unconfirmable(why) = &outcome.verdict {
println!(" not verified: {why}");
}
Ok(())
}
pub async fn cmd_tap(
selector_str: String,
port: u16,
platform: Platform,
ocr_locales: Vec<String>,
) -> Result<(), ActError> {
let selector = parse_selector(&selector_str)
.map_err(|why| ActError::BadSelector(selector_str.clone(), why))?;
let d = driver_for(platform, port);
if let Some(needle) = ocr_needle(&selector) {
return match ocr_frame(d.as_ref(), needle, &ocr_locales).await? {
Some(f) => {
d.tap_at_norm_coord(f.mid_x(), f.mid_y())
.await
.map_err(|e| ActError::Transport(e.to_prompt()))?;
println!(
"tapped: ocrText={needle} at ({:.3}, {:.3}) — not verified: an OCR hit is a text frame, not a resolved element",
f.mid_x(),
f.mid_y()
);
Ok(())
}
None => Err(ActError::Timeout {
selector: format!("ocrText:{needle}"),
timeout_ms: 0,
}),
};
}
if let Selector::Point { nx, ny } = selector {
d.tap_at_norm_coord(nx, ny)
.await
.map_err(|e| ActError::Transport(e.to_prompt()))?;
println!(
"tapped: point=({nx:.3}, {ny:.3}) — not verified: this path sends a touch without resolving a target"
);
return Ok(());
}
let outcome = d
.tap(&selector, None)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("tapped {selector_str}");
if !outcome.observed.is_empty() {
let inside: Vec<String> = outcome
.observed
.iter()
.map(|e| {
if !e.identifier.is_empty() {
e.identifier.clone()
} else if !e.label.is_empty() {
format!("{:?}", e.label)
} else {
"<unnamed>".to_string()
}
})
.collect();
println!(" aimed inside: {}", inside.join(" < "));
}
if let smix_driver::ActVerdict::Unconfirmable(why) = &outcome.verdict {
println!(" not verified: {why}");
}
Ok(())
}
pub async fn cmd_find(
selector_str: String,
port: u16,
platform: Platform,
ocr_locales: Vec<String>,
) -> Result<(), ActError> {
let selector = parse_selector(&selector_str)
.map_err(|why| ActError::BadSelector(selector_str.clone(), why))?;
if matches!(selector, Selector::Point { .. }) {
return Err(ActError::BadSelector(
selector_str.clone(),
"a point names a place, not an element, so there is nothing here to \
find. Only `smix tap` takes one"
.into(),
));
}
let d = driver_for(platform, port);
if let Some(needle) = ocr_needle(&selector) {
let exists = ocr_frame(d.as_ref(), needle, &ocr_locales).await?.is_some();
println!("exists={exists}");
return Ok(());
}
let exists = d
.find(&selector, None)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("exists={exists}");
Ok(())
}
pub async fn cmd_fill(
selector_str: String,
text: String,
port: u16,
platform: Platform,
) -> Result<(), ActError> {
let selector = parse_selector(&selector_str)
.map_err(|why| ActError::BadSelector(selector_str.clone(), why))?;
if ocr_needle(&selector).is_some() {
return Err(ActError::BadSelector(
selector_str.clone(),
"an OCR hit is a text frame on the screen, not a field with a value — find it with `smix find ocrText:…` or tap it, then act on \
what the tap put on screen"
.into(),
));
}
if matches!(selector, Selector::Point { .. }) {
return Err(ActError::BadSelector(
selector_str.clone(),
"a point names a place, not an element, so there is nothing here to \
fill. Only `smix tap` takes one"
.into(),
));
}
let d = driver_for(platform, port);
let names_a_field = !matches!(selector, smix_selector::Selector::Focused { .. });
d.fill(&selector, &text, None, names_a_field)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("filled {selector_str} ({} chars)", text.chars().count());
Ok(())
}
pub async fn cmd_press_key(key_str: String, port: u16, platform: Platform) -> Result<(), ActError> {
let key = parse_key_name(&key_str).ok_or_else(|| {
ActError::BadSelector(format!("key:{key_str}"), "unknown key name".into())
})?;
let d = driver_for(platform, port);
d.press_key(key)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("pressed key:{key_str}");
Ok(())
}
pub async fn cmd_scroll(
selector_str: String,
direction_str: String,
port: u16,
platform: Platform,
) -> Result<(), ActError> {
let selector = parse_selector(&selector_str)
.map_err(|why| ActError::BadSelector(selector_str.clone(), why))?;
if ocr_needle(&selector).is_some() {
return Err(ActError::BadSelector(
selector_str.clone(),
"scrolling needs an element the tree can follow; an OCR frame is where text was one look ago — find it with `smix find ocrText:…` or tap it, then act on \
what the tap put on screen"
.into(),
));
}
if matches!(selector, Selector::Point { .. }) {
return Err(ActError::BadSelector(
selector_str.clone(),
"a point names a place, not an element, so there is nothing here to \
scroll to. Only `smix tap` takes one"
.into(),
));
}
let direction = parse_direction(&direction_str).ok_or_else(|| {
ActError::BadSelector(
format!("direction:{direction_str}"),
"expected up / down / left / right".into(),
)
})?;
let d = driver_for(platform, port);
d.scroll(&selector, direction)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("scrolled {direction_str} to {selector_str}");
Ok(())
}
pub async fn cmd_swipe_between(
from: &str,
to: &str,
port: u16,
platform: Platform,
) -> Result<(), ActError> {
let from_pt = smix_selector::point_from_str(from)
.map_err(|why| ActError::BadSelector(format!("--from {from}"), why))?;
let to_pt = smix_selector::point_from_str(to)
.map_err(|why| ActError::BadSelector(format!("--to {to}"), why))?;
let d = driver_for(platform, port);
d.swipe_at_norm_coord(from_pt, to_pt)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!(
"swiped ({:.3}, {:.3}) → ({:.3}, {:.3})",
from_pt.0, from_pt.1, to_pt.0, to_pt.1
);
Ok(())
}
pub async fn cmd_swipe(
direction_str: String,
port: u16,
platform: Platform,
) -> Result<(), ActError> {
let direction = parse_direction(&direction_str).ok_or_else(|| {
ActError::BadSelector(
format!("direction:{direction_str}"),
"expected up / down / left / right".into(),
)
})?;
let d = driver_for(platform, port);
d.swipe_once(direction)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("swiped {direction_str}");
Ok(())
}
pub async fn cmd_tree(json: bool, port: u16, keyboard: bool) -> Result<(), ActError> {
let d = SimctlDriver::new(HttpRunnerClient::new(port));
let mut tree = d
.tree(None)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
if !keyboard {
collapse_keyboards(&mut tree);
}
if json {
let s = serde_json::to_string_pretty(&tree)
.map_err(|e| ActError::Transport(format!("serde: {e}")))?;
println!("{s}");
} else {
print_tree_outline(&tree, 0);
}
Ok(())
}
fn collapse_keyboards(node: &mut smix_screen::A11yNode) -> usize {
if smix_screen::is_keyboard(node) {
let keys = smix_screen::subtree_len(node) - 1;
node.children.clear();
return keys;
}
node.children.iter_mut().map(collapse_keyboards).sum()
}
pub async fn fetch_tree_json(port: u16) -> Result<serde_json::Value, ActError> {
let d = SimctlDriver::new(HttpRunnerClient::new(port));
let tree = d
.tree(None)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
serde_json::to_value(&tree).map_err(|e| ActError::Transport(format!("serialize tree: {e}")))
}
fn outline_line(node: &smix_screen::A11yNode, depth: usize) -> String {
let indent = " ".repeat(depth);
let id = node.identifier.as_deref().unwrap_or("");
let label = node.label.as_deref().unwrap_or("");
let text = node.text.as_deref().unwrap_or("");
let visible = if node.visible { "✓" } else { "·" };
let note = if smix_screen::is_keyboard(node) && node.children.is_empty() {
" (keys collapsed — --keyboard to include them)"
} else {
""
};
let text_field = if text.is_empty() {
String::new()
} else {
format!(" text={text:?}")
};
format!("{indent}{visible} id={id:?} label={label:?}{text_field}{note}")
}
fn print_tree_outline(node: &smix_screen::A11yNode, depth: usize) {
println!("{}", outline_line(node, depth));
for child in &node.children {
print_tree_outline(child, depth + 1);
}
}
pub async fn cmd_describe(json: bool, port: u16) -> Result<(), ActError> {
let d = SimctlDriver::new(HttpRunnerClient::new(port));
let desc = d
.describe()
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
if json {
let s = serde_json::to_string_pretty(&desc)
.map_err(|e| ActError::Transport(format!("serde: {e}")))?;
println!("{s}");
} else {
println!("{desc:#?}");
}
Ok(())
}
pub async fn cmd_system_popups(json: bool, port: u16) -> Result<(), ActError> {
let d = SimctlDriver::new(HttpRunnerClient::new(port));
let popups = d
.system_popups(None)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
if json {
let s = serde_json::to_string_pretty(&popups)
.map_err(|e| ActError::Transport(format!("serde: {e}")))?;
println!("{s}");
} else {
if popups.is_empty() {
println!("(no system popups in scope)");
} else {
println!("{popups:#?}");
}
}
Ok(())
}
pub async fn cmd_system_popup_action(
popup_id: &str,
button_id: &str,
port: u16,
) -> Result<(), ActError> {
let d = SimctlDriver::new(HttpRunnerClient::new(port));
let pressed = d
.system_popup_action(popup_id, button_id)
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
if !pressed {
return Err(ActError::Transport(format!(
"no popup {popup_id:?} with button {button_id:?} — list current \
popups via `smix system-popups`"
)));
}
println!("pressed: {button_id} on {popup_id}");
Ok(())
}
pub async fn cmd_hide_keyboard(port: u16, platform: Platform) -> Result<(), ActError> {
let d = driver_for(platform, port);
d.hide_keyboard()
.await
.map_err(|e| ActError::Transport(format!("{e}")))?;
println!("keyboard hidden");
Ok(())
}
pub async fn cmd_wait_for(
selector_str: String,
timeout_secs: u64,
port: u16,
platform: Platform,
absent: bool,
ocr_locales: Vec<String>,
) -> Result<(), ActError> {
let selector = parse_selector(&selector_str)
.map_err(|why| ActError::BadSelector(selector_str.clone(), why))?;
if matches!(selector, Selector::Point { .. }) {
return Err(ActError::BadSelector(
selector_str.clone(),
"a point names a place, not an element, so there is nothing here to \
wait for. Only `smix tap` takes one"
.into(),
));
}
let d = driver_for(platform, port);
let timeout = Duration::from_secs(timeout_secs);
if let Some(needle) = ocr_needle(&selector) {
let deadline = std::time::Instant::now() + timeout;
loop {
let seen = ocr_frame(d.as_ref(), needle, &ocr_locales).await?.is_some();
if seen != absent {
println!("{} {selector_str}", if absent { "gone" } else { "visible" });
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(ActError::Timeout {
selector: selector_str,
timeout_ms: timeout.as_millis() as u64,
});
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
let outcome = if absent {
d.wait_for_not_visible(&selector, timeout).await
} else {
d.wait_for(&selector, timeout, None).await.map(|_| ())
};
match outcome {
Ok(()) => {
println!("{} {selector_str}", if absent { "gone" } else { "visible" });
Ok(())
}
Err(_) => Err(ActError::Timeout {
selector: selector_str,
timeout_ms: timeout.as_millis() as u64,
}),
}
}
#[cfg(test)]
mod tests {
#[test]
fn no_surface_echoes_a_value_the_caller_supplied() {
for (name, src) in [
("smix-cli/act.rs", include_str!("act.rs")),
(
"smix-mcp/main.rs",
include_str!("../../smix-mcp/src/main.rs"),
),
] {
for line in src.lines() {
let l = line.trim();
if !l.starts_with("//") && l.contains("filled") {
assert!(
!l.contains("{text}")
&& !l.contains("params.text\"")
&& !l.contains("`{text}`"),
"{name} echoes the filled value: {l}"
);
}
}
}
}
use super::*;
fn node_with(
id: Option<&str>,
label: Option<&str>,
text: Option<&str>,
) -> smix_screen::A11yNode {
smix_screen::A11yNode {
raw_type: "other".into(),
element_type_raw: 1,
role: None,
identifier: id.map(String::from),
label: label.map(String::from),
title: None,
placeholder_value: None,
value: None,
text: text.map(String::from),
bounds: smix_screen::Rect {
x: 0.0,
y: 0.0,
w: 10.0,
h: 10.0,
},
enabled: true,
selected: false,
has_focus: false,
visible: true,
children: vec![],
}
}
#[test]
fn outline_line_shows_text_in_its_own_position() {
let submit = node_with(Some("fixture_submit"), None, Some("SUBMIT"));
let line = outline_line(&submit, 0);
assert!(
line.contains(r#"id="fixture_submit""#),
"id in its place: {line}"
);
assert!(
line.contains(r#"text="SUBMIT""#),
"text in its place: {line}"
);
assert!(line.contains(r#"label="""#), "label still printed: {line}");
assert!(
!line.contains(r#"id="SUBMIT""#),
"text must not bleed into id: {line}"
);
let bare = node_with(Some("statusBarBackground"), None, None);
assert!(
!outline_line(&bare, 0).contains("text="),
"empty text prints nothing: {}",
outline_line(&bare, 0)
);
}
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn parse_selector_id() {
let s = parse_selector("id:btn-take-photo").expect("parse id");
assert!(matches!(s, Selector::Id { id, .. } if id == "btn-take-photo"));
}
#[test]
fn parse_selector_text_plain() {
let s = parse_selector("text:Welcome to smix").expect("parse text");
match s {
Selector::Text { text, .. } => {
assert!(matches!(text, Pattern::Text(t) if t == "Welcome to smix"));
}
_ => panic!("expected Selector::Text"),
}
}
#[test]
fn parse_selector_label() {
let s = parse_selector("label:Settings").expect("parse label");
assert!(matches!(s, Selector::Label { label, .. } if label == "Settings"));
}
#[test]
fn parse_selector_role_button() {
let s = parse_selector("role:button").expect("parse role");
assert!(matches!(
s,
Selector::Role {
role: smix_screen::Role::Button,
..
}
));
}
#[test]
fn the_cli_reads_the_same_role_vocabulary_as_yaml_and_mcp() {
for (written, expected) in [
("role:Button", smix_screen::Role::Button),
("role:BUTTON", smix_screen::Role::Button),
("role:text_field", smix_screen::Role::TextField),
("role:heading", smix_screen::Role::StaticText),
("role:tab", smix_screen::Role::Tab),
] {
let s = parse_selector(written)
.unwrap_or_else(|e| panic!("{written} is accepted in yaml and MCP: {e}"));
assert!(
matches!(&s, Selector::Role { role, .. } if *role == expected),
"{written} resolved to something else"
);
}
}
#[test]
fn the_wire_spellings_still_parse() {
for w in ["role:button", "role:textField", "role:staticText"] {
assert!(parse_selector(w).is_ok(), "{w}");
}
}
#[test]
fn an_unknown_role_shows_what_is_on_offer() {
let e = parse_selector("role:widget").expect_err("not a role");
assert!(e.contains("widget"), "{e}");
assert!(
e.contains("button"),
"the accepted list has to be in it: {e}"
);
}
#[test]
fn parse_selector_unknown_kind_says_why() {
assert!(parse_selector("xpath://*[1]").is_err());
assert!(parse_selector("nope").is_err()); }
#[test]
fn runner_port_from_env_is_none_when_unset() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("SMIX_RUNNER_PORT") };
assert_eq!(runner_port_from_env_opt(), None);
}
#[test]
fn runner_port_from_env_reads_override() {
let _g = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("SMIX_RUNNER_PORT", "22099") };
assert_eq!(runner_port_from_env_opt(), Some(22099));
unsafe { std::env::remove_var("SMIX_RUNNER_PORT") };
}
#[test]
fn parse_key_name_canonical_and_aliases() {
assert_eq!(parse_key_name("return"), Some(KeyName::Return));
assert_eq!(parse_key_name("enter"), Some(KeyName::Return));
assert_eq!(parse_key_name("delete"), Some(KeyName::Delete));
assert_eq!(parse_key_name("backspace"), Some(KeyName::Delete));
assert_eq!(parse_key_name("arrowUp"), Some(KeyName::ArrowUp));
assert_eq!(parse_key_name("up"), Some(KeyName::ArrowUp));
assert_eq!(parse_key_name("escape"), Some(KeyName::Escape));
assert_eq!(parse_key_name("esc"), Some(KeyName::Escape));
assert_eq!(parse_key_name("volumeUp"), Some(KeyName::VolumeUp));
assert_eq!(parse_key_name("volume-up"), Some(KeyName::VolumeUp));
assert_eq!(parse_key_name("nope"), None);
}
#[test]
fn parse_direction_four_compass_dirs() {
assert_eq!(parse_direction("up"), Some(SwipeDirection::Up));
assert_eq!(parse_direction("down"), Some(SwipeDirection::Down));
assert_eq!(parse_direction("left"), Some(SwipeDirection::Left));
assert_eq!(parse_direction("right"), Some(SwipeDirection::Right));
assert_eq!(parse_direction("nope"), None);
assert_eq!(parse_direction("UP"), None); }
}
#[cfg(test)]
mod point_selector_tests {
use super::parse_selector;
use smix_selector::Selector;
#[test]
fn a_point_parses_and_a_fraction_equals_a_percentage() {
let a = parse_selector("point:50%,25%").expect("percentage");
let b = parse_selector("point:0.5,0.25").expect("fraction");
assert!(matches!(a, Selector::Point { nx, ny } if nx == 0.5 && ny == 0.25));
assert_eq!(format!("{a:?}"), format!("{b:?}"));
}
#[test]
fn pixels_are_answered_with_the_unit_not_a_list_of_kinds() {
let e = parse_selector("point:267,100").expect_err("267 is off screen");
assert!(e.contains("fraction of the viewport"), "{e}");
assert!(
!e.contains("one of id / text"),
"a kind list is not the answer: {e}"
);
}
#[test]
fn an_unknown_kind_and_a_missing_colon_each_say_so() {
assert!(
parse_selector("xpath://div")
.unwrap_err()
.contains("unknown selector kind")
);
assert!(
parse_selector("btn-submit")
.unwrap_err()
.contains("no kind")
);
}
#[test]
fn the_existing_kinds_still_parse() {
for s in ["id:btn", "text:Save", "label:Close", "role:button"] {
assert!(parse_selector(s).is_ok(), "{s}");
}
}
}