mod support;
use std::{
io::{Read, Write},
net::TcpListener,
process::{Command, Stdio},
sync::mpsc,
thread,
time::Duration,
};
use serde_json::Value;
use support::TestWorkspace;
#[test]
fn run_outputs_json_report() {
let server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"{"ok":true,"service":"hen"}"#,
);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"JSON Fixture
Exercises machine-readable output.
---
Fetch fixture
GET {server_url}
^ & body.ok == true
[ true == false ] ^ & body.service == 'hen'
"#
),
);
let output = workspace.run_hen(["run", "collection.hen", "--output", "json"]);
assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
let parsed: Value = serde_json::from_str(&output.stdout).expect("stdout should be valid json");
assert_eq!(parsed["plan"], serde_json::json!([0]));
assert_eq!(parsed["selectedRequests"], serde_json::json!([0]));
assert_eq!(parsed["records"][0]["status"], 200);
assert_eq!(
parsed["records"][0]["assertions"],
serde_json::json!([
{
"assertion": "^ & body.ok == true",
"status": "passed",
"message": null,
},
{
"assertion": "[ true == false ] ^ & body.service == 'hen'",
"status": "skipped",
"message": "guard evaluated to false",
}
])
);
assert!(parsed["records"][0]["body"]
.as_str()
.expect("body should be serialized as a string")
.contains("\"service\":\"hen\""));
assert_eq!(parsed["records"][0]["bodyChars"], 27);
assert_eq!(parsed["records"][0]["bodyTruncated"], false);
assert_eq!(parsed["interrupted"], false);
assert_eq!(parsed["interruptSignal"], Value::Null);
assert_eq!(parsed["failures"], serde_json::json!([]));
}
#[test]
fn run_outputs_ndjson_report() {
let server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"{"ok":true,"service":"hen"}"#,
);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"NDJSON Fixture
Exercises streaming machine-readable output.
---
Fetch fixture
GET {server_url}
^ & body.ok == true
[ true == false ] ^ & body.service == 'hen'
"#
),
);
let output = workspace.run_hen(["run", "collection.hen", "--output", "ndjson"]);
assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
let lines = output
.stdout
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("line should be valid json"))
.collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["type"], "run");
assert_eq!(lines[0]["interrupted"], false);
assert_eq!(lines[0]["interruptSignal"], Value::Null);
assert_eq!(lines[1]["type"], "record");
assert_eq!(lines[1]["status"], 200);
assert_eq!(lines[1]["assertions"][0]["status"], "passed");
assert_eq!(lines[1]["assertions"][1]["status"], "skipped");
}
#[test]
fn run_outputs_json_report_for_failed_assertions() {
let server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"{"ok":false,"service":"hen"}"#,
);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"Failed Assertions Fixture
Exercises assertion-level failure reporting.
---
Fetch fixture
GET {server_url}
^ & body.service == 'hen'
[ true == false ] ^ & body.service == 'hen'
^ & body.ok == true
"#
),
);
let output = workspace.run_hen(["run", "collection.hen", "--output", "json"]);
assert_eq!(output.status_code, 1, "stderr: {}", output.stderr);
assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
let parsed: Value = serde_json::from_str(&output.stdout).expect("stdout should be valid json");
assert_eq!(parsed["executionFailed"], true);
assert_eq!(parsed["interrupted"], false);
assert_eq!(parsed["interruptSignal"], Value::Null);
assert_eq!(parsed["records"], serde_json::json!([]));
assert_eq!(
parsed["failures"][0]["assertions"],
serde_json::json!([
{
"assertion": "^ & body.service == 'hen'",
"status": "passed",
"message": null,
},
{
"assertion": "[ true == false ] ^ & body.service == 'hen'",
"status": "skipped",
"message": "guard evaluated to false",
},
{
"assertion": "^ & body.ok == true",
"status": "failed",
"message": "Assertion failed: ^ & body.ok == true (actual: 'false')",
}
])
);
}
#[test]
fn run_outputs_junit_report_for_assertion_failures() {
let server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"{"ok":false,"service":"hen"}"#,
);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"JUnit Fixture
Exercises CI-oriented test reporting.
---
Fail fixture
GET {server_url}
[ true == false ] ^ & body.service == 'hen'
^ & body.ok == true
"#
),
);
let output = workspace.run_hen(["run", "collection.hen", "--output", "junit"]);
assert_eq!(output.status_code, 1, "stderr: {}", output.stderr);
assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
assert!(
output.stdout.contains("<testsuite"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("<testcase"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("tests=\"2\""),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("<failure type=\"assertion\""),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("Assertion failed"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("<skipped message=\"guard evaluated to false\"/>"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("#0 GET") && output.stdout.contains(":: ^ & body.ok == true"),
"stdout: {}",
output.stdout
);
}
#[test]
fn run_outputs_junit_report_for_passed_and_skipped_assertions() {
let server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"{"ok":true,"service":"hen"}"#,
);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"JUnit Success Fixture
Exercises assertion-level success reporting.
---
Fetch fixture
GET {server_url}
^ & body.ok == true
[ true == false ] ^ & body.service == 'hen'
"#
),
);
let output = workspace.run_hen(["run", "collection.hen", "--output", "junit"]);
assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
assert!(output.stdout.contains("tests=\"2\""), "stdout: {}", output.stdout);
assert!(
output.stdout.contains(":: ^ & body.ok == true"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains(":: [ true == false ] ^ & body.service == 'hen'"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains("<skipped message=\"guard evaluated to false\"/>"),
"stdout: {}",
output.stdout
);
}
#[test]
fn run_outputs_junit_report_for_dependency_capture_assertions() {
let user_server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"{"id":1,"email":"user@example.com","address":{"zipcode":"90210"}}"#,
);
let posts_server_url = spawn_http_server(
200,
"OK",
"application/json",
r#"[{"userId":1,"id":10,"title":"hello world"}]"#,
);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"name = JUnit Dependency Capture Fixture
description = Exercises dependency captures followed by assertions.
---
Get User
GET {user_server_url}
& body.id -> $USER_ID
^ & status == 200
---
Get Posts
> requires: Get User
GET {posts_server_url}
& body.[0].title -> $FIRST_POST_TITLE
&[Get User].body.email -> $USER_EMAIL
&[Get User].body.address.zipcode -> $USER_ZIPCODE
^ & status == 200
^ & body.[0].userId == $USER_ID
^ $FIRST_POST_TITLE != ''
^ $USER_EMAIL ~= /@/
^ $USER_ZIPCODE == '90210'
"#
),
);
let output = workspace.run_hen(["run", "collection.hen", "all", "--output", "junit"]);
assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
assert!(output.stdout.contains("tests=\"6\""), "stdout: {}", output.stdout);
assert!(
output
.stdout
.contains("#1 GET")
&& output.stdout.contains(":: ^ $USER_EMAIL ~= /@/"),
"stdout: {}",
output.stdout
);
assert!(
output.stdout.contains(":: ^ $USER_ZIPCODE == '90210'"),
"stdout: {}",
output.stdout
);
}
#[cfg(unix)]
#[test]
fn run_outputs_partial_text_summary_when_interrupted_by_sigint() {
let fast_server_url = spawn_http_server(200, "OK", "application/json", r#"{"step":1}"#);
let (started_tx, started_rx) = mpsc::channel();
let slow_server_url = spawn_blocking_http_server(started_tx);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"Interrupt Fixture
Exercises partial text summaries on SIGINT.
---
First request
GET {fast_server_url}
---
Second request
GET {slow_server_url}
"#
),
);
let output = run_until_signal(
&workspace,
&["run", "collection.hen", "all", "--parallel"],
"-INT",
started_rx,
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf-8");
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf-8");
assert_eq!(output.status.code(), Some(130), "stdout: {stdout}\nstderr: {stderr}");
assert!(stdout.contains("[ok] #0"), "stdout: {stdout}");
assert!(stdout.contains("Summary"), "stdout: {stdout}");
assert!(stdout.contains("interrupted by SIGINT"), "stdout: {stdout}");
assert!(!stdout.contains("[ok] #1"), "stdout: {stdout}");
assert!(stderr.contains("Execution interrupted by SIGINT"), "stderr: {stderr}");
}
#[cfg(unix)]
#[test]
fn run_outputs_partial_json_report_when_interrupted_by_sigterm() {
let fast_server_url = spawn_http_server(200, "OK", "application/json", r#"{"step":1}"#);
let (started_tx, started_rx) = mpsc::channel();
let slow_server_url = spawn_blocking_http_server(started_tx);
let workspace = TestWorkspace::new();
workspace.write_file(
"collection.hen",
&format!(
r#"Interrupt JSON Fixture
Exercises partial machine-readable summaries on SIGTERM.
---
First request
GET {fast_server_url}
---
Second request
GET {slow_server_url}
"#
),
);
let output = run_until_signal(
&workspace,
&["run", "collection.hen", "all", "--parallel", "--output", "json"],
"-TERM",
started_rx,
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf-8");
assert_eq!(output.status.code(), Some(143), "stderr: {stderr}");
assert!(stderr.is_empty(), "stderr: {stderr}");
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf-8");
let parsed: Value = serde_json::from_str(&stdout).expect("stdout should be valid json");
assert_eq!(parsed["executionFailed"], true);
assert_eq!(parsed["interrupted"], true);
assert_eq!(parsed["interruptSignal"], "SIGTERM");
assert_eq!(parsed["records"].as_array().map(Vec::len), Some(1));
assert_eq!(parsed["records"][0]["status"], 200);
assert_eq!(parsed["records"][0]["url"], fast_server_url);
assert_eq!(parsed["failures"], serde_json::json!([]));
}
fn spawn_http_server(status: u16, reason: &str, content_type: &str, body: &str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("address should be available");
let reason = reason.to_string();
let content_type = content_type.to_string();
let body = body.to_string();
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("connection should be accepted");
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("response should be written");
});
format!("http://{}", address)
}
#[cfg(unix)]
fn spawn_blocking_http_server(started_tx: mpsc::Sender<()>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("address should be available");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("connection should be accepted");
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
started_tx
.send(())
.expect("test should observe the blocking request");
thread::sleep(Duration::from_secs(5));
let body = r#"{"ok":true}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
});
format!("http://{}", address)
}
#[cfg(unix)]
fn run_until_signal(
workspace: &TestWorkspace,
args: &[&str],
signal: &str,
started_rx: mpsc::Receiver<()>,
) -> std::process::Output {
let child = Command::new(env!("CARGO_BIN_EXE_hen"))
.args(args)
.current_dir(workspace.root())
.env("NO_COLOR", "1")
.env("TERM", "dumb")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("hen command should execute");
if started_rx.recv_timeout(Duration::from_secs(5)).is_err() {
let output = child
.wait_with_output()
.expect("child output should be available");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
panic!(
"second request should start before signalling\nstatus: {:?}\nstdout: {}\nstderr: {}",
output.status.code(),
stdout,
stderr,
);
}
thread::sleep(Duration::from_millis(200));
let status = Command::new("kill")
.args([signal, &child.id().to_string()])
.status()
.expect("signal command should execute");
assert!(status.success(), "failed to send signal {signal}");
child
.wait_with_output()
.expect("child output should be available")
}