use std::process::Command;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TestStep {
Launch {
app: String,
},
FindAndClick {
query: String,
},
FindAndType {
query: String,
text: String,
},
WaitForElement {
query: String,
timeout_ms: u64,
},
Screenshot {
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TestAssertion {
ElementExists {
query: String,
},
ElementHasText {
query: String,
expected: String,
},
ElementNotExists {
query: String,
},
ScreenContains {
needle: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TestCase {
pub name: String,
pub steps: Vec<TestStep>,
pub assertions: Vec<TestAssertion>,
}
impl TestCase {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
steps: Vec::new(),
assertions: Vec::new(),
}
}
#[must_use]
pub fn with_step(mut self, step: TestStep) -> Self {
self.steps.push(step);
self
}
#[must_use]
pub fn with_assertion(mut self, assertion: TestAssertion) -> Self {
self.assertions.push(assertion);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TestResult {
pub name: String,
pub passed: bool,
pub steps_completed: usize,
pub failures: Vec<String>,
pub screenshots: Vec<String>,
pub elapsed_ms: u64,
}
impl TestResult {
fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
passed: true,
steps_completed: 0,
failures: Vec::new(),
screenshots: Vec::new(),
elapsed_ms: 0,
}
}
fn fail(&mut self, reason: impl Into<String>) {
self.passed = false;
self.failures.push(reason.into());
}
}
pub struct BlackboxTester {
app_name: String,
}
impl BlackboxTester {
#[must_use]
pub fn new(app_name: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
}
}
pub fn run(&self, case: &TestCase) -> TestResult {
let started = Instant::now();
let mut result = TestResult::new(&case.name);
self.execute_steps(case, &mut result);
self.check_assertions(case, &mut result);
result.elapsed_ms = started.elapsed().as_millis() as u64;
result
}
fn execute_steps(&self, case: &TestCase, result: &mut TestResult) {
for step in &case.steps {
let ok = match step {
TestStep::Launch { app } => self.step_launch(app, result),
TestStep::FindAndClick { query } => self.step_find_and_click(query, result),
TestStep::FindAndType { query, text } => {
self.step_find_and_type(query, text, result)
}
TestStep::WaitForElement { query, timeout_ms } => {
self.step_wait_for_element(query, *timeout_ms, result)
}
TestStep::Screenshot { path } => self.step_screenshot(path, result),
};
if ok {
result.steps_completed += 1;
}
}
}
fn step_launch(&self, app: &str, result: &mut TestResult) -> bool {
if self.app_is_running(app) {
return true;
}
let status = Command::new("open").arg("-a").arg(app).status();
match status {
Ok(s) if s.success() => {
std::thread::sleep(Duration::from_millis(500));
true
}
Ok(s) => {
result.fail(format!("Launch '{app}' exited with status {s}"));
false
}
Err(e) => {
result.fail(format!("Launch '{app}' failed: {e}"));
false
}
}
}
fn step_find_and_click(&self, query: &str, result: &mut TestResult) -> bool {
match self.find_element_text(query) {
Some(_) => true, None => {
result.fail(format!("FindAndClick: element not found for '{query}'"));
false
}
}
}
fn step_find_and_type(&self, query: &str, text: &str, result: &mut TestResult) -> bool {
match self.find_element_text(query) {
Some(_) => {
let _ = text; true
}
None => {
result.fail(format!("FindAndType: element not found for '{query}'"));
false
}
}
}
fn step_wait_for_element(&self, query: &str, timeout_ms: u64, result: &mut TestResult) -> bool {
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
if self.find_element_text(query).is_some() {
return true;
}
if Instant::now() >= deadline {
result.fail(format!(
"WaitForElement: '{query}' not found within {timeout_ms}ms"
));
return false;
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn step_screenshot(&self, path: &str, result: &mut TestResult) -> bool {
let status = Command::new("screencapture").args(["-x", path]).status();
match status {
Ok(s) if s.success() => {
result.screenshots.push(path.to_owned());
true
}
Ok(s) => {
result.fail(format!("Screenshot to '{path}' exited with status {s}"));
false
}
Err(e) => {
result.fail(format!("Screenshot to '{path}' failed: {e}"));
false
}
}
}
fn check_assertions(&self, case: &TestCase, result: &mut TestResult) {
for assertion in &case.assertions {
let ok = match assertion {
TestAssertion::ElementExists { query } => self.assert_element_exists(query, result),
TestAssertion::ElementHasText { query, expected } => {
self.assert_element_has_text(query, expected, result)
}
TestAssertion::ElementNotExists { query } => {
self.assert_element_not_exists(query, result)
}
TestAssertion::ScreenContains { needle } => {
self.assert_screen_contains(needle, result)
}
};
let _ = ok; }
}
fn assert_element_exists(&self, query: &str, result: &mut TestResult) -> bool {
if self.find_element_text(query).is_some() {
true
} else {
result.fail(format!(
"ElementExists: '{query}' not in accessibility tree"
));
false
}
}
fn assert_element_has_text(
&self,
query: &str,
expected: &str,
result: &mut TestResult,
) -> bool {
match self.find_element_text(query) {
Some(text) if text.contains(expected) => true,
Some(text) => {
result.fail(format!(
"ElementHasText: '{query}' has text '{text}', expected '{expected}'"
));
false
}
None => {
result.fail(format!("ElementHasText: '{query}' not found"));
false
}
}
}
fn assert_element_not_exists(&self, query: &str, result: &mut TestResult) -> bool {
if self.find_element_text(query).is_none() {
true
} else {
result.fail(format!(
"ElementNotExists: '{query}' unexpectedly found in accessibility tree"
));
false
}
}
fn assert_screen_contains(&self, needle: &str, result: &mut TestResult) -> bool {
if self.search_ax_tree_for_text(needle) {
true
} else {
result.fail(format!("ScreenContains: '{needle}' not visible on screen"));
false
}
}
fn app_is_running(&self, app: &str) -> bool {
Command::new("pgrep")
.args(["-x", app])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn app_pid(&self) -> Option<i32> {
let output = Command::new("pgrep")
.args(["-x", &self.app_name])
.output()
.ok()?;
let s = String::from_utf8_lossy(&output.stdout);
s.lines().next()?.trim().parse().ok()
}
fn find_element_text(&self, query: &str) -> Option<String> {
use crate::accessibility::create_application_element;
let pid = self.app_pid()?;
let app_elem = create_application_element(pid).ok()?;
find_text_in_tree(app_elem, query)
}
fn search_ax_tree_for_text(&self, needle: &str) -> bool {
use crate::accessibility::create_application_element;
let Some(pid) = self.app_pid() else {
return false;
};
let Ok(app_elem) = create_application_element(pid) else {
return false;
};
find_text_in_tree(app_elem, needle).is_some()
}
}
fn find_text_in_tree(root: crate::accessibility::AXUIElementRef, query: &str) -> Option<String> {
use crate::accessibility::{self, attributes, get_attribute};
use std::collections::VecDeque;
let query_lower = query.to_lowercase();
let mut queue: VecDeque<crate::accessibility::AXUIElementRef> = VecDeque::new();
queue.push_back(root);
while let Some(elem) = queue.pop_front() {
for attr in &[
attributes::AX_TITLE,
attributes::AX_VALUE,
attributes::AX_LABEL,
] {
if let Some(text) = accessibility::get_string_attribute_value(elem, attr) {
if text.to_lowercase().contains(&query_lower) {
return Some(text);
}
}
}
if let Ok(children_ref) = get_attribute(elem, attributes::AX_CHILDREN) {
if let Some(children) = ax_children_to_vec(children_ref) {
for child in children {
queue.push_back(child);
}
}
accessibility::release_cf(children_ref);
}
}
None
}
fn ax_children_to_vec(
cf_ref: core_foundation::base::CFTypeRef,
) -> Option<Vec<crate::accessibility::AXUIElementRef>> {
use core_foundation::array::CFArray;
use core_foundation::base::{CFType, CFTypeRef, TCFType};
if cf_ref.is_null() {
return None;
}
unsafe {
let cf_array: CFArray<CFType> = CFArray::wrap_under_get_rule(cf_ref.cast());
let mut result = Vec::with_capacity(cf_array.len() as usize);
for i in 0..cf_array.len() {
if let Some(element_ref) = cf_array.get(i) {
let ptr = element_ref.as_concrete_TypeRef() as crate::accessibility::AXUIElementRef;
if !ptr.is_null() {
let _ = crate::accessibility::retain_cf(ptr as CFTypeRef);
result.push(ptr);
}
}
}
Some(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_case_builder_accumulates_steps_and_assertions() {
let case = TestCase::new("my_test")
.with_step(TestStep::WaitForElement {
query: "Login".into(),
timeout_ms: 1000,
})
.with_step(TestStep::FindAndClick {
query: "Submit".into(),
})
.with_assertion(TestAssertion::ElementExists {
query: "Dashboard".into(),
});
assert_eq!(case.steps.len(), 2);
assert_eq!(case.assertions.len(), 1);
assert_eq!(case.name, "my_test");
}
#[test]
fn test_case_new_starts_empty() {
let case = TestCase::new("empty");
assert!(case.steps.is_empty());
assert!(case.assertions.is_empty());
}
#[test]
fn test_result_starts_passing() {
let result = TestResult::new("check");
assert!(result.passed);
assert!(result.failures.is_empty());
assert_eq!(result.steps_completed, 0);
}
#[test]
fn test_result_fail_marks_not_passed() {
let mut result = TestResult::new("check");
result.fail("something broke");
assert!(!result.passed);
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0], "something broke");
}
#[test]
fn tester_new_stores_app_name() {
let tester = BlackboxTester::new("Slack");
assert_eq!(tester.app_name, "Slack");
}
#[test]
fn tester_app_is_running_returns_false_for_nonexistent_app() {
let tester = BlackboxTester::new("__axterminator_ghost_app_xyz__");
assert!(!tester.app_is_running("__axterminator_ghost_app_xyz__"));
}
#[test]
fn step_wait_for_element_fails_on_timeout_for_missing_element() {
let tester = BlackboxTester::new("__ghost__");
let mut result = TestResult::new("t");
let ok = tester.step_wait_for_element("SomeButton", 1, &mut result);
assert!(!ok);
assert!(!result.failures.is_empty());
assert!(result.failures[0].contains("SomeButton"));
}
#[test]
fn assert_element_not_exists_passes_for_missing_element() {
let tester = BlackboxTester::new("__ghost__");
let mut result = TestResult::new("t");
let ok = tester.assert_element_not_exists("Invisible", &mut result);
assert!(ok);
assert!(result.failures.is_empty());
}
#[test]
fn assert_screen_contains_fails_gracefully_for_dead_app() {
let tester = BlackboxTester::new("__ghost__");
let mut result = TestResult::new("t");
let ok = tester.assert_screen_contains("SomeText", &mut result);
assert!(!ok);
assert!(!result.failures.is_empty());
}
#[test]
fn test_case_serializes_and_deserializes() {
let case = TestCase::new("serde_test")
.with_step(TestStep::Launch {
app: "TextEdit".into(),
})
.with_step(TestStep::FindAndType {
query: "body".into(),
text: "hello world".into(),
})
.with_assertion(TestAssertion::ElementHasText {
query: "body".into(),
expected: "hello".into(),
});
let json = serde_json::to_string_pretty(&case).unwrap();
let restored: TestCase = serde_json::from_str(&json).unwrap();
assert_eq!(case, restored);
}
#[test]
fn all_test_step_variants_serialize() {
let steps = vec![
TestStep::Launch { app: "App".into() },
TestStep::FindAndClick {
query: "btn".into(),
},
TestStep::FindAndType {
query: "field".into(),
text: "text".into(),
},
TestStep::WaitForElement {
query: "elem".into(),
timeout_ms: 5000,
},
TestStep::Screenshot {
path: "/tmp/shot.png".into(),
},
];
let json = serde_json::to_string(&steps).unwrap();
let restored: Vec<TestStep> = serde_json::from_str(&json).unwrap();
assert_eq!(steps, restored);
}
#[test]
fn all_assertion_variants_serialize() {
let assertions = vec![
TestAssertion::ElementExists { query: "A".into() },
TestAssertion::ElementHasText {
query: "B".into(),
expected: "val".into(),
},
TestAssertion::ElementNotExists { query: "C".into() },
TestAssertion::ScreenContains { needle: "D".into() },
];
let json = serde_json::to_string(&assertions).unwrap();
let restored: Vec<TestAssertion> = serde_json::from_str(&json).unwrap();
assert_eq!(assertions, restored);
}
#[test]
fn run_returns_zero_elapsed_for_empty_case() {
let tester = BlackboxTester::new("__ghost__");
let case = TestCase::new("empty");
let result = tester.run(&case);
assert!(result.passed);
assert_eq!(result.steps_completed, 0);
assert!(result.failures.is_empty());
assert!(result.elapsed_ms < 1000);
}
}