mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-18 13:05:46 +00:00
test: add filesystem-redirected integration tests for run_from_ref
Adds a thread-local HOME override + RAII HomeGuard so RefMap::save and RefMap::load can be exercised against an isolated temp directory without env-var racing across parallel tests. Also adds a StubAdapter that implements PlatformAdapter with a canned subtree response so the full run_from_ref drill-down flow can be unit-tested without standing up macOS AX state. Closes seven Phase 3 plan acceptance items in one pass: - save+load roundtrip with HOME override - oversize save rejection preserves previous file on disk - run_from_ref returns subtree and persists drill refs - stale root ref → STALE_REF with skeleton suggestion - re-drill replaces drill refs only, counter continues - multiple drill-downs from @e1 and @e2 coexist - empty subtree drill-down produces no new refs
This commit is contained in:
parent
8491481ef0
commit
85b7d25584
2 changed files with 338 additions and 0 deletions
|
|
@ -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<Option<PathBuf>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RefEntry {
|
||||
pub pid: i32,
|
||||
|
|
@ -144,11 +149,73 @@ fn refmap_path() -> Result<PathBuf, AppError> {
|
|||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
}
|
||||
|
||||
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<NativeHandle, AdapterError> {
|
||||
self.resolve_calls.set(self.resolve_calls.get() + 1);
|
||||
Ok(NativeHandle::null())
|
||||
}
|
||||
|
||||
fn get_subtree(
|
||||
&self,
|
||||
_handle: &NativeHandle,
|
||||
_opts: &TreeOptions,
|
||||
) -> Result<AccessibilityNode, AdapterError> {
|
||||
Ok(self.subtree.clone())
|
||||
}
|
||||
|
||||
fn execute_action(
|
||||
&self,
|
||||
_handle: &NativeHandle,
|
||||
_action: Action,
|
||||
) -> Result<crate::action::ActionResult, AdapterError> {
|
||||
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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue