use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::Duration;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}
fn fresh_home(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"supercode-ux23-trace-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn drain_request(sock: &mut std::net::TcpStream) {
sock.set_read_timeout(Some(Duration::from_millis(200)))
.expect("set read timeout");
let mut buf = [0u8; 65536];
loop {
match sock.read(&mut buf) {
Ok(0) => break,
Ok(_) => continue,
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
break
}
Err(_) => break,
}
}
sock.set_read_timeout(None).expect("clear read timeout");
}
fn write_sse(sock: &mut std::net::TcpStream, sse: &str) {
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
sse.len(),
sse
);
sock.write_all(resp.as_bytes())
.expect("write stub response");
sock.flush().ok();
}
fn spawn_tool_round_trip_stub() -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept round-trip 1");
drain_request(&mut sock);
let sse = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{}\"}}]}}]}\n\n\
data: {\"choices\":[{\"delta\":{}}],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":3,\"total_tokens\":14}}\n\n\
data: [DONE]\n\n";
write_sse(&mut sock, sse);
drop(sock);
let (mut sock, _) = listener.accept().expect("accept round-trip 2");
drain_request(&mut sock);
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n\
data: {\"choices\":[{\"delta\":{}}],\"usage\":{\"prompt_tokens\":25,\"completion_tokens\":1,\"total_tokens\":26}}\n\n\
data: [DONE]\n\n";
write_sse(&mut sock, sse);
});
(addr, handle)
}
fn run_piped(home: &Path, cwd: &Path, base_url: &str, extra: &[&str]) -> Output {
let mut args = vec![
"--api-key",
"x",
"--base-url",
base_url,
"--quiet",
"--disallow-tool",
"bash",
"--disallow-tool",
"shell",
"--cwd",
];
let cwd_str = cwd.to_str().unwrap();
args.push(cwd_str);
args.extend_from_slice(extra);
Command::new(bin())
.env("SUPERCODE_HOME", home)
.env_remove("OPENROUTER_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.env_remove("NO_COLOR")
.env_remove("SUPERCODE_QUIET")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn the supercode binary")
.wait_with_output()
.expect("child process failed")
}
#[test]
fn stream_json_emits_valid_ndjson_with_tool_and_usage_events() {
let (addr, server) = spawn_tool_round_trip_stub();
let home = fresh_home("ndjson");
let cwd = fresh_home("ndjson-cwd");
std::fs::write(cwd.join("a.txt"), b"hi").unwrap();
let out = run_piped(
&home,
&cwd,
&format!("http://{addr}"),
&["run", "--output-format", "stream-json", "list files"],
);
assert!(
out.status.success(),
"run --output-format stream-json failed: status={:?} stdout={} stderr={}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(
!lines.is_empty(),
"expected at least one NDJSON line, got empty stdout"
);
let mut types = std::collections::HashSet::new();
for line in &lines {
let value: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("line is not valid JSON: {e}\nline: {line}"));
let ty = value
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("line missing a `type` field: {line}"))
.to_string();
types.insert(ty);
}
for expected in [
"text_delta",
"tool_call_started",
"tool_call_completed",
"turn_completed",
"usage",
"result",
] {
assert!(
types.contains(expected),
"expected an event of type `{expected}` in the NDJSON stream; saw types: {types:?}\nfull stdout:\n{stdout}"
);
}
assert!(
!stdout.contains('\u{1b}'),
"stream-json stdout must never carry a raw ESC byte (spinner/color chrome); raw: {stdout:?}"
);
assert!(
out.stderr.is_empty(),
"stream-json stderr must be empty without --trace; got: {}",
String::from_utf8_lossy(&out.stderr)
);
let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
assert_eq!(last["type"], "result");
assert!(
last["result"].as_str().unwrap_or("").contains("done"),
"expected the final result line to carry the assembled reply; got: {last}"
);
server.join().expect("stub server thread panicked");
}
#[test]
fn trace_flag_logs_tool_io_and_usage_to_stderr_and_is_silent_without_it() {
let (addr, server) = spawn_tool_round_trip_stub();
let home = fresh_home("trace-on");
let cwd = fresh_home("trace-on-cwd");
std::fs::write(cwd.join("a.txt"), b"hi").unwrap();
let out = run_piped(
&home,
&cwd,
&format!("http://{addr}"),
&["--trace", "run", "list files"],
);
assert!(
out.status.success(),
"--trace run failed: status={:?} stdout={} stderr={}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
stderr.contains("[trace ") && stderr.contains("tool_call") && stderr.contains("list_dir"),
"expected a [trace ...] tool_call line naming list_dir on stderr; got: {stderr}"
);
assert!(
stderr.contains("tool_result"),
"expected a [trace ...] tool_result line on stderr; got: {stderr}"
);
assert!(
stderr.contains("usage") && stderr.contains("prompt_tokens="),
"expected a [trace ...] usage line on stderr; got: {stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("[trace "),
"trace diagnostics must never appear on stdout; got: {stdout}"
);
server.join().expect("stub server thread panicked");
let (addr2, server2) = spawn_tool_round_trip_stub();
let home2 = fresh_home("trace-off");
let cwd2 = fresh_home("trace-off-cwd");
std::fs::write(cwd2.join("a.txt"), b"hi").unwrap();
let out2 = run_piped(
&home2,
&cwd2,
&format!("http://{addr2}"),
&["run", "list files"],
);
assert!(out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
assert!(
!stderr2.contains("[trace "),
"no --trace flag: stderr must carry zero trace lines; got: {stderr2}"
);
server2.join().expect("stub server thread panicked");
}