nils-api-websocket 0.4.1

CLI crate for nils-api-websocket in the nils-cli workspace.
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::thread;

use nils_test_support::bin::resolve;
use nils_test_support::cmd::{CmdOptions, CmdOutput, run_with};
use nils_test_support::fs::write_json;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tungstenite::Message;

fn api_websocket_bin() -> PathBuf {
    resolve("api-websocket")
}

fn run_api_websocket(cwd: &Path, args: &[&str]) -> CmdOutput {
    let mut options = CmdOptions::default().with_cwd(cwd);
    for key in [
        "WS_URL",
        "WS_ENV_DEFAULT",
        "WS_TOKEN_NAME",
        "WS_HISTORY_ENABLED",
        "WS_HISTORY_FILE",
        "WS_HISTORY_LOG_URL_ENABLED",
        "WS_JWT_VALIDATE_ENABLED",
        "ACCESS_TOKEN",
        "SERVICE_TOKEN",
    ] {
        options = options.with_env_remove(key);
    }

    run_with(&api_websocket_bin(), args, &options)
}

fn spawn_echo_server() -> (String, thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind websocket listener");
    let addr = listener.local_addr().expect("listener addr");

    let handle = thread::spawn(move || {
        let (stream, _) = listener.accept().expect("accept websocket stream");
        let mut ws = tungstenite::accept(stream).expect("accept websocket handshake");
        loop {
            match ws.read() {
                Ok(Message::Text(text)) => {
                    let response = if text.trim() == "ping" {
                        "{\"ok\":true}".to_string()
                    } else {
                        text.to_string()
                    };
                    ws.send(Message::Text(response)).expect("send response");
                }
                Ok(Message::Close(_)) => {
                    let _ = ws.close(None);
                    break;
                }
                Ok(_) => {}
                Err(_) => break,
            }
        }
    });

    (format!("ws://{addr}/ws"), handle)
}

#[test]
fn call_json_success_contains_required_envelope_fields() {
    let tmp = TempDir::new().expect("tmp");
    let root = tmp.path();
    std::fs::create_dir_all(root.join("requests")).expect("mkdir requests");

    let (url, handle) = spawn_echo_server();

    write_json(
        &root.join("requests/health.ws.json"),
        &serde_json::json!({
            "steps": [
                {"type": "send", "text": "ping"},
                {"type": "receive", "expect": {"jq": ".ok == true"}},
                {"type": "close"}
            ]
        }),
    );

    let out = run_api_websocket(
        root,
        &[
            "call",
            "--format",
            "json",
            "--url",
            &url,
            "requests/health.ws.json",
        ],
    );

    assert_eq!(out.code, 0, "stderr={}", out.stderr_text());
    let value: serde_json::Value = serde_json::from_str(&out.stdout_text()).expect("json output");

    assert_eq!(value["schema_version"], "cli.api-websocket.call.v1");
    assert_eq!(value["command"], "api-websocket call");
    assert_eq!(value["ok"], true);
    assert!(value.get("result").is_some());
    assert!(value["result"]["transcript"].is_array());
    assert_eq!(value["result"]["last_received"], "{\"ok\":true}");

    handle.join().expect("join websocket server");
}

#[test]
fn call_json_failure_contains_stable_error_envelope() {
    let tmp = TempDir::new().expect("tmp");
    let out = run_api_websocket(tmp.path(), &["call", "--format", "json", "missing.ws.json"]);

    assert_eq!(out.code, 1);
    let value: serde_json::Value = serde_json::from_str(&out.stdout_text()).expect("json output");

    assert_eq!(value["schema_version"], "cli.api-websocket.call.v1");
    assert_eq!(value["command"], "api-websocket call");
    assert_eq!(value["ok"], false);
    assert_eq!(value["error"]["code"], "request_not_found");
    assert!(
        value["error"]["message"]
            .as_str()
            .unwrap_or_default()
            .contains("Request file not found")
    );
}

#[test]
fn history_json_success_uses_results_contract() {
    let tmp = TempDir::new().expect("tmp");
    let root = tmp.path();
    let setup = root.join("setup/websocket");
    std::fs::create_dir_all(&setup).expect("mkdir setup");
    std::fs::write(
        setup.join(".ws_history"),
        "# stamp exit=0 setup_dir=.\napi-websocket call \\\n  requests/a.ws.json \\\n| jq .\n\n",
    )
    .expect("write history");

    let out = run_api_websocket(
        root,
        &[
            "history",
            "--format",
            "json",
            "--config-dir",
            "setup/websocket",
            "--tail",
            "1",
        ],
    );

    assert_eq!(out.code, 0, "stderr={}", out.stderr_text());
    let value: serde_json::Value = serde_json::from_str(&out.stdout_text()).expect("json output");

    assert_eq!(value["schema_version"], "cli.api-websocket.history.v1");
    assert_eq!(value["command"], "api-websocket history");
    assert_eq!(value["ok"], true);
    assert!(value.get("result").is_some());
    assert_eq!(value["result"]["count"], 1);
    assert_eq!(
        value["result"]["records"].as_array().map(|v| v.len()),
        Some(1)
    );
}