#![cfg(target_os = "macos")]
use native_devtools_mcp::macos::ax::collect_ax_tree_indexed;
use native_devtools_mcp::tools::ax_click::{ax_click, AxClickParams};
use native_devtools_mcp::tools::ax_select::{ax_select, AxSelectParams};
use native_devtools_mcp::tools::ax_session::AxSession;
use native_devtools_mcp::tools::ax_set_value::{ax_set_value, AxSetValueParams};
use native_devtools_mcp::tools::ax_snapshot::format_snapshot;
use native_devtools_mcp::tools::navigation::{
focus_window, list_apps, FocusWindowParams, ListAppsParams,
};
use rmcp::model::CallToolResult;
use std::sync::Arc;
fn new_session() -> Arc<AxSession> {
Arc::new(AxSession::new())
}
async fn snapshot(session: &Arc<AxSession>, app_name: Option<&str>) -> String {
let (nodes, refs) =
collect_ax_tree_indexed(app_name).expect("collect_ax_tree_indexed should succeed");
let generation = session.create_snapshot(refs).await;
format_snapshot(&nodes, Some(generation))
}
fn focus(app_name: &str) -> CallToolResult {
focus_window(FocusWindowParams {
window_id: None,
app_name: Some(app_name.to_string()),
pid: None,
})
}
fn list_apps_all() -> CallToolResult {
list_apps(ListAppsParams {
app_name: None,
user_apps_only: None,
})
}
fn extract_text(r: &CallToolResult) -> String {
r.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("")
}
fn parse_json(s: &str) -> serde_json::Value {
serde_json::from_str(s).expect("response body should be JSON")
}
fn extract_uid_for_named_button(snapshot: &str, button_name: &str) -> String {
for line in snapshot.lines() {
if line.contains(&format!("\"{}\"", button_name)) {
if let Some(after) = line.split_whitespace().next() {
if let Some(uid) = after.strip_prefix("uid=") {
return uid.to_string();
}
}
}
}
panic!(
"no uid for button {} in snapshot:\n{}",
button_name, snapshot
);
}
fn extract_calculator_display_value(snapshot: &str) -> Option<String> {
for line in snapshot.lines() {
let depth = line.len() - line.trim_start().len();
if depth > 6 {
continue; }
let trimmed = line.trim_start();
if !(trimmed.contains(" text ") || trimmed.contains(" text\t")) {
continue;
}
if let Some(v_start) = trimmed.find("value=\"") {
let rest = &trimmed[v_start + "value=\"".len()..];
if let Some(end) = rest.find('"') {
return Some(rest[..end].to_string());
}
}
}
None
}
#[tokio::test]
#[ignore]
async fn ax_click_presses_calculator_five_button() {
let session = new_session();
let snap_text = snapshot(&session, Some("Calculator")).await;
let five_uid = extract_uid_for_named_button(&snap_text, "5");
let before = extract_calculator_display_value(&snap_text).unwrap_or_default();
let click = ax_click(AxClickParams { uid: five_uid }, session.clone()).await;
let body = parse_json(&extract_text(&click));
assert_eq!(body["ok"], true);
assert_eq!(body["dispatched_via"], "AXPress");
let snap2_text = snapshot(&session, Some("Calculator")).await;
let after = extract_calculator_display_value(&snap2_text)
.expect("Calculator display should expose a value after AXPress");
assert_ne!(
before, after,
"display value must change after AXPress (before={before:?}, after={after:?})"
);
assert!(
after.ends_with('5'),
"display should end in '5' after AXPress on the 5 button (got {after:?})"
);
}
#[tokio::test]
#[ignore]
async fn ax_click_stale_generation_returns_snapshot_expired() {
let session = new_session();
let snap1 = snapshot(&session, Some("Calculator")).await;
let five_g1 = extract_uid_for_named_button(&snap1, "5");
let _snap2 = snapshot(&session, Some("Calculator")).await;
let click = ax_click(AxClickParams { uid: five_g1 }, session.clone()).await;
assert_eq!(click.is_error, Some(true));
let body = parse_json(&extract_text(&click));
assert_eq!(
body["error"]["code"], "snapshot_expired",
"stale gen-1 uid must not resolve to gen-2 element"
);
}
#[tokio::test]
#[ignore]
async fn ax_click_unknown_uid_in_current_gen_returns_uid_not_found() {
let session = new_session();
let snap_text = snapshot(&session, Some("Calculator")).await;
let first_line = snap_text.lines().next().expect("non-empty snapshot");
let uid_token = first_line
.split_whitespace()
.next()
.unwrap()
.strip_prefix("uid=")
.unwrap();
let (_, gen_part) = uid_token.split_once('g').unwrap();
let gen: u64 = gen_part.parse().unwrap();
let missing = format!("a99999g{}", gen);
let click = ax_click(AxClickParams { uid: missing }, session.clone()).await;
assert_eq!(click.is_error, Some(true));
let body = parse_json(&extract_text(&click));
assert_eq!(body["error"]["code"], "uid_not_found");
}
#[tokio::test]
#[ignore]
async fn ax_click_on_decorative_label_returns_not_dispatchable_with_fallback() {
let session = new_session();
let snap_text = snapshot(&session, Some("Calculator")).await;
let decorative_line = snap_text
.lines()
.find(|l| (l.contains(" text ") || l.contains(" generic ")) && l.contains("bbox=("))
.expect("calculator should contain at least one non-pressable node with a bbox");
let decorative_uid = decorative_line
.split_whitespace()
.next()
.unwrap()
.strip_prefix("uid=")
.unwrap()
.to_string();
let click = ax_click(
AxClickParams {
uid: decorative_uid,
},
session.clone(),
)
.await;
assert_eq!(click.is_error, Some(true));
let body = parse_json(&extract_text(&click));
assert_eq!(body["error"]["code"], "not_dispatchable");
assert!(
body["error"]["fallback"].is_object(),
"fallback should be populated when bbox is readable"
);
assert!(body["error"]["fallback"]["x"].as_f64().unwrap() > 0.0);
assert!(body["error"]["fallback"]["y"].as_f64().unwrap() > 0.0);
}
#[tokio::test]
#[ignore]
async fn take_ax_snapshot_emits_bbox_on_positioned_nodes() {
let session = new_session();
let snap_text = snapshot(&session, Some("Calculator")).await;
let five_line = snap_text
.lines()
.find(|l| l.contains("\"5\""))
.expect("Calculator should expose a '5' button");
assert!(
five_line.contains("bbox=("),
"positioned button should carry a bbox: {}",
five_line
);
let bbox_start = five_line.find("bbox=(").unwrap() + "bbox=(".len();
let bbox_end = five_line[bbox_start..].find(')').unwrap();
let parts: Vec<&str> = five_line[bbox_start..bbox_start + bbox_end]
.split(',')
.collect();
assert_eq!(parts.len(), 4, "bbox must have four fields");
for p in parts {
p.parse::<i64>().expect("bbox components must be integers");
}
}
fn active_app_name() -> String {
let r = list_apps_all();
let text = extract_text(&r);
let apps: Vec<serde_json::Value> =
serde_json::from_str(&text).expect("list_apps should return a JSON array");
let active = apps
.iter()
.find(|a| a.get("is_active").and_then(|v| v.as_bool()) == Some(true))
.expect("at least one app should be is_active=true");
active
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.expect("active app should have a name")
}
#[tokio::test]
#[ignore]
async fn ax_click_preserves_focus_while_calculator_stays_background() {
let session = new_session();
let snap_text = snapshot(&session, Some("Calculator")).await;
let five_uid = extract_uid_for_named_button(&snap_text, "5");
let before = extract_calculator_display_value(&snap_text).unwrap_or_default();
let _focus = focus("Terminal");
assert_eq!(
active_app_name(),
"Terminal",
"precondition: Terminal should be frontmost before ax_click"
);
let click = ax_click(AxClickParams { uid: five_uid }, session.clone()).await;
let body = parse_json(&extract_text(&click));
assert_eq!(body["ok"], true);
assert_eq!(
active_app_name(),
"Terminal",
"ax_click must not steal focus from Terminal"
);
let snap2_text = snapshot(&session, Some("Calculator")).await;
let after = extract_calculator_display_value(&snap2_text)
.expect("Calculator display should have a value after AXPress");
assert_ne!(
before, after,
"display value must change after AXPress (before={before:?}, after={after:?})"
);
assert!(
after.ends_with('5'),
"display should end in '5' after pressing the 5 button (got {after:?})"
);
}
#[tokio::test]
#[ignore]
async fn ax_set_value_preserves_focus_writing_textedit_while_terminal_is_front() {
let session = new_session();
let snap_text = snapshot(&session, Some("TextEdit")).await;
let doc_line = snap_text
.lines()
.find(|l| l.contains("textbox") || l.contains("textarea"))
.expect("TextEdit should expose a document text area");
let doc_uid = doc_line
.split_whitespace()
.next()
.unwrap()
.strip_prefix("uid=")
.unwrap()
.to_string();
let _ = focus("Terminal");
assert_eq!(
active_app_name(),
"Terminal",
"precondition: Terminal should be frontmost before ax_set_value"
);
let set = ax_set_value(
AxSetValueParams {
uid: doc_uid,
text: "hello".to_string(),
},
session.clone(),
)
.await;
let body = parse_json(&extract_text(&set));
assert_eq!(body["ok"], true, "body was {}", body);
assert_eq!(body["dispatched_via"], "AXSetAttributeValue");
assert_eq!(
active_app_name(),
"Terminal",
"ax_set_value must not steal focus from Terminal"
);
let verify_session = new_session();
let snap2_text = snapshot(&verify_session, Some("TextEdit")).await;
assert!(
snap2_text.contains("value=\"hello\""),
"TextEdit should reflect the written value"
);
}
fn open_system_settings_pane(pane_id: &str) {
let url = format!("x-apple.systempreferences:{}", pane_id);
let status = std::process::Command::new("open")
.args(["-g", &url])
.status()
.expect("`open` should be invocable");
assert!(
status.success(),
"`open -g {}` should succeed; got {:?}",
url,
status
);
}
#[derive(Clone, Debug)]
struct SidebarRow {
uid: String,
selected: bool,
label: String,
}
fn extract_sidebar_rows(snapshot: &str) -> Vec<SidebarRow> {
let lines: Vec<&str> = snapshot.lines().collect();
let mut rows = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !(line.contains(" row ") || line.contains(" row\t")) {
continue;
}
let Some(uid) = line
.split_whitespace()
.next()
.and_then(|t| t.strip_prefix("uid="))
else {
continue;
};
let selected = line.contains("selected");
let row_indent = line.len() - line.trim_start().len();
let mut label = String::new();
for follow in lines.iter().skip(i + 1) {
let follow_indent = follow.len() - follow.trim_start().len();
if follow_indent <= row_indent {
break;
}
if let Some(start) = follow.find('"') {
let rest = &follow[start + 1..];
if let Some(end) = rest.find('"') {
if end > 0 {
label = rest[..end].to_string();
break;
}
}
}
}
rows.push(SidebarRow {
uid: uid.to_string(),
selected,
label,
});
}
rows
}
async fn snapshot_with_sidebar_rows(
session: &Arc<AxSession>,
app_name: &str,
min_rows: usize,
) -> String {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let text = snapshot(session, Some(app_name)).await;
if extract_sidebar_rows(&text).len() >= min_rows {
return text;
}
if std::time::Instant::now() >= deadline {
panic!(
"{} did not expose {} sidebar rows within 5s; last snapshot:\n{}",
app_name, min_rows, text
);
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
}
#[tokio::test]
#[ignore]
async fn ax_select_moves_sidebar_selection_in_system_settings() {
open_system_settings_pane("com.apple.preference.security");
let session = new_session();
let snap_text = snapshot_with_sidebar_rows(&session, "System Settings", 2).await;
let rows = extract_sidebar_rows(&snap_text);
let previously_selected = rows.iter().find(|r| r.selected).cloned();
let target = rows
.iter()
.find(|r| !r.selected)
.expect("at least one sidebar row should be non-selected to target")
.clone();
let result = ax_select(
AxSelectParams {
uid: target.uid.clone(),
},
session.clone(),
)
.await;
let body = parse_json(&extract_text(&result));
assert_eq!(
body["ok"], true,
"ax_select should succeed on a live sidebar row; body={}",
body
);
assert_eq!(body["dispatched_via"], "AXSelectedRows");
let verify_session = new_session();
let snap2_text = snapshot(&verify_session, Some("System Settings")).await;
let rows2 = extract_sidebar_rows(&snap2_text);
let now_selected = rows2
.iter()
.find(|r| r.label == target.label)
.unwrap_or_else(|| {
panic!(
"target row (label={:?}) missing from post-dispatch snapshot",
target.label
)
})
.selected;
assert!(
now_selected,
"target row (label={:?}) should be selected after ax_select",
target.label
);
if let Some(prev) = previously_selected {
if prev.label != target.label {
if let Some(still) = rows2.iter().find(|r| r.label == prev.label) {
assert!(
!still.selected,
"previously-selected row (label={:?}) should no longer be selected",
prev.label
);
}
}
}
}
#[tokio::test]
#[ignore]
async fn ax_select_on_non_row_element_returns_no_row_ancestor() {
let session = new_session();
let snap_text = snapshot(&session, Some("Calculator")).await;
let five_uid = extract_uid_for_named_button(&snap_text, "5");
let result = ax_select(AxSelectParams { uid: five_uid }, session.clone()).await;
assert_eq!(result.is_error, Some(true));
let body = parse_json(&extract_text(&result));
assert_eq!(body["error"]["code"], "no_row_ancestor");
assert!(
body["error"]["fallback"].is_object(),
"fallback should be populated when the starting element has a bbox"
);
}