use smix_driver::{HttpRunnerClient, SimctlDriver};
use smix_error::FailureCode;
use smix_input::{KeyName, SwipeDirection};
use smix_screen::Role;
use smix_screen::{A11yNode, Rect};
use smix_selector::{Modifiers, Pattern, Selector};
use std::time::Duration;
use wiremock::matchers::{body_json, body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mk_node(label: Option<&str>, bounds: Rect, children: Vec<A11yNode>) -> A11yNode {
A11yNode {
raw_type: "other".into(),
element_type_raw: 1,
role: None,
identifier: None,
label: label.map(String::from),
title: None,
placeholder_value: None,
value: None,
text: None,
bounds,
enabled: true,
selected: false,
has_focus: false,
visible: true,
children,
}
}
fn rect(x: f64, y: f64, w: f64, h: f64) -> Rect {
Rect { x, y, w, h }
}
fn tree_with_login() -> A11yNode {
let mut root = mk_node(
None,
rect(0.0, 0.0, 390.0, 844.0),
vec![mk_node(
Some("Login"),
rect(50.0, 100.0, 200.0, 40.0),
vec![],
)],
);
root.raw_type = "application".into();
root
}
fn text_sel(t: &str) -> Selector {
Selector::Text {
text: Pattern::text(t),
modifiers: Modifiers::default(),
}
}
fn driver_for(server: &MockServer) -> SimctlDriver {
SimctlDriver::new(HttpRunnerClient::with_base(server.uri()))
}
#[tokio::test]
async fn tree_passthrough() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
let t = d.tree(None).await.expect("tree");
assert_eq!(t.children.len(), 1);
assert_eq!(t.children[0].label.as_deref(), Some("Login"));
}
#[tokio::test]
async fn describe_returns_visible_summaries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
let desc = d.describe().await.expect("describe");
assert_eq!(desc.elements.len(), 1, "got {:?}", desc.elements);
assert_eq!(desc.elements[0].name.as_deref(), Some("Login"));
}
#[tokio::test]
async fn find_one_resolves_via_tree() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
let node = d.find_one(&text_sel("Login"), None).await.expect("ok");
assert!(node.is_some());
}
#[tokio::test]
async fn find_one_returns_none_when_missing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
let node = d
.find_one(&text_sel("DoesNotExist"), None)
.await
.expect("ok");
assert!(node.is_none());
}
#[tokio::test]
async fn find_quick_probe_passthrough() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/find"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true, "found": true
})))
.mount(&server)
.await;
let d = driver_for(&server);
assert!(d.find(&text_sel("X"), None).await.unwrap());
}
#[tokio::test]
async fn tap_resolves_then_tap_at_norm_coord() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
struct NormCoordNear {
nx: f64,
ny: f64,
}
impl wiremock::Match for NormCoordNear {
fn matches(&self, request: &wiremock::Request) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&request.body) else {
return false;
};
let (Some(nx), Some(ny)) = (v["nx"].as_f64(), v["ny"].as_f64()) else {
return false;
};
(nx - self.nx).abs() < 1e-6 && (ny - self.ny).abs() < 1e-6
}
}
Mock::given(method("POST"))
.and(path("/tap-at-norm-coord"))
.and(NormCoordNear {
nx: 150.0 / 390.0,
ny: 120.0 / 844.0,
})
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true
})))
.expect(1)
.mount(&server)
.await;
let d = driver_for(&server);
d.tap(&text_sel("Login"), None).await.expect("tap ok");
server.verify().await;
}
#[tokio::test]
async fn tap_miss_returns_element_not_found_after_implicit_wait() {
let server = MockServer::start().await;
let empty_tree = mk_node(None, rect(0.0, 0.0, 390.0, 844.0), vec![]);
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(empty_tree))
.mount(&server)
.await;
let d = driver_for(&server);
let err = d.tap(&text_sel("DoesNotExist"), None).await.unwrap_err();
assert_eq!(err.code, FailureCode::ElementNotFound);
assert!(err.message.contains("element not found"));
}
#[tokio::test]
async fn fill_chunks_one_char_per_post() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/fill"))
.and(body_partial_json(serde_json::json!({
"selector": {"text": "Email"}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null, "stages": null
})))
.expect(7) .mount(&server)
.await;
let d = driver_for(&server);
d.fill(&text_sel("Email"), "u@x.com", None).await.unwrap();
}
#[tokio::test]
async fn fill_with_id_selector_focus_taps_then_fills_focused() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_id_and_role()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/tap-at-norm-coord"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/fill"))
.and(body_partial_json(serde_json::json!({
"selector": {"focused": true}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null, "stages": null
})))
.expect(5) .mount(&server)
.await;
let d = driver_for(&server);
d.fill(&id_sel("login-btn"), "Hello", None).await.unwrap();
}
#[tokio::test]
async fn fill_single_char_takes_fast_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/fill"))
.and(body_json(serde_json::json!({
"selector": {"text": "Email"},
"text": "x"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null, "stages": null
})))
.expect(1)
.mount(&server)
.await;
let d = driver_for(&server);
d.fill(&text_sel("Email"), "x", None).await.unwrap();
}
#[tokio::test]
async fn clear_with_text_selector_takes_fast_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/clear"))
.and(body_partial_json(serde_json::json!({
"selector": {"text": "Email"}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null, "stages": null
})))
.expect(1)
.mount(&server)
.await;
let d = driver_for(&server);
d.clear(&text_sel("Email"), None).await.unwrap();
}
#[tokio::test]
async fn clear_with_id_selector_focus_taps_then_clears_focused() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_id_and_role()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/tap-at-norm-coord"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/clear"))
.and(body_partial_json(serde_json::json!({
"selector": {"focused": true}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null, "stages": null
})))
.expect(1)
.mount(&server)
.await;
let d = driver_for(&server);
d.clear(&id_sel("login-btn"), None).await.unwrap();
}
#[tokio::test]
async fn press_key_passthrough() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/press-key"))
.and(body_json(serde_json::json!({"key": "return"})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tree": null, "stages": null
})))
.mount(&server)
.await;
let d = driver_for(&server);
d.press_key(KeyName::Return).await.unwrap();
}
#[tokio::test]
async fn swipe_once_passthrough() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/swipe-once"))
.and(body_json(serde_json::json!({"direction": "up"})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true
})))
.mount(&server)
.await;
let d = driver_for(&server);
d.swipe_once(SwipeDirection::Up).await.unwrap();
}
#[tokio::test]
async fn back_and_hide_keyboard_passthroughs() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/back"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/hide-keyboard"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true
})))
.mount(&server)
.await;
let d = driver_for(&server);
d.back().await.unwrap();
d.hide_keyboard().await.unwrap();
}
#[tokio::test]
async fn wait_for_resolves_immediately_on_hit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
let node = d
.wait_for(&text_sel("Login"), Duration::from_millis(500), None)
.await
.expect("wait_for");
assert_eq!(node.label.as_deref(), Some("Login"));
}
#[tokio::test]
async fn wait_for_times_out_when_never_appears() {
let server = MockServer::start().await;
let empty_tree = mk_node(None, rect(0.0, 0.0, 390.0, 844.0), vec![]);
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(empty_tree))
.mount(&server)
.await;
let d = driver_for(&server);
let err = d
.wait_for(&text_sel("Never"), Duration::from_millis(300), None)
.await
.unwrap_err();
assert_eq!(err.code, FailureCode::Timeout);
}
#[tokio::test]
async fn dispose_idempotent_noop() {
let server = MockServer::start().await;
let d = driver_for(&server);
d.dispose().await.unwrap();
d.dispose().await.unwrap();
}
fn id_sel(s: &str) -> Selector {
Selector::Id {
id: s.to_string(),
modifiers: Modifiers::default(),
}
}
fn label_sel(s: &str) -> Selector {
Selector::Label {
label: s.to_string(),
modifiers: Modifiers::default(),
}
}
fn role_sel(name: &str) -> Selector {
Selector::Role {
role: Role::Button,
name: Some(Pattern::text(name)),
modifiers: Modifiers::default(),
}
}
fn tree_with_id_and_role() -> A11yNode {
let mut button = mk_node(Some("Login"), rect(50.0, 100.0, 200.0, 40.0), vec![]);
button.raw_type = "button".into();
button.identifier = Some("login-btn".into());
let mut root = mk_node(None, rect(0.0, 0.0, 390.0, 844.0), vec![button]);
root.raw_type = "application".into();
root
}
#[tokio::test]
async fn find_with_id_selector_falls_back_to_host_resolve() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_id_and_role()))
.mount(&server)
.await;
let d = driver_for(&server);
assert!(d.find(&id_sel("login-btn"), None).await.unwrap());
assert!(!d.find(&id_sel("missing-btn"), None).await.unwrap());
}
#[tokio::test]
async fn find_with_label_selector_falls_back_to_host_resolve() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
assert!(d.find(&label_sel("Login"), None).await.unwrap());
assert!(!d.find(&label_sel("Logout"), None).await.unwrap());
}
#[tokio::test]
async fn find_with_role_selector_falls_back_to_host_resolve() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_id_and_role()))
.mount(&server)
.await;
let d = driver_for(&server);
assert!(d.find(&role_sel("Login"), None).await.unwrap());
}
#[tokio::test]
async fn find_with_text_plus_index_modifier_falls_back_to_host_resolve() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
let d = driver_for(&server);
let sel = Selector::Text {
text: Pattern::text("Login"),
modifiers: Modifiers {
first: Some(true),
..Default::default()
},
};
assert!(d.find(&sel, None).await.unwrap());
}
#[tokio::test]
async fn find_with_text_plus_ancestor_modifier_falls_back_to_host_resolve() {
let server = MockServer::start().await;
let mut tab_bar = mk_node(
None,
rect(0.0, 760.0, 390.0, 80.0),
vec![mk_node(Some("Same"), rect(50.0, 780.0, 80.0, 40.0), vec![])],
);
tab_bar.raw_type = "tabBar".into();
tab_bar.role = Some(Role::TabBar);
let mut root = mk_node(None, rect(0.0, 0.0, 390.0, 844.0), vec![tab_bar]);
root.raw_type = "application".into();
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(root))
.mount(&server)
.await;
let d = driver_for(&server);
let sel = Selector::Text {
text: Pattern::text("Same"),
modifiers: Modifiers {
ancestor: Some(Box::new(Selector::Role {
role: Role::TabBar,
name: None,
modifiers: Modifiers::default(),
})),
..Default::default()
},
};
assert!(
d.find(&sel, None)
.await
.expect("ancestor selector should dispatch to host-resolve via /tree")
);
}
#[tokio::test]
async fn tap_at_norm_coord_passthrough() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/tap-at-norm-coord"))
.and(body_partial_json(
serde_json::json!({ "nx": 0.5, "ny": 0.95 }),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true })))
.expect(1)
.mount(&server)
.await;
let d = driver_for(&server);
d.tap_at_norm_coord(0.5, 0.95)
.await
.expect("tap_at_norm_coord should pass through to runner");
}
#[tokio::test]
async fn find_host_resolve_retries_transient_tree_500() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(
ResponseTemplate::new(500)
.set_body_json(serde_json::json!({"ok": false, "error": "snapshot_unavailable"})),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_id_and_role()))
.mount(&server)
.await;
let d = driver_for(&server);
let present = d
.find(&id_sel("login-btn"), None)
.await
.expect("find host-resolve branch must retry transient /tree 500");
assert!(present, "Login id node is present after retry");
}
#[tokio::test]
async fn find_runner_route_retries_transient_find_500() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/find"))
.respond_with(
ResponseTemplate::new(500)
.set_body_json(serde_json::json!({"ok": false, "error": "transient"})),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/find"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"found": true, "ok": true})),
)
.mount(&server)
.await;
let d = driver_for(&server);
let present = d
.find(&text_sel("Login"), None)
.await
.expect("find runner-route branch must retry transient /find 500");
assert!(present, "/find returns exists=true after retry");
}
#[tokio::test]
async fn tap_retries_transient_tree_500() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(
ResponseTemplate::new(500)
.set_body_json(serde_json::json!({"ok": false, "error": "snapshot_unavailable"})),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_login()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/tap-at-norm-coord"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&server)
.await;
let d = driver_for(&server);
d.tap(&text_sel("Login"), None)
.await
.expect("tap must retry transient /tree 500 and proceed");
}
#[tokio::test]
async fn find_one_and_find_all_retry_transient_tree_500() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(
ResponseTemplate::new(500)
.set_body_json(serde_json::json!({"ok": false, "error": "snapshot_unavailable"})),
)
.up_to_n_times(2) .mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/tree"))
.respond_with(ResponseTemplate::new(200).set_body_json(tree_with_id_and_role()))
.mount(&server)
.await;
let d = driver_for(&server);
let one = d
.find_one(&id_sel("login-btn"), None)
.await
.expect("find_one must retry transient /tree 500");
assert!(one.is_some(), "find_one should resolve after retry");
let all = d
.find_all(&id_sel("login-btn"), None)
.await
.expect("find_all must retry transient /tree 500");
assert_eq!(all.len(), 1, "find_all should match 1 node after retry");
}
#[tokio::test]
async fn system_popup_action_returns_true_on_runner_ok() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/system-popup-action"))
.and(body_partial_json(
serde_json::json!({"popupId": "p-1", "buttonId": "b-open"}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&server)
.await;
let d = driver_for(&server);
let ok = d
.system_popup_action("p-1", "b-open")
.await
.expect("system_popup_action ok path");
assert!(ok);
}
#[tokio::test]
async fn system_popup_action_returns_false_on_runner_404_not_found() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/system-popup-action"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"ok": false,
"error": "not_found",
"popupId": "p-x",
"buttonId": "b-x"
})))
.mount(&server)
.await;
let d = driver_for(&server);
let ok = d
.system_popup_action("p-x", "b-x")
.await
.expect("system_popup_action 404 surfaces as Ok(false)");
assert!(!ok);
}
#[tokio::test]
async fn android_dispatch_override_explains_itself() {
use smix_driver::AndroidDriver;
use smix_runner_client::{HttpRunnerClient, TapMode};
use smix_selector::Selector;
let driver = AndroidDriver::new(HttpRunnerClient::new(1));
let err = smix_driver::Driver::tap_with_mode(
&driver,
&Selector::Id {
id: "x".into(),
modifiers: Default::default(),
},
TapMode::Resolve,
None,
)
.await
.expect_err("dispatch has no meaning on Android");
let text = format!("{} {}", err.message, err.hint.clone().unwrap_or_default());
assert!(
text.contains("iOS") && text.contains("dispatch"),
"the error must name what the key is and where it belongs: {text}"
);
assert!(
!text.contains("not implemented"),
"reads as a missing feature rather than an inapplicable one: {text}"
);
assert!(
text.contains("remove"),
"an AI-readable failure says what to do next: {text}"
);
}
#[tokio::test]
async fn android_key_events_mode_skips_focus_resolution() {
use smix_driver::{AndroidDriver, Driver};
use smix_runner_client::HttpRunnerClient;
use smix_selector::Selector;
let selector = Selector::Id {
id: "hidden-input".into(),
modifiers: Default::default(),
};
let resolving = AndroidDriver::new(HttpRunnerClient::new(1));
let resolved_err = resolving
.fill(&selector, "hello", None)
.await
.expect_err("no runner");
let mut key_events = AndroidDriver::new(HttpRunnerClient::new(1));
key_events.set_force_key_events(true);
let key_err = key_events
.fill(&selector, "hello", None)
.await
.expect_err("no runner");
assert!(
key_err.message.contains("key-events"),
"key-events mode must take its own path: {}",
key_err.message
);
assert_ne!(
resolved_err.message, key_err.message,
"the flag changed nothing — both modes failed at the same stage"
);
}
#[tokio::test]
async fn android_sends_the_target_package_to_the_runner() {
use smix_driver::{AndroidDriver, Driver};
use smix_runner_client::HttpRunnerClient;
use std::io::Read;
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
let recorder = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept");
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).unwrap_or(0);
String::from_utf8_lossy(&buf[..n]).to_string()
});
let mut driver = AndroidDriver::new(HttpRunnerClient::new(port));
driver.set_target_bundle_id("jp.golia.app");
let _ = driver.tap_by_id("submit").await;
let request = recorder.join().expect("recorder");
let header = request
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("app-bundle-id:"))
.unwrap_or_else(|| panic!("no App-Bundle-Id header reached the runner:\n{request}"));
assert!(
header.contains("jp.golia.app"),
"the header carried the wrong package: {header}"
);
}