supercode-cli 0.4.6

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-15 (waiting spinner + 'Thinking… (Ns)'
//! elapsed status), the byte-cleanliness half of its AC:
//!
//! - dev/02: "The spinner is suppressed when stderr is not a TTY … (piped
//!   output stays clean)."
//!
//! Spawns the built `supercode` binary with BOTH stdout and stderr piped
//! (`Command`'s default capture mode is not a tty on either stream — the
//! same shape as `run ... | cat` or a CI runner) against a real local
//! HTTP/SSE stub that sleeps for `RESPONSE_DELAY` before writing its
//! response, well past the spinner's own start-delay — so if gating were
//! broken, the spinner would have had time to render at least one frame.
//! Follows the raw `TcpListener` + hand-written HTTP/SSE-framing idiom
//! `supercode-core`'s own `provider::tests::streams_a_200_response_into_a_message`
//! already uses (`crates/harness/src/provider.rs`), just from the CLI side of
//! the process boundary.
//!
//! The interactive-tty case (spinner actually appearing on a real terminal
//! and being cleared cleanly) is not exercised here — capturing a real pty
//! from a `cargo test` process is out of scope for an automated,
//! dependency-free test; it was verified manually (see the UX-15 tracker /
//! build report) with `script`+the same delayed stub.

use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
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-ux15-spinner-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Delay before the stub writes ANYTHING back — chosen well past the
/// spinner's own ~200ms start-delay, so a gating bug (spinner rendering on a
/// piped/non-tty stderr) would have a comfortable window to show up as bytes
/// in the captured stream.
const RESPONSE_DELAY: Duration = Duration::from_millis(600);

/// A one-shot local HTTP server that: accepts one connection, reads (and
/// discards) the request, sleeps `RESPONSE_DELAY`, then writes back a
/// hand-framed `text/event-stream` response with a couple of content deltas
/// — mirroring the exact SSE framing `supercode-core`'s `OpenAiProvider`
/// parses (`data: {"choices":[{"delta":{"content":"..."}}]}\n\n`, terminated
/// by `data: [DONE]\n\n`).
fn spawn_delayed_sse_stub() -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
    let addr = listener.local_addr().unwrap();
    let handle = std::thread::spawn(move || {
        let (mut sock, _) = listener.accept().expect("accept one connection");
        let mut buf = [0u8; 4096];
        let _ = sock.read(&mut buf); // drain the request; contents unused.

        std::thread::sleep(RESPONSE_DELAY);

        let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n\
                   data: {\"choices\":[{\"delta\":{\"content\":\"lo there\"}}]}\n\n\
                   data: [DONE]\n\n";
        let resp = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            sse.len(),
            sse
        );
        sock.write_all(resp.as_bytes())
            .expect("write stub response");
        sock.flush().ok();
    });
    (addr, handle)
}

fn run_piped(home: &Path, base_url: &str, extra: &[&str]) -> Output {
    let mut args = vec!["--api-key", "x", "--base-url", base_url];
    args.extend_from_slice(extra);
    Command::new(bin())
        .env("SUPERCODE_HOME", home)
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .env_remove("NO_COLOR")
        .env_remove("SUPERCODE_QUIET")
        .args(&args)
        // Deliberately NOT a pty: this is the "piped" shape (`run ... |
        // cat`, CI). `IsTerminal` on a piped `Stdio` is false on every
        // platform this workspace targets.
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn the supercode binary")
        .wait_with_output()
        .expect("child process failed")
}

/// Bytes that would only ever appear if a spinner frame was drawn: the CSI
/// "clear line" escape it uses to tear itself down, a raw ESC byte at all
/// (this binary/AC has no other reason to emit one on this path), or any of
/// its literal braille frame glyphs / label text.
fn contains_spinner_bytes(raw: &[u8]) -> bool {
    if raw.contains(&0x1b) {
        return true; // ESC — CSI clear-line or any other escape sequence.
    }
    let text = String::from_utf8_lossy(raw);
    text.contains("Thinking…")
        || text.contains("Working…")
        || ['', '', '', '', '', '', '', '', '', '']
            .iter()
            .any(|f| text.contains(*f))
}

#[test]
fn piped_run_against_a_delayed_response_emits_zero_spinner_bytes() {
    let (addr, server) = spawn_delayed_sse_stub();
    let home = fresh_home("piped-clean");

    let out = run_piped(&home, &format!("http://{addr}"), &["run", "say hi"]);

    assert!(
        out.status.success(),
        "run failed: status={:?} stdout={} stderr={}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    // The model's actual reply streamed to stdout, byte-clean.
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("hello there"),
        "expected the streamed reply on stdout, got: {}",
        String::from_utf8_lossy(&out.stdout)
    );

    // The core assertion: NEITHER stream carries a single spinner byte, even
    // though the stub deliberately delayed its response well past the
    // spinner's start-delay — gating suppressed it outright rather than
    // racing to clear it in time.
    assert!(
        !contains_spinner_bytes(&out.stdout),
        "stdout must never carry spinner bytes; raw: {:?}",
        out.stdout
    );
    assert!(
        !contains_spinner_bytes(&out.stderr),
        "piped (non-tty) stderr must carry zero spinner bytes; raw: {:?}",
        out.stderr
    );

    server.join().expect("stub server thread panicked");
}

#[test]
fn piped_json_output_format_emits_zero_spinner_bytes() {
    // `--output-format json` never even constructs a `Spinner` (see
    // `run_cmd`) — belt-and-suspenders proof that the machine-readable path
    // stays byte-clean regardless of tty-ness.
    let (addr, server) = spawn_delayed_sse_stub();
    let home = fresh_home("piped-json-clean");

    let out = run_piped(
        &home,
        &format!("http://{addr}"),
        &["run", "--output-format", "json", "say hi"],
    );

    assert!(
        out.status.success(),
        "run --output-format json failed: status={:?} stdout={} stderr={}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !contains_spinner_bytes(&out.stdout),
        "json stdout must never carry spinner bytes; raw: {:?}",
        out.stdout
    );
    assert!(
        !contains_spinner_bytes(&out.stderr),
        "json-mode stderr must carry zero spinner bytes; raw: {:?}",
        out.stderr
    );

    server.join().expect("stub server thread panicked");
}