hen 0.15.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
mod support;

use support::TestWorkspace;

fn assert_verify_failure(
    source: &str,
    expected_message: &str,
    expected_location: &str,
    expected_excerpt: &str,
) {
    let workspace = TestWorkspace::new();
    workspace.write_file("collection.hen", source);

    let output = workspace.run_hen(["verify", "collection.hen"]);

    assert_eq!(output.status_code, 1, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(
        output.stderr.contains(expected_message),
        "stderr: {}",
        output.stderr
    );
    assert!(
        output.stderr.contains(expected_location),
        "stderr: {}",
        output.stderr
    );
    assert!(
        output.stderr.contains(expected_excerpt),
        "stderr: {}",
        output.stderr
    );
}

#[test]
fn verify_reports_requests_and_required_inputs() {
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "fragments/imported.hen",
        r#"Imported request

POST https://example.com/imported/[[ token ]]
"#,
    );
    workspace.write_file(
        "collection.hen",
        r#"Verify Fixture

Verifies imported requests.

? region = [[ region = us-east-1 ]]

---

Root request

GET https://example.com/root

---

<< fragments/imported.hen
"#,
    );

    let output = workspace.run_hen(["verify", "collection.hen"]);

    assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
    assert!(
        output.stdout.contains("Verification passed"),
        "stdout: {}",
        output.stdout
    );
    assert!(
        output.stdout.contains("[0] GET https://example.com/root"),
        "stdout: {}",
        output.stdout
    );
    assert!(
        output
            .stdout
            .contains("[1] POST https://example.com/imported/[[ token ]]"),
        "stdout: {}",
        output.stdout
    );
    assert!(
        output.stdout.contains("Required inputs"),
        "stdout: {}",
        output.stdout
    );
    assert!(output.stdout.contains("token"), "stdout: {}", output.stdout);
    assert!(
        output.stdout.contains("region (default: us-east-1)"),
        "stdout: {}",
        output.stdout
    );
    assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
}

#[test]
fn verify_does_not_execute_shell_substitutions() {
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        r#"Shell Verify

$ shell_value = $(touch side-effect.txt)

---

Shell request

GET https://example.com/{{ shell_value }}
"#,
    );

    let output = workspace.run_hen(["verify", "collection.hen"]);

    assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
    assert!(
        !workspace.root().join("side-effect.txt").exists(),
        "verify executed a shell substitution"
    );
}

#[test]
fn verify_accepts_websocket_authoring_slice() {
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        r#"WebSocket Verify

---

Open socket

protocol = ws
session = chat
GET wss://example.com/chat

---

Send hello

session = chat
~~~json
{"type":"hello"}
~~~

---

Receive reply

session = chat
receive
within = 2s
"#,
    );

    let output = workspace.run_hen(["verify", "collection.hen"]);

    assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
    assert!(
        output.stdout.contains("[0] GET wss://example.com/chat"),
        "stdout: {}",
        output.stdout
    );
    assert!(
        output.stdout.contains("[1] GET wss://example.com/chat"),
        "stdout: {}",
        output.stdout
    );
    assert!(
        output.stdout.contains("[2] GET wss://example.com/chat"),
        "stdout: {}",
        output.stdout
    );
}

#[test]
fn verify_accepts_websocket_exchange_authoring_slice() {
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        r#"WebSocket Exchange Verify

---

Open socket

protocol = ws
session = chat
GET wss://example.com/chat

---

Send hello

session = chat
within = 2s
~~~json
{"type":"hello"}
~~~
"#,
    );

    let output = workspace.run_hen(["verify", "collection.hen"]);

    assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
    assert!(
        output.stdout.contains("[0] GET wss://example.com/chat"),
        "stdout: {}",
        output.stdout
    );
    assert!(
        output.stdout.contains("[1] GET wss://example.com/chat"),
        "stdout: {}",
        output.stdout
    );
}

#[test]
fn verify_reports_parse_failures_without_running_requests() {
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        r#"Broken Verify

---

Broken assertion

GET https://example.com

^ & body =~ /foo/
"#,
    );

    let output = workspace.run_hen(["verify", "collection.hen"]);

    assert_eq!(output.status_code, 1, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("No valid operator found in assertion"));
}

#[test]
fn verify_reports_unknown_schema_reference_at_declaration_span() {
    assert_verify_failure(
        r#"name = Verify Unknown Reference
description = Ensure unknown schema references point at the owning declaration.

schema User {
  profile: Profile
}

---

Get user

GET https://example.com/users/1
"#,
        "User references unknown validation target Profile",
    "--> 3:1",
        "schema User {",
    );
}

#[test]
fn verify_reports_invalid_scalar_base_reference_at_declaration_span() {
    assert_verify_failure(
        r#"name = Verify Invalid Scalar Base
description = Ensure schema-backed scalar bases point at the scalar declaration.

schema User {
  id: UUID
}

scalar UserAlias = User

---

Get user

GET https://example.com/users/1
"#,
        "scalar UserAlias cannot use schema User as a scalar base",
    "--> 6:1",
        "scalar UserAlias = User",
    );
}

#[test]
fn verify_reports_unknown_schema_assertion_target_at_assertion_span() {
    assert_verify_failure(
        r#"name = Verify Unknown Assertion Target
description = Ensure schema assertions are validated during verify.

---

Get user

GET https://example.com/users/1

^ & body === MissingUser
"#,
        "Unknown schema validation target 'MissingUser'",
    "--> 6:1",
        "^ & body === MissingUser",
    );
}

#[test]
fn verify_reports_duplicate_declaration_names_at_second_declaration() {
    assert_verify_failure(
        r#"name = Verify Duplicate Declaration
description = Ensure duplicate declaration names point at the second definition.

schema User {
  id: UUID
}

scalar User = string

---

Get user

GET https://example.com/users/1
"#,
        "User is already defined",
    "--> 6:1",
        "scalar User = string",
    );
}

#[test]
fn verify_reports_reserved_name_redefinitions_at_declaration_span() {
    assert_verify_failure(
        r#"name = Verify Reserved Name
description = Ensure built-in names cannot be redefined.

scalar UUID = string

---

Get user

GET https://example.com/users/1
"#,
        "UUID is reserved and cannot be redefined",
    "--> 3:1",
        "scalar UUID = string",
    );
}

#[test]
fn verify_reports_invalid_scalar_predicate_combinations_at_declaration_span() {
    assert_verify_failure(
        r#"name = Verify Invalid Scalar Expression
description = Ensure invalid scalar base combinations point at the declaration.

scalar Broken = string & integer

---

Get user

GET https://example.com/users/1
"#,
        "scalar expressions can only declare one base type",
    "--> 3:17",
        "scalar Broken = string & integer",
    );
}

#[test]
fn verify_reports_misplaced_declarations_after_requests_begin() {
    assert_verify_failure(
        r#"name = Verify Misplaced Declaration
description = Ensure declarations after requests begin get a targeted error.

---

Get user

GET https://example.com/users/1

schema User {
  id: UUID
}
"#,
        "schema and scalar declarations must appear before the first ---",
    "--> 6:1",
        "schema User {",
    );
}