hen 0.15.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
use serde_json::Value;

use crate::{helpers::spawn_http_server, 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,
                "mismatch": null,
            },
            {
                "assertion": "[ true == false ] ^ & body.service == 'hen'",
                "status": "skipped",
                "message": "guard evaluated to false",
                "mismatch": null,
            }
        ])
    );
    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_json_report_includes_dns_timing_for_hostname_requests() {
    let server_url = spawn_http_server(200, "OK", "application/json", r#"{"ok":true}"#)
        .replacen("127.0.0.1", "localhost", 1);
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
            r#"DNS Timing Fixture

Exercises hostname-based timing output.

---

Fetch fixture

GET {server_url}

^ & body.ok == true
"#
        ),
    );

    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["records"][0]["timing"]["phases"][0]["name"], "dns");
    assert!(parsed["records"][0]["timing"]["phases"][0]["durationMs"].as_u64().is_some());
    assert_eq!(parsed["records"][0]["timing"]["phases"][1]["name"], "responseStart");
    assert_eq!(parsed["records"][0]["timing"]["phases"][2]["name"], "bodyRead");
}

#[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]["protocol"], "http");
    assert!(parsed["failures"][0]["startedAtUnixMs"].as_u64().is_some());
    assert!(parsed["failures"][0]["durationMs"].as_u64().is_some());
    assert!(parsed["failures"][0]["timing"]["totalMs"].as_u64().is_some());
    assert_eq!(parsed["failures"][0]["timing"]["phases"][0]["name"], "responseStart");
    assert!(parsed["failures"][0]["timing"]["phases"][0]["durationMs"].as_u64().is_some());
    assert_eq!(parsed["failures"][0]["timing"]["phases"][1]["name"], "bodyRead");
    assert!(parsed["failures"][0]["timing"]["phases"][1]["durationMs"].as_u64().is_some());
    assert_eq!(parsed["failures"][0]["transcripts"][0]["direction"], "outgoing");
    assert_eq!(parsed["failures"][0]["transcripts"][0]["label"], "http.request");
    assert_eq!(parsed["failures"][0]["transcripts"][1]["direction"], "incoming");
    assert_eq!(parsed["failures"][0]["transcripts"][1]["label"], "http.response");
    assert!(parsed["failures"][0]["transcripts"][1]["body"]
        .as_str()
        .expect("failure transcript body should be serialized as a string")
        .contains("\"ok\":false"));
    assert_eq!(parsed["failures"][0]["retainedArtifacts"], serde_json::json!([]));
    assert_eq!(
        parsed["failures"][0]["assertions"],
        serde_json::json!([
            {
                "assertion": "^ & body.service == 'hen'",
                "status": "passed",
                "message": null,
                "mismatch": null,
            },
            {
                "assertion": "[ true == false ] ^ & body.service == 'hen'",
                "status": "skipped",
                "message": "guard evaluated to false",
                "mismatch": null,
            },
            {
                "assertion": "^ & body.ok == true",
                "status": "failed",
                "message": "Assertion failed: ^ & body.ok == true (actual path: body.ok, actual type: boolean, actual value: false, operator: ==, compared type: boolean, compared value: true)",
                "mismatch": {
                    "kind": "comparison",
                    "reason": "value_mismatch",
                    "target": null,
                    "path": "body.ok",
                    "actualPath": "body.ok",
                    "comparedPath": null,
                    "operator": "==",
                    "actual": {
                        "type": "boolean",
                        "value": false
                    },
                    "expected": {
                        "type": "boolean",
                        "value": true
                    }
                },
            }
        ])
    );
}

