supercode-cli 0.4.8

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! CLI-level acceptance test for UX-30's load-bearing safety property: the
//! interactive picker (`picker.rs`) must NEVER hang a piped/scripted/non-tty
//! invocation reading for keystrokes nobody will ever send.
//!
//! `picker.rs`'s own unit tests (`available_is_false_under_the_test_harness`)
//! can only prove `picker::available()` itself evaluates to `false` under
//! `cargo test`'s captured-pipe stdin/stdout — they run in-process and can't
//! safely repoint fd 0 at a real pty without stomping process-global state
//! shared with every other test in the binary (the same constraint
//! `hidden_input.rs` documents). This file closes the other half: spawning
//! the REAL, built `supercode` binary (mirroring `login_cli.rs`'s /
//! `quiet_cli.rs`'s idiom) with genuinely piped stdin (never a tty) and
//! proving the two call sites gated on `picker::available()` actually take
//! the non-interactive fallback branch — never blocking on a `read()` that
//! nothing will ever satisfy — rather than merely asserting they exit
//! zero/nonzero, which a hang would never let us observe at all without a
//! timeout. `run_with_timeout` below is the enforcement mechanism: it fails
//! the test loudly, naming the hang, if the process is still running past a
//! generous deadline, instead of blocking the suite forever.
//!
//! Both tests are non-vacuous: comment out the `picker::available()` guard
//! at either call site (`main.rs`'s `Command::Resume` match arm, or
//! `handle_model`) and the picker would instead try to read raw keystrokes
//! from the piped stdin these tests provide — which never arrives, so the
//! process would hang past `TIMEOUT` and `run_with_timeout` would fail the
//! test (rather than the test simply passing regardless of the gating, which
//! would make it vacuous).

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::time::Duration;

fn bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}

fn fresh_home(tag: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!(
        "supercode-ux30-picker-available-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Generous, but finite: real CI machines are slow, but a correctly-gated
/// process here does essentially no I/O and should exit in well under a
/// second. This only needs to be shorter than "the test suite's own overall
/// timeout" to do its job.
const TIMEOUT: Duration = Duration::from_secs(20);

/// Spawn `args` against `home`, write `stdin_data` to the child's stdin and
/// close it (exactly what a real pipe/redirect/CI runner does — there is no
/// way for the child to ever read more), then wait up to `TIMEOUT` for exit.
///
/// The wait happens on a background thread so a hang can't block the test
/// process itself forever: the main thread only ever blocks on
/// `recv_timeout`. If the deadline passes, the child is killed (so a
/// regression doesn't leak a stuck process past this test) and the test
/// fails with a message naming exactly what property broke.
fn run_with_timeout(home: &Path, stdin_data: &str, args: &[&str]) -> Output {
    let mut cmd = Command::new(bin());
    cmd.env("SUPERCODE_HOME", home)
        .env("OPENROUTER_API_KEY", "sk-picker-cli-test-not-real")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .env_remove("NO_COLOR")
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = cmd.spawn().expect("failed to spawn the supercode binary");
    let pid = child.id();
    child
        .stdin
        .take()
        .expect("stdin was piped")
        .write_all(stdin_data.as_bytes())
        .expect("write to child stdin");
    // The `Stdio::piped()` handle taken above is dropped at the end of this
    // statement, closing the write end — i.e. EOF on the child's stdin,
    // exactly like a real `<<< "..."` redirect or a closed pipe from an
    // upstream script. Nothing more will ever arrive on fd 0.

    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let result = child.wait_with_output();
        // The receiver may already be gone (timed out and returned) — a
        // send error here just means nobody's listening any more, which is
        // fine; the process was already killed by the timeout branch below.
        let _ = tx.send(result);
    });

    match rx.recv_timeout(TIMEOUT) {
        Ok(result) => result.expect("child process failed"),
        Err(mpsc::RecvTimeoutError::Timeout) | Err(mpsc::RecvTimeoutError::Disconnected) => {
            // Best-effort: reap the still-running (i.e. hung) child so this
            // test doesn't leak a process reading a dead pipe forever.
            let _ = Command::new("kill")
                .args(["-KILL", &pid.to_string()])
                .status();
            panic!(
                "supercode {args:?} did not exit within {TIMEOUT:?} on piped/non-tty stdin — \
                 the picker likely blocked reading keystrokes from a pipe nobody will ever \
                 write to. This is exactly the hang `picker::available()`'s TTY-gating exists \
                 to prevent (see picker.rs's module doc comment)."
            );
        }
    }
}

fn stderr(out: &Output) -> String {
    String::from_utf8_lossy(&out.stderr).into_owned()
}

/// `resume` with no session-file argument, on piped stdin: `main.rs`'s
/// `Command::Resume` arm checks `picker::available()` before ever launching
/// the picker; piped stdin makes it `false`, so this must take the
/// non-interactive fallback — a prompt `anyhow::bail!` naming the missing
/// argument — rather than constructing a picker that reads for an
/// arrow-key/Enter sequence stdin will never deliver.
#[test]
fn resume_with_no_session_arg_on_piped_stdin_bails_promptly_instead_of_hanging() {
    let home = fresh_home("resume-no-arg");

    // Empty stdin, immediately closed — the same shape as `supercode resume`
    // invoked from a script, cron job, or CI step with no controlling tty.
    let out = run_with_timeout(&home, "", &["resume"]);

    assert!(
        !out.status.success(),
        "resume with no session arg and no tty must fail (there is nothing to resume and no \
         picker can run), got status={:?} stderr={}",
        out.status,
        stderr(&out)
    );
    let err = stderr(&out);
    assert!(
        err.contains("no interactive terminal available for the picker"),
        "expected the non-tty fallback bail message, got: {err}"
    );
    assert!(
        err.contains("pass a `.jsonl` path"),
        "expected the bail message to name the workaround, got: {err}"
    );
}

/// `chat`'s `/model` (no argument), on piped stdin: `handle_model` checks
/// `picker::available()` before launching the model-switch picker; piped
/// stdin makes it `false`, so this must print the non-tty fallback line and
/// return control to the REPL loop (which then hits EOF on the closed stdin
/// and exits 0 cleanly) rather than blocking on a picker read.
#[test]
fn chat_slash_model_on_piped_stdin_does_not_hang_and_prints_non_tty_fallback() {
    let home = fresh_home("chat-slash-model");

    // `/model` with no argument, then stdin closes (EOF) — the REPL's next
    // `readline()` call sees `ReadlineError::Eof` and exits the loop.
    let out = run_with_timeout(&home, "/model\n", &["chat"]);

    assert!(
        out.status.success(),
        "chat fed `/model` on piped stdin should exit cleanly (EOF after the fallback line), \
         got status={:?} stderr={}",
        out.status,
        stderr(&out)
    );
    let err = stderr(&out);
    assert!(
        err.contains("not a terminal") && err.contains("--model"),
        "expected the non-tty `/model` fallback line, got: {err}"
    );
}