use crate::helpers::{fixture_path, AftProcess};
fn setup_watcher_fixture() -> (tempfile::TempDir, String) {
let fixtures = fixture_path("callgraph");
let tmp = tempfile::tempdir().expect("create temp dir");
for entry in std::fs::read_dir(&fixtures).expect("read fixtures dir") {
let entry = entry.expect("read entry");
let src = entry.path();
if src.is_file() {
let dst = tmp.path().join(entry.file_name());
std::fs::copy(&src, &dst).expect("copy fixture file");
}
}
let root = tmp.path().display().to_string();
(tmp, root)
}
fn poll_watcher_update_after_mutation<M, F>(
aft: &mut AftProcess,
query: &str,
mut mutate: M,
predicate: F,
description: &str,
) -> serde_json::Value
where
M: FnMut(u32),
F: Fn(&serde_json::Value) -> bool,
{
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let poll_interval = std::time::Duration::from_millis(100);
let mut last_response = serde_json::Value::Null;
let mut ping_id = 1000;
let mut attempt = 0;
while std::time::Instant::now() < deadline {
attempt += 1;
mutate(attempt);
ping_id += 1;
aft.send(&format!(r#"{{"id":"ping-{}","command":"ping"}}"#, ping_id));
let resp = aft.send(query);
if predicate(&resp) {
return resp;
}
last_response = resp;
std::thread::sleep(poll_interval);
}
panic!(
"watcher update did not propagate within 10s: {}\nlast response: {:?}",
description, last_response
);
}
#[test]
fn callgraph_watcher_add_caller() {
let _watcher_guard = crate::helpers::watcher_serial_lock();
let (_tmp, root) = setup_watcher_fixture();
let mut aft = AftProcess::spawn_with_real_watcher();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","harness":"opencode","project_root":{}}}"#,
crate::helpers::json_string(&root)
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":{},"symbol":"validate","depth":1}}"#,
crate::helpers::json_string(&format!("{}/helpers.ts", root))
));
assert_eq!(
resp["success"], true,
"initial callers should succeed: {:?}",
resp
);
let initial_total = resp["total_callers"].as_u64().unwrap();
assert!(initial_total > 0, "validate should have initial callers");
let new_file = std::path::Path::new(&root).join("extra_caller.ts");
let query = format!(
r#"{{"id":"4","command":"callers","file":{},"symbol":"validate","depth":1}}"#,
crate::helpers::json_string(&format!("{}/helpers.ts", root))
);
let resp = poll_watcher_update_after_mutation(
&mut aft,
&query,
|attempt| {
std::fs::write(
&new_file,
format!(
r#"import {{ validate }} from './helpers';
export function extraCheck(input: string): boolean {{
// mutation attempt {attempt}
return validate(input);
}}
"#
),
)
.expect("write new caller file");
},
|r| {
r["success"] == true
&& r["total_callers"].as_u64().unwrap_or(0) > initial_total
&& r["callers"]
.as_array()
.map(|cs| {
cs.iter()
.any(|g| g["file"].as_str().unwrap_or("").contains("extra_caller.ts"))
})
.unwrap_or(false)
},
"extra_caller.ts should appear as a new caller of validate",
);
let new_total = resp["total_callers"].as_u64().unwrap();
assert!(
new_total > initial_total,
"adding a caller should increase total_callers: initial={}, new={}",
initial_total,
new_total
);
aft.shutdown();
}
#[test]
fn callgraph_watcher_remove_caller() {
let _watcher_guard = crate::helpers::watcher_serial_lock();
let (_tmp, root) = setup_watcher_fixture();
let mut aft = AftProcess::spawn_with_real_watcher();
let resp = aft.send(&format!(
r#"{{"id":"1","command":"configure","harness":"opencode","project_root":{}}}"#,
crate::helpers::json_string(&root)
));
assert_eq!(
resp["success"], true,
"configure should succeed: {:?}",
resp
);
let resp = aft.send(&format!(
r#"{{"id":"2","command":"callers","file":{},"symbol":"validate","depth":1}}"#,
crate::helpers::json_string(&format!("{}/helpers.ts", root))
));
assert_eq!(
resp["success"], true,
"initial callers should succeed: {:?}",
resp
);
let callers = resp["callers"].as_array().expect("callers array");
let utils_group = callers
.iter()
.find(|g| g["file"].as_str().unwrap_or("").contains("utils.ts"));
assert!(
utils_group.is_some(),
"validate should initially be called from utils.ts"
);
let utils_path = std::path::Path::new(&root).join("utils.ts");
let query = format!(
r#"{{"id":"4","command":"callers","file":{},"symbol":"validate","depth":1}}"#,
crate::helpers::json_string(&format!("{}/helpers.ts", root))
);
poll_watcher_update_after_mutation(
&mut aft,
&query,
|attempt| {
std::fs::write(
&utils_path,
format!(
r#"export function processData(input: string): string {{
// validate call removed on attempt {attempt}
return input.toUpperCase();
}}
"#
),
)
.expect("rewrite utils.ts");
},
|r| {
if r["success"] != true {
return false;
}
let callers = match r["callers"].as_array() {
Some(cs) => cs,
None => return false,
};
let utils_group = callers
.iter()
.find(|g| g["file"].as_str().unwrap_or("").contains("utils.ts"));
match utils_group {
None => true, Some(group) => group["callers"]
.as_array()
.map(|entries| {
entries
.iter()
.all(|e| e["callee"].as_str().unwrap_or("") != "validate")
})
.unwrap_or(false),
}
},
"validate call should be removed from utils.ts after rewrite",
);
aft.shutdown();
}