#![cfg(target_os = "macos")]
use iron_shell::env::Env;
use iron_shell::value::Value;
use std::process::{Child, Command};
use std::sync::Once;
use std::time::{Duration, Instant};
const OSASCRIPT: &str = "/usr/bin/osascript";
fn disposable() -> bool {
std::env::var("AE_DISPOSABLE_HOST").as_deref() == Ok("1")
}
fn call(name: &str, args: Vec<Value>) -> anyhow::Result<Value> {
static ISOLATED: Once = Once::new();
ISOLATED.call_once(|| {
let dir = std::env::temp_dir().join(format!("ae_mac_input_{}", std::process::id()));
std::env::set_var("IRONSHELL_SESSION_DIR", dir);
});
iron_shell::builtins::call(name, args, &mut Env::new())
}
fn code_of(e: &anyhow::Error) -> String {
let text = e.to_string();
match text.find("E_") {
Some(i) => text[i..]
.split(|c: char| !(c.is_ascii_uppercase() || c == '_'))
.next()
.unwrap_or("E_?")
.to_string(),
None => format!("NO_CODE: {text}"),
}
}
const ORACLE_SCRIPT: &str = r#"ObjC.import('AppKit');
ObjC.import('CoreGraphics');
ObjC.import('ApplicationServices');
function run(argv) {
var q = argv[0];
if (q === 'trusted') {
return $.AXIsProcessTrusted() ? 'true' : 'false';
}
if (q === 'pos') {
var p = $.CGEventGetLocation($.CGEventCreate(null));
return p.x + ' ' + p.y;
}
if (q === 'screen') {
var frame = ($.NSScreen.mainScreen || $.NSScreen.screens.objectAtIndex(0)).frame;
return frame.size.width + ' ' + frame.size.height;
}
throw new Error('unknown question ' + q);
}
"#;
fn oracle(question: &str) -> String {
let out = Command::new(OSASCRIPT)
.args(["-l", "JavaScript", "-e", ORACLE_SCRIPT, question])
.output()
.expect("/usr/bin/osascript runs on the disposable runner");
assert!(
out.status.success(),
"the oracle `{question}` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn oracle_pos() -> (i64, i64) {
let text = oracle("pos");
let mut parts = text.split_whitespace().map(|p| {
p.parse::<f64>()
.unwrap_or_else(|_| panic!("the oracle's position `{text}` is not two numbers"))
});
let x = parts.next().expect("the oracle gives an x");
let y = parts.next().expect("the oracle gives a y");
(x.round() as i64, y.round() as i64)
}
fn pair_of(v: &Value, a: &str, b: &str) -> (i64, i64) {
match v {
Value::Record(r) => match (r.get(a), r.get(b)) {
(Some(Value::Int(x)), Some(Value::Int(y))) => (*x, *y),
_ => panic!("the Record {v:?} has no Int {a} and {b}"),
},
other => panic!("expected a Record with {a} and {b}, got {other:?}"),
}
}
fn sent_or_denied(name: &str, args: Vec<Value>) -> Option<Value> {
match call(name, args) {
Ok(v) => {
assert_eq!(
oracle("trusted"),
"true",
"{name} sent events, but the oracle says this process is not trusted for Accessibility"
);
Some(v)
}
Err(e) => {
assert_eq!(
code_of(&e),
"E_OS_DENIED",
"{name} failed with something other than the Accessibility refusal: {e}"
);
eprintln!(
"{name}: Accessibility is not granted to this runner, so the outcome is E_OS_DENIED and nothing was sent: {e}"
);
None
}
}
}
fn wait_until(what: &str, within: Duration, mut done: impl FnMut() -> bool) {
let start = Instant::now();
while !done() {
assert!(
start.elapsed() < within,
"timed out after {within:?} waiting for {what}"
);
std::thread::sleep(Duration::from_millis(200));
}
}
struct Helper {
child: Child,
record: std::path::PathBuf,
}
impl Drop for Helper {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_file(&self.record);
}
}
const FIELD_SCRIPT: &str = r#"ObjC.import('AppKit');
function run(argv) {
var record = argv[0];
var title = argv[1];
var app = $.NSApplication.sharedApplication;
app.setActivationPolicy(0);
var win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(200, 200, 420, 120), 15, 2, false);
win.setTitle(title);
var field = $.NSTextField.alloc.initWithFrame($.NSMakeRect(20, 40, 380, 24));
win.contentView.addSubview(field);
win.makeKeyAndOrderFront(null);
win.makeFirstResponder(field);
app.activateIgnoringOtherApps(true);
var typed = '';
$.NSEvent.addLocalMonitorForEventsMatchingMaskHandler($.NSEventMaskKeyDown, function (ev) {
var c = ObjC.unwrap(ev.characters);
if (c) typed += c;
$(typed).writeToFileAtomicallyEncodingError(record, true, $.NSUTF8StringEncoding, null);
return ev;
});
app.performSelectorWithObjectAfterDelay('terminate:', null, 90);
app.run();
}
"#;
fn start_field(title: &str) -> Helper {
let record = std::env::temp_dir().join(format!("ae_mac_input_{}.txt", std::process::id()));
let child = Command::new(OSASCRIPT)
.args(["-l", "JavaScript", "-e", FIELD_SCRIPT])
.arg(record.to_str().expect("the temp path is UTF-8"))
.arg(title)
.spawn()
.expect("/usr/bin/osascript runs on the disposable runner");
Helper { child, record }
}
#[test]
#[ignore = "moves the real mouse pointer: disposable macOS runner only (AE_DISPOSABLE_HOST=1)"]
fn the_pointer_moves_and_reads_back_at_the_same_point() {
if !disposable() {
return;
}
let (tx, ty) = (300, 300);
let Some(moved) = sent_or_denied("gui_mouse_move", vec![Value::Int(tx), Value::Int(ty)]) else {
return;
};
assert!(
matches!(moved, Value::Bool(true)),
"gui_mouse_move answered {moved:?}; expected true"
);
let (bx, by) = pair_of(
&call("gui_mouse_position", vec![]).expect("gui_mouse_position reads the pointer"),
"x",
"y",
);
let (ox, oy) = oracle_pos();
for (who, (x, y)) in [("the builtin", (bx, by)), ("the oracle", (ox, oy))] {
assert!(
(x - tx).abs() <= 1 && (y - ty).abs() <= 1,
"{who} reads the pointer at {x},{y}; expected {tx},{ty} within 1 px"
);
}
assert!(
(bx - ox).abs() <= 1 && (by - oy).abs() <= 1,
"the builtin says {bx},{by} and the oracle says {ox},{oy}"
);
}
#[test]
#[ignore = "reads the screen: disposable macOS runner only (AE_DISPOSABLE_HOST=1)"]
fn the_screen_size_matches_the_main_screen_frame() {
if !disposable() {
return;
}
let (w, h) = pair_of(
&call("gui_screen_size", vec![]).expect("gui_screen_size reads the screen"),
"width",
"height",
);
let text = oracle("screen");
let mut parts = text
.split_whitespace()
.map(|p| p.parse::<f64>().expect("a number"));
let ow = parts.next().expect("the oracle gives a width");
let oh = parts.next().expect("the oracle gives a height");
assert!(
(w as f64 - ow).abs() <= 0.5 && (h as f64 - oh).abs() <= 0.5,
"gui_screen_size says {w}x{h}; NSScreen.mainScreen.frame says {ow}x{oh}"
);
}
#[test]
#[ignore = "types into a window: disposable macOS runner only (AE_DISPOSABLE_HOST=1)"]
fn typed_text_reaches_the_focused_window_unchanged() {
if !disposable() {
return;
}
let title = format!("ae input test {}", std::process::id());
let helper = start_field(&title);
let pid = i64::from(helper.child.id());
wait_until(
"the helper to be the frontmost app",
Duration::from_secs(20),
|| match call("gui_get_active_window", vec![]) {
Ok(Value::Record(r)) => matches!(r.get("pid"), Some(Value::Int(p)) if *p == pid),
Ok(_) => false,
Err(_) => false,
},
);
let Some(_) = sent_or_denied("gui_key_press", vec![Value::Str("x".to_string())]) else {
return;
};
let reached = (0..25).any(|_| {
std::thread::sleep(Duration::from_millis(200));
std::fs::read_to_string(&helper.record).is_ok_and(|got| got == "x")
});
eprintln!(
"a key press reached the helper: {reached}; it recorded {:?}",
std::fs::read_to_string(&helper.record).ok()
);
let _ = std::fs::write(&helper.record, "");
let text = "ae typed 42 \u{fc}ber the limit of twenty units";
let Some(sent) = sent_or_denied("gui_type_text", vec![Value::Str(text.to_string())]) else {
return;
};
assert!(
matches!(sent, Value::Bool(true)),
"gui_type_text answered {sent:?}; expected true"
);
let arrived = (0..50).any(|_| {
std::thread::sleep(Duration::from_millis(200));
std::fs::read_to_string(&helper.record).is_ok_and(|got| got.ends_with(text))
});
let got = std::fs::read_to_string(&helper.record).unwrap_or_default();
assert!(
arrived,
"the helper recorded {got:?} where {text:?} was typed"
);
}
#[test]
#[ignore = "sends key and mouse input: disposable macOS runner only (AE_DISPOSABLE_HOST=1)"]
fn an_unknown_key_or_an_out_of_range_coordinate_is_refused_with_no_side_effects() {
if !disposable() {
return;
}
let before = pair_of(
&call("gui_mouse_position", vec![]).expect("gui_mouse_position reads the pointer"),
"x",
"y",
);
let cases: [(&str, Vec<Value>); 7] = [
("gui_key_press", vec![Value::Str("nope".into())]),
(
"gui_key_combo",
vec![Value::Str("ctrl".into()), Value::Str("nope".into())],
),
("gui_mouse_move", vec![Value::Int(100_001), Value::Int(0)]),
(
"gui_mouse_drag",
vec![
Value::Int(1),
Value::Int(2),
Value::Int(100_001),
Value::Int(4),
],
),
("gui_mouse_click", vec![Value::Str("up".into())]),
("gui_mouse_scroll", vec![Value::Int(101)]),
("gui_type_text", vec![Value::Str(String::new())]),
];
for (name, args) in cases {
match call(name, args) {
Ok(v) => panic!("{name} answered {v:?}; expected E_BAD_ARG"),
Err(e) => assert_eq!(code_of(&e), "E_BAD_ARG", "{name}: {e}"),
}
}
let after = pair_of(
&call("gui_mouse_position", vec![]).expect("gui_mouse_position reads the pointer"),
"x",
"y",
);
assert_eq!(before, after, "a refused call moved the pointer");
}
#[test]
#[ignore = "moves to the current point: disposable macOS runner only (AE_DISPOSABLE_HOST=1)"]
fn accessibility_refusal_is_e_os_denied_and_says_why() {
if !disposable() {
return;
}
let (x, y) = oracle_pos();
let outcome = call("gui_mouse_move", vec![Value::Int(x), Value::Int(y)]);
if oracle("trusted") == "true" {
assert!(
matches!(outcome, Ok(Value::Bool(true))),
"with Accessibility granted gui_mouse_move answered {outcome:?}; expected true"
);
eprintln!("Accessibility is granted, so gui_mouse_move sent its event");
} else {
let e = outcome.expect_err("without Accessibility the move must be refused");
assert_eq!(code_of(&e), "E_OS_DENIED", "{e}");
eprintln!(
"Accessibility is not granted to this runner (AXIsProcessTrusted is false), so gui_mouse_move refused with E_OS_DENIED: {e}"
);
}
}