use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
const UPSTREAM_BODY: &str = r#"{"role":"assistant","content":[{"type":"text","text":"stub upstream ok"}],"stop_reason":"end_turn"}"#;
fn agent_src(exit_code: u8) -> String {
format!(
r#"import os, sys, json, urllib.request
base = os.environ["AMBERFORK_TEST_BASE_URL"]
data = json.dumps({{"model": "claude-sonnet-5", "messages": [{{"role": "user", "content": "ping"}}]}}).encode()
req = urllib.request.Request(base + "/v1/messages", data=data, headers={{"content-type": "application/json"}})
urllib.request.urlopen(req).read()
sys.exit({exit_code})
"#
)
}
fn spawn_stub_upstream() -> SocketAddr {
let listener =
TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("stub upstream binds");
let addr = listener.local_addr().expect("stub upstream addr");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
serve_one(stream);
}
});
addr
}
fn serve_one(mut stream: TcpStream) {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
let Ok(n) = stream.read(&mut chunk) else {
return;
};
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if let Some(header_end) = find(&buf, b"\r\n\r\n") {
let content_len = content_length(&buf[..header_end]);
if buf.len() >= header_end + 4 + content_len {
break;
}
}
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{UPSTREAM_BODY}",
UPSTREAM_BODY.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn content_length(head: &[u8]) -> usize {
let head = String::from_utf8_lossy(head);
head.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.trim()
.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0)
}
fn amberfork_record(upstream: SocketAddr, out: &std::path::Path, agent: &str) -> Command {
let mut cmd = Command::cargo_bin("amberfork").expect("amberfork binary builds");
cmd.arg("record")
.arg("--upstream")
.arg(format!("http://{upstream}"))
.arg("--base-url-env")
.arg("AMBERFORK_TEST_BASE_URL")
.arg("--out")
.arg(out)
.arg("--id")
.arg("test-recording")
.arg("--")
.arg("python3")
.arg("-c")
.arg(agent);
cmd
}
#[test]
fn records_the_provider_exchange_into_a_cassette() {
let upstream = spawn_stub_upstream();
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("run.cassette.json");
amberfork_record(upstream, &out, &agent_src(0))
.assert()
.code(0);
let cassette: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&out).expect("cassette written"))
.expect("cassette is valid JSON");
assert_eq!(cassette["cassette_version"], "0.1");
let exchanges = cassette["exchanges"].as_array().expect("exchanges array");
assert_eq!(exchanges.len(), 1, "one provider call → one exchange");
assert_eq!(exchanges[0]["request"]["body"]["model"], "claude-sonnet-5");
assert_eq!(exchanges[0]["response"]["status"], 200);
assert_eq!(
exchanges[0]["response"]["body"]["content"][0]["text"],
"stub upstream ok"
);
}
#[test]
fn warns_that_the_cassette_is_unredacted_before_the_agent_runs() {
let upstream = spawn_stub_upstream();
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("run.cassette.json");
amberfork_record(upstream, &out, &agent_src(0))
.assert()
.code(0)
.stderr(contains("unredacted").and(contains("do not share")));
}
#[test]
fn writes_the_cassette_and_propagates_a_failing_agent_exit() {
let upstream = spawn_stub_upstream();
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("run.cassette.json");
amberfork_record(upstream, &out, &agent_src(7))
.assert()
.code(7);
let cassette: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&out).expect("cassette written even on agent failure"),
)
.expect("cassette is valid JSON");
assert_eq!(
cassette["exchanges"].as_array().expect("exchanges").len(),
1
);
}