diff --git a/crates/core/src/refs.rs b/crates/core/src/refs.rs index 47c1705..efc89db 100644 --- a/crates/core/src/refs.rs +++ b/crates/core/src/refs.rs @@ -1,10 +1,15 @@ use crate::error::AppError; use serde::{Deserialize, Serialize}; +use std::cell::RefCell; use std::collections::HashMap; use std::path::PathBuf; const MAX_REFMAP_BYTES: u64 = 1_048_576; // 1 MB +thread_local! { + static HOME_OVERRIDE: RefCell> = const { RefCell::new(None) }; +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefEntry { pub pid: i32, @@ -144,11 +149,73 @@ fn refmap_path() -> Result { } fn home_dir() -> Option { + if let Some(p) = HOME_OVERRIDE.with(|cell| cell.borrow().clone()) { + return Some(p); + } std::env::var_os("HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) } +#[cfg(test)] +pub(crate) struct HomeGuard { + _dir: tempdir::TempDir, + prev: Option, +} + +#[cfg(test)] +mod tempdir { + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + pub struct TempDir(PathBuf); + + impl TempDir { + pub fn new() -> Self { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!("agent-desktop-test-{nanos}-{n}")); + fs::create_dir_all(&path).expect("create tempdir"); + Self(path) + } + + pub fn path(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } +} + +#[cfg(test)] +impl HomeGuard { + pub fn new() -> Self { + let dir = tempdir::TempDir::new(); + let prev = HOME_OVERRIDE.with(|cell| cell.borrow().clone()); + HOME_OVERRIDE.with(|cell| *cell.borrow_mut() = Some(dir.path().to_path_buf())); + Self { _dir: dir, prev } + } +} + +#[cfg(test)] +impl Drop for HomeGuard { + fn drop(&mut self) { + let prev = self.prev.take(); + HOME_OVERRIDE.with(|cell| *cell.borrow_mut() = prev); + } +} + #[cfg(test)] mod tests { use super::*; @@ -384,6 +451,75 @@ mod tests { assert!(result.is_ok(), "normal-sized refmap should serialize"); } + #[test] + fn test_save_load_roundtrip_with_home_override() { + let _guard = HomeGuard::new(); + let mut map = RefMap::new(); + map.allocate(RefEntry { + pid: 7, + role: "button".into(), + name: Some("Send".into()), + value: None, + states: vec![], + bounds: None, + bounds_hash: Some(42), + available_actions: vec!["Click".into()], + source_app: Some("TestApp".into()), + root_ref: None, + }); + map.save().expect("save should succeed under HomeGuard"); + + let loaded = RefMap::load().expect("load should succeed"); + assert_eq!(loaded.len(), 1); + let entry = loaded.get("@e1").unwrap(); + assert_eq!(entry.pid, 7); + assert_eq!(entry.name.as_deref(), Some("Send")); + } + + #[test] + fn test_save_oversize_preserves_previous_file() { + let _guard = HomeGuard::new(); + + let mut original = RefMap::new(); + original.allocate(RefEntry { + pid: 1, + role: "button".into(), + name: Some("Original".into()), + value: None, + states: vec![], + bounds: None, + bounds_hash: None, + available_actions: vec!["Click".into()], + source_app: None, + root_ref: None, + }); + original.save().expect("baseline save"); + + let mut oversize = RefMap::new(); + let big = "x".repeat(2048); + for _ in 0..600 { + oversize.allocate(RefEntry { + pid: 1, + role: "button".into(), + name: Some(big.clone()), + value: None, + states: vec![], + bounds: None, + bounds_hash: None, + available_actions: vec!["Click".into()], + source_app: None, + root_ref: None, + }); + } + let result = oversize.save(); + assert!(result.is_err(), "oversize save must reject"); + + let reloaded = RefMap::load().expect("previous file must still load"); + assert_eq!(reloaded.len(), 1); + let entry = reloaded.get("@e1").unwrap(); + assert_eq!(entry.name.as_deref(), Some("Original")); + } + #[test] fn test_root_ref_none_omitted() { let entry = RefEntry { diff --git a/crates/core/src/snapshot_ref.rs b/crates/core/src/snapshot_ref.rs index 43a18f3..3cda36d 100644 --- a/crates/core/src/snapshot_ref.rs +++ b/crates/core/src/snapshot_ref.rs @@ -112,7 +112,12 @@ fn allocate_refs_with_root( #[cfg(test)] mod tests { use super::*; + use crate::action::Action; + use crate::adapter::{NativeHandle, PermissionStatus, PlatformAdapter}; + use crate::error::AdapterError; use crate::node::AccessibilityNode; + use crate::refs::HomeGuard; + use std::cell::Cell; fn node(role: &str) -> AccessibilityNode { AccessibilityNode { @@ -129,6 +134,203 @@ mod tests { } } + fn named(role: &str, name: &str) -> AccessibilityNode { + let mut n = node(role); + n.name = Some(name.into()); + n + } + + struct StubAdapter { + subtree: AccessibilityNode, + resolve_calls: Cell, + } + + impl StubAdapter { + fn new(subtree: AccessibilityNode) -> Self { + Self { + subtree, + resolve_calls: Cell::new(0), + } + } + } + + unsafe impl Send for StubAdapter {} + unsafe impl Sync for StubAdapter {} + + impl PlatformAdapter for StubAdapter { + fn check_permissions(&self) -> PermissionStatus { + PermissionStatus::Granted + } + + fn resolve_element( + &self, + _entry: &crate::refs::RefEntry, + ) -> Result { + self.resolve_calls.set(self.resolve_calls.get() + 1); + Ok(NativeHandle::null()) + } + + fn get_subtree( + &self, + _handle: &NativeHandle, + _opts: &TreeOptions, + ) -> Result { + Ok(self.subtree.clone()) + } + + fn execute_action( + &self, + _handle: &NativeHandle, + _action: Action, + ) -> Result { + Err(AdapterError::not_supported("execute_action")) + } + } + + fn seed_skeleton_refmap() -> RefMap { + let mut map = RefMap::new(); + let anchor = ref_entry_from_node(&named("group", "Sidebar"), 42, Some("TestApp"), None); + let _ = map.allocate(anchor); + let other = ref_entry_from_node(&named("button", "Toolbar"), 42, Some("TestApp"), None); + let _ = map.allocate(other); + map + } + + fn drill_opts() -> TreeOptions { + TreeOptions { + interactive_only: false, + ..Default::default() + } + } + + #[test] + fn test_run_from_ref_returns_subtree_and_persists_refs() { + let _guard = HomeGuard::new(); + seed_skeleton_refmap().save().unwrap(); + + let mut child_btn = named("button", "Save"); + child_btn.children = vec![]; + let mut subtree_root = named("group", "Sidebar"); + subtree_root.children = vec![child_btn]; + + let adapter = StubAdapter::new(subtree_root); + let result = run_from_ref(&adapter, &drill_opts(), "@e1").expect("drill should succeed"); + + let on_disk = RefMap::load().unwrap(); + assert_eq!(on_disk.len(), result.refmap.len()); + assert!( + result.refmap.len() >= 3, + "expected at least 2 skeleton + 1 drill ref, got {}", + result.refmap.len() + ); + + let drill_ref = result + .tree + .children + .iter() + .find(|c| c.role == "button") + .and_then(|c| c.ref_id.as_deref()) + .expect("button child should carry a ref"); + let drill_entry = on_disk.get(drill_ref).expect("entry persisted"); + assert_eq!(drill_entry.root_ref.as_deref(), Some("@e1")); + assert_eq!(adapter.resolve_calls.get(), 1); + } + + #[test] + fn test_run_from_ref_stale_root_returns_stale_ref() { + let _guard = HomeGuard::new(); + RefMap::new().save().unwrap(); + + let adapter = StubAdapter::new(named("group", "Sidebar")); + let result = run_from_ref(&adapter, &drill_opts(), "@e99"); + let err = match result { + Ok(_) => panic!("stale root must error"), + Err(e) => e, + }; + match err { + AppError::Adapter(adapter_err) => { + assert_eq!(adapter_err.code, crate::error::ErrorCode::StaleRef); + let suggestion = adapter_err.suggestion.as_deref().unwrap_or(""); + assert!( + suggestion.contains("skeleton"), + "stale-ref suggestion should mention skeleton, got: {suggestion}" + ); + } + other => panic!("expected Adapter(StaleRef), got {other:?}"), + } + } + + #[test] + fn test_run_from_ref_re_drill_replaces_drill_refs_only() { + let _guard = HomeGuard::new(); + seed_skeleton_refmap().save().unwrap(); + + let subtree = named("button", "Save"); + let adapter = StubAdapter::new(subtree); + + let first = run_from_ref(&adapter, &drill_opts(), "@e1").unwrap(); + let first_count = first.refmap.len(); + let first_button_ref = first.tree.ref_id.clone().expect("button should get a ref"); + + let second = run_from_ref(&adapter, &drill_opts(), "@e1").unwrap(); + let second_count = second.refmap.len(); + let second_button_ref = second.tree.ref_id.clone().expect("button should get a ref"); + + assert_eq!( + first_count, second_count, + "ref count stable across re-drill" + ); + assert_ne!( + first_button_ref, second_button_ref, + "re-drill should issue a fresh ref id (counter continues)" + ); + let on_disk = RefMap::load().unwrap(); + assert!(on_disk.get("@e1").is_some(), "skeleton anchor preserved"); + assert!(on_disk.get(&second_button_ref).is_some()); + assert!( + on_disk.get(&first_button_ref).is_none(), + "first drill ref must be invalidated by remove_by_root_ref" + ); + } + + #[test] + fn test_run_from_ref_multiple_drill_downs_accumulate() { + let _guard = HomeGuard::new(); + seed_skeleton_refmap().save().unwrap(); + + let adapter_one = StubAdapter::new(named("button", "FromE1")); + let first = run_from_ref(&adapter_one, &drill_opts(), "@e1").unwrap(); + let from_e1_ref = first.tree.ref_id.clone().expect("first drill ref"); + + let adapter_two = StubAdapter::new(named("button", "FromE2")); + let second = run_from_ref(&adapter_two, &drill_opts(), "@e2").unwrap(); + let from_e2_ref = second.tree.ref_id.clone().expect("second drill ref"); + + let on_disk = RefMap::load().unwrap(); + assert!(on_disk.get("@e1").is_some(), "skeleton @e1 preserved"); + assert!(on_disk.get("@e2").is_some(), "skeleton @e2 preserved"); + let entry_one = on_disk.get(&from_e1_ref).expect("@e1 drill survives"); + assert_eq!(entry_one.root_ref.as_deref(), Some("@e1")); + let entry_two = on_disk.get(&from_e2_ref).expect("@e2 drill survives"); + assert_eq!(entry_two.root_ref.as_deref(), Some("@e2")); + } + + #[test] + fn test_run_from_ref_empty_subtree() { + let _guard = HomeGuard::new(); + seed_skeleton_refmap().save().unwrap(); + + let adapter = StubAdapter::new(node("group")); + let result = run_from_ref(&adapter, &drill_opts(), "@e1").unwrap(); + + assert!(result.tree.children.is_empty()); + assert_eq!( + result.refmap.len(), + 2, + "no new refs added for empty subtree" + ); + } + #[test] fn test_allocate_refs_with_root_tags_entries() { let mut btn = node("button");