github-copilot-sdk 1.0.0-beta.10

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. Technical preview, pre-1.0.
//! Tests for the build-time and runtime CLI provisioning path.
//!
//! Covers the `COPILOT_CLI_PATH` env override, the build-time-extracted
//! binary used when `bundled-cli` is off, and the embed-mode lazy
//! extraction. Mutating env vars is process-global, so all such tests
//! use `serial_test` to avoid races with each other (and with the e2e
//! tests which also read them).

use std::path::PathBuf;

use github_copilot_sdk::{
    CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli,
};
use serial_test::serial;

fn unset_env(key: &str) {
    // SAFETY: these tests are serialized with #[serial(copilot_cli_path)]
    // so no other test in this binary mutates COPILOT_CLI_PATH while
    // we hold the lock. POSIX `setenv`/`unsetenv` are generally
    // thread-safe on modern platforms, and we use `current_thread`
    // tokio runtimes to avoid concurrent reads from worker threads.
    // This doesn't satisfy the strict Rust 2024 safety contract
    // (other tests in the binary may read env vars), but the practical
    // race window is negligible.
    unsafe { std::env::remove_var(key) };
}

fn set_env(key: &str, value: &str) {
    // SAFETY: see `unset_env`.
    unsafe { std::env::set_var(key, value) };
}

/// COPILOT_CLI_PATH wins when it points at a real file, regardless of
/// build mode.
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn env_override_resolves_to_pointed_file() {
    let tmp = tempfile::NamedTempFile::new().expect("create tempfile");
    // resolve.rs only checks `is_file()` for COPILOT_CLI_PATH, so a plain
    // tempfile is sufficient — we don't need it to be executable. The
    // downstream `Client::start` call will fail to exec an empty file,
    // which we tolerate below; we just need to observe that the resolver
    // returned the env-override path rather than `BinaryNotFound`.
    let path = tmp.path().to_path_buf();

    set_env(
        "COPILOT_CLI_PATH",
        path.to_str().expect("utf-8 tempfile path"),
    );
    let opts = ClientOptions::default().with_program(CliProgram::Resolve);

    // `Client::start` reads the env var via resolve.rs. We don't want to
    // actually launch a subprocess against our empty temp file, so go
    // through the public API just far enough to observe the resolution.
    // The easiest observable behavior is that `Client::start` doesn't
    // return `Error::BinaryNotFound` — it'll fail later trying to exec
    // the empty file, which we tolerate.
    let result = Client::start(opts).await;
    unset_env("COPILOT_CLI_PATH");

    match result {
        Ok(_) => {}
        Err(e) => {
            let msg = format!("{e}");
            assert!(
                !msg.contains("not found"),
                "expected COPILOT_CLI_PATH to win; got {msg}"
            );
        }
    }

    // Drop tmp explicitly so the file outlives the assertions above.
    drop(tmp);
    let _ = path;
}

/// A stale (non-existent) COPILOT_CLI_PATH falls through to the next
/// resolution source (embed or dev) rather than failing outright.
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn stale_env_override_falls_through() {
    set_env("COPILOT_CLI_PATH", "/definitely/does/not/exist/copilot");
    let opts = ClientOptions::default().with_program(CliProgram::Resolve);
    let result = Client::start(opts).await;
    unset_env("COPILOT_CLI_PATH");

    // In a normally-configured build (either `bundled-cli` on or off)
    // the resolver should find a binary via the next source. Failing
    // here would mean fallthrough is broken.
    if let Err(e) = &result {
        assert!(
            !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
            "stale COPILOT_CLI_PATH should fall through; got BinaryNotFound: {e}"
        );
    }
}

/// With `bundled-cli` off, `build.rs` extracts the binary into the
/// per-user cache and the runtime resolver recomputes its location from
/// `COPILOT_SDK_CLI_VERSION` + the OS-derived binary name. This test
/// mirrors that convention and asserts the file is on disk where the
/// resolver expects to find it.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[test]
fn extracted_binary_present_at_conventional_path() {
    let version = env!("COPILOT_SDK_CLI_VERSION");
    let binary = if cfg!(windows) {
        "copilot.exe"
    } else {
        "copilot"
    };
    let sanitized = sanitize_version_for_test(version);
    let path = dirs::cache_dir()
        .expect("platform cache dir")
        .join("github-copilot-sdk")
        .join("cli")
        .join(sanitized)
        .join(binary);
    assert!(
        path.is_file(),
        "expected build.rs to extract the CLI to {} (`bundled-cli` off)",
        path.display()
    );
}

#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
fn sanitize_version_for_test(version: &str) -> String {
    version
        .chars()
        .map(|c| match c {
            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c,
            _ => '_',
        })
        .collect()
}

