use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
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,
}
}
static READY_SERVER: OnceLock<u16> = OnceLock::new();
fn ready_port() -> Option<u16> {
let hprof = philosophers()?;
let port = READY_SERVER.get_or_init(|| {
let (_, port) = start_server(&hprof);
curl_post(port, "/analyze", "");
wait_for_ready(port);
port
});
Some(*port)
}
fn start_server(hprof: &str) -> (Child, u16) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let child = Command::new(BIN)
.args(["server", hprof, "--port", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn server");
let url = format!("http://127.0.0.1:{port}/status");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
if std::time::Instant::now() > deadline {
panic!("server on port {port} did not start within 60 s");
}
let ok = Command::new("curl")
.args(["-s", "--max-time", "1", &url])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
(child, port)
}
fn curl_get(port: u16, path: &str) -> (u32, String) {
let url = format!("http://127.0.0.1:{port}{path}");
let out = Command::new("curl")
.args(["-s", "-w", "\n%{http_code}", &url])
.output()
.expect("curl failed");
let raw = String::from_utf8_lossy(&out.stdout);
parse_curl_output(&raw)
}
fn curl_post(port: u16, path: &str, body: &str) -> (u32, String) {
let url = format!("http://127.0.0.1:{port}{path}");
let out = Command::new("curl")
.args(["-s", "-w", "\n%{http_code}", "-X", "POST", "-d", body, &url])
.output()
.expect("curl failed");
let raw = String::from_utf8_lossy(&out.stdout);
parse_curl_output(&raw)
}
fn parse_curl_output(raw: &str) -> (u32, String) {
let mut lines: Vec<&str> = raw.lines().collect();
let status: u32 = lines.pop().and_then(|l| l.trim().parse().ok()).unwrap_or(0);
let body = lines.join("\n");
(status, body)
}
fn wait_for_ready(port: u16) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
loop {
if std::time::Instant::now() > deadline {
panic!("server on port {port} did not reach ready within 120 s");
}
let (_, body) = curl_get(port, "/status");
if body.contains("\"ready\"") {
return;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
#[test]
fn server_status_not_started() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_get(port, "/status");
child.kill().ok();
child.wait().ok();
assert_eq!(status, 200, "expected HTTP 200 from /status, got {status}");
assert!(
body.contains("not_started"),
"/status before any analysis should return not_started, got: {body}"
);
}
#[test]
fn server_analyze_returns_started() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(port, "/analyze", "");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from POST /analyze, got {status}"
);
let recognised = body.contains("started")
|| body.contains("already_running")
|| body.contains("already_done");
assert!(
recognised,
"POST /analyze body should contain started/already_running/already_done, got: {body}"
);
}
#[test]
fn server_status_ready_after_analyze() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/status");
assert_eq!(status, 200, "expected HTTP 200 from /status, got {status}");
assert!(
body.contains("\"ready\""),
"/status after analysis should contain 'ready', got: {body}"
);
}
#[test]
fn server_report_json_has_fields() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report");
assert_eq!(status, 200, "expected HTTP 200 from /report, got {status}");
let trimmed = body.trim();
assert!(
trimmed.starts_with('{'),
"/report should return a JSON object, got: {}",
&body[..body.len().min(200)]
);
assert!(
body.contains("\"schema_version\""),
"/report JSON missing 'schema_version' field: {}",
&body[..body.len().min(300)]
);
assert!(
!body.contains("\"error\":{\"kind\":"),
"/report returned an error response: {body}"
);
}
#[test]
fn server_report_overview_json() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/overview");
assert_eq!(
status, 200,
"expected HTTP 200 from /report/overview, got {status}"
);
assert!(
body.trim().starts_with('{'),
"/report/overview should return a JSON object, got: {}",
&body[..body.len().min(200)]
);
}
#[test]
fn server_report_overview_md() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/overview?format=md");
assert_eq!(
status, 200,
"expected HTTP 200 from /report/overview?format=md, got {status}"
);
assert!(
body.to_lowercase().contains("heap"),
"/report/overview?format=md should mention 'heap', got: {}",
&body[..body.len().min(400)]
);
}
#[test]
fn server_report_leaks_json() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/leaks");
assert_eq!(
status, 200,
"expected HTTP 200 from /report/leaks, got {status}"
);
assert!(
body.trim().starts_with('{'),
"/report/leaks should return a JSON object, got: {}",
&body[..body.len().min(200)]
);
assert!(
body.contains("\"suspects\"") && body.contains("\"total_shallow\""),
"/report/leaks JSON missing expected keys 'suspects'/'total_shallow': {}",
&body[..body.len().min(300)]
);
}
#[test]
fn server_report_top_json() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/top");
assert_eq!(
status, 200,
"expected HTTP 200 from /report/top, got {status}"
);
assert!(
body.trim().starts_with('{'),
"/report/top should return a JSON object, got: {}",
&body[..body.len().min(200)]
);
}
#[test]
fn server_report_threads_json() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/threads");
assert_eq!(
status, 200,
"expected HTTP 200 from /report/threads, got {status}"
);
assert!(
body.trim().starts_with('{'),
"/report/threads should return a JSON object, got: {}",
&body[..body.len().min(200)]
);
}
#[test]
fn server_oql_post_works() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(port, "/", "SELECT COUNT(*) FROM java.lang.String");
child.kill().ok();
child.wait().ok();
assert_eq!(status, 200, "expected HTTP 200 from POST /, got {status}");
let has_result_fields = body.contains("\"rows\"") || body.contains("\"columns\"");
assert!(
has_result_fields,
"OQL response should contain 'rows' or 'columns', got: {}",
&body[..body.len().min(400)]
);
}
#[test]
fn server_version_lists_report() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_get(port, "/version");
child.kill().ok();
child.wait().ok();
assert_eq!(status, 200, "expected HTTP 200 from /version, got {status}");
assert!(
body.contains("/report"),
"/version JSON should list /report endpoint, got: {body}"
);
}
fn curl_request(port: u16, method: &str, path: &str) -> (u32, String) {
let url = format!("http://127.0.0.1:{port}{path}");
let out = Command::new("curl")
.args(["-s", "-w", "\n%{http_code}", "-X", method, &url])
.output()
.expect("curl failed");
let raw = String::from_utf8_lossy(&out.stdout);
parse_curl_output(&raw)
}
#[test]
fn server_wrong_method_405() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_request(port, "GET", "/analyze");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 405,
"expected HTTP 405 for GET /analyze, got {status}: {body}"
);
assert!(
body.contains("\"kind\"") || body.contains("method"),
"405 body should describe the error, got: {body}"
);
}
#[test]
fn server_report_post_is_405() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, _body) = curl_post(port, "/report", "");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 405,
"expected HTTP 405 for POST /report, got {status}"
);
}
#[test]
fn server_report_invalid_section_404() {
let Some(port) = ready_port() else { return };
let (status, _body) = curl_get(port, "/report/bogus-section");
assert_eq!(
status, 404,
"expected 404 for /report/bogus-section, got {status}"
);
}
#[test]
fn server_report_no_trailing_slash_404() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, _body) = curl_get(port, "/reportgarbage");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 404,
"/reportgarbage should be 404 (not a valid report path), got {status}"
);
}
#[test]
fn server_oql_syntax_error_400() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(port, "/", "THIS IS NOT VALID OQL AT ALL !!!!");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 400,
"expected HTTP 400 for bad OQL, got {status}: {body}"
);
assert!(
body.contains("error") || body.contains("Error"),
"400 response should describe the parse error, got: {body}"
);
}
#[test]
fn server_oql_query_alias_works() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(port, "/query", "SELECT COUNT(*) FROM java.lang.String");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from POST /query, got {status}"
);
assert!(
body.contains("\"rows\"") || body.contains("\"columns\""),
"POST /query should return QueryResult shape, got: {}",
&body[..body.len().min(300)]
);
}
#[test]
fn server_stream_endpoint_works() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(port, "/stream", "SELECT COUNT(*) FROM java.lang.String");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from POST /stream, got {status}"
);
let first_line = body.lines().next().unwrap_or("");
assert!(
first_line.trim_start().starts_with('{'),
"POST /stream first line should be a JSON object, got: {first_line}"
);
}
#[test]
fn server_report_full_md() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report?format=md");
assert_eq!(
status, 200,
"expected HTTP 200 from /report?format=md, got {status}"
);
assert!(
body.contains("##") || body.starts_with('#'),
"/report?format=md should contain Markdown headings, got: {}",
&body[..body.len().min(300)]
);
}
#[test]
fn server_report_threads_md_has_thread_names() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/threads?format=md");
assert_eq!(
status, 200,
"expected HTTP 200 from /report/threads?format=md, got {status}"
);
assert!(
body.contains("Thread") || body.contains("thread"),
"/report/threads?format=md should mention threads, got: {}",
&body[..body.len().min(400)]
);
}
#[test]
fn server_oql_group_by_order_by() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(
port,
"/",
"SELECT @displayName, COUNT(*) AS n FROM INSTANCEOF java.lang.Object \
GROUP BY @displayName ORDER BY n DESC LIMIT 5",
);
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from GROUP BY query, got {status}"
);
assert!(
body.contains("\"rows\""),
"GROUP BY response should contain 'rows', got: {}",
&body[..body.len().min(500)]
);
}
#[test]
fn server_oql_where_predicate() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(
port,
"/",
"SELECT COUNT(*) FROM java.lang.String WHERE @usedHeapSize > 0",
);
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from WHERE query, got {status}"
);
assert!(
body.contains("\"rows\""),
"WHERE query should have rows field, got: {}",
&body[..body.len().min(300)]
);
assert!(
body.contains("\"value\"") || body.contains("COUNT"),
"WHERE count result should contain a value, got: {}",
&body[..body.len().min(300)]
);
}
#[test]
fn server_oql_union() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(
port,
"/",
"SELECT @displayName FROM java.lang.String LIMIT 2 \
UNION SELECT @displayName FROM java.lang.Thread LIMIT 2",
);
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from UNION query, got {status}"
);
assert!(
body.contains("\"rows\""),
"UNION response should contain 'rows', got: {}",
&body[..body.len().min(400)]
);
}
#[test]
fn server_oql_aggregate_sum() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(port, "/", "SELECT SUM(@usedHeapSize) FROM java.lang.String");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from SUM query, got {status}"
);
assert!(
body.contains("\"rows\""),
"SUM response should contain 'rows', got: {}",
&body[..body.len().min(300)]
);
}
#[test]
fn server_oql_distinct() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, body) = curl_post(
port,
"/",
"SELECT DISTINCT @displayName FROM java.lang.Thread",
);
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"expected HTTP 200 from DISTINCT query, got {status}"
);
assert!(
body.contains("\"rows\""),
"DISTINCT response should contain 'rows', got: {}",
&body[..body.len().min(300)]
);
let row_count = body.matches("java.lang.Thread").count();
assert!(
row_count >= 1,
"DISTINCT threads should yield at least one row with 'java.lang.Thread', got: {}",
&body[..body.len().min(400)]
);
}
#[test]
fn server_unknown_route_404() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let (status, _body) = curl_get(port, "/bogus-nonexistent");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 404,
"expected HTTP 404 for unknown route, got {status}"
);
}
#[test]
fn server_report_leaks_limit() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/leaks?limit=2");
assert_eq!(
status, 200,
"expected 200 from /report/leaks?limit=2, got {status}"
);
assert!(
body.contains("\"suspects\""),
"response should have suspects field: {}",
&body[..body.len().min(300)]
);
let suspect_count = body.matches("\"is_single\"").count();
assert!(
suspect_count <= 2,
"expected at most 2 suspects with limit=2, counted {suspect_count} in: {}",
&body[..body.len().min(500)]
);
}
#[test]
fn server_report_leaks_no_limit() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_get(port, "/report/leaks");
assert_eq!(status, 200, "expected 200 from /report/leaks, got {status}");
assert!(
body.contains("\"suspects\""),
"response should have suspects field: {}",
&body[..body.len().min(300)]
);
}
#[test]
fn server_oql_retained_size_after_analysis() {
let Some(port) = ready_port() else { return };
let (status, body) = curl_post(
port,
"/",
"SELECT @displayName, @retainedHeapSize FROM java.lang.Thread \
ORDER BY @retainedHeapSize DESC LIMIT 3",
);
assert_eq!(
status, 200,
"expected HTTP 200 for @retainedHeapSize query after analysis, got {status}: {body}"
);
assert!(
body.contains("\"rows\""),
"@retainedHeapSize query should contain 'rows', got: {}",
&body[..body.len().min(500)]
);
assert!(
!body.contains("requires the full analysis pipeline"),
"@retainedHeapSize query must not error with 'requires the full analysis pipeline', got: {}",
&body[..body.len().min(500)]
);
}
#[test]
fn server_oql_json_body() {
let Some(hprof) = philosophers() else { return };
let (mut child, port) = start_server(&hprof);
let url = format!("http://127.0.0.1:{port}/");
let out = std::process::Command::new("curl")
.args([
"-s",
"-w",
"\n%{http_code}",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
r#"{"query":"SELECT COUNT(*) FROM java.lang.String"}"#,
&url,
])
.output()
.expect("curl failed");
let raw = String::from_utf8_lossy(&out.stdout);
let mut lines: Vec<&str> = raw.lines().collect();
let status: u32 = lines.pop().and_then(|l| l.trim().parse().ok()).unwrap_or(0);
let body = lines.join("\n");
child.kill().ok();
child.wait().ok();
assert_eq!(
status, 200,
"JSON body POST / should return 200, got {status}: {body}"
);
assert!(
body.contains("\"rows\"") || body.contains("\"columns\""),
"JSON body OQL response should contain QueryResult fields, got: {}",
&body[..body.len().min(400)]
);
}