#[path = "../src/md_test.rs"]
mod md_test;
use md_test::Md;
fn hprof_header() -> Vec<u8> {
let mut h = b"JAVA PROFILE 1.0.2\0".to_vec();
h.extend_from_slice(&8u32.to_be_bytes()); h.extend_from_slice(&0u64.to_be_bytes()); h
}
fn string_record(id: u64, text: &[u8]) -> Vec<u8> {
let body_len = 8 + text.len() as u32; let mut rec = vec![0x01u8]; rec.extend_from_slice(&0u32.to_be_bytes()); rec.extend_from_slice(&body_len.to_be_bytes());
rec.extend_from_slice(&id.to_be_bytes());
rec.extend_from_slice(text);
rec
}
fn load_class_record(serial: u32, class_addr: u64, name_id: u64) -> Vec<u8> {
let body_len: u32 = 4 + 8 + 4 + 8; let mut rec = vec![0x02u8]; rec.extend_from_slice(&0u32.to_be_bytes());
rec.extend_from_slice(&body_len.to_be_bytes());
rec.extend_from_slice(&serial.to_be_bytes());
rec.extend_from_slice(&class_addr.to_be_bytes());
rec.extend_from_slice(&0u32.to_be_bytes()); rec.extend_from_slice(&name_id.to_be_bytes());
rec
}
fn run_json(path: &std::path::Path) -> (bool, String, String) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(path)
.arg("--format")
.arg("json")
.output()
.expect("failed to spawn hprof-analyzer");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn run_report(path: &std::path::Path) -> (bool, String) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg("report")
.arg(path)
.output()
.expect("failed to spawn hprof-analyzer");
let combined = format!(
"STDOUT:\n{}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(out.status.success(), combined)
}
fn assert_no_panic(combined: &str) {
assert!(
!combined.contains("panicked at"),
"tool panicked!\n{combined}"
);
}
fn assert_error_message_is_clean(stderr: &str, label: &str) {
for bad in &[
"eof in read_into",
"eof in skip",
"failed to fill whole buffer",
] {
assert!(
!stderr.contains(bad),
"{label}: stderr contains raw internal message {bad:?}\nstderr: {stderr}"
);
}
}
fn json_is_valid_report(json: &str) -> bool {
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
return false;
};
v["schema_version"].is_number()
}
fn json_report_has_content(json: &str) -> bool {
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
return false;
};
let objects = v["overview"]["total_objects"].as_u64().unwrap_or(0);
objects > 0
}
#[test]
fn end_to_end_dump0() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/dump_0_fj-kmeans.hprof"
);
match std::fs::metadata(path) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(path)
.output()
.expect("failed to run hprof-analyzer");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
let doc = Md::parse(&md);
assert_eq!(
doc.heading("System Overview").map(|h| h.level()),
Some(2),
"missing System Overview (H2)"
);
assert_eq!(
doc.heading("Leak Suspects").map(|h| h.level()),
Some(2),
"missing Leak Suspects (H2)"
);
assert_eq!(
doc.heading("Top Consumers").map(|h| h.level()),
Some(2),
"missing Top Consumers (H2)"
);
assert_eq!(
doc.heading("Heap Summary").map(|h| h.level()),
Some(3),
"missing Heap Summary (H3)"
);
assert_eq!(
doc.heading("Class Histogram").map(|h| h.level()),
Some(3),
"missing Class Histogram (H3)"
);
assert_eq!(
doc.heading("Biggest Objects").map(|h| h.level()),
Some(3),
"missing Biggest Objects (H3)"
);
assert_eq!(
doc.heading("Biggest Classes").map(|h| h.level()),
Some(3),
"missing Biggest Classes (H3)"
);
assert_eq!(
doc.heading("Biggest Packages").map(|h| h.level()),
Some(3),
"missing Biggest Packages (H3)"
);
let sys = doc.section("System Overview").unwrap();
assert!(
sys.body_contains("### Heap Summary"),
"Heap Summary should be nested under System Overview"
);
let hist = doc
.section("Class Histogram")
.expect("Class Histogram section");
let table = hist.table(0).expect("Class Histogram renders a table");
assert!(
table.has_column("Class"),
"histogram table should have a Class column, got {:?}",
table.columns()
);
assert!(
table.has_column("Retained Heap"),
"histogram table should have a Retained Heap column"
);
}
fn normalize_nondeterministic(v: &mut serde_json::Value) {
if let Some(obj) = v.as_object_mut() {
if obj.contains_key("generated") {
obj["generated"] = serde_json::Value::Null;
}
if let Some(ov) = obj.get_mut("overview").and_then(|o| o.as_object_mut()) {
if ov.contains_key("file_path") {
ov["file_path"] = serde_json::Value::Null;
}
}
}
}
#[test]
fn json_golden_snapshot() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
let hprof = format!("{dir}/dump_4_philosophers.hprof");
let golden_path = format!("{dir}/dump_4_philosophers_report.json");
match std::fs::metadata(&hprof) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(&hprof)
.arg("--format")
.arg("json")
.output()
.expect("failed to run hprof-analyzer");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut got: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("analyzer stdout was not valid JSON");
let golden_text = std::fs::read_to_string(&golden_path)
.unwrap_or_else(|e| panic!("cannot read golden {golden_path}: {e}"));
let mut want: serde_json::Value =
serde_json::from_str(&golden_text).expect("golden fixture was not valid JSON");
normalize_nondeterministic(&mut got);
normalize_nondeterministic(&mut want);
assert_eq!(
got, want,
"JSON report drifted from the golden snapshot at {golden_path}. If this \
change is intended, regenerate the golden with:\n \
cargo run --release -- analyze tests/fixtures/dump_4_philosophers.hprof \
--format json > tests/fixtures/dump_4_philosophers_report.json"
);
}
#[test]
fn json_gzip_roundtrip() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
let hprof = format!("{dir}/dump_4_philosophers.hprof");
match std::fs::metadata(&hprof) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
let bin = env!("CARGO_BIN_EXE_hprof-analyzer");
let tmp = std::env::temp_dir().join(format!("hprof_gz_roundtrip_{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let gz_path = tmp.join("report.json.gz");
let status = std::process::Command::new(bin)
.arg(&hprof)
.arg("--format")
.arg("json")
.arg(&gz_path)
.status()
.expect("failed to run analyzer");
assert!(status.success(), "analyze to .json.gz exited non-zero");
let raw = std::fs::read(&gz_path).unwrap();
assert!(
raw.starts_with(&[0x1f, 0x8b]),
"output .json.gz is not gzip-compressed (magic {:x?})",
&raw[..raw.len().min(2)]
);
let out = std::process::Command::new(bin)
.arg(&gz_path)
.arg("--format")
.arg("json")
.output()
.expect("failed to run render");
assert!(
out.status.success(),
"render .json.gz stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let mut got: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("render output was not valid JSON");
let plain = std::process::Command::new(bin)
.arg(&hprof)
.arg("--format")
.arg("json")
.output()
.expect("failed to run analyzer (plain)");
assert!(plain.status.success());
let mut want: serde_json::Value =
serde_json::from_slice(&plain.stdout).expect("plain output was not valid JSON");
normalize_nondeterministic(&mut got);
normalize_nondeterministic(&mut want);
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(
got, want,
"rendered .json.gz did not match a plain JSON render of the same dump"
);
}
#[test]
fn ref_paths_flag_smoke() {
let hprof = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/dump_4_philosophers.hprof"
);
match std::fs::metadata(hprof) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(hprof)
.arg("--ref-paths")
.arg("--format")
.arg("json")
.output()
.expect("failed to run hprof-analyzer --ref-paths");
assert!(
out.status.success(),
"--ref-paths exited non-zero; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("--ref-paths JSON output was not valid JSON");
assert!(
v.get("leaks").is_some(),
"--ref-paths JSON missing 'leaks' key"
);
let suspects = v["leaks"]["suspects"].as_array();
if suspects.map(|a| !a.is_empty()).unwrap_or(false) {
let has_field_edge = suspects.unwrap().iter().any(|s| {
s["root_path"]
.as_array()
.unwrap_or(&vec![])
.iter()
.any(|step| step["field_edge"].is_string())
});
let _ = has_field_edge;
}
}
#[test]
fn field_stats_smoke() {
let hprof = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/dump_4_philosophers.hprof"
);
match std::fs::metadata(hprof) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(hprof)
.arg("--field-stats")
.arg("--format")
.arg("json")
.output()
.expect("failed to run hprof-analyzer --field-stats");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let classes = v["field_stats"]["classes"]
.as_array()
.expect("field_stats.classes must be an array");
assert!(!classes.is_empty(), "field_stats.classes must not be empty");
let has_named = classes.iter().any(|c| {
c["ref_fields"]
.as_array()
.unwrap_or(&vec![])
.iter()
.any(|f| {
f["field_name"]
.as_str()
.map(|s| !s.is_empty())
.unwrap_or(false)
})
});
assert!(
has_named,
"expected at least one named ref field in --field-stats output"
);
}
#[test]
fn full_analysis_smoke() {
let hprof = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/dump_4_philosophers.hprof"
);
match std::fs::metadata(hprof) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(hprof)
.arg("--full-analysis")
.arg("--format")
.arg("json")
.output()
.expect("failed to run hprof-analyzer --full-analysis");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(
v["overview"].is_object(),
"--full-analysis: expected overview section"
);
let has_extra = v.get("obj_graph_flat").is_some()
|| v.get("collection_attribution").is_some()
|| v.get("waste_summary").is_some();
assert!(
has_extra,
"--full-analysis: expected obj_graph_flat, collection_attribution, or waste_summary in output"
);
}
#[test]
fn parity_field_stats_golden() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
let hprof = format!("{dir}/dump_4_philosophers.hprof");
let golden_path = format!("{dir}/dump_4_philosophers_field_stats.json");
match std::fs::metadata(&hprof) {
Ok(m) if m.len() >= 1024 => {}
_ => return,
}
if !std::path::Path::new(&golden_path).exists() {
return;
}
let out = std::process::Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
.arg(&hprof)
.arg("--field-stats")
.arg("--format")
.arg("json")
.output()
.expect("failed to run hprof-analyzer --field-stats");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let actual: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let expected: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&golden_path).unwrap()).unwrap();
assert_eq!(
actual["field_stats"], expected["field_stats"],
"field_stats diverged from golden. Re-capture with:\n \
./target/release/hprof-analyzer tests/fixtures/dump_4_philosophers.hprof \
tests/fixtures/dump_4_philosophers_field_stats.json --field-stats --format json"
);
}
#[test]
fn broken_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.hprof");
std::fs::write(&path, b"").unwrap();
let (ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "empty file");
assert!(!ok, "empty file should not succeed\n{out}");
}
#[test]
fn broken_garbage_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("garbage.hprof");
std::fs::write(&path, [0xDE, 0xAD, 0xBE, 0xEF].repeat(128)).unwrap();
let (ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "garbage file");
assert!(!ok, "garbage file should not succeed\n{out}");
}
#[test]
fn broken_header_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("header_only.hprof");
std::fs::write(&path, hprof_header()).unwrap();
let (ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "header only");
let _ = ok;
}
#[test]
fn broken_truncated_mid_record_header() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mid_header.hprof");
let mut data = hprof_header();
data.push(0x01u8); std::fs::write(&path, data).unwrap();
let (_ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "truncated mid-record-header");
}
#[test]
fn broken_truncated_mid_record_body() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mid_body.hprof");
let mut data = hprof_header();
data.push(0x01u8); data.extend_from_slice(&0u32.to_be_bytes()); data.extend_from_slice(&108u32.to_be_bytes()); data.extend_from_slice(&1u64.to_be_bytes()); data.extend_from_slice(&[b'x'; 10]); std::fs::write(&path, data).unwrap();
let (_ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "truncated mid-record-body");
}
#[test]
fn broken_truncated_heap_dump_segment() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("truncated_heap.hprof");
let mut data = hprof_header();
data.extend(string_record(1, b"java.lang.Object"));
data.extend(load_class_record(1, 0x1000, 1));
data.push(0x1Cu8); data.extend_from_slice(&0u32.to_be_bytes()); data.extend_from_slice(&200u32.to_be_bytes()); data.extend_from_slice(&[0u8; 40]); std::fs::write(&path, data).unwrap();
let (_ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "truncated heap dump segment");
}
#[test]
fn broken_heap_dump_with_valid_prefix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("valid_then_garbage.hprof");
let mut data = hprof_header();
data.extend(string_record(1, b"java.lang.Object"));
data.extend(load_class_record(1, 0x1000, 1));
data.push(0x2Cu8);
data.extend_from_slice(&0u32.to_be_bytes());
data.extend_from_slice(&0u32.to_be_bytes());
data.extend_from_slice(&[0xFFu8; 64]);
std::fs::write(&path, data).unwrap();
let (_ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "valid prefix then garbage");
}
#[test]
fn broken_truncated_gz() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("truncated.hprof.gz");
let hprof = {
let mut d = hprof_header();
d.extend(string_record(1, b"java.lang.Object"));
d.extend(load_class_record(1, 0x1000, 1));
d
};
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&hprof).unwrap();
let full_gz = encoder.finish().unwrap();
let truncated = &full_gz[..full_gz.len() * 60 / 100];
std::fs::write(&path, truncated).unwrap();
let (_ok, out) = run_report(&path);
assert_no_panic(&out);
assert_error_message_is_clean(&out, "truncated gz");
}
fn mnemonics_bytes() -> Option<Vec<u8>> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/dump_1_mnemonics.hprof"
);
let data = std::fs::read(path).ok()?;
if data.len() < 1024 * 1024 {
return None; }
Some(data)
}
fn gz_wrap(data: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
fn tar_gz_wrap(data: &[u8], inner_name: &str) -> Vec<u8> {
let gz_buf = Vec::new();
let enc = flate2::write::GzEncoder::new(gz_buf, flate2::Compression::default());
let mut tar = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, inner_name, data).unwrap();
let enc = tar.into_inner().unwrap();
enc.finish().unwrap()
}
const HPROF_CUT_FRACS: &[(u64, u64)] = &[
(1, 64), (1, 32), (1, 16), (1, 8), (3, 16), (3, 8), (1, 2), (5, 8), (3, 4), (15, 16), ];
const GZ_CUT_FRACS: &[(u64, u64)] = &[(1, 16), (1, 4), (3, 8), (5, 8), (7, 8)];
#[test]
fn truncated_real_hprof_plain() {
let data = match mnemonics_bytes() {
Some(d) => d,
None => return,
};
let dir = tempfile::tempdir().unwrap();
let total = data.len() as u64;
for &(num, den) in HPROF_CUT_FRACS {
let cut = ((total * num) / den) as usize;
let path = dir.path().join(format!("cut_{num}_{den}.hprof"));
std::fs::write(&path, &data[..cut]).unwrap();
let (ok, json, stderr) = run_json(&path);
let label = format!("plain .hprof cut at {num}/{den} ({cut} bytes)");
assert_no_panic(&format!("{json}\n{stderr}"));
assert_error_message_is_clean(&stderr, &label);
assert!(ok, "{label}: tool exited non-zero\nstderr: {stderr}");
assert!(
json_is_valid_report(&json),
"{label}: exit=0 but output is not a valid JSON report\nstderr: {stderr}\njson: {json}"
);
if num * 8 > den {
assert!(
json_report_has_content(&json),
"{label}: cut past 1/8 but report has zero objects\nstderr: {stderr}"
);
}
}
}
#[test]
fn truncated_real_hprof_gz() {
let data = match mnemonics_bytes() {
Some(d) => d,
None => return,
};
let full_gz = gz_wrap(&data);
let dir = tempfile::tempdir().unwrap();
let total = full_gz.len() as u64;
for &(num, den) in GZ_CUT_FRACS {
let cut = ((total * num) / den) as usize;
let path = dir.path().join(format!("cut_{num}_{den}.hprof.gz"));
std::fs::write(&path, &full_gz[..cut]).unwrap();
let (ok, json, stderr) = run_json(&path);
let label = format!(".hprof.gz cut at {num}/{den} ({cut} bytes)");
assert_no_panic(&format!("{json}\n{stderr}"));
assert_error_message_is_clean(&stderr, &label);
assert!(ok, "{label}: tool exited non-zero\nstderr: {stderr}");
assert!(
json_is_valid_report(&json),
"{label}: exit=0 but output is not a valid JSON report\nstderr: {stderr}\njson: {json}"
);
}
}
#[test]
fn truncated_real_hprof_tar_gz() {
let data = match mnemonics_bytes() {
Some(d) => d,
None => return,
};
let full_tgz = tar_gz_wrap(&data, "dump.hprof");
let dir = tempfile::tempdir().unwrap();
let total = full_tgz.len() as u64;
for &(num, den) in GZ_CUT_FRACS {
let cut = ((total * num) / den) as usize;
let path = dir.path().join(format!("cut_{num}_{den}.hprof.tar.gz"));
std::fs::write(&path, &full_tgz[..cut]).unwrap();
let (ok, json, stderr) = run_json(&path);
let label = format!(".hprof.tar.gz cut at {num}/{den} ({cut} bytes)");
assert_no_panic(&format!("{json}\n{stderr}"));
assert_error_message_is_clean(&stderr, &label);
assert!(ok, "{label}: tool exited non-zero\nstderr: {stderr}");
assert!(
json_is_valid_report(&json),
"{label}: exit=0 but output is not a valid JSON report\nstderr: {stderr}\njson: {json}"
);
}
}