Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions cmd/crates/soroban-test/tests/it/doctor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/*
`doctor` reports on things it discovers outside the process: which Stellar CLI
executables are on `PATH`, and which CLI last refreshed the shared version
cache. Both inputs are injectable -- `PATH` like in `plugin.rs`, and the cache
via `STELLAR_DATA_HOME` -- so the reporting can be exercised end to end.

Unix only: the fake CLIs are shell scripts that need an execute bit.
*/

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use soroban_cli::commands::version::pkg;
use soroban_test::TestEnv;

/// A stand-in CLI on `PATH` that answers version queries and nothing else.
///
/// `supports_only_version` distinguishes a current CLI from one old enough to
/// reject `version --only-version` -- the releases that cause the version
/// confusion these checks exist to surface only answer `--version`.
fn write_fake_cli(dir: &Path, name: &str, version: &str, supports_only_version: bool) {
let only_version = if supports_only_version {
format!("echo \"{version}\"")
} else {
"echo \"error: unexpected argument '--only-version' found\" >&2; exit 2".to_string()
};

let script = format!(
"#!/bin/sh\n\
case \"$*\" in\n\
\"version --only-version\") {only_version} ;;\n\
\"--version\") echo \"stellar {version} (0000000000000000000000000000000000000000)\" ;;\n\
*) echo \"unexpected args: $*\" >&2; exit 1 ;;\n\
esac\n"
);

let path = dir.join(name);
fs::write(&path, script).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}

fn empty_dir(sandbox: &TestEnv, name: &str) -> PathBuf {
let dir = sandbox.dir().join(name);
fs::create_dir_all(&dir).unwrap();
dir
}

/// The path `doctor` sees as its own executable, in the canonicalized form it
/// compares cache entries against.
fn running_binary() -> String {
let path = assert_cmd::cargo::cargo_bin("stellar");
let path = path.canonicalize().unwrap_or(path);
path.to_string_lossy().into_owned()
}

/// Seed the shared version cache with a given writer, and return the data home
/// that holds it.
///
/// `latest_check_time` is irrelevant to what is asserted here: `doctor` calls
/// `has_available_upgrade` with caching off, so it always attempts a refresh.
/// The seeded writer is still what gets reported, because `doctor` reads it
/// before that refresh can overwrite it.
fn seed_cache_writer(sandbox: &TestEnv, writer: serde_json::Value) -> PathBuf {
let data_home = empty_dir(sandbox, "cache-data-home");

let cache = serde_json::json!({
"latest_check_time": "2026-08-04T10:00:00Z",
"max_stable_version": "27.1.0",
"max_version": "27.1.0",
"last_checked_by": writer,
});

fs::write(
data_home.join("upgrade_check.json"),
serde_json::to_string(&cache).unwrap(),
)
.unwrap();

data_home
}

/// `doctor` with `PATH` and the version cache pointed at test-controlled state.
fn doctor(sandbox: &TestEnv, path: &Path, data_home: &Path) -> Command {
let mut cmd = sandbox.new_assert_cmd("doctor");
cmd.env("PATH", path).env("STELLAR_DATA_HOME", data_home);
cmd
}

#[test]
fn reports_the_running_executable_and_a_lone_install() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "one-install");
let data_home = empty_dir(&sandbox, "data-home");
write_fake_cli(&bin_dir, "stellar", "27.1.0", true);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(format!("Running executable: {}", running_binary())))
.stderr(contains("Only one Stellar CLI found on PATH"));
}

#[test]
fn reports_when_no_cli_is_on_path() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "no-installs");
let data_home = empty_dir(&sandbox, "data-home");

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains("No Stellar CLI found on PATH"))
// Nothing was found, so claiming a single install would be a lie.
.stderr(contains("Only one Stellar CLI").not());
}

#[test]
fn does_not_warn_when_both_binary_names_report_the_same_version() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "two-installs-agreeing");
let data_home = empty_dir(&sandbox, "data-home");
// What an ordinary install looks like: one crate, two binary names.
write_fake_cli(&bin_dir, "stellar", "27.1.0", true);
write_fake_cli(&bin_dir, "soroban", "27.1.0", true);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(
"2 Stellar CLI executables on PATH, all reporting 27.1.0",
))
.stderr(contains("different versions").not());
}

