lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! CLI integration tests for `lifeloop receipt emit|show` (issue #9).

use std::io::Write;
use std::process::{Command, Stdio};

use lifeloop::{
    CallbackRequest, CallbackResponse, FrameContext, IntegrationMode, LifecycleEventKind,
    PayloadRef, ReceiptStatus, SCHEMA_VERSION,
};

fn lifeloop_bin() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop"))
}

fn run(args: &[&str], stdin_bytes: &[u8]) -> (i32, String, String) {
    let mut child = Command::new(lifeloop_bin())
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn");
    // Tolerate EPIPE: tests like `*_is_usage_error` invoke the binary
    // with an early-exit failure path, which closes stdin before the
    // write completes. Those tests assert on exit code and stderr, not
    // on delivery. Other write failures still panic.
    match child.stdin.as_mut().unwrap().write_all(stdin_bytes) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
        Err(e) => panic!("pipe stdin: {e}"),
    }
    let out = child.wait_with_output().expect("wait");
    (
        out.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

fn codex_request() -> CallbackRequest {
    let entry = lifeloop::lookup_manifest("codex").unwrap();
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::SessionStarted,
        event_id: "evt-r-1".into(),
        adapter_id: "codex".into(),
        adapter_version: entry.manifest.adapter_version,
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: "inv-r-1".into(),
        harness_session_id: Some("sess-r".into()),
        harness_run_id: Some("run-r".into()),
        harness_task_id: None,
        frame_context: None,
        capability_snapshot_ref: None,
        payload_refs: Vec::new(),
        sequence: Some(1),
        idempotency_key: Some("idem-r-1".into()),
        metadata: serde_json::Map::new(),
    }
}

#[test]
fn receipt_emit_synthesizes_a_valid_receipt() {
    let request = codex_request();
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let envelope = serde_json::json!({
        "request": request,
        "response": response,
        "context": {
            "client_id": "ccd",
            "receipt_id": "rcpt-cli-r-1",
            "at_epoch_s": 1700000000_u64,
            "harness_run_id": "run-r",
        }
    });
    let bytes = serde_json::to_vec(&envelope).unwrap();
    let (code, stdout, stderr) = run(&["receipt", "emit"], &bytes);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(
        v.get("receipt_id").and_then(|s| s.as_str()),
        Some("rcpt-cli-r-1")
    );
    assert_eq!(v.get("client_id").and_then(|s| s.as_str()), Some("ccd"));
    assert_eq!(v.get("status").and_then(|s| s.as_str()), Some("delivered"));
}

#[test]
fn receipt_emit_invalid_request_is_validation_error() {
    let mut request = codex_request();
    request.event = LifecycleEventKind::FrameOpening;
    // FrameOpening requires frame_context — leaving it None makes the
    // request invalid.
    request.frame_context = None;
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let envelope = serde_json::json!({
        "request": request,
        "response": response,
        "context": {
            "client_id": "ccd",
            "receipt_id": "rcpt-1",
            "at_epoch_s": 0_u64,
        }
    });
    let bytes = serde_json::to_vec(&envelope).unwrap();
    let (code, _stdout, stderr) = run(&["receipt", "emit"], &bytes);
    assert_eq!(code, 1, "expected validation exit (1), got {code}");
    assert!(stderr.contains("frame_context") || stderr.contains("CallbackRequest"));
}

#[test]
fn receipt_show_validates_and_pretty_prints() {
    // Build a receipt by piping through emit, then feed that JSON to show.
    let request = codex_request();
    let response = CallbackResponse::ok(ReceiptStatus::Delivered);
    let envelope = serde_json::json!({
        "request": request,
        "response": response,
        "context": {
            "client_id": "ccd",
            "receipt_id": "rcpt-show-1",
            "at_epoch_s": 1700000000_u64,
            "harness_run_id": "run-r",
        }
    });
    let bytes = serde_json::to_vec(&envelope).unwrap();
    let (code, emitted, _) = run(&["receipt", "emit"], &bytes);
    assert_eq!(code, 0);

    let (code, stdout, stderr) = run(&["receipt", "show"], emitted.as_bytes());
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(
        v.get("receipt_id").and_then(|s| s.as_str()),
        Some("rcpt-show-1")
    );
}

#[test]
fn receipt_show_rejects_invalid_receipt() {
    // A receipt JSON missing required fields fails parse.
    let bytes = b"{\"schema_version\":\"lifeloop.v0.2\"}";
    let (code, _stdout, stderr) = run(&["receipt", "show"], bytes);
    assert_eq!(code, 3, "expected input exit (3), got {code}");
    assert!(stderr.contains("invalid JSON") || stderr.contains("missing"));
}

#[test]
fn receipt_unknown_subcommand_is_usage_error() {
    let (code, _stdout, stderr) = run(&["receipt", "weird"], b"{}");
    assert_eq!(code, 2);
    assert!(stderr.contains("unknown subcommand"));
}

// Suppress unused-import lint when the test file's helpers don't use
// every import in every test.
#[allow(dead_code)]
fn _unused_imports_keepalive() -> (FrameContext, PayloadRef) {
    (
        FrameContext::top_level("x"),
        PayloadRef {
            payload_id: "p".into(),
            payload_kind: "k".into(),
            content_digest: None,
            byte_size: None,
        },
    )
}