extern crate hrdr_test_support;
mod common;
use std::process::{Command, Output};
use common::{
Chat, MockServer, stop_chunk, text_chunk, tool_args_chunk, tool_calls_stop_chunk,
tool_start_chunk, write_config,
};
fn run_hrdr(server: &MockServer, args: &[&str]) -> Output {
run_hrdr_in(server, args, None)
}
fn run_hrdr_in(server: &MockServer, args: &[&str], project: Option<&std::path::Path>) -> Output {
let home = tempfile::tempdir().expect("temp home");
write_config(home.path(), &server.base_url());
let fresh = tempfile::tempdir().expect("temp project");
let cwd = project.unwrap_or_else(|| fresh.path());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_hrdr"));
cmd.args(args);
cmd.current_dir(cwd);
for (key, value) in [
("HOME", home.path()),
("USERPROFILE", home.path()),
("APPDATA", home.path()),
("LOCALAPPDATA", home.path()),
("XDG_CONFIG_HOME", home.path()),
("XDG_DATA_HOME", home.path()),
("XDG_STATE_HOME", home.path()),
] {
cmd.env(key, value);
}
for key in ["HRDR_MODEL", "HRDR_API_KEY", "RUST_LOG"] {
cmd.env_remove(key);
}
cmd.output().expect("spawn hrdr")
}
fn text_turn(text: &str) -> Chat {
Chat::Sse(vec![
text_chunk("c1", text),
stop_chunk("c1"),
"[DONE]".to_string(),
])
}
#[test]
fn run_streams_plain_text_to_stdout() {
let server = MockServer::start(vec![text_turn("Hello from the mock endpoint.")]);
let out = run_hrdr(&server, &["run", "say hello"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"exit {:?}\nstdout: {stdout}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("Hello from the mock endpoint."),
"reply text must reach stdout, got: {stdout:?}"
);
}
#[test]
fn run_completes_a_tool_round_trip() {
let project = tempfile::tempdir().expect("temp project");
let file = project.path().join("note.txt");
std::fs::write(&file, "the-secret-content").unwrap();
let args_json = serde_json::to_string(&serde_json::json!({
"path": file.to_string_lossy(),
}))
.unwrap();
let server = MockServer::start(vec![
Chat::Sse(vec![
tool_start_chunk("c1", "call_1", "read"),
tool_args_chunk("c1", &args_json),
tool_calls_stop_chunk("c1"),
"[DONE]".to_string(),
]),
text_turn("I read the file successfully."),
]);
let out = run_hrdr_in(&server, &["run", "read the note"], Some(project.path()));
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"exit {:?}\nstdout: {stdout}\nstderr: {stderr}",
out.status.code()
);
assert!(
stdout.contains("I read the file successfully."),
"the post-tool answer must reach stdout, got: {stdout:?}"
);
assert!(
stderr.contains("read"),
"the tool-start chrome names the tool on stderr, got: {stderr:?}"
);
}
#[test]
fn run_json_emits_well_formed_ndjson() {
let server = MockServer::start(vec![text_turn("streamed reply")]);
let out = run_hrdr(&server, &["run", "--json", "hi"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"exit {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(!lines.is_empty(), "some NDJSON must be emitted");
let events: Vec<serde_json::Value> = lines
.iter()
.map(|l| {
serde_json::from_str(l).unwrap_or_else(|e| panic!("line is not JSON: {l:?} ({e})"))
})
.collect();
for ev in &events {
assert!(
ev.get("type").and_then(|t| t.as_str()).is_some(),
"every event has a string `type`: {ev}"
);
}
assert!(
events.iter().any(|e| e["type"] == "text"
&& e["text"]
.as_str()
.is_some_and(|t| t.contains("streamed reply"))),
"a text event carries the reply: {events:?}"
);
let usage = events
.iter()
.find(|e| e["type"] == "usage")
.expect("a usage event is emitted");
assert!(usage["prompt_tokens"].is_number(), "usage: {usage}");
assert!(usage["completion_tokens"].is_number(), "usage: {usage}");
for field in [
"cached_prompt_tokens",
"reasoning_tokens",
"cost_usd",
"session_cost_usd",
"cost_partial",
] {
assert!(usage.get(field).is_some(), "usage missing {field}: {usage}");
}
assert_eq!(
events.last().map(|e| e["type"].clone()),
Some(serde_json::json!("done")),
"the last event is `done`: {events:?}"
);
assert_eq!(
events.iter().filter(|e| e["type"] == "done").count(),
1,
"exactly one `done`"
);
}
#[test]
fn run_json_reports_errors_as_a_json_event_and_exits_nonzero() {
let server = MockServer::start(vec![Chat::Status(400)]);
let out = run_hrdr(&server, &["run", "--json", "hi"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!out.status.success(),
"a failed turn must exit non-zero; stdout: {stdout}"
);
let err = stdout
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.find(|e| e["type"] == "error")
.unwrap_or_else(|| panic!("an error event must be on stdout, got: {stdout:?}"));
assert!(
err["message"].as_str().is_some_and(|m| !m.is_empty()),
"the error event carries a message: {err}"
);
}
#[test]
fn run_reports_a_network_failure_on_stderr_and_exits_nonzero() {
let server = MockServer::start(vec![
Chat::Drop,
Chat::Drop,
Chat::Drop,
Chat::Drop,
Chat::Drop,
Chat::Drop,
]);
let out = run_hrdr(&server, &["run", "hi"]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"a network failure must exit non-zero.\nstdout: {stdout}\nstderr: {stderr}"
);
assert!(
!stderr.trim().is_empty(),
"a diagnostic must land on stderr, got empty stderr (stdout: {stdout:?})"
);
}
#[test]
fn run_max_cost_zero_stops_before_any_model_call() {
let server = MockServer::start(vec![Chat::Status(500)]);
let out = run_hrdr(&server, &["run", "--max-cost", "0", "hi"]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"a tripped cost budget must exit non-zero.\nstdout: {stdout}\nstderr: {stderr}"
);
assert!(
stderr.to_lowercase().contains("budget") || stderr.to_lowercase().contains("cost"),
"stderr names the budget as the reason, got: {stderr:?}"
);
}
#[test]
fn run_rejects_a_negative_max_cost() {
let server = MockServer::start(vec![text_turn("unused")]);
let out = run_hrdr(&server, &["run", "--max-cost", "-1", "hi"]);
assert!(
!out.status.success(),
"a negative cap is a usage error: {:?}",
String::from_utf8_lossy(&out.stderr)
);
}