#[test]
fn warns_when_installs_report_different_versions() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "two-installs-disagreeing");
let data_home = empty_dir(&sandbox, "data-home");
write_fake_cli(&bin_dir, "stellar", "27.1.0", true);
// Old enough to only answer `--version`, which is the fallback path.
write_fake_cli(&bin_dir, "soroban", "22.8.0", false);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(
"Found 2 Stellar CLI executables on PATH reporting different versions",
))
.stderr(contains(format!(
"- {} (27.1.0)",
bin_dir.join("stellar").to_string_lossy()
)))
.stderr(contains(format!(
"- {} (22.8.0)",
bin_dir.join("soroban").to_string_lossy()
)));
}

#[test]
fn confirms_the_cache_writer_when_it_is_this_cli() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "bin");
let data_home = seed_cache_writer(
&sandbox,
serde_json::json!({ "version": pkg(), "executable": running_binary() }),
);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(format!(
"Version cache last refreshed by: {} ({})",
pkg(),
running_binary()
)));
}

#[test]
fn does_not_warn_when_the_same_install_wrote_the_cache_at_an_older_version() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "bin");
// An in-place upgrade: same path, earlier version recorded against it.
let data_home = seed_cache_writer(
&sandbox,
serde_json::json!({ "version": "26.1.0", "executable": running_binary() }),
);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(format!(
"Version cache was last refreshed by this install running 26.1.0; it is now {}",
pkg()
)))
.stderr(contains("a different Stellar CLI").not());
}

#[test]
fn warns_when_a_different_install_wrote_the_cache() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "bin");
let data_home = seed_cache_writer(
&sandbox,
serde_json::json!({ "version": "22.8.0", "executable": "/opt/elsewhere/bin/soroban" }),
);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(
"Version cache was last refreshed by a different Stellar CLI: \
22.8.0 (/opt/elsewhere/bin/soroban)",
))
.stderr(contains(format!("this one is {} (", pkg())));
}

#[test]
fn reports_an_unknown_cache_writer_without_claiming_a_mismatch() {
let sandbox = TestEnv::default();
let bin_dir = empty_dir(&sandbox, "bin");
// A cache written before the writer was recorded.
let data_home = seed_cache_writer(&sandbox, serde_json::Value::Null);

doctor(&sandbox, &bin_dir, &data_home)
.assert()
.success()
.stderr(contains(
"Version cache was last refreshed by an unknown Stellar CLI",
))
.stderr(contains("a different Stellar CLI").not());
}
2 changes: 2 additions & 0 deletions cmd/crates/soroban-test/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ mod build;
mod config;
#[cfg(unix)]
mod container;
#[cfg(unix)]
mod doctor;
#[cfg(feature = "emulator-tests")]
mod emulator;
mod help;
Expand Down
29 changes: 27 additions & 2 deletions cmd/soroban-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ pub async fn main() {
// Spawn a thread to check if a new version exists.
// It depends on logger, so we need to place it after
// the code block that initializes the logger.
tokio::spawn(async move {
upgrade_check(root.global_args.quiet).await;
let quiet = root.global_args.quiet;
let upgrade_check_handle = tokio::spawn(async move {
upgrade_check(quiet).await;
});

let printer = Print::new(root.global_args.quiet);
Expand Down Expand Up @@ -106,6 +107,30 @@ pub async fn main() {
printer.errorln(format!("error: {e}"));
std::process::exit(1);
}

finish_upgrade_check(upgrade_check_handle).await;
}

// Returning from `main` ends the runtime, so a still-running upgrade check is
// dropped where it stands. For a command that finishes faster than the request
// to crates.io, that meant the fetched versions were never written to the cache
// and the next run started over -- the check could keep re-fetching and keep
// reporting whatever stale versions the cache already held.
//
// Give it a brief chance to land instead. The fetch runs alongside the command,
// so by this point it has usually already finished and this returns
// immediately; the wait only bites when the check actually went to the network,
// which is at most once a day. Dropping it after the grace period is no worse
// than the unconditional drop it replaces.
const UPGRADE_CHECK_GRACE: std::time::Duration = std::time::Duration::from_secs(2);

async fn finish_upgrade_check(handle: tokio::task::JoinHandle<()>) {
if tokio::time::timeout(UPGRADE_CHECK_GRACE, handle)
.await
.is_err()
{
tracing::debug!("upgrade check did not finish within its grace period");
}
}

// Load config.toml defaults as env vars, honoring --config-dir if present in raw args.
Expand Down
Loading