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, write_config_with,
};
fn run_hrdr(server: &MockServer, args: &[&str]) -> Output {
run_hrdr_in(server, args, None)
}
fn run_hrdr_env(server: &MockServer, args: &[&str], env: &[(&str, &str)]) -> Output {
run_hrdr_inner(server, args, None, env)
}
fn run_hrdr_in(server: &MockServer, args: &[&str], project: Option<&std::path::Path>) -> Output {
run_hrdr_inner(server, args, project, &[])
}
fn run_hrdr_with_home(server: &MockServer, home: &std::path::Path, args: &[&str]) -> Output {
run_hrdr_inner_with_home(server, args, None, &[], Some(home))
}
fn run_hrdr_inner(
server: &MockServer,
args: &[&str],
project: Option<&std::path::Path>,
env: &[(&str, &str)],
) -> Output {
run_hrdr_inner_with_home(server, args, project, env, None)
}
fn run_hrdr_inner_with_home(
server: &MockServer,
args: &[&str],
project: Option<&std::path::Path>,
env: &[(&str, &str)],
preset_home: Option<&std::path::Path>,
) -> Output {
let fresh_home = tempfile::tempdir().expect("temp home");
let home = preset_home.unwrap_or_else(|| fresh_home.path());
if preset_home.is_none() {
write_config(home, &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),
("USERPROFILE", home),
("APPDATA", home),
("LOCALAPPDATA", home),
("XDG_CONFIG_HOME", home),
("XDG_DATA_HOME", home),
("XDG_STATE_HOME", home),
("XDG_CACHE_HOME", home),
] {
cmd.env(key, value);
}
for key in ["HRDR_MODEL", "HRDR_API_KEY", "RUST_LOG"] {
cmd.env_remove(key);
}
for (key, value) in env {
cmd.env(key, value);
}
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 captured_stderr_carries_no_escape_codes() {
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("Read it."),
]);
let out = run_hrdr_in(&server, &["run", "read the note"], Some(project.path()));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("read") && stderr.contains("[usage]"),
"the chrome this test is about must have run: {stderr:?}"
);
assert!(
!stderr.contains('\x1b'),
"a captured stderr must be plain text: {stderr:?}"
);
}
#[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_env(&server, &["run", "hi"], &[("HRDR_RETRY_ATTEMPTS", "2")]);
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)
);
}
#[test]
fn run_drops_a_rejected_parameter_and_finishes_the_turn() {
let server = MockServer::start(vec![
Chat::StatusBody(
400,
r#"{"error":{"message":"Unsupported parameter: temperature"}}"#.to_string(),
),
text_turn("recovered"),
]);
let home = tempfile::tempdir().expect("temp home");
write_config_with(home.path(), &server.base_url(), "temperature = 0.7\n");
let out = run_hrdr_with_home(&server, home.path(), &["run", "hi"]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"the turn must survive the rejection.\nstdout: {stdout}\nstderr: {stderr}"
);
assert_eq!(stdout, "recovered\n", "the reply still reaches stdout");
assert!(
stderr.contains("rejected `temperature`"),
"the user is told their configured parameter was dropped, got: {stderr:?}"
);
let bodies = server.chat_bodies();
assert_eq!(bodies.len(), 2, "rejected once, then retried: {bodies:#?}");
assert!(
bodies[0].get("temperature").is_some(),
"the first attempt carried the configured parameter: {bodies:#?}"
);
assert!(
bodies[1].get("temperature").is_none(),
"the retry omits it: {bodies:#?}"
);
}
#[test]
fn run_json_reports_a_dropped_parameter_as_a_notice() {
let server = MockServer::start(vec![
Chat::StatusBody(
400,
r#"{"error":{"message":"Unrecognized request argument supplied: top_p"}}"#.to_string(),
),
text_turn("ok"),
]);
let home = tempfile::tempdir().expect("temp home");
write_config_with(home.path(), &server.base_url(), "top_p = 0.9\n");
let out = run_hrdr_with_home(&server, home.path(), &["run", "--json", "hi"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"the turn must survive the rejection: {stdout}"
);
let events: Vec<serde_json::Value> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("each line is one JSON event"))
.collect();
assert!(
events.iter().any(|e| e["type"] == "notice"
&& e["text"]
.as_str()
.is_some_and(|m| m.contains("rejected `top_p`"))),
"the drop is a notice event: {events:#?}"
);
assert_eq!(
events.iter().filter(|e| e["type"] == "done").count(),
1,
"and the turn still ends exactly once: {events:#?}"
);
assert!(
events.iter().all(|e| e["type"] != "error"),
"a recovered rejection is not an error: {events:#?}"
);
}
#[test]
fn run_treats_a_400_overflow_as_overflow_not_a_rejected_parameter() {
let server = MockServer::start(vec![
Chat::StatusBody(
400,
r#"{"error":{"message":"This model's maximum context length is 8192 tokens"}}"#
.to_string(),
),
text_turn("after compaction"),
]);
let home = tempfile::tempdir().expect("temp home");
write_config_with(home.path(), &server.base_url(), "temperature = 0.7\n");
let out = run_hrdr_with_home(&server, home.path(), &["run", "hi"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("rejected `temperature`"),
"an overflow must not be mistaken for a parameter rejection: {stderr:?}"
);
let bodies = server.chat_bodies();
assert!(
bodies
.first()
.is_some_and(|body| body.get("temperature").is_some()),
"and the parameter was never dropped: {bodies:#?}"
);
}