use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const CHAIN_SENTINEL: &str = "CHAINSENTINEL";
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_token() -> String {
let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{}-{nanos}-{counter}", std::process::id())
}
fn run_understatus(args: &[&str], stdin: &str) -> Vec<u8> {
run_understatus_with_config(args, stdin, "/nonexistent/understatus-test-config.toml")
}
fn run_understatus_with_config(args: &[&str], stdin: &str, config_path: &str) -> Vec<u8> {
let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
.args(args)
.env("NO_COLOR", "1")
.env("UNDERSTATUS_CONFIG", config_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("understatus 바이너리 실행 실패");
child
.stdin
.take()
.expect("stdin 핸들 없음")
.write_all(stdin.as_bytes())
.expect("stdin 쓰기 실패");
let output = child.wait_with_output().expect("자식 종료 대기 실패");
assert!(
output.status.success(),
"종료 코드 비정상: {:?}",
output.status
);
output.stdout
}
fn write_chain_config(tag: &str) -> String {
let path = std::env::temp_dir().join(format!(
"understatus-chain-cfg-{}-{}.toml",
std::process::id(),
tag
));
let toml = format!("[chain]\nchain_command = \"printf {CHAIN_SENTINEL}\"\n");
std::fs::write(&path, toml).expect("임시 config 작성 실패");
path.to_string_lossy().into_owned()
}
#[test]
fn oneline_emits_single_line_without_trailing_newline() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(!text.ends_with('\n'), "후행 개행이 있으면 안 됨: {text:?}");
assert_eq!(
text.matches('\n').count(),
0,
"정확히 1행이어야 함(개행 0): {text:?}"
);
assert!(!text.is_empty(), "코어 출력이 비면 안 됨");
assert!(text.contains('%'), "시스템 지표(%)가 있어야 함: {text:?}");
}
#[test]
fn default_render_has_trailing_newline() {
let stdout = run_understatus(&["render"], "{}");
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.ends_with('\n'),
"기본 render는 println!으로 후행 개행이 있어야 함: {text:?}"
);
}
#[test]
fn oneline_does_not_run_chain() {
let config = write_chain_config("oneline-chain-skip");
let oneline_out = run_understatus_with_config(
&["render", "--oneline"],
r#"{"session_id":"oneline-skip-a"}"#,
&config,
);
let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
assert!(
!oneline_text.contains(CHAIN_SENTINEL),
"--oneline은 chain을 수행하면 안 됨(센티널 부재): {oneline_text:?}"
);
let control_out = run_understatus_with_config(
&["render"],
r#"{"session_id":"oneline-skip-control"}"#,
&config,
);
let control_text = String::from_utf8(control_out).expect("stdout는 UTF-8이어야 함");
assert!(
control_text.contains(CHAIN_SENTINEL),
"대조군(--oneline 없음, claude)은 chain이 실제로 돌아 센티널이 있어야 함: {control_text:?}"
);
let lterm_out = run_understatus_with_config(
&["render", "--source", "lterm"],
r#"{"source":"lterm","session":"oneline-skip-lterm","pane":"%1"}"#,
&config,
);
let lterm_text = String::from_utf8(lterm_out).expect("stdout는 UTF-8이어야 함");
assert!(
!lterm_text.contains(CHAIN_SENTINEL),
"--source lterm은 chain 기본 off여야 함(센티널 부재): {lterm_text:?}"
);
let _ = std::fs::remove_file(&config);
}
#[test]
fn oneline_cols_hint_does_not_force_truncation() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","cols":10,"rows":2}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.contains("proj"),
"cols 힌트가 cwd를 잘라내면 안 됨(폭 권위는 lterm): {text:?}"
);
}
#[test]
fn oneline_lterm_has_no_git_segment() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
!text.contains('⎇'),
"lterm 소스는 git 세그먼트(⎇)가 없어야 함: {text:?}"
);
}
#[test]
fn oneline_lterm_shows_session_pane_label() {
let stdout = run_understatus(
&["render", "--source", "lterm", "--oneline"],
r#"{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":"/x/ios_cleaner"}"#,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert!(
text.contains("codex/%3"),
"lterm 출력에 session/pane 라벨이 있어야 함: {text:?}"
);
assert!(
text.contains("codex/%3 · ios_cleaner"),
"session/pane은 cwd 바로 앞에 표시되어야 함: {text:?}"
);
}
fn write_wide_config() -> String {
let path =
std::env::temp_dir().join(format!("understatus-codex-e2e-cfg-{}.toml", unique_token()));
std::fs::write(&path, "[display]\nmax_width = 200\n").expect("임시 config 작성 실패");
path.to_string_lossy().into_owned()
}
fn run_with_codex_env(
args: &[&str],
stdin: &str,
codex_home: &str,
home: &str,
config_path: &str,
) -> Vec<u8> {
let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
.args(args)
.env("NO_COLOR", "1")
.env("UNDERSTATUS_CONFIG", config_path)
.env("CODEX_HOME", codex_home)
.env("HOME", home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("understatus 바이너리 실행 실패");
child
.stdin
.take()
.expect("stdin 핸들 없음")
.write_all(stdin.as_bytes())
.expect("stdin 쓰기 실패");
let output = child.wait_with_output().expect("자식 종료 대기 실패");
assert!(
output.status.success(),
"종료 코드 비정상: {:?}",
output.status
);
output.stdout
}
fn write_synthetic_codex_session(cwd: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let unique = unique_token();
let codex_home = std::env::temp_dir().join(format!("understatus-e2e-codex-{unique}"));
let cache_home = std::env::temp_dir().join(format!("understatus-e2e-home-{unique}"));
let day_dir = codex_home
.join("sessions")
.join("2026")
.join("06")
.join("05");
std::fs::create_dir_all(&day_dir).expect("일자 디렉터리 생성 실패");
std::fs::create_dir_all(&cache_home).expect("캐시 홈 생성 실패");
let session_meta = format!(
r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"codex-tui","cli_version":"0.137.0"}}}}"#
);
let turn_context = r#"{"type":"turn_context","payload":{"model":"gpt-5.5","effort":"xhigh","summary":"auto"}}"#;
let token_count = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":9999999},"last_token_usage":{"total_tokens":275},"model_context_window":1000},"rate_limits":{"limit_id":"codex","primary":{"used_percent":3.0,"window_minutes":300},"secondary":{"used_percent":21.0,"window_minutes":10080},"plan_type":"pro"}}}"#;
let path = day_dir.join("rollout-2026-06-05T20-40-45-e2e.jsonl");
let body = format!("{session_meta}\n{turn_context}\n{token_count}\n");
std::fs::write(&path, body).expect("합성 세션 쓰기 실패");
(codex_home, cache_home)
}
#[test]
fn e2e_codex_single_session_full_profile() {
let cwd = "/Users/me/e2e-codex-proj";
let (codex_home, cache_home) = write_synthetic_codex_session(cwd);
let config = write_wide_config();
let stdin = format!(
r#"{{"source":"lterm","session":"codex","pane":"%9","cwd":"{cwd}","agent":"codex"}}"#
);
let stdout = run_with_codex_env(
&["render", "--source", "lterm", "--oneline"],
&stdin,
&codex_home.to_string_lossy(),
&cache_home.to_string_lossy(),
&config,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert_eq!(
text.matches('\n').count(),
0,
"정확히 1행이어야 함: {text:?}"
);
assert!(text.contains("gpt-5.5"), "실모델 표시: {text:?}");
assert!(
text.contains("ctx 28%") || text.contains("ctx 27%"),
"ctx% 표시: {text:?}"
);
assert!(text.contains("5h 3%"), "5h 한도 표시: {text:?}");
assert!(text.contains("wk 21%"), "주간 한도 표시: {text:?}");
assert!(text.contains("pro"), "plan(bare value) 표시: {text:?}");
assert!(text.contains("xhigh"), "effort(bare value) 표시: {text:?}");
assert!(
!text.contains(" codex "),
"model 슬롯이 실모델로 enrich되어야 함: {text:?}"
);
let _ = std::fs::remove_dir_all(&codex_home);
let _ = std::fs::remove_dir_all(&cache_home);
let _ = std::fs::remove_file(&config);
}
#[test]
fn e2e_codex_unmatched_degrades_to_bare_lterm() {
let session_cwd = "/Users/me/e2e-codex-has-session";
let (codex_home, cache_home) = write_synthetic_codex_session(session_cwd);
let config = write_wide_config();
let stdin = r#"{"source":"lterm","session":"codex","pane":"%8","cwd":"/Users/me/e2e-no-match","agent":"codex"}"#;
let stdout = run_with_codex_env(
&["render", "--source", "lterm", "--oneline"],
stdin,
&codex_home.to_string_lossy(),
&cache_home.to_string_lossy(),
&config,
);
let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
assert_eq!(
text.matches('\n').count(),
0,
"정확히 1행이어야 함: {text:?}"
);
assert!(!text.contains("5h "), "미매칭은 5h 세그먼트 없음: {text:?}");
assert!(!text.contains("wk "), "미매칭은 wk 세그먼트 없음: {text:?}");
assert!(
!text.contains("gpt-5.5"),
"미매칭은 실모델 enrich 없음: {text:?}"
);
assert!(
!text.contains("ctx "),
"미매칭은 ctx 세그먼트 없음: {text:?}"
);
assert!(
text.contains("codex"),
"미매칭은 bare codex로 정직하게 저하해야 함: {text:?}"
);
let _ = std::fs::remove_dir_all(&codex_home);
let _ = std::fs::remove_dir_all(&cache_home);
let _ = std::fs::remove_file(&config);
}