use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_hprof-analyzer");
fn philosophers() -> Option<String> {
let p = format!(
"{}/tests/fixtures/dump_4_philosophers.hprof",
env!("CARGO_MANIFEST_DIR")
);
match std::fs::metadata(&p) {
Ok(m) if m.len() >= 1024 => Some(p),
_ => None,
}
}
#[test]
fn bare_path_hprof_analyzes() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN).arg(&hprof).output().unwrap();
assert!(
out.status.success(),
"bare-path analyze failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(md.contains("## System Overview"), "missing System Overview");
}
#[test]
fn bare_path_json_rerenders() {
let Some(hprof) = philosophers() else { return };
let json = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.output()
.unwrap();
assert!(
json.status.success(),
"setup analyze→json failed: {}",
String::from_utf8_lossy(&json.stderr)
);
let tmp = std::env::temp_dir().join(format!("hprof_cli_{}.json", std::process::id()));
std::fs::write(&tmp, &json.stdout).unwrap();
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"bare-path re-render failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## System Overview"),
"re-render missing sections"
);
}
#[test]
fn analyze_flag_on_json_errors() {
let Some(hprof) = philosophers() else { return };
let json = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.output()
.unwrap();
assert!(
json.status.success(),
"setup analyze→json failed: {}",
String::from_utf8_lossy(&json.stderr)
);
let tmp = std::env::temp_dir().join(format!("hprof_cli_flag_{}.json", std::process::id()));
std::fs::write(&tmp, &json.stdout).unwrap();
let out = Command::new(BIN)
.arg(&tmp)
.arg("--collections")
.output()
.unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(!out.status.success(), "--collections on JSON should fail");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("--collections has no effect"),
"missing hint, got: {err}"
);
}
#[test]
fn help_has_no_analyze_or_render_subcommands() {
let out = Command::new(BIN).arg("--help").output().unwrap();
assert!(out.status.success());
let help = String::from_utf8_lossy(&out.stdout);
for line in help.lines() {
let t = line.trim_start();
assert!(
!t.starts_with("analyze") && !t.starts_with("render"),
"help still lists a removed subcommand: {line}"
);
}
assert!(
help.contains("compare"),
"compare subcommand missing from help"
);
assert!(
help.contains("completions"),
"completions missing from help"
);
}
fn analyze_to_json(hprof: &str, dest: &std::path::Path) {
let json = Command::new(BIN)
.arg(hprof)
.args(["--format", "json"])
.output()
.unwrap();
assert!(
json.status.success(),
"setup analyze→json failed: {}",
String::from_utf8_lossy(&json.stderr)
);
std::fs::write(dest, &json.stdout).unwrap();
}
#[test]
fn stdin_dash_rerenders_json() {
let Some(hprof) = philosophers() else { return };
let tmp = std::env::temp_dir().join(format!("hprof_cli_stdin_{}.json", std::process::id()));
analyze_to_json(&hprof, &tmp);
let json = std::fs::File::open(&tmp).unwrap();
let out = Command::new(BIN).arg("-").stdin(json).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"stdin re-render failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## System Overview"),
"stdin re-render missing sections"
);
}
#[test]
fn misnamed_json_dot_hprof_hints() {
let Some(hprof) = philosophers() else { return };
let tmp = std::env::temp_dir().join(format!("hprof_cli_misnamed_{}.hprof", std::process::id()));
analyze_to_json(&hprof, &tmp);
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(
!out.status.success(),
"misnamed .hprof JSON should fail to analyze"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("does not start with the HPROF magic"),
"missing misnamed-report hint, got: {err}"
);
}
#[test]
fn bare_path_hprof_gz_analyzes() {
use flate2::{Compression, write::GzEncoder};
use std::io::Write;
let Some(hprof) = philosophers() else { return };
let raw = std::fs::read(&hprof).unwrap();
let tmp = std::env::temp_dir().join(format!("hprof_cli_{}.hprof.gz", std::process::id()));
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
enc.write_all(&raw).unwrap();
std::fs::write(&tmp, enc.finish().unwrap()).unwrap();
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"bare-path .hprof.gz analyze failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## System Overview"),
"gz analyze missing System Overview"
);
}
#[test]
fn rerender_html_input_hints_html() {
let Some(hprof) = philosophers() else { return };
let tmp = std::env::temp_dir().join(format!("hprof_cli_{}.html", std::process::id()));
let setup = Command::new(BIN)
.arg(&hprof)
.arg(&tmp)
.args(["--format", "html"])
.output()
.unwrap();
assert!(
setup.status.success(),
"setup analyze→html failed: {}",
String::from_utf8_lossy(&setup.stderr)
);
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(!out.status.success(), "HTML re-render input should fail");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("rendered HTML report"),
"missing HTML re-render hint, got: {err}"
);
}
#[test]
fn rerender_gz_markdown_input_hints_markdown() {
let Some(hprof) = philosophers() else { return };
let tmp = std::env::temp_dir().join(format!("hprof_cli_{}.md.gz", std::process::id()));
let setup = Command::new(BIN).arg(&hprof).arg(&tmp).output().unwrap();
assert!(
setup.status.success(),
"setup analyze→md.gz failed: {}",
String::from_utf8_lossy(&setup.stderr)
);
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(!out.status.success(), "gz-md re-render input should fail");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("rendered Markdown report"),
"missing Markdown re-render hint, got: {err}"
);
}
#[test]
fn truncated_dump_hints_corrupt() {
let Some(hprof) = philosophers() else { return };
let raw = std::fs::read(&hprof).unwrap();
assert!(raw.len() > 5000, "fixture unexpectedly small");
let tmp = std::env::temp_dir().join(format!("hprof_trunc_{}.hprof", std::process::id()));
std::fs::write(&tmp, &raw[..5000]).unwrap();
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("no heap objects") || err.contains("truncated"),
"expected truncation warning, got: {err}"
);
}
#[test]
fn dup_strings_markdown_has_no_control_bytes() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.arg("--find-duplicates")
.output()
.unwrap();
assert!(
out.status.success(),
"dup-strings analyze failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let bad = out
.stdout
.iter()
.filter(|&&b| (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r') || b == 0x7f)
.count();
assert_eq!(bad, 0, "dup-strings Markdown leaked {bad} control byte(s)");
}
fn scala_doku() -> Option<String> {
let p = format!(
"{}/tests/fixtures/dump_2_scala-doku.hprof",
env!("CARGO_MANIFEST_DIR")
);
match std::fs::metadata(&p) {
Ok(m) if m.len() >= 1024 => Some(p),
_ => None,
}
}
#[test]
fn compare_reports_renders_growth_section() {
let (Some(base), Some(curr)) = (philosophers(), scala_doku()) else {
return;
};
let dir = std::env::temp_dir();
let a = dir.join(format!("hprof_cmp_a_{}.json", std::process::id()));
let b = dir.join(format!("hprof_cmp_b_{}.json", std::process::id()));
analyze_to_json(&base, &a);
analyze_to_json(&curr, &b);
let out = Command::new(BIN)
.args(["compare", "reports"])
.arg(&a)
.arg(&b)
.output()
.unwrap();
let _ = std::fs::remove_file(&a);
let _ = std::fs::remove_file(&b);
assert!(
out.status.success(),
"compare reports failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Cross-Dump Growth"),
"missing growth section"
);
assert!(md.contains("**Verdict:**"), "missing verdict line");
assert!(
md.contains("### Headline Totals"),
"missing headline totals"
);
assert!(
md.contains("Gross Retained churn"),
"missing gross-churn headline: {md}"
);
}
#[test]
fn compare_reports_output_gz_roundtrips() {
use std::io::Read;
let (Some(base), Some(curr)) = (philosophers(), scala_doku()) else {
return;
};
let dir = std::env::temp_dir();
let a = dir.join(format!("hprof_cmpgz_a_{}.json", std::process::id()));
let b = dir.join(format!("hprof_cmpgz_b_{}.json", std::process::id()));
let gz = dir.join(format!("hprof_cmpgz_out_{}.md.gz", std::process::id()));
analyze_to_json(&base, &a);
analyze_to_json(&curr, &b);
let out = Command::new(BIN)
.args(["compare", "reports"])
.arg(&a)
.arg(&b)
.arg("--output")
.arg(&gz)
.output()
.unwrap();
assert!(
out.status.success(),
"compare --output .gz failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"compare --output should not also print to stdout"
);
let raw = std::fs::read(&gz).unwrap();
let _ = std::fs::remove_file(&a);
let _ = std::fs::remove_file(&b);
let _ = std::fs::remove_file(&gz);
assert!(
raw.len() >= 2 && raw[0] == 0x1f && raw[1] == 0x8b,
"output file is not gzip-compressed"
);
let mut dec = flate2::read::GzDecoder::new(&raw[..]);
let mut md = String::new();
dec.read_to_string(&mut md).unwrap();
assert!(
md.contains("## Cross-Dump Growth"),
"decompressed report missing growth section"
);
}
#[test]
fn collections_report_is_deterministic() {
let Some(hprof) = philosophers() else { return };
let run = || {
let out = Command::new(BIN)
.arg(&hprof)
.args(["--find-duplicates", "--collections", "--progress", "never"])
.output()
.unwrap();
assert!(
out.status.success(),
"collections analyze failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.contains("Generated by hprof-analyzer"))
.collect::<Vec<_>>()
.join("\n")
};
let first = run();
let second = run();
assert_eq!(
first, second,
"two --collections runs diverged; held-via attribution is nondeterministic"
);
}
#[test]
fn bad_id_size_gives_malformed_hint() {
let mut header: Vec<u8> = b"JAVA PROFILE 1.0.2\0".to_vec();
header.extend_from_slice(&3u32.to_be_bytes()); header.extend_from_slice(&0u64.to_be_bytes()); let tmp = std::env::temp_dir().join(format!("hprof_badid_{}.hprof", std::process::id()));
std::fs::write(&tmp, &header).unwrap();
let out = Command::new(BIN).arg(&tmp).output().unwrap();
let _ = std::fs::remove_file(&tmp);
assert!(!out.status.success(), "bad id_size dump should fail");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("parsed as HPROF but a record was malformed")
|| err.contains("unsupported id_size"),
"missing malformed-record hint for bad id_size, got: {err}"
);
}