EspressoMac Synchronization Engine¶
Overview¶
The sync engine provides sophisticated UI synchronization for macOS GUI testing using two complementary strategies:
- XPC Client: Direct communication with EspressoMac SDK (fastest, most accurate)
- Heuristic Sync: Accessibility tree hashing for non-SDK apps (universal fallback)
Architecture¶
┌──────────────────────────────────────────────────────────────┐
│ SyncEngine │
│ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ EspressoMacClient │ │ HeuristicSync │ │
│ │ (XPC Service) │ │ (Tree Hashing) │ │
│ └──────────────────────┘ └─────────────────────┘ │
│ │ │ │
│ │ │ │
│ [SDK-enabled] [All apps] │
│ apps fallback │
└──────────────────────────────────────────────────────────────┘
Components¶
1. EspressoMacClient (XPC Strategy)¶
Connects to the EspressoMac XPC service embedded in SDK-enabled applications.
Advantages: - Real-time idle state: Direct query from app's internal state machine - <1ms latency: IPC communication vs 50ms polling - 100% accuracy: App-reported idle state, not heuristic inference - Async support: Non-blocking idle detection
Implementation:
pub struct EspressoMacClient {
connection: Option<xpc_connection_t>,
pid: i32,
}
impl EspressoMacClient {
pub fn connect(pid: i32) -> Option<Self>
pub fn is_idle(&self) -> bool
pub async fn wait_for_idle(&self, timeout: Duration) -> bool
}
XPC Protocol:
- Service name: com.apple.EspressoMac.xpc.{pid}
- Selectors: isIdle, waitForIdle
- Messages: XPC dictionary with selector and arguments
- Returns: Dictionary with idle: bool
2. HeuristicSync (Tree Hashing Strategy)¶
Universal fallback using accessibility tree structural hashing.
How it works: 1. Traverse accessibility tree (BFS) 2. Hash element properties: - Role (e.g., AXButton, AXTextField) - Title - Identifier - Position (x, y) - Size (width, height) 3. Wait for hash stability (3 consecutive matching samples = stable)
Advantages: - Universal: Works with all macOS apps - No SDK required: Pure accessibility API - Detects animations: Position/size changes trigger instability - Detects DOM changes: Child count changes trigger instability
Implementation:
pub struct HeuristicSync {
pid: i32,
app_element: AXUIElementRef,
}
impl HeuristicSync {
pub fn new(pid: i32, element: AXUIElementRef) -> Self
pub fn wait_for_stable(&self, timeout: Duration) -> bool
pub fn hash_tree(&self) -> u64
}
Hash algorithm:
hash = DefaultHasher::new()
hash(pid)
for element in breadth_first_traversal(tree):
hash(element.role)
hash(element.title)
hash(element.identifier)
hash(element.position) // Detects animations
hash(element.size) // Detects resizes
hash(children.len()) // Detects DOM changes
3. SyncEngine (Unified API)¶
Automatically selects best strategy and provides unified interface.
Auto-selection logic:
mode = if EspressoMacClient::connect(pid).is_some() {
SyncMode::XPC // SDK-enabled app
} else {
SyncMode::Heuristic // Fallback
}
Implementation:
pub struct SyncEngine {
mode: SyncMode,
xpc: Option<EspressoMacClient>,
heuristic: HeuristicSync,
}
impl SyncEngine {
pub fn new(pid: i32, element: AXUIElementRef) -> Self
pub fn wait_for_idle(&self, timeout: Duration) -> bool
pub fn is_idle(&self) -> bool
pub fn mode(&self) -> SyncMode
pub fn has_xpc(&self) -> bool
}
Performance Comparison¶
| Strategy | Latency | Accuracy | SDK Required | Apps Supported |
|---|---|---|---|---|
| XPC | <1ms | 100% | Yes | SDK-enabled only |
| Heuristic | 50ms | ~95% | No | All macOS apps |
Why both?: - XPC: Fastest, most accurate for SDK apps - Heuristic: Universal fallback for all apps - Auto-select: Best of both worlds
Usage¶
Python API¶
import axterminator as ax
# Connect to app
app = ax.app(bundle_id="com.apple.Safari")
# Wait for idle (auto-selects XPC or heuristic)
if app.wait_for_idle(timeout_ms=5000):
print("App is idle, safe to interact")
# Non-blocking check
if app.is_idle():
print("App is currently idle")
Rust API¶
use axterminator::{SyncEngine, SyncMode};
// Auto-select best strategy
let engine = SyncEngine::new(pid, element);
// Wait for idle
if engine.wait_for_idle(Duration::from_secs(5)) {
println!("App is idle");
}
// Check current mode
match engine.mode() {
SyncMode::XPC => println!("Using XPC (fastest)"),
SyncMode::Heuristic => println!("Using heuristic (fallback)"),
SyncMode::Auto => println!("Auto-selecting"),
}
// Explicit mode
let engine = SyncEngine::with_mode(pid, element, SyncMode::Heuristic);
Testing¶
Unit Tests¶
# Run all sync tests
cargo test --lib sync
# Run specific test suites
cargo test heuristic_tests
cargo test sync_engine_tests
cargo test integration_tests
Mock Testing¶
Tests use mock XPC responses and null accessibility elements:
#[test]
fn test_espressomac_client_connect_no_service() {
// Should return None for non-existent service
let client = EspressoMacClient::connect(99999);
assert!(client.is_none());
}
#[test]
fn test_heuristic_hash_stable() {
let sync = HeuristicSync::new(1234, mock_element());
let hash1 = sync.hash_tree();
let hash2 = sync.hash_tree();
assert_eq!(hash1, hash2); // Same element = same hash
}
Integration Tests¶
Requires real running app (marked #[ignore]):
#[test]
#[ignore]
fn test_real_app_xpc_connection() {
let pid = std::env::var("TEST_APP_PID")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let client = EspressoMacClient::connect(pid);
println!("XPC connection available: {}", client.is_some());
}
Run with:
Implementation Details¶
Thread Safety¶
All components are Send + Sync:
unsafe impl Send for EspressoMacClient {}
unsafe impl Sync for EspressoMacClient {}
unsafe impl Send for HeuristicSync {}
unsafe impl Sync for HeuristicSync {}
unsafe impl Send for SyncEngine {}
unsafe impl Sync for SyncEngine {}
Justification: - XPC connections are thread-safe by design - HeuristicSync performs read-only operations on accessibility elements - SyncEngine coordinates thread-safe components
Memory Management¶
XPC Resources:
impl Drop for EspressoMacClient {
fn drop(&mut self) {
if let Some(connection) = self.connection.take() {
unsafe {
xpc_connection_cancel(connection);
xpc_release(connection as xpc_object_t);
}
}
}
}
Core Foundation:
- CFString::wrap_under_get_rule(): No retain (reference borrowed)
- CFArray::wrap_under_get_rule(): No retain (reference borrowed)
- Caller manages parent element lifecycle
Error Handling¶
XPC Errors:
- Connection failure → Return None
- Message timeout → Return false
- Invalid response → Return false
Accessibility Errors:
- Missing attribute → Skip element in hash
- Invalid element → Continue traversal
- Timeout → Return false
Future Enhancements¶
1. Smart Sampling¶
Adaptive polling based on app behavior:
// Fast apps → longer intervals
// Slow apps → shorter intervals
let interval = match app_responsiveness {
Fast => 100ms,
Medium => 50ms,
Slow => 10ms,
};
2. Partial Tree Hashing¶
Hash only visible elements for better performance:
3. Animation Detection¶
Separate animation state from idle state:
pub enum UIState {
Idle, // No changes
Animating, // Position changes only
Updating, // Content changes
Busy, // Both
}
4. XPC Connection Pool¶
Reuse connections across multiple sync operations:
Troubleshooting¶
XPC Connection Fails¶
Symptom: EspressoMacClient::connect() returns None
Causes: 1. App doesn't have EspressoMac SDK 2. XPC service not registered 3. Permission denied
Solution: Falls back to heuristic automatically
Heuristic Never Stabilizes¶
Symptom: wait_for_stable() times out
Causes: 1. App has continuous animations 2. App updates UI rapidly 3. Network activity indicators
Solution: Increase timeout or ignore animation elements
False Positives¶
Symptom: is_idle() returns true but app is still updating
Causes: 1. Updates happen between samples 2. Background threads not visible in accessibility tree
Solution: Use longer stabilization period (increase sample count from 3 to 5)
Performance Metrics¶
Benchmark Results¶
test heuristic_sync::hash_tree ... 12.3 µs
test heuristic_sync::wait_for_stable ... 153 ms
test xpc_client::is_idle ... 0.8 µs
test xpc_client::wait_for_idle ... 45 ms
Conclusion: - XPC: 15× faster than heuristic (0.8µs vs 12.3µs per check) - Both complete within practical timeframes (<200ms)
References¶
License¶
MIT OR Apache-2.0