#[test]
fn run_outputs_json_report_for_structural_match_failures() {
    let server_url = spawn_http_server(
        200,
        "OK",
        "application/json",
        r#"{"user":{"profile":{"name":"Bob","active":true}}}"#,
    );
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
            r#"Structural Failure Fixture

Exercises nested structural mismatch reporting.

---

Fetch fixture

GET {server_url}

^ & body.user ~= {{"profile":{{"name":"Alice"}}}}
"#
        ),
    );

    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");
    let message = parsed["failures"][0]["assertions"][0]["message"]
        .as_str()
        .expect("message should be serialized as a string");
    let mismatch = &parsed["failures"][0]["assertions"][0]["mismatch"];

    assert!(message.contains("mismatch path: body.user.profile.name"));
    assert!(message.contains("mismatch actual value: \"Bob\""));
    assert!(message.contains("mismatch expected value: \"Alice\""));
    assert_eq!(mismatch["kind"], "match");
    assert_eq!(mismatch["reason"], "value_mismatch");
    assert_eq!(mismatch["target"], Value::Null);
    assert_eq!(mismatch["path"], "body.user.profile.name");
    assert_eq!(mismatch["actualPath"], "body.user");
    assert_eq!(mismatch["operator"], "~=");
    assert_eq!(mismatch["actual"]["type"], "string");
    assert_eq!(mismatch["actual"]["value"], "Bob");
    assert_eq!(mismatch["expected"]["type"], "string");
    assert_eq!(mismatch["expected"]["value"], "Alice");
}

#[test]
fn run_outputs_json_report_for_filtered_selector_failures() {
    let server_url = spawn_http_server(
        200,
        "OK",
        "application/json",
        r#"{"jobs":[{"recipient":"bob@example.com","status":"queued"},{"recipient":"alice@example.com","status":"succeeded"}]}"#,
    );
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
            r#"Filtered Selector Failure Fixture

Exercises concrete selector-path reporting in JSON output.

---

Fetch fixture

GET {server_url}

^ & body.jobs[? recipient == "alice@example.com"].status == 'queued'
"#
        ),
    );

    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");
    let message = parsed["failures"][0]["assertions"][0]["message"]
        .as_str()
        .expect("message should be serialized as a string");
    let mismatch = &parsed["failures"][0]["assertions"][0]["mismatch"];

    assert!(message.contains("actual path: body.jobs[1].status"));
    assert_eq!(mismatch["path"], "body.jobs[1].status");
    assert_eq!(mismatch["actualPath"], "body.jobs[1].status");
    assert_eq!(mismatch["target"], Value::Null);
    assert_eq!(mismatch["actual"]["value"], "succeeded");
    assert_eq!(mismatch["expected"]["value"], "queued");
}

#[test]
fn run_outputs_json_report_for_schema_failures() {
    let server_url = spawn_http_server(
        200,
        "OK",
        "application/json",
        r#"{"id":"550e8400-e29b-41d4-a716-446655440000","address":{}}"#,
    );
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
            r#"schema Address {{
  postalCode: string
}}

schema User {{
  id: UUID
  address: Address
}}

---

Schema Failure Fixture

GET {server_url}

^ & body === User
"#
        ),
    );

    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");
    let message = parsed["failures"][0]["assertions"][0]["message"]
        .as_str()
        .expect("message should be serialized as a string");
    let mismatch = &parsed["failures"][0]["assertions"][0]["mismatch"];

    assert!(message.contains("schema target: User"));
    assert!(message.contains("mismatch path: body.address.postalCode"));
    assert_eq!(mismatch["kind"], "schema");
    assert_eq!(mismatch["reason"], "missing_required_field");
    assert_eq!(mismatch["target"], "User");
    assert_eq!(mismatch["path"], "body.address.postalCode");
    assert_eq!(mismatch["actualPath"], "body");
    assert_eq!(mismatch["operator"], "===");
    assert_eq!(mismatch["actual"]["type"], "missing");
    assert_eq!(mismatch["actual"]["value"], Value::Null);
    assert_eq!(mismatch["expected"]["type"], "required_field");
    assert_eq!(mismatch["expected"]["value"], "postalCode");
}