/// With `bundled-cli` off, the resolver locates the build-time-extracted
/// binary without any runtime configuration. Observed via
/// `Client::start`: any outcome other than `BinaryNotFound` means the
/// resolver succeeded.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn unbundled_resolver_finds_extracted_binary() {
    unset_env("COPILOT_CLI_PATH");
    unset_env("COPILOT_CLI_EXTRACT_DIR");

    let opts = ClientOptions::default().with_program(CliProgram::Resolve);
    let result = Client::start(opts).await;
    if let Err(e) = result {
        assert!(
            !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
            "resolver returned BinaryNotFound with `bundled-cli` off: {e}"
        );
    }
}

/// With `bundled-cli` off, `COPILOT_CLI_EXTRACT_DIR` set at runtime
/// redirects the resolver to look directly under the named directory
/// (no per-version subdir, matching the build-time write semantics).
/// We place a fake `copilot[.exe]` there and assert the resolver picks
/// it up — failing here means the build-time / runtime convention has
/// drifted.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn extract_dir_runtime_override_is_honored() {
    let tmp = tempfile::tempdir().expect("create tempdir");
    let binary = if cfg!(windows) {
        "copilot.exe"
    } else {
        "copilot"
    };
    let fake = tmp.path().join(binary);
    std::fs::write(&fake, b"").expect("write fake binary");

    unset_env("COPILOT_CLI_PATH");
    set_env(
        "COPILOT_CLI_EXTRACT_DIR",
        tmp.path().to_str().expect("utf-8 tempdir path"),
    );

    let opts = ClientOptions::default().with_program(CliProgram::Resolve);
    let result = Client::start(opts).await;

    unset_env("COPILOT_CLI_EXTRACT_DIR");

    if let Err(e) = result {
        assert!(
            !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
            "EXTRACT_DIR-redirected resolver returned BinaryNotFound: {e}"
        );
    }

    drop(tmp);
    let _ = fake;
}

/// Build-time version pin: `cli-version.txt` (when present) must be a
/// combined snapshot — a `version=X.Y.Z` line plus per-asset hash lines.
/// When absent, build.rs falls through to `../nodejs/package-lock.json` —
/// both are accepted, this test only checks the pin file's format if it's
/// there.
#[test]
fn pin_file_when_present_is_well_formed() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let pin = PathBuf::from(manifest_dir).join("cli-version.txt");
    if !pin.is_file() {
        // Contributor build path — no assertion needed.
        return;
    }
    let contents = std::fs::read_to_string(&pin).expect("read cli-version.txt");
    let mut saw_version = false;
    for raw in contents.lines() {
        let line = raw.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let (key, value) = line
            .split_once('=')
            .unwrap_or_else(|| panic!("malformed line: {raw:?}"));
        assert!(!value.trim().is_empty(), "empty value for key {key:?}");
        if key.trim() == "version" {
            saw_version = true;
        }
    }
    assert!(saw_version, "cli-version.txt missing `version=` line");
}

/// With `bundled-cli` on AND a supported target, `install_bundled_cli`
/// returns a real on-disk path and is idempotent across calls.
#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
#[test]
fn install_bundled_cli_returns_extracted_path() {
    const { assert!(HAS_BUNDLED_CLI) };

    let first = install_bundled_cli().expect("bundled CLI should install");
    assert!(
        first.is_file(),
        "install_bundled_cli returned a path that is not a file: {}",
        first.display()
    );

    let second = install_bundled_cli().expect("second call should also succeed");
    assert_eq!(
        first, second,
        "install_bundled_cli must be idempotent across calls"
    );
}

/// `install_bundled_cli` returns the same path the runtime resolver
/// hands to `Client::start` for `CliProgram::Resolve` with no
/// `COPILOT_CLI_PATH` override. Observed indirectly: the binary the
/// public API points at must exist, and `Client::start` must not
/// report `BinaryNotFound` under the same env conditions.
#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn install_bundled_cli_matches_resolver() {
    unset_env("COPILOT_CLI_PATH");
    unset_env("COPILOT_CLI_EXTRACT_DIR");

    let direct = install_bundled_cli().expect("bundled CLI should install");
    assert!(direct.is_file());

    let opts = ClientOptions::default().with_program(CliProgram::Resolve);
    if let Err(e) = Client::start(opts).await {
        assert!(
            !matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
            "resolver returned BinaryNotFound while install_bundled_cli succeeded: {e}"
        );
    }
}

/// With `bundled-cli` off (or the target unsupported), the public API
/// reports no bundled CLI and does not fall back to the
/// build-time-extracted dev-cache path that `CliProgram::Resolve` uses.
#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))]
#[test]
fn install_bundled_cli_is_none_without_embed() {
    const { assert!(!HAS_BUNDLED_CLI) };
    assert!(
        install_bundled_cli().is_none(),
        "install_bundled_cli must not fall back to the dev-cache path"
    );
}