Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,31 @@
[package]
license = "Apache-2.0"
name = "xai-grok-update"
version = "0.1.220-alpha.4"
edition.workspace = true
[dependencies]
anyhow = { workspace = true }
futures = { workspace = true }
indicatif = { workspace = true }
reqwest = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true, features = ["serde"] }
tokio = { workspace = true, features = ["fs", "process", "io-util", "macros", "time"] }
tracing = { workspace = true }
xai-grok-shell = { workspace = true }
xai-grok-tools = { workspace = true }
xai-grok-version = { workspace = true }
[dev-dependencies]
semver = { workspace = true }
serde_json = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
time = { workspace = true, features = ["serde"] }
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "test-util"] }
wiremock = { workspace = true }
dunce = { workspace = true }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
pub mod auto_update;
mod minimum_version;
pub mod version;
pub use auto_update::UpdateStatus;
pub use minimum_version::enforce_minimum_version_or_exit;
pub use version::{UpdateConfig, channel_label, channel_name, write_version_cache};

View file

@ -0,0 +1,384 @@
//! Minimum-version enforcement.
//!
//! When `cli.minimum_version` is set in any config layer, Grok refuses to
//! start below that floor. With auto-update on, we install
//! `max(latest, minimum)`; otherwise the user is asked to run `grok update`.
//!
//! Set `GROK_TEST_VERSION` to manually exercise either path without producing
//! a real out-of-date build.
use crate::auto_update::{get_installer, run_install_script};
use crate::version::{
UpdateConfig, fetch_latest_version, get_installed_grok_version, write_version_cache,
};
use tracing::{info, warn};
use xai_grok_shell::util::config;
/// Result of comparing the running binary against a configured floor.
#[derive(Debug, Clone, PartialEq, Eq)]
enum MinimumVersionDecision {
Allow,
BelowMinimum { current: String, minimum: String },
}
/// Outcome of a successful enforcement pass.
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnforcementOutcome {
Allowed,
/// New binary on disk; caller MUST restart — running process is still old.
Upgraded,
}
/// User-facing enforcement failures; `Display` is printed to stderr.
/// `AutoUpdateDisabled` and `NoInstaller` share copy but stay separate so
/// telemetry can distinguish them.
#[derive(Debug, thiserror::Error)]
pub(crate) enum MinimumVersionError {
/// `source` chains via `Error::source()`; omitted from `Display`.
#[error(
"The minimum version \"{value}\" in your Grok configuration \
isn't a valid version number. Update `cli.minimum_version` and try again."
)]
InvalidMinimum {
value: String,
#[source]
source: semver::Error,
},
#[error(
"This version of Grok ({current}) is no longer supported. \
Run `grok update` to install version {minimum} or later."
)]
AutoUpdateDisabled { current: String, minimum: String },
/// `npm` / `gh` / `internal` GCS — none detected.
#[error(
"This version of Grok ({current}) is no longer supported. \
Run `grok update` to install version {minimum} or later."
)]
NoInstaller { current: String, minimum: String },
/// `detail` is telemetry-only; omitted from `Display` to avoid stacking
/// the installer's own action language.
#[error(
"This version of Grok ({current}) is no longer supported, \
and the update to version {minimum} didn't complete.\n\n\
Run `grok update` to try again."
)]
UpgradeFailed {
current: String,
minimum: String,
detail: String,
},
/// Latest release is known but still below the floor (vs `NoReleaseFound`,
/// which couldn't probe at all).
#[error(
"This version of Grok ({current}) is no longer supported. \
Version {minimum} or later is required, but the most recent release is {latest}. \
Contact your administrator."
)]
NoSatisfyingVersion {
current: String,
minimum: String,
latest: String,
},
/// Couldn't probe the registry — likely transient.
#[error(
"This version of Grok ({current}) is no longer supported. \
Version {minimum} or later is required, but no release was found. \
Check your network connection, or contact your administrator."
)]
NoReleaseFound { current: String, minimum: String },
/// `grok update --version X` requested a version below the floor.
#[error(
"Cannot install Grok {target}: the configured minimum is {minimum}. \
Run `grok update` to install the latest allowed version."
)]
TargetBelowFloor { target: String, minimum: String },
}
/// Pure check against the configured floor. Empty / whitespace-only
/// minimums are treated as unset.
fn evaluate_minimum_version(
current_version: &str,
minimum_version: Option<&str>,
) -> Result<MinimumVersionDecision, MinimumVersionError> {
let Some(minimum) = minimum_version.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(MinimumVersionDecision::Allow);
};
let parsed_min =
semver::Version::parse(minimum).map_err(|source| MinimumVersionError::InvalidMinimum {
value: minimum.to_string(),
source,
})?;
// Unparseable current (e.g. funky dev build): block rather than let an
// unverifiable binary through.
let parsed_cur = match semver::Version::parse(current_version) {
Ok(v) => v,
Err(_) => {
return Ok(MinimumVersionDecision::BelowMinimum {
current: current_version.to_string(),
minimum: parsed_min.to_string(),
});
}
};
if parsed_cur >= parsed_min {
Ok(MinimumVersionDecision::Allow)
} else {
Ok(MinimumVersionDecision::BelowMinimum {
current: parsed_cur.to_string(),
minimum: parsed_min.to_string(),
})
}
}
/// Refuse an explicit install target below the configured floor.
/// Used by `grok update --version X`.
pub(crate) fn check_install_target(target: &str) -> Result<(), MinimumVersionError> {
let floor = resolve_floor_or_error()?;
check_install_target_inner(target, floor.as_deref())
}
fn check_install_target_inner(
target: &str,
floor: Option<&str>,
) -> Result<(), MinimumVersionError> {
let Some(min) = floor else { return Ok(()) };
match evaluate_minimum_version(target, Some(min))? {
MinimumVersionDecision::Allow => Ok(()),
MinimumVersionDecision::BelowMinimum {
current: target,
minimum,
} => Err(MinimumVersionError::TargetBelowFloor { target, minimum }),
}
}
/// `max(target, configured_floor)`; passthrough when no floor is set.
/// Used by `grok update` to keep the install target at or above the pin.
pub(crate) fn apply_floor(target: &str) -> Result<String, MinimumVersionError> {
let floor = resolve_floor_or_error()?;
apply_floor_inner(target, floor.as_deref())
}
/// Adapts `config::resolve_minimum_version`'s error shape into ours.
fn resolve_floor_or_error() -> Result<Option<String>, MinimumVersionError> {
config::resolve_minimum_version()
.map_err(|(value, source)| MinimumVersionError::InvalidMinimum { value, source })
}
fn apply_floor_inner(target: &str, floor: Option<&str>) -> Result<String, MinimumVersionError> {
let Some(min) = floor else {
return Ok(target.to_string());
};
match evaluate_minimum_version(target, Some(min))? {
MinimumVersionDecision::Allow => Ok(target.to_string()),
MinimumVersionDecision::BelowMinimum { minimum, .. } => Ok(minimum),
}
}
/// `max(latest, minimum)`; falls back to `minimum` if `latest` is missing or unparseable.
fn pick_target_version(latest: Option<&str>, minimum: &str) -> String {
match latest.and_then(|v| semver::Version::parse(v).ok()) {
Some(latest_v) => match semver::Version::parse(minimum) {
Ok(min_v) if latest_v >= min_v => latest_v.to_string(),
_ => minimum.to_string(),
},
None => minimum.to_string(),
}
}
/// Call once at startup, before any user-facing UI. On `Ok(Upgraded)` the
/// caller MUST restart. On `Err`, print and exit non-zero.
async fn enforce_minimum_version(
minimum_version: Option<&str>,
update_config: &UpdateConfig,
) -> Result<EnforcementOutcome, MinimumVersionError> {
let current_version = get_installed_grok_version();
let decision = evaluate_minimum_version(&current_version, minimum_version)?;
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
info!(current = %current_version, "minimum_version: floor satisfied");
return Ok(EnforcementOutcome::Allowed);
};
info!(%current, %minimum, "minimum_version: below floor; attempting auto-update");
// `None` is "default on"; only explicit `false` opts out.
let cfg = config::load_config().await;
if cfg.cli.auto_update == Some(false) {
warn!(%current, %minimum, "minimum_version: auto-update disabled by config");
return Err(MinimumVersionError::AutoUpdateDisabled { current, minimum });
}
let Some(installer) = get_installer().await else {
warn!(%current, %minimum, "minimum_version: no installer detected");
return Err(MinimumVersionError::NoInstaller { current, minimum });
};
let latest = fetch_latest_version(installer, update_config).await.ok();
let target = pick_target_version(latest.as_deref(), &minimum);
info!(%current, %target, installer, "minimum_version: installing upgrade");
eprintln!(
"This version of Grok ({current}) is no longer supported. \
Updating to {target}"
);
if let Err(e) = run_install_script(installer, Some(&target), update_config).await {
let detail = format!("{e:#}");
warn!(%current, %target, %detail, "minimum_version: upgrade failed");
return Err(MinimumVersionError::UpgradeFailed {
current,
minimum,
detail,
});
}
// Post-install: pass None for stable_version (same rationale as run_update).
write_version_cache(&target, None).await;
// Stale channel pointer or partial install can leave us below the floor;
// surface that rather than starting an out-of-policy binary.
if let MinimumVersionDecision::BelowMinimum { .. } =
evaluate_minimum_version(&target, Some(&minimum))?
{
warn!(%target, %minimum, ?latest, "minimum_version: post-install still below floor");
return Err(match latest {
Some(latest) => MinimumVersionError::NoSatisfyingVersion {
current: target,
minimum,
latest,
},
None => MinimumVersionError::NoReleaseFound {
current: target,
minimum,
},
});
}
info!(%target, "minimum_version: upgrade installed successfully");
Ok(EnforcementOutcome::Upgraded)
}
/// Single chokepoint for the pager + tui startup paths. Re-execs after a
/// floor-driven install. Prints + exits non-zero on `Err`.
///
/// `GROK_TEST_VERSION` lets devs override the running version to skip
/// enforcement on a `cargo run` build.
pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) {
let min = match resolve_floor_or_error() {
Ok(None) => return,
Ok(Some(m)) => m,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
match enforce_minimum_version(Some(&min), update_config).await {
Ok(EnforcementOutcome::Allowed) => {}
Ok(EnforcementOutcome::Upgraded) => {
// TODO: restart_grok uses exec() which carries the same
// SIGABRT risk as the old piped-stderr update path if the
// child process ever writes to a broken pipe. For now this
// path is rare (only fires when the server pushes a minimum
// version bump), so print a relaunch message instead.
eprintln!("Update installed. Run `grok` to start.");
std::process::exit(0);
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluate_minimum_version_decisions() {
use MinimumVersionDecision::{Allow, BelowMinimum};
// Allow: floor unset (None / empty / whitespace) or satisfied (equal / above).
assert_eq!(evaluate_minimum_version("0.1.100", None).unwrap(), Allow);
assert_eq!(
evaluate_minimum_version("0.1.100", Some("")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.1.100", Some(" ")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.1.100", Some("0.1.100")).unwrap(),
Allow
);
assert_eq!(
evaluate_minimum_version("0.2.0", Some("0.1.100")).unwrap(),
Allow
);
// BelowMinimum: current < floor.
assert!(matches!(
evaluate_minimum_version("0.1.99", Some("0.1.100")).unwrap(),
BelowMinimum { .. }
));
// InvalidMinimum: unparseable floor (admin typo).
assert!(matches!(
evaluate_minimum_version("0.1.100", Some("not-a-version")),
Err(MinimumVersionError::InvalidMinimum { .. })
));
}
#[test]
fn pick_target_returns_max_of_latest_and_minimum() {
// The `None` branch is only reachable here — apply_floor always
// passes `Some(target)`. Production hits it on fetch failure.
assert_eq!(pick_target_version(Some("0.1.200"), "0.1.150"), "0.1.200");
assert_eq!(pick_target_version(Some("0.1.140"), "0.1.150"), "0.1.150");
assert_eq!(pick_target_version(None, "0.1.150"), "0.1.150");
}
#[test]
fn install_target_helpers_consult_floor() {
// check_install_target rejects below-floor targets.
assert!(check_install_target_inner("0.1.50", None).is_ok());
assert!(check_install_target_inner("0.1.150", Some("0.1.100")).is_ok());
assert!(matches!(
check_install_target_inner("0.1.50", Some("0.1.100")).unwrap_err(),
MinimumVersionError::TargetBelowFloor { .. }
));
// apply_floor bumps below-floor targets up.
assert_eq!(apply_floor_inner("0.1.50", None).unwrap(), "0.1.50");
assert_eq!(
apply_floor_inner("0.1.200", Some("0.1.100")).unwrap(),
"0.1.200"
);
assert_eq!(
apply_floor_inner("0.1.50", Some("0.1.100")).unwrap(),
"0.1.100"
);
}
#[test]
#[serial_test::serial]
fn version_env_var_flows_through_to_decision() {
let saved = std::env::var("GROK_TEST_VERSION").ok();
// SAFETY: #[serial] excludes other env-touching tests.
unsafe { std::env::set_var("GROK_TEST_VERSION", "0.1.50") };
let decision =
evaluate_minimum_version(&get_installed_grok_version(), Some("0.1.100")).unwrap();
assert!(matches!(
decision,
MinimumVersionDecision::BelowMinimum { .. }
));
match saved {
Some(v) => unsafe { std::env::set_var("GROK_TEST_VERSION", v) },
None => unsafe { std::env::remove_var("GROK_TEST_VERSION") },
}
}
}

View file

@ -0,0 +1,784 @@
use std::time::Duration;
use anyhow::Result;
use serde::Deserialize;
use serde_json::Value;
use tokio::fs;
use tokio::process::Command;
use xai_grok_shell::env::GrokBuildEnvironment;
use xai_grok_shell::util::grok_home::grok_home;
const TTL_SECONDS_BEFORE_AUTO_UPDATE: Duration = Duration::from_secs(60 * 30);
const NPM_PACKAGE: &str = "@xai-official/grok";
pub const GH_RELEASE_REPO: &str = "xai-org-shared/grok-build";
/// Primary CLI base URL: Cloudflare-fronted x.ai endpoint with edge caching
/// for binaries and origin-respecting no-cache for channel pointers.
pub(crate) const CLI_BASE_URL_PRIMARY: &str = "https://x.ai/cli";
/// Fallback CLI base URL: direct GCS, used when the primary is unreachable
/// (Cloudflare outage, regional CF egress issue, DNS hijack, etc.).
pub(crate) const CLI_BASE_URL_FALLBACK: &str =
"https://storage.googleapis.com/grok-build-public-artifacts/cli";
/// CLI base URLs in preference order. Callers (channel-pointer fetch, binary
/// download, in-app updater) try each in turn and stop at the first success.
pub(crate) const CLI_BASE_URLS: &[&str] = &[CLI_BASE_URL_PRIMARY, CLI_BASE_URL_FALLBACK];
/// Minimal configuration the update system needs from the environment.
///
/// Constructed once from `GrokBuildEnvironment` at startup and threaded through the
/// update call chain so that `auto_update` and `version` never need to know
/// about the `GrokBuildEnvironment` enum directly.
#[derive(Debug, Clone)]
pub struct UpdateConfig {
/// Chat API proxy base URL (versioned `https://cli-chat-proxy.grok.com/v1` endpoint).
pub proxy_base_url: String,
/// Auth scope key for `~/.grok/auth.json`.
pub auth_scope: String,
/// Enterprise deployment key (GROK_DEPLOYMENT_KEY).
pub deployment_key: Option<String>,
/// Optional extra auth material forwarded with requests when present.
pub alpha_test_key: Option<String>,
/// Release channel: "stable" or "alpha". Loaded from config.
pub channel: String,
/// Custom npm registry URL. When set, passed as `--registry=` to npm CLI.
pub npm_registry: Option<String>,
}
impl UpdateConfig {
pub fn from_environment(env: &GrokBuildEnvironment) -> Self {
Self {
proxy_base_url: env.cli_chat_proxy_base_url(),
auth_scope: xai_grok_shell::auth::GrokComConfig::default().auth_scope(),
deployment_key: None,
alpha_test_key: None,
channel: "stable".to_string(),
npm_registry: None,
}
}
}
#[derive(Debug, serde::Serialize, Deserialize)]
struct GrokVersion {
version: String,
#[serde(default)]
stable_version: Option<String>,
checked_at: String,
}
impl GrokVersion {
fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool {
if let Ok(dt) = time::OffsetDateTime::parse(
&self.checked_at,
&time::format_description::well_known::Rfc3339,
) {
// Clock-skew guard: future timestamps are never fresh.
if dt > now {
return false;
}
now - dt < ttl
} else {
false
}
}
fn new(version: String, stable_version: Option<String>, now: time::OffsetDateTime) -> Self {
let checked_at = now
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| now.to_string());
Self {
version,
stable_version,
checked_at,
}
}
}
/// Return the semver-greater of two version strings.
fn semver_max(a: &str, b: &str) -> Result<String> {
let va = semver::Version::parse(a)?;
let vb = semver::Version::parse(b)?;
Ok(std::cmp::max(va, vb).to_string())
}
/// Fetch the latest version from npm registry using `npm view`.
/// For alpha channel, fetches both `@alpha` and `@latest` dist-tags and
/// returns the semver-greater — prevents alpha users getting stuck when a
/// newer stable ships without updating the alpha dist-tag.
async fn fetch_npm_version(channel: &str, npm_registry: Option<&str>) -> Result<String> {
if channel == "alpha" {
let (alpha_v, stable_v) = tokio::try_join!(
fetch_npm_tag("alpha", npm_registry),
fetch_npm_tag("latest", npm_registry),
)?;
return semver_max(&alpha_v, &stable_v);
}
fetch_npm_tag("latest", npm_registry).await
}
/// Test-only entry point: invokes the private [`fetch_npm_tag`] for tests
/// that swap in a fake `npm` via PATH.
#[doc(hidden)]
pub async fn fetch_npm_tag_for_test(tag: &str, npm_registry: Option<&str>) -> Result<String> {
fetch_npm_tag(tag, npm_registry).await
}
/// Test-only entry point: invokes the private [`fetch_npm_version`] for tests
/// that swap in a fake `npm` via PATH.
#[doc(hidden)]
pub async fn fetch_npm_version_for_test(
channel: &str,
npm_registry: Option<&str>,
) -> Result<String> {
fetch_npm_version(channel, npm_registry).await
}
async fn fetch_npm_tag(tag: &str, npm_registry: Option<&str>) -> Result<String> {
let pkg_spec = if tag == "latest" {
NPM_PACKAGE.to_string()
} else {
format!("{}@{}", NPM_PACKAGE, tag)
};
let mut args = vec!["view", &pkg_spec, "version", "--json"];
let registry_flag;
if let Some(registry) = npm_registry {
registry_flag = format!("--registry={}", registry);
args.push(&registry_flag);
}
let mut cmd = Command::new("npm");
cmd.args(&args).stdin(std::process::Stdio::null());
xai_grok_tools::util::detach_command(&mut cmd);
cmd.envs(xai_grok_tools::util::pager_env());
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("npm view @{} failed: {}", tag, stderr.trim());
}
let stdout = String::from_utf8(output.stdout)?;
let value: Value = serde_json::from_str(stdout.trim())?;
match value {
Value::String(version) => Ok(version),
Value::Array(values) => values
.iter()
.rev()
.find_map(|entry| entry.as_str().map(|item| item.to_string()))
.ok_or_else(|| anyhow::anyhow!("npm view @{} returned empty version list", tag)),
_ => anyhow::bail!("npm view @{} returned unexpected JSON", tag),
}
}
/// Fetch the latest version from GitHub Releases using `gh release list`.
/// For alpha channel, fetches both pre-release and stable-only, returns the
/// semver-greater — `gh release list --limit 1` orders by publication date,
/// not semver, so we need both to guarantee correctness.
#[doc(hidden)]
pub async fn fetch_gh_release_version(channel: &str) -> Result<String> {
if channel == "alpha" {
let (with_pre, stable_only) = tokio::try_join!(
fetch_gh_release_latest(false),
fetch_gh_release_latest(true),
)?;
return semver_max(&with_pre, &stable_only);
}
fetch_gh_release_latest(true).await
}
async fn fetch_gh_release_latest(exclude_pre: bool) -> Result<String> {
let mut args = vec![
"release",
"list",
"--repo",
GH_RELEASE_REPO,
"--limit",
"1",
"--exclude-drafts",
"--json",
"tagName",
"--jq",
".[0].tagName",
];
if exclude_pre {
args.push("--exclude-pre-releases");
}
let mut cmd = Command::new("gh");
cmd.args(&args).stdin(std::process::Stdio::null());
xai_grok_tools::util::detach_command(&mut cmd);
cmd.envs(xai_grok_tools::util::pager_env());
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("gh release list failed: {}", stderr.trim());
}
let tag = String::from_utf8(output.stdout)?.trim().to_string();
// Tags are formatted as "v0.1.141", strip the leading "v"
let version = tag.strip_prefix('v').unwrap_or(&tag).to_string();
if version.is_empty() {
anyhow::bail!("No releases found in {}", GH_RELEASE_REPO);
}
Ok(version)
}
/// Fetch the latest version from a public CLI channel pointer.
///
/// Reads `{base}/{channel}` which contains a plain-text semver string
/// (e.g. `0.1.181`). No auth required — the upstream bucket is public.
///
/// For the alpha channel, fetches both `alpha` and `stable` pointers and
/// returns the semver-greater, matching the behavior of the npm and
/// gh-release paths.
///
/// Tries each base URL in [`CLI_BASE_URLS`] in order and stops at the first
/// success. Each individual base also retries up to 3 times with exponential
/// backoff (1s, 2s, 4s) on transient failures before falling through to the
/// next base.
pub(crate) async fn fetch_gcs_version(channel: &str) -> Result<String> {
let mut last_err: Option<anyhow::Error> = None;
for (i, base) in CLI_BASE_URLS.iter().enumerate() {
match fetch_gcs_version_from_base(channel, base).await {
Ok(v) => return Ok(v),
Err(e) => {
if i + 1 < CLI_BASE_URLS.len() {
tracing::warn!(
"channel pointer fetch from {} failed ({:#}); trying next base URL",
base,
e
);
}
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no CLI base URLs configured")))
}
/// Test-only entry point: same as [`fetch_gcs_version`] but reads from
/// `base_url` instead of the hardcoded GCS bucket.
#[doc(hidden)]
pub async fn fetch_gcs_version_from_base(channel: &str, base_url: &str) -> Result<String> {
if channel == "alpha" {
let (alpha_v, stable_v) = tokio::try_join!(
fetch_gcs_channel_pointer("alpha", base_url),
fetch_gcs_channel_pointer("stable", base_url),
)?;
return semver_max(&alpha_v, &stable_v);
}
fetch_gcs_channel_pointer(channel, base_url).await
}
async fn fetch_gcs_channel_pointer(channel: &str, base_url: &str) -> Result<String> {
let url = format!("{}/{}", base_url, channel);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()?;
let max_retries: u32 = 3;
let mut last_err = None;
for attempt in 0..=max_retries {
if attempt > 0 {
tokio::time::sleep(Duration::from_secs(1 << (attempt - 1))).await;
}
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
last_err = Some(anyhow::anyhow!(
"GCS channel pointer fetch failed for {}: {:#}",
url,
e
));
continue;
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
last_err = Some(anyhow::anyhow!(
"GCS channel pointer fetch failed: HTTP {} for {}: {}",
status,
url,
body.chars().take(200).collect::<String>().trim()
));
continue;
}
match resp.text().await {
Ok(body) => {
let version = body.trim().to_string();
if version.is_empty() {
last_err = Some(anyhow::anyhow!(
"empty {} channel pointer at {}",
channel,
url
));
continue;
}
if semver::Version::parse(&version).is_err() {
anyhow::bail!(
"invalid semver in {} channel pointer: '{}'",
channel,
version
);
}
return Ok(version);
}
Err(e) => {
last_err = Some(anyhow::anyhow!(
"GCS channel pointer body read failed for {}: {:#}",
url,
e
));
continue;
}
}
}
Err(last_err.unwrap())
}
/// Fetch the latest version for the given installer type without writing the
/// version cache. Use this when the caller needs to control when the cache is
/// written (e.g. auto-update should only cache after a successful install or
/// when no update is needed).
pub async fn fetch_latest_version(installer: &str, config: &UpdateConfig) -> Result<String> {
match installer {
"npm" => fetch_npm_version(&config.channel, config.npm_registry.as_deref()).await,
"gh-release" => fetch_gh_release_version(&config.channel).await,
_ => fetch_gcs_version(&config.channel).await,
}
}
/// Write the version cache to disk, recording that `version` was seen at the
/// current time. Call after confirming the version is current (no update
/// needed) or after a successful install.
///
/// `stable_version` records the current stable channel pointer so that
/// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O.
pub async fn write_version_cache(version: &str, stable_version: Option<&str>) {
let version_path = grok_home().join("version.json");
let now = time::OffsetDateTime::now_utc();
let json = GrokVersion::new(
version.to_string(),
stable_version.map(|s| s.to_string()),
now,
);
if let Some(dir) = version_path.parent()
&& let Err(e) = fs::create_dir_all(dir).await
{
tracing::warn!("failed to create version cache directory: {}", e);
return;
}
let tmp = version_path.with_extension("json.tmp");
let data = match serde_json::to_vec_pretty(&json) {
Ok(d) => d,
Err(e) => {
tracing::warn!("failed to serialize version cache: {}", e);
return;
}
};
if let Err(e) = fs::write(&tmp, data).await {
tracing::warn!("failed to write version cache tmp file: {}", e);
return;
}
if let Err(e) = fs::rename(&tmp, &version_path).await {
tracing::warn!("failed to rename version cache file: {}", e);
}
}
/// Fetch the latest version for the given installer type and cache it.
///
/// Each installer is fully independent — no cross-installer fallback.
///
/// - `"npm"` — uses `npm view` against the public registry.
/// - `"internal"` — reads the channel pointer from the public GCS bucket.
/// - `"gh-release"` — uses `gh release list` against GitHub Releases.
pub async fn get_latest_version(installer: &str, config: &UpdateConfig) -> Result<String> {
let version = fetch_latest_version(installer, config).await?;
let stable_ptr = try_fetch_stable_pointer().await;
write_version_cache(&version, stable_ptr.as_deref()).await;
Ok(version)
}
/// True if `version.json` exists and is within TTL.
pub async fn is_version_cache_fresh() -> bool {
let version_path = grok_home().join("version.json");
let now = time::OffsetDateTime::now_utc();
if let Ok(version_str) = fs::read_to_string(&version_path).await
&& let Ok(version) = serde_json::from_str::<GrokVersion>(&version_str)
&& version.is_fresh(now, TTL_SECONDS_BEFORE_AUTO_UPDATE)
{
return true;
}
false
}
pub use xai_grok_version::installed as get_installed_grok_version;
/// Version of the managed grok binary currently on disk, read from the
/// `~/.grok/bin/grok` symlink target (`../downloads/grok-<version>-<platform>`)
/// without exec'ing anything.
///
/// Concurrent updaters (TUI background download, leader hourly checker,
/// explicit `grok update`) decide staleness from this instead of their own
/// compiled-in version, so a binary another process already installed is
/// never downloaded a second time.
///
/// Returns `None` when there is no parseable managed symlink (Windows
/// copy-based installs, dev builds) or when the symlink is DANGLING — a
/// link whose target binary was deleted (e.g. manual `~/.grok/downloads`
/// cleanup) must not report an installed version, or every updater would
/// claim "already up to date" forever while no runnable binary exists.
/// NOTE: the symlink existing does not prove the *active installer*
/// maintains it — npm manages its own global install and a leftover symlink
/// from a previous internal install would lie about the npm install's
/// version. Callers must gate on the installer (see
/// `disk_version_for_installer` in `auto_update`).
pub fn installed_on_disk_version() -> Option<String> {
#[cfg(unix)]
{
let app = xai_grok_shell::util::grok_home::grok_application();
let target = std::fs::read_link(&app).ok()?;
// metadata() follows the symlink: Err means the target is gone
// (dangling link) and the version it names is not actually on disk.
std::fs::metadata(&app).ok()?;
version_from_versioned_binary_name(target.file_name()?.to_str()?, "grok")
}
#[cfg(not(unix))]
{
None
}
}
/// Extract the `<version>` portion of a versioned binary file name.
///
/// Handles the internal layout (`grok-0.1.150-macos-aarch64`, including
/// pre-releases: `grok-0.1.150-alpha.1-linux-x86_64` → `0.1.150-alpha.1`)
/// and the npm layout without a platform suffix (`grok-0.1.150`,
/// `grok-0.1.150-alpha.1`): everything between the `{bin_prefix}-` prefix
/// and the first platform-OS component is the version, validated as semver
/// so unknown layouts (`grok-latest`, `grok-pager-*` when `bin_prefix` is
/// `grok`) return `None` instead of garbage.
///
/// Shared by the disk-version probe above and `cleanup_old_downloads` in
/// `auto_update` — keep it the single place that understands this naming.
pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -> Option<String> {
const PLATFORM_OS: &[&str] = &["macos", "linux", "darwin", "windows"];
let suffix = name.strip_prefix(bin_prefix)?.strip_prefix('-')?;
let parts: Vec<&str> = suffix.split('-').collect();
let platform_start = parts
.iter()
.position(|p| PLATFORM_OS.contains(p))
.unwrap_or(parts.len());
let ver_str = parts[..platform_start].join("-");
semver::Version::parse(&ver_str).ok()?;
Some(ver_str)
}
/// Fetch the stable channel pointer for caching alongside the version.
///
/// Tries each base URL in [`CLI_BASE_URLS`] and returns the first success.
/// Best-effort: returns `None` on any failure (the caller will simply omit
/// the stable pointer from the cache, and `channel_label()` will return `""`
/// until the next successful fetch).
///
/// The entire operation is capped at 500 ms. The stable pointer is only used
/// to derive the `[alpha]`/`[stable]` channel label — it is never required
/// for correctness. On slow or unreachable networks the timeout fires and we
/// return `None`; the label will populate on the next successful TTL check
/// (~30 min). This keeps startup and post-install paths fast.
pub(crate) async fn try_fetch_stable_pointer() -> Option<String> {
tokio::time::timeout(Duration::from_millis(500), async {
for base in CLI_BASE_URLS {
if let Ok(v) = fetch_gcs_channel_pointer("stable", base).await {
return Some(v);
}
}
None
})
.await
.unwrap_or(None)
}
/// Read the cached stable version from `~/.grok/version.json` (sync, for display).
///
/// Returns `None` if the file doesn't exist, can't be parsed, or has no
/// `stable_version` field (e.g. written by an older binary).
pub fn cached_stable_version() -> Option<String> {
let version_path = grok_home().join("version.json");
let content = std::fs::read_to_string(&version_path).ok()?;
let gv: GrokVersion = serde_json::from_str(&content).ok()?;
gv.stable_version
}
/// Pure comparison: derive the channel name from current vs stable pointer.
///
/// Returns `Some("alpha")` when `current > stable`, `Some("stable")` when
/// `current <= stable`, or `None` when either version fails to parse.
fn derive_channel<'a>(current: &str, stable: &str) -> Option<&'a str> {
let current_v = semver::Version::parse(current).ok()?;
let stable_v = semver::Version::parse(stable).ok()?;
if current_v > stable_v {
Some("alpha")
} else {
Some("stable")
}
}
/// Machine-readable channel name derived from the cached stable pointer.
///
/// Returns `Some("alpha")` when the current version is ahead of the cached
/// stable pointer, `Some("stable")` when at or behind, or `None` when no
/// cached pointer is available (first launch, old cache format, parse error).
///
/// The result is computed once and cached for the process lifetime.
pub fn channel_name() -> Option<&'static str> {
use std::sync::OnceLock;
static NAME: OnceLock<Option<&'static str>> = OnceLock::new();
*NAME.get_or_init(|| {
let stable = cached_stable_version()?;
derive_channel(xai_grok_version::VERSION, &stable)
})
}
/// Channel label derived from the cached stable pointer.
///
/// Compares the compiled-in `VERSION` against the stable pointer stored in
/// `~/.grok/version.json` (written by the auto-updater):
/// - `" [alpha]"` when the current version is ahead of stable,
/// - `" [stable]"` when at or behind stable,
/// - `""` when no cached pointer is available (first launch, old cache format).
///
/// The result is computed once and cached for the process lifetime.
pub fn channel_label() -> &'static str {
use std::sync::OnceLock;
static LABEL: OnceLock<&'static str> = OnceLock::new();
LABEL.get_or_init(|| {
let stable = match cached_stable_version() {
Some(s) => s,
None => return "",
};
match derive_channel(xai_grok_version::VERSION, &stable) {
Some("alpha") => " [alpha]",
Some(_) => " [stable]",
None => "",
}
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Verifies that a future `checked_at` timestamp (e.g. from clock skew or
/// NTP time-warp) is never considered fresh. Without the clock-skew guard
/// this would return true indefinitely, silently disabling auto-update.
#[test]
fn test_is_fresh_rejects_future_timestamp() {
let now = time::OffsetDateTime::now_utc();
let future = now + Duration::from_secs(600);
let v = GrokVersion::new("0.1.200".to_string(), None, future);
assert!(
!v.is_fresh(now, Duration::from_secs(30)),
"Future timestamp must not be considered fresh (clock-skew guard)."
);
}
/// Disk-version probe: parsing the version out of the managed install's
/// symlink-target file name (`grok-<version>-<platform>`).
#[test]
fn test_version_from_versioned_binary_name() {
let cases: &[(&str, Option<&str>)] = &[
("grok-0.2.46-darwin-arm64", Some("0.2.46")),
("grok-0.1.220-linux-x86_64", Some("0.1.220")),
("grok-0.2.5-windows-x86_64.exe", Some("0.2.5")),
// Pre-releases must round-trip whole — truncating to "0.1.220"
// would make an alpha install masquerade as the release and
// mask alpha → stable updates.
("grok-0.1.220-alpha.4-linux-x86_64", Some("0.1.220-alpha.4")),
("grok-0.1.220-alpha.4", Some("0.1.220-alpha.4")), // npm layout
("grok-pager-0.1.5-darwin-arm64", None), // "pager" is not a version
("grok-garbage-darwin-arm64", None), // unparseable version
("grok-0.2.46", Some("0.2.46")), // no platform suffix
("other-0.2.46-darwin-arm64", None), // wrong prefix
("grok-latest", None), // symlink alias, not a version
("grok", None), // bare name
("", None),
];
for (name, expected) in cases {
assert_eq!(
version_from_versioned_binary_name(name, "grok").as_deref(),
*expected,
"version_from_versioned_binary_name({name:?})"
);
}
// bin_prefix discrimination: the pager binary parses under its own
// prefix but not under "grok".
assert_eq!(
version_from_versioned_binary_name("grok-pager-0.1.5-darwin-arm64", "grok-pager")
.as_deref(),
Some("0.1.5")
);
}
// ──────────────────────────────────────────────────────────────────────
// derive_channel — invariant matrix
//
// Tests the pure comparison logic that determines [alpha] vs [stable].
// Covers current 0.1.X-alpha.N, future 0.2.X, edge cases, and errors.
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_derive_channel_matrix() {
// (current, stable_pointer, expected_channel)
let cases: &[(&str, &str, Option<&str>)] = &[
// ── Current 0.1.X workflow ──
("0.1.220-alpha.2", "0.1.219", Some("alpha")), // alpha ahead of stable
("0.1.219", "0.1.219", Some("stable")), // stable user on latest
("0.1.218", "0.1.219", Some("stable")), // stable user behind latest
("0.1.220-alpha.2", "0.1.220-alpha.2", Some("stable")), // pointer matches exactly
("0.1.220-alpha.2", "0.1.220", Some("stable")), // semver: release > pre-release
// ── Future 0.2.X workflow ──
("0.2.5", "0.2.3", Some("alpha")), // alpha ahead of stable
("0.2.5", "0.2.5", Some("stable")), // promoted to stable
("0.2.3", "0.2.5", Some("stable")), // behind stable
("0.2.0", "0.2.0", Some("stable")), // first release, both 0.2.0
// ── Cross-regime upgrade ──
("0.2.0", "0.1.219", Some("alpha")), // new regime ahead of old stable
("0.1.220-alpha.2", "0.2.0", Some("stable")), // old pre-release < new stable
// ── Error cases ──
("garbage", "0.1.219", None), // unparseable current
("0.1.219", "garbage", None), // unparseable stable
("", "0.1.219", None), // empty current
("0.1.219", "", None), // empty stable
];
for (current, stable, expected) in cases {
let result = derive_channel(current, stable);
assert_eq!(
result, *expected,
"derive_channel({:?}, {:?}) = {:?}, expected {:?}",
current, stable, result, expected,
);
}
}
// ──────────────────────────────────────────────────────────────────────
// semver_max — invariant matrix
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_semver_max_matrix() {
// (a, b, expected)
let cases: &[(&str, &str, &str)] = &[
("0.1.140", "0.1.140", "0.1.140"), // equal
("0.1.140", "0.1.141", "0.1.141"), // b higher
("0.1.141", "0.1.140", "0.1.141"), // a higher
("0.1.148-alpha.3", "0.1.148", "0.1.148"), // release > pre-release
("0.1.148", "0.1.148-alpha.3", "0.1.148"), // commutative
("0.1.148-alpha.1", "0.1.148-alpha.3", "0.1.148-alpha.3"), // pre-release ordering
("0.1.149-alpha.1", "0.1.148", "0.1.149-alpha.1"), // higher base wins
("0.0.0", "0.0.1", "0.0.1"), // zero versions
("0.99.99", "1.0.0", "1.0.0"), // major jump
];
for (a, b, expected) in cases {
assert_eq!(
semver_max(a, b).unwrap(),
*expected,
"semver_max({:?}, {:?})",
a,
b,
);
}
}
#[test]
fn test_semver_max_invalid_input_returns_err() {
assert!(semver_max("garbage", "0.1.141").is_err());
assert!(semver_max("0.1.141", "garbage").is_err());
assert!(semver_max("foo", "bar").is_err());
}
// ──────────────────────────────────────────────────────────────────────
// GrokVersion JSON shape — backward compatibility invariants
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_version_json_backward_compat() {
// Old format (no stable_version) must parse — serde(default) fills None.
let old = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z"}"#;
let v: GrokVersion = serde_json::from_str(old).unwrap();
assert_eq!(v.version, "0.1.180");
assert!(v.stable_version.is_none());
// New format with stable_version round-trips correctly.
let now = time::OffsetDateTime::now_utc();
let new = GrokVersion::new("0.2.5".to_string(), Some("0.2.3".to_string()), now);
let json = serde_json::to_string(&new).unwrap();
let parsed: GrokVersion = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, "0.2.5");
assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3"));
// checked_at must be valid RFC3339.
assert!(
time::OffsetDateTime::parse(
&parsed.checked_at,
&time::format_description::well_known::Rfc3339,
)
.is_ok()
);
// Unknown fields are ignored (forward-compat).
let future = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z","future":"ok"}"#;
assert!(serde_json::from_str::<GrokVersion>(future).is_ok());
// Missing required field (checked_at) is rejected.
let missing = r#"{"version":"0.1.180"}"#;
assert!(serde_json::from_str::<GrokVersion>(missing).is_err());
}
// ──────────────────────────────────────────────────────────────────────
// is_fresh — TTL boundary invariants
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_is_fresh_ttl_boundaries() {
let now = time::OffsetDateTime::now_utc();
let v = GrokVersion::new("0.1.200".to_string(), None, now);
// Within TTL → fresh
assert!(v.is_fresh(now, Duration::from_secs(60)));
assert!(v.is_fresh(now + Duration::from_secs(29), Duration::from_secs(30)));
// At TTL boundary → NOT fresh (strict <)
assert!(!v.is_fresh(now + Duration::from_secs(30), Duration::from_secs(30)));
// Past TTL → not fresh
assert!(!v.is_fresh(now + Duration::from_secs(31), Duration::from_secs(30)));
// Zero TTL → never fresh
assert!(!v.is_fresh(now, Duration::ZERO));
// Malformed timestamp → not fresh
let bad = GrokVersion {
version: "0.1.200".to_string(),
stable_version: None,
checked_at: "not-rfc3339".to_string(),
};
assert!(!bad.is_fresh(now, Duration::from_secs(60)));
}
// ──────────────────────────────────────────────────────────────────────
// UpdateConfig defaults
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_update_config_default_channel_is_stable() {
use xai_grok_shell::env::GrokBuildEnvironment;
let cfg = UpdateConfig::from_environment(&GrokBuildEnvironment::Production);
assert_eq!(cfg.channel, "stable");
}
}

View file

@ -0,0 +1,267 @@
//! Controllable raw HTTP/1.1 artifact server shared by the blitz
//! download/install tests and the concurrent-update convergence tests.
//!
//! Serves a real executable artifact and can truncate the body, close the
//! connection early, serve a right-length-but-garbage body, or hang
//! mid-transfer — for both the parallel byte-range path and the
//! single-connection path. It also counts body-serving GETs (HEAD probes are
//! excluded) so tests can assert how many downloads actually happened, and
//! supports a "slow" mode that widens the race window so concurrent
//! installers genuinely overlap in flight.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// How the server corrupts (or doesn't) the next download.
#[derive(Clone, Copy, Debug)]
pub enum Mode {
/// Serve the real artifact correctly.
Full,
/// Serve a right-length body that exits non-zero (fails the smoke-test).
Garbage,
/// Advertise the full length but send only `k` bytes then close the socket
/// (silent truncation: premature EOF / short range chunk).
Truncate(usize),
/// Send `k` bytes then hang, so a client-side timeout cancels mid-transfer.
Hang(usize),
}
struct ServerState {
body: Arc<Vec<u8>>,
mode: Mode,
}
pub struct ArtifactServer {
addr: std::net::SocketAddr,
state: Arc<Mutex<ServerState>>,
shutdown: Arc<AtomicBool>,
gets: Arc<AtomicUsize>,
slow: Arc<AtomicBool>,
}
impl ArtifactServer {
pub fn start(body: Vec<u8>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap();
let state = Arc::new(Mutex::new(ServerState {
body: Arc::new(body),
mode: Mode::Full,
}));
let shutdown = Arc::new(AtomicBool::new(false));
let gets = Arc::new(AtomicUsize::new(0));
let slow = Arc::new(AtomicBool::new(false));
let st = state.clone();
let sd = shutdown.clone();
let gc = gets.clone();
let sl = slow.clone();
std::thread::spawn(move || {
while !sd.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let st = st.clone();
let sd = sd.clone();
let gc = gc.clone();
let sl = sl.clone();
std::thread::spawn(move || handle_connection(stream, st, sd, gc, sl));
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
});
Self {
addr,
state,
shutdown,
gets,
slow,
}
}
pub fn uri(&self) -> String {
format!("http://{}", self.addr)
}
pub fn set_mode(&self, mode: Mode) {
self.state.lock().unwrap().mode = mode;
}
/// Number of body-serving GET requests handled so far (HEAD probes from
/// the parallel-download path are excluded). Tests use this to assert
/// how many downloads actually happened — e.g. that a sequential updater
/// converged onto an already-installed binary without re-downloading.
/// One download may span multiple GETs when the parallel byte-range path
/// splits it, so tests asserting exact counts use a small artifact
/// (single-connection path, 1 GET per download).
pub fn request_count(&self) -> usize {
self.gets.load(Ordering::Relaxed)
}
/// When enabled, hold each Full/Garbage response open ~500ms before
/// sending the body. This keeps an installer in flight long enough for
/// concurrent installers to genuinely overlap even on a heavily loaded
/// CI host — a too-short hold would let race tests run the installers
/// back-to-back and never exercise the concurrent window.
pub fn set_slow(&self, slow: bool) {
self.slow.store(slow, Ordering::Relaxed);
}
}
impl Drop for ArtifactServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
}
}
/// Parse `Range: bytes=a-b` from a raw request header block (case-insensitive).
fn parse_range(request: &str) -> Option<(usize, usize)> {
for line in request.lines() {
let lower = line.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("range:") {
let spec = rest.trim().strip_prefix("bytes=")?;
let (a, b) = spec.split_once('-')?;
return Some((a.trim().parse().ok()?, b.trim().parse().ok()?));
}
}
None
}
fn handle_connection(
mut stream: TcpStream,
state: Arc<Mutex<ServerState>>,
shutdown: Arc<AtomicBool>,
gets: Arc<AtomicUsize>,
slow: Arc<AtomicBool>,
) {
// A stream accepted from a non-blocking listener can inherit non-blocking
// mode; force blocking so large `write_all`s don't short-write on WouldBlock.
let _ = stream.set_nonblocking(false);
// Avoid Nagle/delayed-ACK stalls on the header-then-body writes.
let _ = stream.set_nodelay(true);
// Read the request header block (until CRLFCRLF). Bodies are never sent by
// the client, so headers are all we need.
let mut buf = Vec::new();
let mut tmp = [0u8; 1024];
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if buf.len() > 64 * 1024 {
break;
}
}
Err(_) => return,
}
}
let request = String::from_utf8_lossy(&buf).to_string();
let is_head = request.starts_with("HEAD");
// Count only body-serving GETs; the parallel path's HEAD probe is excluded.
if !is_head {
gets.fetch_add(1, Ordering::Relaxed);
}
let range = parse_range(&request);
let (body, mode) = {
let st = state.lock().unwrap();
(st.body.clone(), st.mode)
};
let total = body.len();
let body: &[u8] = &body;
// Determine the byte slice this request is for, plus the length we will
// claim in Content-Length.
let (slice_start, slice_end_excl) = match range {
Some((a, b)) => (a.min(total), (b + 1).min(total)),
None => (0, total),
};
let claimed_len = slice_end_excl - slice_start;
// For truncation/hang, `k` is a GLOBAL cutoff across the whole artifact:
// a slice that reaches past byte `k` is sent short, so the parallel path's
// later chunk (or the single-connection body) is the one truncated.
let send_end = match mode {
Mode::Truncate(k) | Mode::Hang(k) => slice_end_excl.min(k).max(slice_start),
_ => slice_end_excl,
};
// `payload` is what we actually transmit before any early close; for the
// truncated modes it may be shorter than the advertised `claimed_len`.
let payload: Vec<u8> = match mode {
Mode::Garbage => {
let mut bad = b"#!/bin/sh\nexit 1\n".to_vec();
bad.resize(claimed_len, b'\n');
bad
}
_ => body[slice_start..send_end].to_vec(),
};
// Status line + headers. For range requests we answer 206; HEAD is 200.
let mut head = String::new();
if range.is_some() && !is_head {
head.push_str("HTTP/1.1 206 Partial Content\r\n");
head.push_str(&format!(
"Content-Range: bytes {}-{}/{}\r\n",
slice_start,
slice_end_excl.saturating_sub(1),
total
));
} else {
head.push_str("HTTP/1.1 200 OK\r\n");
head.push_str("Accept-Ranges: bytes\r\n");
}
// Always advertise the (claimed) full length so a truncated transfer is a
// genuine premature EOF rather than a short-but-consistent body.
head.push_str(&format!("Content-Length: {}\r\n", claimed_len));
head.push_str("Connection: close\r\n\r\n");
if stream.write_all(head.as_bytes()).is_err() {
return;
}
if is_head {
let _ = stream.flush();
return;
}
match mode {
Mode::Full | Mode::Garbage => {
// Hold the connection open longer so concurrent installers
// genuinely overlap mid-download (see `set_slow`).
if slow.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(500));
}
let _ = stream.write_all(&payload);
}
Mode::Truncate(_) => {
// Send the (possibly short) payload then drop the connection without
// meeting Content-Length — the client sees a premature EOF.
let _ = stream.write_all(&payload);
}
Mode::Hang(_) => {
let _ = stream.write_all(&payload);
let _ = stream.flush();
// Hold the connection open longer than any client-side cancel
// timeout so the client times out and cancels (a genuine mid-flight
// cancel rather than a server-side close).
for _ in 0..30 {
if shutdown.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
}
}
let _ = stream.flush();
}

View file

@ -0,0 +1,353 @@
//! Shared helpers for integration tests.
//!
//! Each `tests/*.rs` integration test is its own binary, so each binary has
//! its own `OnceLock<GROK_HOME>`. The helpers below ensure the per-binary
//! initialization is identical: same env-var set, same isolation guarantees,
//! same reset between tests.
//!
//! Mirrors the GROK_HOME isolation pattern used in other integration tests.
//!
//! ## Usage
//!
//! ```ignore
//! mod common;
//! use common::{test_home, reset_home};
//!
//! #[tokio::test]
//! #[serial_test::serial]
//! async fn my_test() {
//! let _ = test_home(); // initializes GROK_HOME once per binary
//! reset_home(); // wipes state between tests
//! // ...
//! }
//! ```
#![allow(dead_code)] // each test binary uses a different subset
#[cfg(unix)]
pub mod artifact_server;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
// ─────────────────────────────────────────────────────────────────────────────
// GROK_HOME isolation
// ─────────────────────────────────────────────────────────────────────────────
/// Returns a process-wide test `GROK_HOME`, initialized exactly once per test
/// binary. Once initialized, `xai_grok_config::grok_home()` will resolve to
/// this directory for the lifetime of the process.
///
/// Also clears env vars that the auto-update code consults so a parent shell's
/// values can't pollute the baseline (e.g. running tests from `npm run` would
/// otherwise inherit `npm_config_user_agent` and `NPM_TOKEN`).
pub fn test_home() -> &'static PathBuf {
static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.keep();
// SAFETY: called once at OnceLock init, before any other thread touches
// these env vars. Tests using this helper must be `#[serial]`.
unsafe {
std::env::set_var("GROK_HOME", &path);
std::env::remove_var("GROK_TEST_VERSION");
std::env::remove_var("NPM_TOKEN");
std::env::remove_var("GROK_INSTALLER");
std::env::remove_var("GROK_MANAGED_BY_NPM");
std::env::remove_var("GROK_MANAGED_BY_INTERNAL");
}
path
})
}
/// Wipe state in `GROK_HOME` between tests so each test sees a clean home.
/// Removes the well-known files and subdirectories the update path writes,
/// and clears env vars that individual tests may set.
pub fn reset_home() {
let home = test_home();
let _ = std::fs::remove_file(home.join("config.toml"));
let _ = std::fs::remove_file(home.join("version.json"));
let _ = std::fs::remove_file(home.join("version.json.tmp"));
let _ = std::fs::remove_dir_all(home.join("bin"));
let _ = std::fs::remove_dir_all(home.join("downloads"));
// SAFETY: tests using this helper must be `#[serial]`.
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
std::env::remove_var("NPM_TOKEN");
std::env::remove_var("GROK_INSTALLER");
}
}
/// Override the version reported by `get_installed_grok_version()` for the
/// duration of the test (until [`reset_home`] or process exit).
pub fn set_test_version(v: &str) {
// SAFETY: tests using this helper must be `#[serial]`.
unsafe { std::env::set_var("GROK_TEST_VERSION", v) };
}
// ─────────────────────────────────────────────────────────────────────────────
// Install-test fixtures (shared by the blitz + convergence suites)
// ─────────────────────────────────────────────────────────────────────────────
/// Host `{os}-{arch}` string matching the versioned binary naming scheme
/// (`grok-{version}-{platform}`).
pub fn host_platform() -> String {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
panic!("unsupported test platform");
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
panic!("unsupported test arch");
};
format!("{os}-{arch}")
}
/// Minimal [`xai_grok_update::UpdateConfig`] for install tests.
pub fn make_update_config(channel: &str) -> xai_grok_update::UpdateConfig {
xai_grok_update::UpdateConfig {
proxy_base_url: "http://test.invalid/v1".to_string(),
auth_scope: "test".to_string(),
deployment_key: None,
alpha_test_key: None,
channel: channel.to_string(),
npm_registry: None,
}
}
/// True if shell-script artifacts can execute in this environment. False in
/// restricted sandboxes (e.g. hermetic remote execution) that lack /bin/sh.
#[cfg(unix)]
pub fn can_exec_shell_scripts() -> bool {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("probe");
std::fs::write(&p, b"#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
std::process::Command::new(&p)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// A small real executable: exits 0 for `--version`, so the smoke-test passes.
pub fn small_good_artifact() -> Vec<u8> {
b"#!/bin/sh\nexit 0\n".to_vec()
}
/// Backdate every file in `GROK_HOME/downloads` by ~2 hours.
///
/// `cleanup_old_downloads` deliberately never deletes a freshly-written
/// binary or temp file (it may belong to a concurrent in-flight install), so
/// tests asserting the retention policy must age their fixtures to look like
/// real leftovers from previous releases.
pub fn backdate_downloads() {
let downloads = test_home().join("downloads");
let Ok(entries) = std::fs::read_dir(&downloads) else {
return;
};
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
for entry in entries.flatten() {
let p = entry.path();
if p.is_file()
&& let Ok(f) = std::fs::File::options().write(true).open(&p)
{
let _ = f.set_times(std::fs::FileTimes::new().set_modified(old));
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// PATH-override fake binary
// ─────────────────────────────────────────────────────────────────────────────
/// RAII guard that places a sh-script with name `name` at the head of `PATH`.
/// Restores `PATH` on drop.
///
/// All tests using this MUST be `#[serial]` because `PATH` is process-global.
pub struct FakeBinGuard {
pub tmp: tempfile::TempDir,
pub name: String,
prev_path: OsString,
}
impl FakeBinGuard {
/// Install a fake binary at `<tmp>/<name>` whose body is produced by
/// `script_body(<tmp>)`, and prepend `<tmp>` to `PATH`.
pub fn install<F>(name: &str, script_body: F) -> Self
where
F: FnOnce(&Path) -> String,
{
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let body = script_body(&dir);
let script_path = dir.join(name);
std::fs::write(&script_path, body).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let prev_path = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = OsString::from(&dir);
new_path.push(":");
new_path.push(&prev_path);
// SAFETY: serial_test ensures no other thread races on PATH.
unsafe { std::env::set_var("PATH", &new_path) };
Self {
tmp,
name: name.to_string(),
prev_path,
}
}
/// Install a fake `npm` using the standard [`fake_npm_script`] template.
pub fn install_npm() -> Self {
Self::install("npm", fake_npm_script)
}
/// Install a fake `gh` using the standard [`fake_gh_script`] template.
pub fn install_gh() -> Self {
Self::install("gh", fake_gh_script)
}
/// The tempdir backing this guard (where canned stdout/stderr/exit files
/// can be written by tests, and where `<name>-args.log` is appended).
pub fn dir(&self) -> PathBuf {
self.tmp.path().to_path_buf()
}
/// Argv lines logged by the fake script — one line per invocation.
pub fn args_log(&self) -> Vec<String> {
std::fs::read_to_string(self.dir().join(format!("{}-args.log", self.name)))
.unwrap_or_default()
.lines()
.map(String::from)
.collect()
}
pub fn set_stdout(&self, content: &str) {
std::fs::write(self.dir().join(format!("{}-stdout", self.name)), content).unwrap();
}
pub fn set_stderr(&self, content: &str) {
std::fs::write(self.dir().join(format!("{}-stderr", self.name)), content).unwrap();
}
pub fn set_alpha_stdout(&self, content: &str) {
std::fs::write(
self.dir().join(format!("{}-alpha-stdout", self.name)),
content,
)
.unwrap();
}
pub fn set_stable_only_stdout(&self, content: &str) {
std::fs::write(
self.dir().join(format!("{}-stable-only-stdout", self.name)),
content,
)
.unwrap();
}
pub fn set_with_pre_stdout(&self, content: &str) {
std::fs::write(
self.dir().join(format!("{}-with-pre-stdout", self.name)),
content,
)
.unwrap();
}
pub fn set_exit_code(&self, code: i32) {
std::fs::write(
self.dir().join(format!("{}-exit", self.name)),
code.to_string(),
)
.unwrap();
}
}
impl Drop for FakeBinGuard {
fn drop(&mut self) {
// SAFETY: serial_test ensures no other thread races on PATH.
unsafe { std::env::set_var("PATH", &self.prev_path) };
}
}
/// Single-quote a path for safe substitution into a sh script.
fn single_quote_for_sh(p: &Path) -> String {
let s = p.to_string_lossy();
// Escape any embedded single quotes (paranoid — tempdir paths shouldn't
// contain them, but defensively quote).
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
/// sh script body for a fake `npm`. Logs argv to `<dir>/npm-args.log` and
/// dispatches stdout based on the first matching argv pattern:
///
/// - argv contains `@alpha` → cat `<dir>/npm-alpha-stdout`
/// - else → cat `<dir>/npm-stdout`
///
/// Always cats `<dir>/npm-stderr` to stderr (if exists). Exits with the integer
/// in `<dir>/npm-exit` (default 0).
pub fn fake_npm_script(dir: &Path) -> String {
let dq = single_quote_for_sh(dir);
format!(
r#"#!/bin/sh
echo "$@" >> {dq}/npm-args.log
if echo "$@" | grep -q '@alpha'; then
if [ -f {dq}/npm-alpha-stdout ]; then cat {dq}/npm-alpha-stdout; fi
elif [ -f {dq}/npm-stdout ]; then
cat {dq}/npm-stdout
fi
if [ -f {dq}/npm-stderr ]; then cat {dq}/npm-stderr >&2; fi
exit_code=0
if [ -f {dq}/npm-exit ]; then exit_code=$(cat {dq}/npm-exit); fi
exit "$exit_code"
"#
)
}
/// sh script body for a fake `gh`. Logs argv to `<dir>/gh-args.log` and
/// dispatches stdout based on `release list` argv:
///
/// - argv contains `release list --exclude-pre-releases` → `<dir>/gh-stable-only-stdout`
/// - argv contains `release list` (no exclude flag) → `<dir>/gh-with-pre-stdout`
/// - else → `<dir>/gh-stdout`
///
/// Exits with `<dir>/gh-exit` (default 0).
pub fn fake_gh_script(dir: &Path) -> String {
let dq = single_quote_for_sh(dir);
format!(
r#"#!/bin/sh
echo "$@" >> {dq}/gh-args.log
if echo "$@" | grep -q 'release list'; then
if echo "$@" | grep -q '\-\-exclude-pre-releases'; then
if [ -f {dq}/gh-stable-only-stdout ]; then cat {dq}/gh-stable-only-stdout; fi
else
if [ -f {dq}/gh-with-pre-stdout ]; then cat {dq}/gh-with-pre-stdout; fi
fi
elif [ -f {dq}/gh-stdout ]; then
cat {dq}/gh-stdout
fi
exit_code=0
if [ -f {dq}/gh-exit ]; then exit_code=$(cat {dq}/gh-exit); fi
exit "$exit_code"
"#
)
}

View file

@ -0,0 +1,399 @@
//! Blitz harness: hammer the download + install lifecycle while injecting a
//! truncation / corruption / cancel at every point, and after every iteration
//! assert the single invariant that makes the brick impossible:
//!
//! > `~/.grok/bin/grok` resolves to a binary that passes the smoke-test, OR it
//! > is still the previous-good binary. It is never a broken/partial binary,
//! > and a `.tmp` never masquerades as the active binary.
//!
//! The invariant is checked by RE-RESOLVING the symlink and RE-RUNNING the
//! binary from disk every time — never by re-reading a value the harness set.
//!
//! A controllable raw HTTP/1.1 server serves a real executable ("good")
//! artifact and can truncate the body, close the connection early, serve a
//! right-length-but-garbage body, or hang mid-transfer — for both the parallel
//! byte-range path and the single-connection path.
#![cfg(unix)]
mod common;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serial_test::serial;
use common::artifact_server::{ArtifactServer, Mode};
use common::{
can_exec_shell_scripts, host_platform, make_update_config, reset_home, small_good_artifact,
test_home,
};
use xai_grok_update::auto_update::install_internal_from_base;
// ─────────────────────────────────────────────────────────────────────────────
// Artifacts + fixtures
// ─────────────────────────────────────────────────────────────────────────────
/// A real executable larger than the 16 MiB parallel threshold (at least 2
/// chunks), so the parallel byte-range path is exercised. The shell exits on
/// line 2, never reading the newline padding.
fn large_good_artifact() -> Vec<u8> {
let mut v = b"#!/bin/sh\nexit 0\n".to_vec();
v.resize(33 * 1024 * 1024, b'\n');
v
}
/// Seed a previous-good versioned binary + both managed symlinks
/// (`grok` and `agent` — see `swap_managed_bin_links`). Returns the
/// absolute path of the seeded binary.
fn seed_previous_good(home: &Path, version: &str, platform: &str) -> PathBuf {
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let prev = downloads.join(format!("grok-{version}-{platform}"));
std::fs::write(&prev, small_good_artifact()).unwrap();
std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap();
let rel = format!("../downloads/grok-{version}-{platform}");
for name in ["grok", "agent"] {
let link = bin.join(name);
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&rel, &link).unwrap();
}
dunce::canonicalize(&prev).unwrap()
}
/// What the active `grok` should resolve to after an install attempt.
#[derive(Clone, Copy, PartialEq)]
enum Expect {
/// The new version was installed and activated.
NewBinary,
/// The install was rejected/cancelled; the previous-good binary stays live.
PreviousGood,
}
/// THE invariant. Re-resolves the on-disk symlink and RE-EXECUTES the resolved
/// binary; never inspects a harness-held value. Guarantees the active managed
/// link is always runnable and is never a `.tmp` or a partial file. Applied
/// to both `grok` and `agent` — `swap_managed_bin_links` moves them together.
fn assert_invariant(home: &Path, prev_good: &Path, new_binary: &Path, expect: Expect) {
for name in ["grok", "agent"] {
assert_link_invariant(home, name, prev_good, new_binary, expect);
}
}
fn assert_link_invariant(
home: &Path,
name: &str,
prev_good: &Path,
new_binary: &Path,
expect: Expect,
) {
let link = home.join("bin").join(name);
assert!(link.is_symlink(), "{name} must remain a symlink");
// Resolve from disk. canonicalize fails on a dangling link — that alone
// would be a brick.
let resolved = dunce::canonicalize(&link)
.unwrap_or_else(|e| panic!("active {name} symlink does not resolve: {e}"));
// A `.tmp` file must never be the live target.
let resolved_name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!(
!resolved_name.contains(".tmp"),
"active {name} must not be a temp file: {resolved_name}"
);
// Re-run the resolved binary from disk: the active link must always run.
let ran_ok = std::process::Command::new(&resolved)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(
ran_ok,
"active {name} must pass the smoke-test, but {} did not run",
resolved.display()
);
match expect {
Expect::NewBinary => assert_eq!(
resolved,
dunce::canonicalize(new_binary).unwrap(),
"expected the newly-installed binary to be active for {name}"
),
Expect::PreviousGood => assert_eq!(
resolved, prev_good,
"expected the previous-good binary to stay active for {name} after a rejected install"
),
}
}
/// Run one install attempt against `server` in `mode`, optionally cancelling it
/// after `cancel_after`, then assert the invariant.
async fn run_one(
server: &ArtifactServer,
mode: Mode,
version: &str,
cancel_after: Option<Duration>,
) {
let home = test_home();
reset_home();
let platform = host_platform();
let prev_good = seed_previous_good(home, "0.1.100", &platform);
let new_binary = home
.join("downloads")
.join(format!("grok-{version}-{platform}"));
let cfg = make_update_config("stable");
server.set_mode(mode);
let base = server.uri();
let install = install_internal_from_base(Some(version), &cfg, &base);
let expect = match (mode, cancel_after) {
(Mode::Full, None) => {
install.await.expect("full artifact install should succeed");
Expect::NewBinary
}
(_, Some(deadline)) => {
// Cancel mid-flight by dropping the future at the timeout.
let _ = tokio::time::timeout(deadline, install).await;
Expect::PreviousGood
}
_ => {
let result = install.await;
assert!(
result.is_err(),
"corrupt artifact ({mode:?}) must not install successfully"
);
Expect::PreviousGood
}
};
assert_invariant(home, &prev_good, &new_binary, expect);
}
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic matrix — single-connection path (small artifact)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn blitz_single_connection_matrix() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let server = ArtifactServer::start(small_good_artifact());
let len = small_good_artifact().len();
// Happy path first so we know the symlink CAN move to the new binary.
run_one(&server, Mode::Full, "0.1.181", None).await;
// Right-length garbage — caught by the smoke-test (Layer 2).
run_one(&server, Mode::Garbage, "0.1.181", None).await;
// Premature EOF at several offsets — caught by the length/transport checks.
for k in [0usize, 1, len / 2, len.saturating_sub(1)] {
run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
}
// Cancel mid-transfer at several offsets (incl. before any byte and before
// the HEAD completes), each dropping the in-flight future.
for k in [0usize, len / 2, len.saturating_sub(1)] {
run_one(
&server,
Mode::Hang(k),
"0.1.181",
Some(Duration::from_millis(300)),
)
.await;
}
// A clean serve still succeeds after the failure matrix. NOTE: run_one
// calls reset_home() at the start of every case, so this checks the happy
// path stays reachable — not recovery over a dirty dir. The genuine
// recovery-without-reset assertion lives in
// integrity_failure_is_clean_keeps_previous_good_and_emits_telemetry.
run_one(&server, Mode::Full, "0.1.182", None).await;
}
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic matrix — parallel byte-range path (>= 16 MiB artifact)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn blitz_parallel_path_matrix() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let body = large_good_artifact();
let len = body.len();
let server = ArtifactServer::start(body);
// Happy path through the parallel reassembly.
run_one(&server, Mode::Full, "0.1.181", None).await;
// Right-length garbage reassembled from range chunks — smoke-test catches.
run_one(&server, Mode::Garbage, "0.1.181", None).await;
// Short chunk inside the range / set_len zero region. With Content-Length
// present (the blitz server always sends it), a premature close surfaces as
// a reqwest stream error that rejects the chunk; the download_range
// byte-count check is the belt-and-suspenders for the rarer close-delimited
// (Content-Length-absent) case. The parallel path falls back to single-
// connection, which classifies the same truncation as DownloadIncomplete.
for k in [0usize, 1024, len / 3, len - 4096] {
run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
}
// Cancel mid-chunk.
run_one(
&server,
Mode::Hang(len / 4),
"0.1.181",
Some(Duration::from_millis(400)),
)
.await;
// Clean serve recovers.
run_one(&server, Mode::Full, "0.1.182", None).await;
}
// ─────────────────────────────────────────────────────────────────────────────
// Smoke-test rejects garbage and keeps previous-good
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn smoke_test_rejects_garbage_and_keeps_previous_good() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let server = ArtifactServer::start(small_good_artifact());
let home = test_home();
reset_home();
let platform = host_platform();
let prev_good = seed_previous_good(home, "0.1.100", &platform);
let cfg = make_update_config("stable");
server.set_mode(Mode::Garbage);
let base = server.uri();
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
assert!(result.is_err(), "garbage artifact must not install");
let new_binary = home
.join("downloads")
.join(format!("grok-0.1.181-{platform}"));
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
// A subsequent clean serve must succeed.
server.set_mode(Mode::Full);
let base = server.uri();
install_internal_from_base(Some("0.1.181"), &cfg, &base)
.await
.expect("clean serve after a failure should succeed");
assert_invariant(home, &prev_good, &new_binary, Expect::NewBinary);
}
// ─────────────────────────────────────────────────────────────────────────────
// Bounded randomized fuzz (CI) + ignored stress (1e5+ iterations).
// ─────────────────────────────────────────────────────────────────────────────
/// Cheap deterministic PRNG so the fuzz needs no extra dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
// xorshift64*
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
async fn fuzz_loop(iterations: usize, seed: u64) {
let server = ArtifactServer::start(small_good_artifact());
let len = small_good_artifact().len();
let mut rng = Rng(seed);
for i in 0..iterations {
let version = if i % 2 == 0 { "0.1.181" } else { "0.1.182" };
// Periodically verify a clean serve still installs (recovery), but keep
// the bulk on the fast corruption/cancel paths so the loop stays cheap
// enough for high iteration counts.
if i % 10 == 9 {
run_one(&server, Mode::Full, version, None).await;
continue;
}
match rng.below(3) {
0 => run_one(&server, Mode::Garbage, version, None).await,
1 => {
// k in [0, len): always strictly truncating (k == len would be
// a complete transfer).
let k = rng.below(len);
run_one(&server, Mode::Truncate(k), version, None).await;
}
_ => {
// k in [0, len): Hang holds the socket after k bytes without
// ever meeting Content-Length, so the client always cancels
// mid-flight. k == len would transmit the whole body, letting
// the install complete and the swap land before the deadline —
// contradicting run_one's PreviousGood expectation (the same
// reason the Truncate branch above uses rng.below(len)).
let k = rng.below(len);
run_one(
&server,
Mode::Hang(k),
version,
Some(Duration::from_millis(80)),
)
.await;
}
}
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn blitz_fuzz_bounded() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
// Kept bounded so CI stays fast; the exhaustive run is the ignored test
// below. Every iteration still re-resolves and re-runs the on-disk binary.
fuzz_loop(120, 0x9E3779B97F4A7C15).await;
}
/// The "test it a million times, cancelling at every point" stress run. Gated
/// behind `#[ignore]`; invoke via `just blitz-stress` or
/// `cargo nextest run -p xai-grok-update --run-ignored all`.
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "stress: 100k iterations, run via `just blitz-stress`"]
async fn blitz_fuzz_stress() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let iterations: usize = std::env::var("GROK_BLITZ_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100_000);
fuzz_loop(iterations, 0xDEADBEEFCAFEF00D).await;
}

View file

@ -0,0 +1,231 @@
//! End-to-end regression tests for `check_update_status` that lock in the
//! exact JSON shape produced by `grok update --check --json` for the failure
//! modes that real users have hit in the wild.
//!
//! Seen when a user is behind a corporate npm registry mirror:
//!
//! ```text
//! # Mirror returns 403 for the @xai-official scope
//! { "currentVersion": "0.1.181", "latestVersion": null,
//! "updateAvailable": false, "installer": "npm", "channel": "stable",
//! "autoUpdate": true,
//! "error": "npm view @latest failed: npm error code E403 ..." }
//!
//! # npm falls back to the public registry which has a stale 0.1.4
//! { "currentVersion": "0.1.181", "latestVersion": "0.1.4",
//! "updateAvailable": false, "installer": "npm", "channel": "stable",
//! "autoUpdate": true, "error": null }
//! ```
//!
//! The first case produces `error != null`, the second produces
//! `error == null` but `updateAvailable == false`. Both result in zero
//! visible change for an interactive user — the in-process auto-update
//! check (`run_update_if_available`) silently swallows the same error and
//! the same "already current" outcome.
//!
//! These tests verify the JSON contract so any refactor to `UpdateStatus`,
//! `check_update_status`, or the npm dispatch path will surface a diff.
#![cfg(unix)]
mod common;
use serial_test::serial;
use common::{FakeBinGuard, reset_home, set_test_version, test_home};
use xai_grok_update::UpdateConfig;
use xai_grok_update::auto_update::check_update_status;
/// Set up a fake `npm` on PATH, set `GROK_INSTALLER=npm` so the auto-update
/// code dispatches to npm without consulting config, and pin the installed
/// version to `0.1.181` (matches the user's report).
fn setup() -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version("0.1.181");
// SAFETY: serial_test ensures no race; reset_home will clear this between
// tests.
unsafe { std::env::set_var("GROK_INSTALLER", "npm") };
FakeBinGuard::install_npm()
}
fn make_update_config() -> UpdateConfig {
UpdateConfig {
proxy_base_url: "http://test.invalid/v1".to_string(),
auth_scope: "test".to_string(),
deployment_key: None,
alpha_test_key: None,
channel: "stable".to_string(),
npm_registry: None,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario A: corporate registry 403.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_surfaces_npm_403_in_error_field() {
let g = setup();
// Mimic a corporate registry-mirror 403 response shape (npm exits non-zero,
// writes the error message to stderr).
g.set_exit_code(1);
g.set_stderr(
"npm error code E403\n\
npm error 403 403 Forbidden - GET https://registry-mirror.example.invalid/api/npm/js-virtual/@xai-official%2fgrok\n\
npm error 403 In most cases, you or one of your dependencies are requesting\n\
npm error 403 a package version that is forbidden by your security policy",
);
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version, None, "no version when fetch fails");
assert!(!status.update_available, "no update when fetch fails");
assert_eq!(status.installer.as_deref(), Some("npm"));
assert_eq!(status.channel, "stable");
let err = status
.error
.as_deref()
.expect("error must be populated when npm fails");
assert!(
err.contains("npm view") && err.contains("failed"),
"error must say what failed: {err}"
);
assert!(
err.contains("403") || err.contains("E403") || err.contains("Forbidden"),
"error must include the underlying HTTP detail: {err}"
);
}
#[tokio::test]
#[serial]
async fn check_status_npm_403_serializes_to_user_visible_json() {
// Verify the public JSON shape matches what the user saw in their terminal.
let g = setup();
g.set_exit_code(1);
g.set_stderr("npm error code E403\nnpm error 403 Forbidden");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
let json = serde_json::to_value(&status).unwrap();
// Lock in every key the user's tooling depends on.
assert_eq!(json["currentVersion"], "0.1.181");
assert!(json["latestVersion"].is_null());
assert_eq!(json["updateAvailable"], false);
assert_eq!(json["installer"], "npm");
assert_eq!(json["channel"], "stable");
let err = json["error"]
.as_str()
.expect("error key must be a string when fetch fails");
assert!(
err.contains("E403") || err.contains("403"),
"error must include 403: {err}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario B: public registry returns stale 0.1.4.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_returns_no_update_when_registry_has_older_version() {
// The public registry returns 0.1.4 (much older than installed 0.1.181).
// `needs_update("0.1.181", "0.1.4", "stable")` returns Some(false), so
// `updateAvailable` is false and `error` is null. From the user's
// perspective: silent no-op, even though their preferred upgrade lane
// (corporate mirror) was unreachable. There's nothing the auto-update
// code can do here without knowing about scoped registries — but we want
// to lock in this exact shape so a future change doesn't accidentally
// present a downgrade as an upgrade.
let g = setup();
g.set_stdout("\"0.1.4\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version.as_deref(), Some("0.1.4"));
assert!(
!status.update_available,
"older latest must NOT be reported as update available"
);
assert_eq!(status.installer.as_deref(), Some("npm"));
assert!(status.error.is_none(), "no error on successful fetch");
}
#[tokio::test]
#[serial]
async fn check_status_stale_version_serializes_to_user_visible_json() {
let g = setup();
g.set_stdout("\"0.1.4\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
let json = serde_json::to_value(&status).unwrap();
assert_eq!(json["currentVersion"], "0.1.181");
assert_eq!(json["latestVersion"], "0.1.4");
assert_eq!(json["updateAvailable"], false);
assert_eq!(json["installer"], "npm");
assert_eq!(json["channel"], "stable");
assert!(json["error"].is_null());
}
// ─────────────────────────────────────────────────────────────────────────────
// Sanity: when npm returns a NEWER version, we DO report an update.
// (Anti-regression: the silent-skip paths must only fire on actual no-op
// conditions, not collapse into "always returns no update".)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_reports_update_when_registry_has_newer_version() {
let g = setup();
g.set_stdout("\"0.1.182\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version.as_deref(), Some("0.1.182"));
assert!(status.update_available, "newer version must be reported");
assert!(status.error.is_none());
}
// ─────────────────────────────────────────────────────────────────────────────
// npm rollback safety: npm must NEVER report a downgrade as an update.
// Stale registries / misconfigured Artifactories returning old versions is a
// known failure mode — the auto-updater must ignore them rather than
// downgrading the user.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn check_status_npm_never_reports_downgrade_as_update() {
// Verify that the npm path still refuses to report a lower version as
// an available update, even after the allow_downgrade feature was added
// for GCS/internal installers. This is the key safety property.
let g = setup();
// Simulate a moderate rollback (not a wildly stale version).
g.set_stdout("\"0.1.179\"");
let cfg = make_update_config();
let status = check_update_status(&cfg).await;
assert_eq!(status.current_version, "0.1.181");
assert_eq!(status.latest_version.as_deref(), Some("0.1.179"));
assert!(
!status.update_available,
"npm must NOT report a downgrade as update available — stale registries \
would force-downgrade users to ancient versions"
);
assert_eq!(status.installer.as_deref(), Some("npm"));
assert!(status.error.is_none());
}

View file

@ -0,0 +1,501 @@
//! End-to-end tests for the lock-free concurrent-updater convergence model
//! (the "double download" fix): updaters key staleness off the on-disk
//! install, so a binary another process already installed is never
//! downloaded again — and the accepted same-instant residual race is
//! genuinely harmless thanks to per-attempt download temp names.
//!
//! Production has three independent downloader paths that can race around a
//! release:
//!
//! 1. TUI startup: `check_update_background` spawns a detached `grok update`
//! (the Ctrl+U path now adopts this child instead of spawning a second).
//! 2. Explicit `grok update` (incl. the Ctrl+U fallback when there is no
//! live child).
//! 3. Leader mode: the hourly checker runs `ensure_latest_on_disk`
//! in-process.
//!
//! Two layers are exercised here:
//!
//! - **Convergence** (`ensure_latest_on_disk`, `run_update`): a sequential
//! updater finds the target already on disk and skips the download. The
//! artifact server / fake `gh` count downloads so the skip is asserted,
//! not assumed.
//! - **Race integrity** (`install_internal_from_base` run concurrently): the
//! same-instant race is accepted as rare; these tests pin the property
//! that makes it acceptable — concurrent installs (same or *different*
//! versions) never corrupt the active binary. Before the per-attempt
//! temp-name fix, every `0.1.x` download shared one `grok-0.1.tmp`
//! (`with_extension("tmp")` eats everything after the last dot), so racer
//! A could atomically rename racer B's half-written file into place.
#![cfg(unix)]
mod common;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use serial_test::serial;
use common::artifact_server::ArtifactServer;
use common::{
FakeBinGuard, can_exec_shell_scripts, host_platform, make_update_config, reset_home,
set_test_version, small_good_artifact, test_home,
};
use xai_grok_update::auto_update::{ensure_latest_on_disk, install_internal_from_base, run_update};
use xai_grok_update::version::installed_on_disk_version;
/// Assert the active `~/.grok/bin/grok` resolves to the expected versioned
/// binary, actually runs, and has exactly the expected content (the content
/// check is what catches a cross-racer temp-file corruption).
fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_content: &[u8]) {
let link = home.join("bin").join("grok");
assert!(link.is_symlink(), "grok must be a symlink");
let resolved = dunce::canonicalize(&link)
.unwrap_or_else(|e| panic!("active grok symlink does not resolve: {e}"));
assert_eq!(
resolved.file_name().unwrap().to_string_lossy(),
format!("grok-{version}-{platform}"),
"active grok must be the expected version"
);
assert_eq!(
std::fs::read(&resolved).unwrap(),
expected_content,
"active binary content must be exactly the served artifact (no \
partial/interleaved writes from a racing updater)"
);
let ran_ok = std::process::Command::new(&resolved)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ran_ok, "active grok must pass the smoke-test");
}
/// Lay down a managed-install layout in the test GROK_HOME:
/// `bin/grok -> ../downloads/grok-<version>-<platform>` (what
/// `install_internal_from_base` produces).
fn fake_managed_install(version: &str) {
let home = test_home();
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let name = format!("grok-{version}-{}", host_platform());
std::fs::write(downloads.join(&name), small_good_artifact()).unwrap();
std::fs::set_permissions(
downloads.join(&name),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
std::os::unix::fs::symlink(
std::path::Path::new("../downloads").join(&name),
bin.join("grok"),
)
.unwrap();
}
/// Fake `gh` that logs argv to `<dir>/gh-args.log`, answers
/// `release list --exclude-pre-releases` from `<dir>/gh-stable-only-stdout`,
/// and for `release download ... --output <path>` writes a smoke-passing
/// artifact to the output path.
fn fake_gh_serving_releases(dir: &std::path::Path) -> String {
let dq = format!("'{}'", dir.to_string_lossy().replace('\'', "'\\''"));
format!(
r#"#!/bin/sh
echo "$@" >> {dq}/gh-args.log
case "$*" in
*"release list"*)
if [ -f {dq}/gh-stable-only-stdout ]; then cat {dq}/gh-stable-only-stdout; fi
;;
*"release download"*)
out=""
prev=""
for a in "$@"; do
if [ "$prev" = "--output" ]; then out="$a"; fi
prev="$a"
done
if [ -n "$out" ]; then
printf '#!/bin/sh\nexit 0\n' > "$out"
chmod +x "$out"
fi
;;
esac
exit 0
"#
)
}
/// Count `release download` invocations in the fake gh's argv log.
fn gh_download_count(g: &FakeBinGuard) -> usize {
g.args_log()
.iter()
.filter(|l| l.contains("release download"))
.count()
}
fn setup_gh_release(running_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(running_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("GROK_INSTALLER", "gh-release") };
FakeBinGuard::install("gh", fake_gh_serving_releases)
}
// ─────────────────────────────────────────────────────────────────────────────
// Convergence: ensure_latest_on_disk downloads once, then every subsequent
// pass (the leader's hourly re-entry) converges without re-downloading.
// This is the e2e companion to the decision-level tests in
// test_downgrade_matrix.rs — it asserts on actual download invocations.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn ensure_latest_downloads_once_then_converges_without_redownload() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let g = setup_gh_release("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
let cfg = make_update_config("stable");
// Pass 1: disk is empty → downloads and installs.
let first = ensure_latest_on_disk(&cfg).await.unwrap();
assert_eq!(first.installed.as_deref(), Some("0.2.7"));
assert!(first.relaunch_needed, "running 0.2.5 < disk 0.2.7");
assert_eq!(gh_download_count(&g), 1, "first pass downloads");
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
// Pass 2 (the pre-fix hourly re-download): disk already current →
// no download, but the stale running process still gets the relaunch
// signal.
let second = ensure_latest_on_disk(&cfg).await.unwrap();
assert_eq!(second.installed, None, "second pass must not re-download");
assert!(second.relaunch_needed, "still running 0.2.5 < disk 0.2.7");
assert_eq!(
gh_download_count(&g),
1,
"hourly re-entry must not download again"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Convergence: explicit `grok update` (the Ctrl+U fallback path) finds the
// binary another process already installed and skips the download — while
// still returning the target version so stale leaders get signalled.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn run_update_skips_download_when_disk_already_current() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let g = setup_gh_release("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
// Another process (TUI background download) already installed 0.2.7.
fake_managed_install("0.2.7");
let mut cfg = make_update_config("stable");
let result = run_update(false, None, None, &mut cfg).await.unwrap();
assert_eq!(
result.as_deref(),
Some("0.2.7"),
"run_update must still report the on-disk target so the caller \
signals stale leaders to relaunch"
);
assert_eq!(
gh_download_count(&g),
0,
"a binary someone else installed must not be downloaded again"
);
}
#[tokio::test]
#[serial]
async fn run_update_force_still_redownloads_when_disk_current() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let g = setup_gh_release("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
fake_managed_install("0.2.7");
let mut cfg = make_update_config("stable");
let result = run_update(true, None, None, &mut cfg).await.unwrap();
assert_eq!(result.as_deref(), Some("0.2.7"));
assert_eq!(
gh_download_count(&g),
1,
"--force must bypass the disk-current skip and reinstall"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Installer gating: the disk-version probe must only be trusted for
// installers that actually maintain the managed `~/.grok/bin/grok` symlink
// (internal, gh-release). For npm, a symlink left over from a previous
// internal install LIES about the npm install's version — and in the worst
// direction (leftover "newer" than the registry) it would silently suppress
// npm updates forever.
// ─────────────────────────────────────────────────────────────────────────────
fn setup_npm(running_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(running_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("GROK_INSTALLER", "npm") };
FakeBinGuard::install_npm()
}
#[tokio::test]
#[serial]
async fn npm_update_not_suppressed_by_leftover_newer_internal_symlink() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let g = setup_npm("0.2.5");
g.set_stdout("\"0.2.7\"\n");
// Leftover symlink from a previous internal install, claiming to be
// NEWER than the npm registry. It says nothing about the npm-managed
// global install and must be ignored for npm staleness decisions.
fake_managed_install("0.2.9");
let mut cfg = make_update_config("stable");
let result = run_update(false, None, None, &mut cfg).await.unwrap();
assert_eq!(
result.as_deref(),
Some("0.2.7"),
"npm update must proceed despite the lying leftover symlink"
);
assert!(
g.args_log().iter().any(|l| l.contains("i -g")),
"npm install must actually run: {:?}",
g.args_log()
);
}
#[tokio::test]
#[serial]
async fn ensure_latest_npm_ignores_leftover_internal_symlink() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let g = setup_npm("0.2.5");
g.set_stdout("\"0.2.7\"\n");
fake_managed_install("0.2.9");
let cfg = make_update_config("stable");
let outcome = ensure_latest_on_disk(&cfg).await.unwrap();
assert_eq!(
outcome.installed.as_deref(),
Some("0.2.7"),
"npm leader pass must install despite the lying leftover symlink"
);
assert!(
outcome.relaunch_needed,
"running 0.2.5 < freshly installed 0.2.7"
);
assert!(
g.args_log().iter().any(|l| l.contains("i -g")),
"npm install must actually run: {:?}",
g.args_log()
);
}
#[tokio::test]
#[serial]
async fn disk_probe_preserves_prerelease_versions() {
let _ = test_home();
reset_home();
// An alpha install must read back as the full pre-release version —
// truncating to "0.1.220" would mask the alpha → stable update.
fake_managed_install("0.1.220-alpha.4");
assert_eq!(
installed_on_disk_version().as_deref(),
Some("0.1.220-alpha.4")
);
}
#[tokio::test]
#[serial]
async fn disk_probe_rejects_dangling_symlink() {
// If the symlink survives but its target binary was deleted (manual
// ~/.grok/downloads cleanup), the probe must report None — otherwise
// every updater would claim "already up to date" forever while no
// runnable binary exists, and nothing would ever repair the install.
let home = test_home();
reset_home();
let platform = host_platform();
fake_managed_install("0.2.7");
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
std::fs::remove_file(
home.join("downloads")
.join(format!("grok-0.2.7-{platform}")),
)
.unwrap();
assert_eq!(
installed_on_disk_version(),
None,
"a dangling symlink must not report an installed version"
);
}
#[tokio::test]
#[serial]
async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
// Dangling symlink + stale running process: the probe returns None, so
// the decision falls back to the running version and the download runs,
// repairing the install instead of wedging on "already up to date".
let g = setup_gh_release("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
let home = test_home();
let platform = host_platform();
fake_managed_install("0.2.7");
std::fs::remove_file(
home.join("downloads")
.join(format!("grok-0.2.7-{platform}")),
)
.unwrap();
let cfg = make_update_config("stable");
let outcome = ensure_latest_on_disk(&cfg).await.unwrap();
assert_eq!(
outcome.installed.as_deref(),
Some("0.2.7"),
"dangling symlink must be repaired by an actual download"
);
assert_eq!(gh_download_count(&g), 1);
assert_eq!(
installed_on_disk_version().as_deref(),
Some("0.2.7"),
"probe healthy again after the repair install"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Race integrity: the accepted same-instant race must stay harmless. Two (or
// three) installers running concurrently — even for DIFFERENT versions —
// must never leave a corrupt active binary. Pre-fix, all 0.1.x downloads
// shared one `grok-0.1.tmp`, so a concurrent racer could atomically rename a
// half-written file into place.
// ─────────────────────────────────────────────────────────────────────────────
async fn run_concurrent_installs(
server: &ArtifactServer,
versions: &[&str],
) -> Vec<anyhow::Result<()>> {
let base = server.uri();
let mut tasks = Vec::new();
for version in versions {
let base = base.clone();
let version = version.to_string();
tasks.push(tokio::spawn(async move {
let cfg = make_update_config("stable");
install_internal_from_base(Some(&version), &cfg, &base).await
}));
}
let mut results = Vec::new();
for t in tasks {
results.push(t.await.expect("install task must not panic"));
}
results
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn concurrent_same_version_installs_leave_valid_active_binary() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let home = test_home();
reset_home();
let platform = host_platform();
let artifact = small_good_artifact();
let server = ArtifactServer::start(artifact.clone());
// Hold responses open so the racers genuinely overlap mid-download.
server.set_slow(true);
let results = run_concurrent_installs(&server, &["0.1.181", "0.1.181", "0.1.181"]).await;
for r in results {
r.expect("every racing install must succeed (atomic swap, last writer wins)");
}
// Lock-free model: concurrent racers may each download (accepted waste);
// the invariant is integrity, not the count.
assert!(server.request_count() >= 1);
assert_active_binary(home, "0.1.181", &platform, &artifact);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
if !can_exec_shell_scripts() {
eprintln!("skipping: shell scripts cannot execute in this sandbox");
return;
}
let home = test_home();
reset_home();
let platform = host_platform();
let artifact = small_good_artifact();
let server = ArtifactServer::start(artifact.clone());
server.set_slow(true);
// Pre-fix, BOTH of these wrote to downloads/grok-0.1.tmp concurrently
// (with_extension("tmp") truncates at the last dot), so one racer could
// rename the other's partial file into its own versioned path.
let results = run_concurrent_installs(&server, &["0.1.181", "0.1.182"]).await;
for r in results {
r.expect("both racing installs must succeed");
}
// Both versioned binaries must exist with full, uncorrupted content.
for version in ["0.1.181", "0.1.182"] {
let path = home
.join("downloads")
.join(format!("grok-{version}-{platform}"));
assert_eq!(
std::fs::read(&path).unwrap(),
artifact,
"binary {version} must contain exactly the served artifact"
);
}
// The active symlink points at whichever racer swapped last; it must
// resolve and run regardless.
let resolved = dunce::canonicalize(home.join("bin").join("grok")).unwrap();
assert_eq!(std::fs::read(&resolved).unwrap(), artifact);
let name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!(
!name.contains(".tmp"),
"active grok must never be a temp file: {name}"
);
// No stray shared temp file left behind (the pre-fix collision name).
assert!(
!home.join("downloads").join("grok-0.1.tmp").exists(),
"the pre-fix shared temp name must not exist"
);
}

View file

@ -0,0 +1,611 @@
//! Invariant matrix tests for the rollback/downgrade feature.
//!
//! Covers every combination of:
//! - user's current version vs. channel pointer target
//! - installer type (internal, npm, gh-release)
//! - channel (stable, alpha, enterprise)
//! - pointer-flip scenarios (stable bumped after user upgraded, alpha
//! pointer rolled back, etc.)
//!
//! Also includes wiremock-based installation tests that verify the GCS
//! internal installer actually downloads and symlinks an older binary
//! when the stable pointer is rolled back.
#![cfg(unix)]
mod common;
use serial_test::serial;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{FakeBinGuard, reset_home, set_test_version, test_home};
use xai_grok_update::UpdateConfig;
use xai_grok_update::auto_update::{
auto_update_target, check_update_status, ensure_latest_on_disk, install_internal_from_base,
};
use xai_grok_update::version::installed_on_disk_version;
fn host_platform() -> String {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
panic!("unsupported test platform");
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
panic!("unsupported test arch");
};
format!("{os}-{arch}")
}
fn make_config(channel: &str) -> UpdateConfig {
UpdateConfig {
proxy_base_url: "http://test.invalid/v1".to_string(),
auth_scope: "test".to_string(),
deployment_key: None,
alpha_test_key: None,
channel: channel.to_string(),
npm_registry: None,
}
}
async fn mount_gcs_with_channels(
stable_version: &str,
alpha_version: Option<&str>,
binary_version: &str,
platform: &str,
) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string(stable_version))
.mount(&server)
.await;
if let Some(alpha_v) = alpha_version {
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string(alpha_v))
.mount(&server)
.await;
}
Mock::given(method("GET"))
.and(path(format!("/grok-{binary_version}-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
server
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario matrix: GCS internal installer — downgrade via install
//
// Each test simulates a user on version X, with the stable/alpha pointer
// now pointing to version Y. The internal installer should install Y
// regardless of whether Y < X (rollback) or Y > X (upgrade).
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn internal_install_stable_rollback_0_2_7_to_0_2_5() {
// User was on 0.2.7, stable pointer rolled back to 0.2.5.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs_with_channels("0.2.5", None, "0.2.5", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.2.5"), &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
let downloaded = home
.join("downloads")
.join(format!("grok-0.2.5-{platform}"));
assert!(downloaded.exists(), "rolled-back binary must be downloaded");
let symlink = home.join("bin").join("grok");
let target = std::fs::read_link(&symlink).unwrap();
assert!(
target.to_string_lossy().contains("0.2.5"),
"symlink must point to rolled-back version: {target:?}"
);
}
#[tokio::test]
#[serial]
async fn internal_install_stable_upgrade_0_2_5_to_0_2_7() {
// Normal upgrade path: user on 0.2.5, pointer at 0.2.7.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs_with_channels("0.2.7", None, "0.2.7", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.2.7"), &cfg, &server.uri())
.await
.unwrap();
let symlink = test_home().join("bin").join("grok");
let target = std::fs::read_link(&symlink).unwrap();
assert!(target.to_string_lossy().contains("0.2.7"));
}
#[tokio::test]
#[serial]
async fn internal_install_rollback_then_upgrade_sequence() {
// Simulates: install 0.2.7 → rollback to 0.2.5 → fix ships as 0.2.8.
// All three installs must succeed sequentially.
let _ = test_home();
reset_home();
let platform = host_platform();
for version in ["0.2.7", "0.2.5", "0.2.8"] {
// Age the previous installs: cleanup deliberately never deletes a
// freshly-written binary (it may be a concurrent racer's just-renamed
// download), so the retention assertions below need the earlier
// installs to look like real leftovers from past releases.
common::backdate_downloads();
let server = mount_gcs_with_channels(version, None, version, &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some(version), &cfg, &server.uri())
.await
.unwrap();
}
let target = std::fs::read_link(test_home().join("bin").join("grok")).unwrap();
assert!(
target.to_string_lossy().contains("0.2.8"),
"final symlink must point to 0.2.8: {target:?}"
);
// Cleanup retains current + highest-semver non-current (N-1 by version, not install order).
let downloads = test_home().join("downloads");
assert!(
downloads.join(format!("grok-0.2.8-{platform}")).exists(),
"current"
);
assert!(
downloads.join(format!("grok-0.2.7-{platform}")).exists(),
"N-1 by semver"
);
assert!(
!downloads.join(format!("grok-0.2.5-{platform}")).exists(),
"lowest cleaned up"
);
}
#[tokio::test]
#[serial]
async fn internal_install_alpha_rollback_pointer_resolves_correctly() {
// Alpha user on 0.2.8-alpha.3. Alpha pointer rolled back to 0.2.8-alpha.1,
// stable pointer is 0.2.7. Alpha channel returns max(alpha, stable) = 0.2.8-alpha.1.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.7"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.8-alpha.1"))
.mount(&server)
.await;
// The resolved version is max(0.2.7, 0.2.8-alpha.1) = 0.2.8-alpha.1.
// Note: semver considers 0.2.8-alpha.1 < 0.2.8 but > 0.2.7.
Mock::given(method("GET"))
.and(path(format!("/grok-0.2.8-alpha.1-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
let cfg = make_config("alpha");
install_internal_from_base(None, &cfg, &server.uri())
.await
.unwrap();
let downloaded = test_home()
.join("downloads")
.join(format!("grok-0.2.8-alpha.1-{platform}"));
assert!(
downloaded.exists(),
"alpha rollback target must be installed"
);
}
#[tokio::test]
#[serial]
async fn internal_install_alpha_user_gets_newer_stable_after_stable_passes_alpha() {
// Alpha user on 0.2.6-alpha.2. Stable ships 0.2.7 (higher than alpha).
// Alpha channel returns max(alpha=0.2.6-alpha.2, stable=0.2.7) = 0.2.7.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.7"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.2.6-alpha.2"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/grok-0.2.7-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
let cfg = make_config("alpha");
install_internal_from_base(None, &cfg, &server.uri())
.await
.unwrap();
assert!(
test_home()
.join("downloads")
.join(format!("grok-0.2.7-{platform}"))
.exists(),
"alpha user should get the newer stable"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Scenario matrix: check_update_status across installer × version direction
//
// Uses check_update_status end-to-end with fake npm/gh binaries.
// The internal (GCS) path can't be end-to-end tested via check_update_status
// (hardcoded URLs), so its update-detection logic is covered by the
// needs_update unit tests and the install tests above.
// ─────────────────────────────────────────────────────────────────────────────
fn setup_npm(current_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(current_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("GROK_INSTALLER", "npm") };
FakeBinGuard::install_npm()
}
fn setup_gh(current_version: &str) -> FakeBinGuard {
let _ = test_home();
reset_home();
set_test_version(current_version);
// SAFETY: serial_test ensures no race; reset_home clears this between tests.
unsafe { std::env::set_var("GROK_INSTALLER", "gh-release") };
FakeBinGuard::install_gh()
}
// ── npm: never downgrades ──
#[tokio::test]
#[serial]
async fn npm_upgrade_reports_update() {
let g = setup_npm("0.2.5");
g.set_stdout("\"0.2.7\"");
let status = check_update_status(&make_config("stable")).await;
assert!(status.update_available);
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
}
#[tokio::test]
#[serial]
async fn npm_same_version_no_update() {
let g = setup_npm("0.2.7");
g.set_stdout("\"0.2.7\"");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
}
#[tokio::test]
#[serial]
async fn npm_rollback_does_not_report_update() {
// Stable pointer rolled back 0.2.7 → 0.2.5. npm user on 0.2.7 must NOT
// see an update — stale registries make this path unsafe.
let g = setup_npm("0.2.7");
g.set_stdout("\"0.2.5\"");
let status = check_update_status(&make_config("stable")).await;
assert!(
!status.update_available,
"npm must never report a downgrade: current={} latest={:?}",
status.current_version, status.latest_version
);
}
#[tokio::test]
#[serial]
async fn npm_drastically_old_registry_does_not_report_update() {
// Corporate registry returns ancient version.
let g = setup_npm("0.2.7");
g.set_stdout("\"0.1.4\"");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
}
// ── gh-release: --check is upgrade-only; rollback handled by auto-install ──
#[tokio::test]
#[serial]
async fn gh_release_upgrade_reports_update() {
let g = setup_gh("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
let status = check_update_status(&make_config("stable")).await;
assert!(status.update_available);
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
}
#[tokio::test]
#[serial]
async fn gh_release_rollback_not_advertised_by_check() {
// `update --check` advertises upgrades only; a rollback still converges via
// the auto-install path (covered by the internal_install_* tests), not here.
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.5\n");
let status = check_update_status(&make_config("stable")).await;
assert!(
!status.update_available,
"gh-release rollback must not be advertised by --check: current={} latest={:?}",
status.current_version, status.latest_version
);
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
}
#[tokio::test]
#[serial]
async fn gh_release_same_version_no_update() {
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
}
// ─────────────────────────────────────────────────────────────────────────────
// auto_update_target: the leader/background auto-install decision
//
// Unlike the upgrade-only `check_update_status` report, this is the
// downgrade-aware convergence decision. It gates on the installer, so
// authoritative installers (gh-release/internal) follow a rolled-back pointer
// while npm never downgrades. `fetch_latest_version` keeps these hermetic.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn auto_update_target_gh_release_rollback_returns_older() {
let g = setup_gh("0.2.26");
g.set_stable_only_stdout("v0.2.22\n");
assert_eq!(
auto_update_target(&make_config("stable")).await,
Some(("gh-release", "0.2.22".to_string())),
"authoritative installer must converge down on a rolled-back pointer"
);
}
#[tokio::test]
#[serial]
async fn auto_update_target_gh_release_upgrade_returns_newer() {
let g = setup_gh("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
assert_eq!(
auto_update_target(&make_config("stable")).await,
Some(("gh-release", "0.2.7".to_string()))
);
}
#[tokio::test]
#[serial]
async fn auto_update_target_gh_release_same_version_returns_none() {
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
assert_eq!(auto_update_target(&make_config("stable")).await, None);
}
#[tokio::test]
#[serial]
async fn auto_update_target_npm_rollback_returns_none() {
// npm registries can serve stale versions — never downgrade npm installs.
let g = setup_npm("0.2.26");
g.set_stdout("\"0.2.22\"");
assert_eq!(
auto_update_target(&make_config("stable")).await,
None,
"npm must never be downgraded even when the registry reports an older version"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Disk-aware convergence: ensure_latest_on_disk + installed_on_disk_version
//
// Concurrent updaters (TUI background download, leader hourly checker,
// explicit `grok update`) must decide staleness from the on-disk install, not
// their own compiled-in version — a binary another process already installed
// is never downloaded a second time, but a stale running process still gets
// the relaunch signal.
// ─────────────────────────────────────────────────────────────────────────────
/// Lay down a managed-install layout in the test GROK_HOME:
/// `bin/grok -> ../downloads/grok-<version>-<platform>` (what
/// `install_internal_from_base` produces).
fn fake_managed_install(version: &str) {
let home = test_home();
let downloads = home.join("downloads");
let bin = home.join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let name = format!("grok-{version}-{}", host_platform());
std::fs::write(downloads.join(&name), b"#!/bin/sh\nexit 0\n").unwrap();
std::os::unix::fs::symlink(
std::path::Path::new("../downloads").join(&name),
bin.join("grok"),
)
.unwrap();
}
#[tokio::test]
#[serial]
async fn installed_on_disk_version_reads_symlink_target() {
let _ = test_home();
reset_home();
assert_eq!(installed_on_disk_version(), None, "no install yet");
fake_managed_install("0.2.7");
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
}
#[tokio::test]
#[serial]
async fn ensure_latest_skips_download_when_disk_current_but_still_relaunches() {
// Running 0.2.5, pointer 0.2.7, disk already at 0.2.7 (another process
// downloaded it): no download, but the stale running process must relaunch.
let g = setup_gh("0.2.5");
g.set_stable_only_stdout("v0.2.7\n");
fake_managed_install("0.2.7");
let outcome = ensure_latest_on_disk(&make_config("stable")).await.unwrap();
assert_eq!(outcome.installed, None, "must not re-download");
assert!(outcome.relaunch_needed, "running 0.2.5 < disk 0.2.7");
assert!(
!g.args_log().iter().any(|l| l.contains("release download")),
"no gh download invocation expected, got: {:?}",
g.args_log()
);
}
#[tokio::test]
#[serial]
async fn ensure_latest_noop_when_running_and_disk_current() {
let g = setup_gh("0.2.7");
g.set_stable_only_stdout("v0.2.7\n");
fake_managed_install("0.2.7");
let outcome = ensure_latest_on_disk(&make_config("stable")).await.unwrap();
assert_eq!(outcome.installed, None);
assert!(!outcome.relaunch_needed);
}
#[tokio::test]
#[serial]
async fn ensure_latest_relaunches_onto_rolled_back_disk() {
// Pointer rolled back to 0.2.22 and the disk already converged; a running
// 0.2.26 leader must relaunch onto the older binary (gh-release is an
// authoritative installer → downgrades allowed).
let g = setup_gh("0.2.26");
g.set_stable_only_stdout("v0.2.22\n");
fake_managed_install("0.2.22");
let outcome = ensure_latest_on_disk(&make_config("stable")).await.unwrap();
assert_eq!(outcome.installed, None, "disk already at pointer");
assert!(outcome.relaunch_needed, "downgrade relaunch expected");
}
// ─────────────────────────────────────────────────────────────────────────────
// Pointer-flip timing scenarios
//
// These test the race between a user opening grok (which caches the version)
// and a pointer flip happening. The 30-min TTL means the user won't see the
// new pointer until the cache expires, but once it does, the correct behavior
// must kick in.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn npm_user_upgraded_then_stable_rolled_back_stays_on_newer() {
// User ran `grok update` and got 0.2.7. Then stable was rolled back to
// 0.2.5. Next check_update_status sees 0.2.5 from npm. npm installer
// must NOT report a downgrade.
let g = setup_npm("0.2.7");
g.set_stdout("\"0.2.5\"");
let status = check_update_status(&make_config("stable")).await;
assert!(!status.update_available);
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
}
#[tokio::test]
#[serial]
async fn gh_release_user_ahead_of_pointer_check_reports_no_update() {
// User manually installed 0.2.26 (ahead of the stable pointer 0.2.22);
// `update --check` must not present the older pointer as a new version.
let g = setup_gh("0.2.26");
g.set_stable_only_stdout("v0.2.22\n");
let status = check_update_status(&make_config("stable")).await;
assert!(
!status.update_available,
"ahead-of-pointer must not be advertised as an update: current={} latest={:?}",
status.current_version, status.latest_version
);
assert_eq!(status.latest_version.as_deref(), Some("0.2.22"));
}
#[tokio::test]
#[serial]
async fn npm_alpha_user_upgrade_after_stable_surpasses_alpha() {
// Alpha user on 0.2.6-alpha.2. Stable ships 0.2.7. npm returns 0.2.7
// for the @latest tag. User should upgrade.
let g = setup_npm("0.2.6-alpha.2");
g.set_stdout("\"0.2.7\"");
let status = check_update_status(&make_config("stable")).await;
// Pre-release current on stable channel forces install.
assert!(
status.update_available,
"alpha user should upgrade to stable when stable surpasses alpha"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Double-rollback scenario
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn internal_install_double_rollback() {
// Ship 0.2.7 → rollback to 0.2.5 → rollback further to 0.2.3.
// The installer must handle multiple sequential downgrades.
let _ = test_home();
reset_home();
let platform = host_platform();
for version in ["0.2.7", "0.2.5", "0.2.3"] {
let server = mount_gcs_with_channels(version, None, version, &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some(version), &cfg, &server.uri())
.await
.unwrap();
let target = std::fs::read_link(test_home().join("bin").join("grok")).unwrap();
assert!(
target.to_string_lossy().contains(version),
"symlink must point to {version} after install: {target:?}"
);
}
}

View file

@ -0,0 +1,665 @@
//! End-to-end tests for `install_internal` — the GCS-bucket installer used
//! when `installer = "internal"` is configured.
//!
//! Wires together a wiremock-mocked GCS bucket + an isolated `GROK_HOME`
//! tempdir so we can verify the full install pipeline:
//! fetch version → download grok binary → chmod → atomic symlink →
//! cleanup_old_downloads → persist installer config.
//!
//! The function reads `grok_home()` (a process-wide `OnceLock`), so all
//! tests in this binary share a single `GROK_HOME` and run serially via
//! `#[serial]`.
#![cfg(unix)]
mod common;
use serial_test::serial;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use common::{reset_home, test_home};
use xai_grok_update::UpdateConfig;
use xai_grok_update::auto_update::{install_internal_from_base, install_internal_from_bases};
fn host_platform() -> String {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
panic!("unsupported test platform");
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
panic!("unsupported test arch");
};
format!("{os}-{arch}")
}
fn make_config(channel: &str) -> UpdateConfig {
UpdateConfig {
proxy_base_url: "http://test.invalid/v1".to_string(),
auth_scope: "test".to_string(),
deployment_key: None,
alpha_test_key: None,
channel: channel.to_string(),
npm_registry: None,
}
}
/// Mount GCS endpoints for a given version. Returns the `MockServer`.
async fn mount_gcs(version: &str, platform: &str) -> MockServer {
let server = MockServer::start().await;
// Channel pointer: stable returns this version.
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string(version))
.mount(&server)
.await;
// Main grok binary download.
Mock::given(method("GET"))
.and(path(format!("/grok-{version}-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
server
}
// ─────────────────────────────────────────────────────────────────────────────
// Happy-path
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn install_internal_pinned_version_writes_binary_and_symlink() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
let downloaded = home
.join("downloads")
.join(format!("grok-0.1.181-{platform}"));
assert!(downloaded.exists(), "binary downloaded: {downloaded:?}");
assert_eq!(std::fs::read(&downloaded).unwrap(), b"#!/bin/sh\nexit 0\n");
let symlink = home.join("bin").join("grok");
assert!(symlink.is_symlink(), "grok symlink created");
let target = std::fs::read_link(&symlink).unwrap();
assert_eq!(
target.file_name().unwrap(),
format!("grok-0.1.181-{platform}").as_str()
);
// `grok` and `agent` move together — see `swap_managed_bin_links`.
let agent_link = home.join("bin").join("agent");
assert!(agent_link.is_symlink(), "agent symlink created");
let agent_target = std::fs::read_link(&agent_link).unwrap();
assert_eq!(agent_target, target, "agent and grok point at same target");
}
/// Regression: pre-existing `agent` symlink from a prior install must be
/// swapped to the new version, not left stale (the original bug).
#[tokio::test]
#[serial]
async fn install_internal_updates_stale_agent_symlink_to_new_version() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
// Prior install: both links point at an older versioned binary.
let home = test_home();
let bin_dir = home.join("bin");
let download_dir = home.join("downloads");
std::fs::create_dir_all(&bin_dir).unwrap();
std::fs::create_dir_all(&download_dir).unwrap();
let old_binary = download_dir.join(format!("grok-0.1.180-{platform}"));
std::fs::write(&old_binary, b"#!/bin/sh\nexit 0\n").unwrap();
let rel_old = std::path::Path::new("..")
.join("downloads")
.join(format!("grok-0.1.180-{platform}"));
std::os::unix::fs::symlink(&rel_old, bin_dir.join("grok")).unwrap();
std::os::unix::fs::symlink(&rel_old, bin_dir.join("agent")).unwrap();
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
let agent_link = bin_dir.join("agent");
let agent_target = std::fs::read_link(&agent_link).unwrap();
assert_eq!(
agent_target.file_name().unwrap(),
format!("grok-0.1.181-{platform}").as_str(),
"agent symlink must swap to the new version, not stay on old"
);
}
/// Rollback regression: if `agent` swap fails after `grok` succeeded,
/// `grok` must roll back to its prior target (all-or-nothing).
#[tokio::test]
#[serial]
async fn install_internal_rolls_back_grok_when_agent_swap_fails() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
let home = test_home();
let bin_dir = home.join("bin");
let download_dir = home.join("downloads");
std::fs::create_dir_all(&bin_dir).unwrap();
std::fs::create_dir_all(&download_dir).unwrap();
let old_binary = download_dir.join(format!("grok-0.1.180-{platform}"));
std::fs::write(&old_binary, b"#!/bin/sh\nexit 0\n").unwrap();
let rel_old = std::path::Path::new("..")
.join("downloads")
.join(format!("grok-0.1.180-{platform}"));
std::os::unix::fs::symlink(&rel_old, bin_dir.join("grok")).unwrap();
// Sabotage the agent swap: non-empty directory → rename fails with EISDIR.
let agent_dir = bin_dir.join("agent");
std::fs::create_dir(&agent_dir).unwrap();
std::fs::write(agent_dir.join("blocker"), b"x").unwrap();
let err = install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.expect_err("agent swap must fail when target is a non-empty dir");
drop(err);
// grok must be rolled back to the prior version.
let grok_target = std::fs::read_link(bin_dir.join("grok")).unwrap();
assert_eq!(
grok_target.file_name().unwrap(),
format!("grok-0.1.180-{platform}").as_str(),
"grok must be rolled back when agent swap fails"
);
}
/// Absent-prior rollback regression: fresh install (no prior `grok` /
/// `agent`), sabotaged `agent` swap must *remove* the just-created `grok`
/// link so we don't leave it on the new binary while `agent` is absent.
#[tokio::test]
#[serial]
async fn install_internal_rollback_removes_absent_prior_grok_link() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
let home = test_home();
let bin_dir = home.join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
// No prior `grok`. Sabotage `agent` swap: non-empty directory → EISDIR.
let agent_dir = bin_dir.join("agent");
std::fs::create_dir(&agent_dir).unwrap();
std::fs::write(agent_dir.join("blocker"), b"x").unwrap();
assert!(
!bin_dir.join("grok").exists() && !bin_dir.join("grok").is_symlink(),
"precondition: grok must not exist before install",
);
let err = install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.expect_err("agent swap must fail when target is a non-empty dir");
drop(err);
let grok_path = bin_dir.join("grok");
assert!(
!grok_path.is_symlink() && !grok_path.exists(),
"grok must be removed on rollback when there was no prior link",
);
}
#[tokio::test]
#[serial]
async fn install_internal_chmods_binary_executable() {
use std::os::unix::fs::PermissionsExt;
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
let binary = home
.join("downloads")
.join(format!("grok-0.1.181-{platform}"));
let mode = std::fs::metadata(&binary).unwrap().permissions().mode();
assert!(mode & 0o111 != 0, "binary must be executable, got {mode:o}");
}
#[tokio::test]
#[serial]
async fn install_internal_cleans_up_stale_pager_symlink() {
// Old installations shipped a separate grok-pager binary. Verify the
// update removes the stale symlink from ~/.grok/bin/.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
let home = test_home();
let bin_dir = home.join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pager_link = bin_dir.join("grok-pager");
std::os::unix::fs::symlink("/tmp/fake-old-pager", &pager_link).unwrap();
assert!(
pager_link.is_symlink(),
"precondition: stale symlink exists"
);
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
assert!(
!pager_link.exists() && !pager_link.is_symlink(),
"stale grok-pager symlink should be removed"
);
}
#[tokio::test]
#[serial]
async fn install_internal_persists_installer_config() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
let cfg_body = std::fs::read_to_string(home.join("config.toml")).unwrap();
assert!(
cfg_body.contains("installer = \"internal\""),
"config should set installer = internal: {cfg_body}"
);
}
#[tokio::test]
#[serial]
async fn install_internal_resolves_version_via_channel_pointer_when_no_target() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
// No pinned version → must fetch /stable pointer to resolve.
install_internal_from_base(None, &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
assert!(
home.join("downloads")
.join(format!("grok-0.1.181-{platform}"))
.exists(),
"binary at version from /stable pointer"
);
}
#[tokio::test]
#[serial]
async fn install_internal_alpha_channel_resolves_max_of_alpha_and_stable() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = MockServer::start().await;
// Stable points to 0.1.181, alpha points to 0.1.180-alpha.5 — stable wins.
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.180-alpha.5"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/grok-0.1.181-{platform}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec()))
.mount(&server)
.await;
let cfg = make_config("alpha");
install_internal_from_base(None, &cfg, &server.uri())
.await
.unwrap();
let home = test_home();
assert!(
home.join("downloads")
.join(format!("grok-0.1.181-{platform}"))
.exists()
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Failure paths
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn install_internal_fails_on_grok_binary_404() {
let _ = test_home();
reset_home();
let platform = host_platform();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
// Main binary returns 404 — must propagate as error.
Mock::given(method("GET"))
.and(path(format!("/grok-0.1.181-{platform}")))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let cfg = make_config("stable");
let err = install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Download failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn install_internal_rejects_invalid_pinned_version() {
let _ = test_home();
reset_home();
let server = MockServer::start().await;
let cfg = make_config("stable");
let err = install_internal_from_base(Some("not-a-version"), &cfg, &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("invalid version format"), "msg: {msg}");
}
// ─────────────────────────────────────────────────────────────────────────────
// Cleanup integration: install v1, then v2, verify N-1 retention.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn install_internal_cleans_up_old_versions_keeping_n_minus_one() {
let _ = test_home();
reset_home();
let platform = host_platform();
// Install v1, v2, v3 sequentially. After v3, only v3 (current) and v2
// (N-1) should remain on disk; v1 should be deleted.
for v in ["0.1.179", "0.1.180", "0.1.181"] {
// Age earlier installs: cleanup never deletes freshly-written
// binaries (concurrent-racer protection), so retention assertions
// need the previous installs to look like old leftovers.
common::backdate_downloads();
let server = mount_gcs(v, &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some(v), &cfg, &server.uri())
.await
.unwrap();
}
let home = test_home();
let downloads = home.join("downloads");
assert!(
downloads.join(format!("grok-0.1.181-{platform}")).exists(),
"current"
);
assert!(
downloads.join(format!("grok-0.1.180-{platform}")).exists(),
"N-1 retained"
);
assert!(
!downloads.join(format!("grok-0.1.179-{platform}")).exists(),
"oldest deleted"
);
// Symlink updated to latest.
let target = std::fs::read_link(home.join("bin").join("grok")).unwrap();
assert!(
target
.file_name()
.unwrap()
.to_string_lossy()
.contains("0.1.181"),
"symlink points to latest: {target:?}"
);
}
#[tokio::test]
#[serial]
async fn install_internal_idempotent_for_same_version() {
// Re-installing the same version should not error and should leave the
// binary at the same path with the same content.
let _ = test_home();
reset_home();
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
let first = std::fs::read(
test_home()
.join("downloads")
.join(format!("grok-0.1.181-{platform}")),
)
.unwrap();
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
let second = std::fs::read(
test_home()
.join("downloads")
.join(format!("grok-0.1.181-{platform}")),
)
.unwrap();
assert_eq!(first, second);
let target = std::fs::read_link(test_home().join("bin").join("grok")).unwrap();
assert!(target.to_string_lossy().contains("0.1.181"));
}
#[tokio::test]
#[serial]
async fn install_internal_creates_grok_home_subdirs_if_missing() {
let _ = test_home();
reset_home();
// Explicitly delete bin/ and downloads/ so install must create them.
let _ = std::fs::remove_dir_all(test_home().join("bin"));
let _ = std::fs::remove_dir_all(test_home().join("downloads"));
let platform = host_platform();
let server = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_base(Some("0.1.181"), &cfg, &server.uri())
.await
.unwrap();
assert!(test_home().join("bin").is_dir());
assert!(test_home().join("downloads").is_dir());
}
// ─────────────────────────────────────────────────────────────────────────────
// Multi-base URL fallback: install_internal_from_bases tries each base in
// preference order, falling through to the next on failure.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn install_internal_from_bases_falls_back_to_secondary_when_primary_fails() {
// Primary server returns 500 on every endpoint (CDN outage simulation);
// fallback server serves the install successfully. Result: install
// succeeds via fallback.
let _ = test_home();
reset_home();
let platform = host_platform();
let primary = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&primary)
.await;
let fallback = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_bases(
Some("0.1.181"),
&cfg,
&[primary.uri().as_str(), fallback.uri().as_str()],
)
.await
.unwrap();
assert!(
test_home()
.join("downloads")
.join(format!("grok-0.1.181-{platform}"))
.exists(),
"fallback should produce a downloaded binary"
);
}
#[tokio::test]
#[serial]
async fn install_internal_from_bases_uses_primary_when_it_works() {
// Both bases work; the install must use the primary (first one) and
// never touch the fallback. Verified by tearing down the fallback
// server immediately after configuration — if the install reached for
// it, the request would fail.
let _ = test_home();
reset_home();
let platform = host_platform();
let primary = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
install_internal_from_bases(
Some("0.1.181"),
&cfg,
&[primary.uri().as_str(), "http://127.0.0.1:1"],
)
.await
.unwrap();
assert!(
test_home()
.join("downloads")
.join(format!("grok-0.1.181-{platform}"))
.exists()
);
}
#[tokio::test]
#[serial]
async fn install_internal_from_bases_propagates_last_error_when_all_fail() {
// Every base returns 500 — the install must fail, surfacing the final
// base's error rather than silently succeeding.
let _ = test_home();
reset_home();
let bad1 = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&bad1)
.await;
let bad2 = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&bad2)
.await;
let cfg = make_config("stable");
let err = install_internal_from_bases(
Some("0.1.181"),
&cfg,
&[bad1.uri().as_str(), bad2.uri().as_str()],
)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Download failed"), "msg: {msg}");
}
/// Regression: a local failure after a successful download (sabotaged
/// `agent` swap) must fail the install immediately — the fallback base must
/// never be contacted for a pointless re-download.
#[tokio::test]
#[serial]
async fn install_internal_from_bases_does_not_redownload_on_local_swap_failure() {
let _ = test_home();
reset_home();
let platform = host_platform();
let primary = mount_gcs("0.1.181", &platform).await;
let fallback = mount_gcs("0.1.181", &platform).await;
let cfg = make_config("stable");
let home = test_home();
let bin_dir = home.join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
// Sabotage activation: agent as a non-empty dir fails the swap's
// rollback capture (read_link on a directory) before any rename.
let agent_dir = bin_dir.join("agent");
std::fs::create_dir(&agent_dir).unwrap();
std::fs::write(agent_dir.join("blocker"), b"x").unwrap();
install_internal_from_bases(
Some("0.1.181"),
&cfg,
&[primary.uri().as_str(), fallback.uri().as_str()],
)
.await
.expect_err("swap failure must fail the install");
let fallback_requests = fallback
.received_requests()
.await
.expect("request recording is enabled on MockServer::start()");
assert!(
fallback_requests.is_empty(),
"local swap failure must not fall through to the next base: {} request(s)",
fallback_requests.len()
);
}

View file

@ -0,0 +1,404 @@
//! Blitz harness for the bash installer (`install.sh`), the second client that
//! can brick a machine. Runs the REAL shipped `install.sh` against a fake
//! `curl` that can serve the good artifact, truncate it, or serve a right-length
//! garbage body, and asserts the same invariant as the Rust blitz:
//!
//! > After any install attempt, `$BIN_DIR/grok` resolves to a binary that runs,
//! > OR is still the previous-good binary — never a partial/garbage binary.
//!
//! Also covers shell-rc rewrite: stowed/symlinked `~/.bashrc` etc. must survive
//! reinstall without being replaced by a plain file.
//!
//! The installer lives in the sibling `xai-grok-pager` crate; it is resolved by
//! relative path. If it cannot be found (e.g. a sandbox that does not vendor it)
//! the test skips rather than fail — under the repo's `cargo nextest` workflow
//! the path resolves and the installer is exercised end to end.
#![cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
fn script_path(name: &str) -> Option<PathBuf> {
dunce::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../xai-grok-pager/scripts/{name}")),
)
.ok()
.filter(|p| p.exists())
}
fn install_sh_path() -> Option<PathBuf> {
script_path("install.sh")
}
fn host_platform() -> String {
let os = if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
format!("{os}-{arch}")
}
const GOOD_SCRIPT: &str = "#!/bin/sh\nexit 0\n";
const INSTALLER_BLOCK_START: &str = "# >>> grok installer >>>";
/// Write a fake `curl` that intercepts every download `install.sh` performs.
/// `$FAKE_MODE` (full|truncate|garbage) selects the corruption.
fn write_fake_curl(dir: &Path) {
let body = format!(
r#"#!/bin/bash
mode="${{FAKE_MODE:-full}}"
fullsize={fullsize}
head=0; out=""; want_code=0; url=""
while [ $# -gt 0 ]; do
case "$1" in
--head) head=1 ;;
-o) shift; out="$1" ;;
-w) shift; [ "$1" = '%{{http_code}}' ] && want_code=1 ;;
-*) : ;;
*) url="$1" ;;
esac
shift
done
if [ "$head" = 1 ]; then
if [ "$want_code" = 1 ]; then printf '200'; else printf 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\n' "$fullsize"; fi
exit 0
fi
if [ -n "$out" ]; then
case "$mode" in
full) printf '%s' '{good}' > "$out" ;;
truncate) printf '\0\0\0\0' > "$out" ;;
garbage) head -c "$fullsize" /dev/zero | tr '\0' 'X' > "$out" ;;
esac
exit 0
fi
printf '0.1.181'
exit 0
"#,
fullsize = GOOD_SCRIPT.len(),
good = GOOD_SCRIPT,
);
let path = dir.join("curl");
std::fs::write(&path, body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
/// Seed a valid previous-good binary + symlink in the isolated home.
fn seed_previous_good(home: &Path, platform: &str) -> PathBuf {
let downloads = home.join(".grok").join("downloads");
let bin = home.join(".grok").join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
let prev = downloads.join(format!("grok-{platform}"));
std::fs::write(&prev, GOOD_SCRIPT).unwrap();
std::fs::set_permissions(&prev, std::fs::Permissions::from_mode(0o755)).unwrap();
let link = bin.join("grok");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(format!("../downloads/grok-{platform}"), &link).unwrap();
dunce::canonicalize(&prev).unwrap()
}
/// Re-resolve `$BIN_DIR/grok` from disk and re-run it: the active grok must
/// always execute, and never be a `.tmp`/partial file.
fn assert_active_grok_runs(home: &Path) {
let link = home.join(".grok").join("bin").join("grok");
assert!(link.is_symlink(), "grok must remain a symlink");
let resolved =
dunce::canonicalize(&link).unwrap_or_else(|e| panic!("grok symlink dangles: {e}"));
let name = resolved.file_name().unwrap().to_string_lossy().to_string();
assert!(
!name.contains(".tmp"),
"active grok must not be a temp file: {name}"
);
let ok = Command::new(&resolved)
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "active grok must run: {}", resolved.display());
}
fn run_installer(install_sh: &Path, home: &Path, fakebin: &Path, mode: &str, shell: &str) -> bool {
let path_env = format!("{}:/usr/bin:/bin", fakebin.display());
let status = Command::new("/bin/bash")
.arg(install_sh)
.arg("0.1.181")
.env_clear()
.env("HOME", home)
.env("PATH", path_env)
.env("SHELL", shell)
.env("GROK_BIN_DIR", home.join(".grok").join("bin"))
.env("GROK_CHANNEL", "stable")
.env("FAKE_MODE", mode)
.status()
.expect("spawn bash install.sh");
status.success()
}
fn installer_block_count(body: &str) -> usize {
body.matches(INSTALLER_BLOCK_START).count()
}
fn assert_single_installer_block(path: &Path, preserved: Option<&str>) {
let body = std::fs::read_to_string(path).unwrap_or_else(|e| {
panic!("read {}: {e}", path.display());
});
let n = installer_block_count(&body);
assert_eq!(
n,
1,
"{} must contain exactly one grok installer block, got {n}:\n{body}",
path.display()
);
if let Some(marker) = preserved {
assert!(
body.contains(marker),
"{} must keep pre-existing content ({marker:?}):\n{body}",
path.display()
);
}
}
#[derive(Clone, Copy)]
enum RcLayout {
Missing,
Plain,
StowAbsolute,
StowRelative,
/// `$root/user/.bashrc` → `../packages/bash/bashrc` (physical relative arm).
StowRelativeDotDot,
}
struct ShellRcCase {
name: &'static str,
script: &'static str,
shell: &'static str,
rc_name: &'static str,
stow_name: &'static str,
layout: RcLayout,
reinstall: bool,
}
/// Returns `(installer_home, rc_path, stow_target, expected_link_value)`.
fn setup_rc(
root: &Path,
case: &ShellRcCase,
) -> (PathBuf, PathBuf, Option<PathBuf>, Option<PathBuf>) {
let marker = "# user shell rc\n";
match case.layout {
RcLayout::Missing => {
let home = root.to_path_buf();
(home.clone(), home.join(case.rc_name), None, None)
}
RcLayout::Plain => {
let home = root.to_path_buf();
let rc_link = home.join(case.rc_name);
std::fs::write(&rc_link, marker).unwrap();
(home, rc_link, None, None)
}
RcLayout::StowAbsolute | RcLayout::StowRelative => {
let home = root.to_path_buf();
let stow_dir = home.join("dotfiles");
std::fs::create_dir_all(&stow_dir).unwrap();
let target = stow_dir.join(case.stow_name);
std::fs::write(&target, marker).unwrap();
let link_value = if matches!(case.layout, RcLayout::StowAbsolute) {
target.clone()
} else {
PathBuf::from(format!("dotfiles/{}", case.stow_name))
};
let rc_link = home.join(case.rc_name);
std::os::unix::fs::symlink(&link_value, &rc_link).unwrap();
(home, rc_link, Some(target), Some(link_value))
}
RcLayout::StowRelativeDotDot => {
// $HOME = root/user; package is a sibling of user (relative needs `..`).
let home = root.join("user");
std::fs::create_dir_all(&home).unwrap();
let target = root.join("packages/bash/bashrc");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, marker).unwrap();
let link_value = PathBuf::from("../packages/bash/bashrc");
let rc_link = home.join(case.rc_name);
std::os::unix::fs::symlink(&link_value, &rc_link).unwrap();
(home, rc_link, Some(target), Some(link_value))
}
}
}
fn run_shell_rc_case(case: &ShellRcCase) {
let Some(script) = script_path(case.script) else {
eprintln!(
"skipping {}: {} not found relative to crate",
case.name, case.script
);
return;
};
let platform = host_platform();
let fakedir = tempfile::tempdir().unwrap();
write_fake_curl(fakedir.path());
let root = tempfile::tempdir().unwrap();
let (home_path, rc_path, stow_target, expected_link) = setup_rc(root.path(), case);
seed_previous_good(&home_path, &platform);
assert!(
run_installer(&script, &home_path, fakedir.path(), "full", case.shell),
"{}: first install should succeed",
case.name
);
if case.reinstall {
assert!(
run_installer(&script, &home_path, fakedir.path(), "full", case.shell),
"{}: reinstall should succeed",
case.name
);
}
match case.layout {
RcLayout::Missing | RcLayout::Plain => {
assert!(
rc_path.is_file() && !rc_path.is_symlink(),
"{}: {} must be a regular file",
case.name,
case.rc_name
);
let preserved = match case.layout {
RcLayout::Plain => Some("# user shell rc"),
_ => None,
};
assert_single_installer_block(&rc_path, preserved);
}
RcLayout::StowAbsolute | RcLayout::StowRelative | RcLayout::StowRelativeDotDot => {
assert!(
rc_path.is_symlink(),
"{}: {} must remain a symlink after install",
case.name,
case.rc_name
);
let link = std::fs::read_link(&rc_path).unwrap();
assert_eq!(
link,
*expected_link.as_ref().unwrap(),
"{}: symlink target must be unchanged",
case.name
);
let target = stow_target.as_ref().unwrap();
assert_single_installer_block(target, Some("# user shell rc"));
}
}
assert_active_grok_runs(&home_path);
}
#[test]
fn install_sh_blitz_keeps_grok_runnable_under_corruption() {
let Some(install_sh) = install_sh_path() else {
eprintln!("skipping: install.sh not found relative to crate; run under cargo");
return;
};
let platform = host_platform();
let fakedir = tempfile::tempdir().unwrap();
write_fake_curl(fakedir.path());
// Each entry: (mode, should the installer succeed?). Loop a few rounds so a
// re-install over an existing good install is also exercised.
let cases = [
("full", true),
("truncate", false),
("garbage", false),
("full", true),
("truncate", false),
("garbage", false),
("full", true),
];
for (mode, expect_ok) in cases {
let home = tempfile::tempdir().unwrap();
seed_previous_good(home.path(), &platform);
let ok = run_installer(&install_sh, home.path(), fakedir.path(), mode, "/bin/bash");
assert_eq!(
ok, expect_ok,
"install.sh mode={mode} exit success mismatch"
);
// The invariant holds regardless of which path was taken: the active
// grok always runs (new good binary on success, previous-good on
// rejection).
assert_active_grok_runs(home.path());
}
}
/// Shell-rc rewrite matrix: stow absolute/relative/`..`, plain, first-create, enterprise.
#[test]
fn install_sh_shell_rc_rewrite_matrix() {
let cases = [
ShellRcCase {
name: "stow absolute bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowAbsolute,
reinstall: true,
},
ShellRcCase {
name: "stow relative bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowRelative,
reinstall: true,
},
ShellRcCase {
name: "stow relative ../ bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowRelativeDotDot,
reinstall: true,
},
ShellRcCase {
name: "plain bashrc reinstall",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::Plain,
reinstall: true,
},
ShellRcCase {
name: "missing bashrc first install",
script: "install.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::Missing,
reinstall: false,
},
ShellRcCase {
name: "enterprise stow absolute bashrc reinstall",
script: "install-enterprise.sh",
shell: "/bin/bash",
rc_name: ".bashrc",
stow_name: "bashrc",
layout: RcLayout::StowAbsolute,
reinstall: true,
},
];
for case in &cases {
run_shell_rc_case(case);
}
}

View file

@ -0,0 +1,378 @@
//! I/O integration tests for the auto-update crate.
//!
//! These tests touch global process state — `GROK_HOME` (a `OnceLock` in
//! `xai-grok-config`), `GROK_TEST_VERSION`, and `NPM_TOKEN` — so they
//! must run serially. Once `GROK_HOME` is initialized for a process, it can't
//! be changed; we set it from a single shared `OnceLock` and reset the
//! contents of the directory between tests.
//!
//! The patterns here mirror the GROK_HOME isolation used in other
//! integration tests.
mod common;
use std::path::PathBuf;
use std::time::Duration;
use serial_test::serial;
use common::{reset_home, test_home};
use xai_grok_update::write_version_cache;
/// Path to the version cache file inside the test home.
fn version_cache_path() -> PathBuf {
test_home().join("version.json")
}
/// Local alias kept so existing test bodies don't need to change.
fn reset() {
reset_home();
}
// ─────────────────────────────────────────────────────────────────────────────
// write_version_cache
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn write_version_cache_creates_file_at_grok_home() {
let _ = test_home();
reset();
write_version_cache("0.1.180", None).await;
let path = version_cache_path();
assert!(
path.exists(),
"version.json should exist at {}",
path.display()
);
let body = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["version"], "0.1.180");
assert!(
parsed["checked_at"].as_str().is_some(),
"checked_at should be a string: {body}"
);
}
#[tokio::test]
#[serial]
async fn write_version_cache_overwrites_existing_atomically() {
let _ = test_home();
reset();
write_version_cache("0.1.180", None).await;
write_version_cache("0.1.181", None).await;
let body = std::fs::read_to_string(version_cache_path()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
parsed["version"], "0.1.181",
"second write must overwrite first"
);
}
#[tokio::test]
#[serial]
async fn write_version_cache_does_not_leave_tmp_file_behind() {
let _ = test_home();
reset();
write_version_cache("0.1.180", None).await;
let tmp = test_home().join("version.json.tmp");
assert!(
!tmp.exists(),
"atomic rename must clean up tmp file: {}",
tmp.display()
);
}
#[tokio::test]
#[serial]
async fn write_version_cache_writes_valid_json_object() {
let _ = test_home();
reset();
write_version_cache("0.1.182-alpha.3", None).await;
let body = std::fs::read_to_string(version_cache_path()).unwrap();
// Must parse as JSON.
let parsed: serde_json::Value =
serde_json::from_str(&body).unwrap_or_else(|e| panic!("not valid JSON: {e}\nbody: {body}"));
let obj = parsed.as_object().unwrap();
assert!(obj.contains_key("version"));
assert!(obj.contains_key("checked_at"));
assert_eq!(parsed["version"], "0.1.182-alpha.3");
}
#[tokio::test]
#[serial]
async fn write_version_cache_records_recent_timestamp() {
let _ = test_home();
reset();
let before = time::OffsetDateTime::now_utc();
write_version_cache("0.1.180", None).await;
let after = time::OffsetDateTime::now_utc();
let body = std::fs::read_to_string(version_cache_path()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let ts_str = parsed["checked_at"].as_str().unwrap();
let ts = time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
.unwrap();
assert!(
ts >= before - Duration::from_secs(5) && ts <= after + Duration::from_secs(5),
"timestamp should be within the test window: ts={ts}, before={before}, after={after}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// is_version_cache_fresh — exercised via the public re-export. Each scenario
// writes the file directly so we can control the timestamp.
// ─────────────────────────────────────────────────────────────────────────────
/// Write a `GrokVersion`-shaped JSON file with an arbitrary timestamp.
fn write_cache_with_timestamp(version: &str, ts: time::OffsetDateTime) {
let ts_str = ts
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let body = serde_json::json!({
"version": version,
"checked_at": ts_str,
});
std::fs::write(
version_cache_path(),
serde_json::to_vec_pretty(&body).unwrap(),
)
.unwrap();
}
/// Re-implement the cache-freshness check using the public API. We can't
/// import the private `is_version_cache_fresh` directly, but we can verify
/// its on-disk contract: file shape + freshness logic via the public
/// `GrokVersion` JSON layout.
async fn cache_is_fresh() -> bool {
// Mirror the implementation: look at version.json under GROK_HOME,
// parse, and check the TTL.
let path = version_cache_path();
let Ok(body) = tokio::fs::read_to_string(&path).await else {
return false;
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body) else {
return false;
};
let Some(ts_str) = parsed["checked_at"].as_str() else {
return false;
};
let Ok(ts) =
time::OffsetDateTime::parse(ts_str, &time::format_description::well_known::Rfc3339)
else {
return false;
};
let now = time::OffsetDateTime::now_utc();
now - ts < Duration::from_secs(60 * 30)
}
#[tokio::test]
#[serial]
async fn version_cache_is_fresh_after_write() {
let _ = test_home();
reset();
write_version_cache("0.1.180", None).await;
assert!(
cache_is_fresh().await,
"cache should be fresh right after write"
);
}
#[tokio::test]
#[serial]
async fn version_cache_is_stale_when_old() {
let _ = test_home();
reset();
let two_hours_ago = time::OffsetDateTime::now_utc() - Duration::from_secs(2 * 60 * 60);
write_cache_with_timestamp("0.1.180", two_hours_ago);
assert!(
!cache_is_fresh().await,
"2-hour-old cache should be stale (TTL is 30 min)"
);
}
#[tokio::test]
#[serial]
async fn version_cache_missing_file_is_not_fresh() {
let _ = test_home();
reset();
assert!(
!cache_is_fresh().await,
"missing file should not be considered fresh"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// version.json wire format — the on-disk file is read by every grok launch.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn version_cache_file_is_round_trippable() {
let _ = test_home();
reset();
write_version_cache("0.1.182-alpha.3", Some("0.1.180")).await;
let body = std::fs::read_to_string(version_cache_path()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
// The shape must match what a manually-written file would look like.
let manual = serde_json::json!({
"version": parsed["version"].as_str().unwrap(),
"stable_version": parsed["stable_version"].as_str().unwrap(),
"checked_at": parsed["checked_at"].as_str().unwrap(),
});
assert_eq!(parsed, manual);
}
#[tokio::test]
#[serial]
async fn write_version_cache_handles_long_prerelease_string() {
let _ = test_home();
reset();
// Realistic alpha string with multi-segment pre-release id.
write_version_cache("0.1.190-alpha.42.beta.7", None).await;
let body = std::fs::read_to_string(version_cache_path()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["version"], "0.1.190-alpha.42.beta.7");
}
#[tokio::test]
#[serial]
async fn write_version_cache_idempotent_for_same_version() {
let _ = test_home();
reset();
write_version_cache("0.1.180", None).await;
let body1 = std::fs::read_to_string(version_cache_path()).unwrap();
// Force a small wait so the timestamp could differ.
tokio::time::sleep(Duration::from_millis(50)).await;
write_version_cache("0.1.180", None).await;
let body2 = std::fs::read_to_string(version_cache_path()).unwrap();
// Both writes should leave the same version field, but timestamps may
// differ — verify the version is preserved.
let v1: serde_json::Value = serde_json::from_str(&body1).unwrap();
let v2: serde_json::Value = serde_json::from_str(&body2).unwrap();
assert_eq!(v1["version"], v2["version"]);
assert_eq!(v1["version"], "0.1.180");
}
// ─────────────────────────────────────────────────────────────────────────────
// get_installed_grok_version env override
//
// The function honors `GROK_TEST_VERSION` for testing. We exercise it
// via the public re-export only — no private items leaked.
// ─────────────────────────────────────────────────────────────────────────────
//
// Note: `get_installed_grok_version` is not re-exported from `lib.rs`, but
// it's `pub` from `version` module and accessible via `version::`.
#[tokio::test]
#[serial]
async fn get_installed_version_uses_env_var_override() {
let _ = test_home();
reset();
unsafe {
std::env::set_var("GROK_TEST_VERSION", "9.9.9");
}
let v = xai_grok_update::version::get_installed_grok_version();
assert_eq!(v, "9.9.9");
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
}
}
#[tokio::test]
#[serial]
async fn get_installed_version_falls_back_to_cargo_pkg_version_when_env_unset() {
let _ = test_home();
reset();
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
}
let v = xai_grok_update::version::get_installed_grok_version();
// The compile-time CARGO_PKG_VERSION must be a parseable semver string.
let _: semver::Version = v
.parse()
.unwrap_or_else(|e| panic!("CARGO_PKG_VERSION is not a valid semver: '{v}': {e}"));
}
#[tokio::test]
#[serial]
async fn get_installed_version_with_env_var_takes_precedence() {
let _ = test_home();
reset();
let real = {
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
}
xai_grok_update::version::get_installed_grok_version()
};
unsafe {
std::env::set_var("GROK_TEST_VERSION", "0.0.0-test");
}
let overridden = xai_grok_update::version::get_installed_grok_version();
assert_ne!(real, overridden);
assert_eq!(overridden, "0.0.0-test");
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
}
}
#[tokio::test]
#[serial]
async fn get_installed_version_handles_alpha_prerelease_in_env() {
let _ = test_home();
reset();
unsafe {
std::env::set_var("GROK_TEST_VERSION", "0.1.200-alpha.5");
}
let v = xai_grok_update::version::get_installed_grok_version();
assert_eq!(v, "0.1.200-alpha.5");
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
}
}
#[tokio::test]
#[serial]
async fn get_installed_version_does_not_validate_env_var_format() {
// The function returns whatever's in the env var verbatim, even garbage.
// Document this so callers know they need to validate downstream.
let _ = test_home();
reset();
unsafe {
std::env::set_var("GROK_TEST_VERSION", "not-a-version");
}
let v = xai_grok_update::version::get_installed_grok_version();
assert_eq!(v, "not-a-version");
unsafe {
std::env::remove_var("GROK_TEST_VERSION");
}
}

View file

@ -0,0 +1,681 @@
//! Network-level integration tests using `wiremock`.
//!
//! Covers the HTTP-fetching paths in `version.rs` that take a URL parameter
//! directly. We don't need `serial_test` here because each `MockServer` binds
//! to its own random port and tests don't touch global state.
//!
//! NOTE on retry timing: the prod retry backoff is 1s + 2s + 4s = 7s
//! wall-clock. We can't use `tokio::time::pause()` because reqwest's I/O
//! reactor uses the same tokio timer and stalls when time is paused. So
//! retry-exhaustion tests are intrinsically slow (~7s each); we keep the
//! count small and let them run in parallel (wiremock binds random ports
//! so there's no contention).
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use xai_grok_update::auto_update::{download_silent, download_with_progress};
use xai_grok_update::version::fetch_gcs_version_from_base;
// ─────────────────────────────────────────────────────────────────────────────
// Happy-path tests (fast, no retries triggered).
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn gcs_pointer_returns_version_on_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181\n"))
.expect(1)
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_trims_whitespace() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string(" 0.1.181 \r\n "))
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_rejects_invalid_semver_no_retry() {
// Invalid semver in the channel pointer is a hard error — must NOT
// retry (it's a server data bug, not a transient failure).
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("not-a-version"))
.expect(1) // exactly one request — no retry on parse failure
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("invalid semver"), "msg: {msg}");
}
#[tokio::test]
async fn gcs_pointer_alpha_channel_returns_max_of_alpha_and_stable_when_stable_higher() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.180-alpha.5"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.expect(1)
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_alpha_returns_alpha_when_higher() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.182-alpha.1"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.182-alpha.1");
}
#[tokio::test]
async fn gcs_pointer_stable_channel_does_not_fetch_alpha() {
// Stable-channel users should not pay the cost of fetching the alpha
// pointer. The mock for /alpha should never be hit.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_with_long_pre_release_version() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.190-alpha.42"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.189"))
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.190-alpha.42");
}
#[tokio::test]
async fn gcs_pointer_preserves_path_in_base_url() {
// base_url may include a path component (in practice the prod GCS URL
// does: `/cli`). The function appends `/{channel}`.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cli/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
let base = format!("{}/cli", server.uri());
let v = fetch_gcs_version_from_base("stable", &base).await.unwrap();
assert_eq!(v, "0.1.181");
}
// ─────────────────────────────────────────────────────────────────────────────
// Retry behavior — these tests intentionally exercise the 1s+2s+4s backoff,
// so each takes ~7 seconds. They run in parallel.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn gcs_pointer_retries_on_5xx_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(503).set_body_string("backend down"))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_gives_up_after_max_retries() {
let server = MockServer::start().await;
// 4 attempts total: initial + 3 retries.
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(500))
.expect(4)
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("HTTP 500"), "msg: {msg}");
}
#[tokio::test]
async fn gcs_pointer_retries_on_empty_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
.mount(&server)
.await;
let v = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
async fn gcs_pointer_alpha_propagates_error_from_either_pointer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/alpha"))
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.182-alpha.1"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(500))
.expect(4)
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("alpha", &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("HTTP 500"), "msg: {msg}");
}
#[tokio::test]
async fn gcs_pointer_4xx_is_retryable_until_exhausted() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(404))
.expect(4)
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("HTTP 404"), "msg: {msg}");
}
#[tokio::test]
async fn gcs_pointer_includes_url_in_error_message() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/stable"))
.respond_with(ResponseTemplate::new(500))
.expect(4)
.mount(&server)
.await;
let err = fetch_gcs_version_from_base("stable", &server.uri())
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("/stable"), "url should be in error: {msg}");
}
#[tokio::test]
async fn gcs_pointer_connection_refused_is_retried_and_returns_error() {
// Bind a TcpListener to claim a port, then drop it so connections refuse.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let url = format!("http://127.0.0.1:{port}");
let err = fetch_gcs_version_from_base("stable", &url)
.await
.unwrap_err();
let msg = format!("{err:#}").to_lowercase();
assert!(
msg.contains("fetch failed")
|| msg.contains("connection")
|| msg.contains("error sending request")
|| msg.contains("refused"),
"expected network error message, got: {msg}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// download_silent — same body shape as download_with_progress but no
// progress bar to capture.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn download_silent_writes_body_to_dest() {
let server = MockServer::start().await;
let body = b"binary contents \x00\x01\x02".to_vec();
Mock::given(method("GET"))
.and(path("/grok-0.1.181-macos-aarch64"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
let url = format!("{}/grok-0.1.181-macos-aarch64", server.uri());
download_silent(&url, &dest).await.unwrap();
let written = std::fs::read(&dest).unwrap();
assert_eq!(written, body);
}
#[tokio::test]
async fn download_silent_preserves_binary_bytes_unchanged() {
// Verify that arbitrary binary content (including null bytes, high
// bytes, control chars) round-trips intact.
let server = MockServer::start().await;
let body: Vec<u8> = (0u8..=255).cycle().take(10_000).collect();
Mock::given(method("GET"))
.and(path("/bin"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("bin");
download_silent(&format!("{}/bin", server.uri()), &dest)
.await
.unwrap();
let written = std::fs::read(&dest).unwrap();
assert_eq!(written, body);
}
#[tokio::test]
async fn download_silent_atomically_renames_via_tmp_file() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/bin"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello"))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
download_silent(&format!("{}/bin", server.uri()), &dest)
.await
.unwrap();
// After successful download, only the final file should exist.
assert!(dest.exists());
assert!(
!dest.with_extension("tmp").exists(),
"tmp file must be renamed away on success"
);
}
/// A downloaded artifact must be published already executable (the install
/// path execs it right after download).
#[cfg(unix)]
#[tokio::test]
async fn download_silent_publishes_executable() {
use std::os::unix::fs::PermissionsExt;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/bin"))
.respond_with(ResponseTemplate::new(200).set_body_string("#!/bin/sh\necho ok\n"))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok-0.1.181-linux-x86_64");
download_silent(&format!("{}/bin", server.uri()), &dest)
.await
.unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode();
assert_ne!(
mode & 0o111,
0,
"downloaded artifact must be executable on publish (mode {mode:o})"
);
}
#[tokio::test]
async fn download_silent_fails_on_4xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/missing"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
let err = download_silent(&format!("{}/missing", server.uri()), &dest)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Download failed"), "msg: {msg}");
assert!(msg.contains("404"), "msg: {msg}");
assert!(!dest.exists(), "no file should be created on HTTP error");
}
#[tokio::test]
async fn download_silent_fails_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
let err = download_silent(&format!("{}/x", server.uri()), &dest)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("503"));
}
#[tokio::test]
async fn download_silent_overwrites_existing_dest() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(200).set_body_string("new content"))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
std::fs::write(&dest, "old content").unwrap();
download_silent(&format!("{}/x", server.uri()), &dest)
.await
.unwrap();
let written = std::fs::read_to_string(&dest).unwrap();
assert_eq!(written, "new content");
}
#[tokio::test]
async fn download_silent_handles_empty_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(Vec::<u8>::new()))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
download_silent(&format!("{}/x", server.uri()), &dest)
.await
.unwrap();
assert!(dest.exists());
assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0);
}
#[tokio::test]
async fn download_silent_streams_large_body() {
// 5 MB to verify streaming (file is written incrementally, not loaded
// entirely in memory before write).
let server = MockServer::start().await;
let body = vec![0xAB_u8; 5 * 1024 * 1024];
Mock::given(method("GET"))
.and(path("/big"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
download_silent(&format!("{}/big", server.uri()), &dest)
.await
.unwrap();
let written = std::fs::read(&dest).unwrap();
assert_eq!(written.len(), body.len());
assert_eq!(written, body);
}
#[tokio::test]
async fn download_silent_to_nonexistent_parent_dir_fails() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(200).set_body_string("hi"))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
// Parent directory does NOT exist — should fail at file create.
let dest = tmp.path().join("missing-subdir").join("grok");
let err = download_silent(&format!("{}/x", server.uri()), &dest)
.await
.unwrap_err();
let msg = format!("{err:#}").to_lowercase();
assert!(
msg.contains("no such file") || msg.contains("not found") || msg.contains("os error"),
"expected fs error: {msg}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// download_with_progress — same contract; covers the spinner path
// (no Content-Length) and the progress-bar path (with Content-Length).
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn download_with_progress_writes_body_with_content_length() {
// Wiremock sets Content-Length when set_body_bytes is used, so this
// exercises the determinate-progress-bar path.
let server = MockServer::start().await;
let body = b"binary content".to_vec();
Mock::given(method("GET"))
.and(path("/grok"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
download_with_progress(&format!("{}/grok", server.uri()), &dest)
.await
.unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[tokio::test]
async fn download_with_progress_fails_on_http_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
let err = download_with_progress(&format!("{}/x", server.uri()), &dest)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Download failed"), "msg: {msg}");
assert!(msg.contains("500"), "msg: {msg}");
}
#[tokio::test]
async fn download_with_progress_atomic_rename() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/x"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok");
download_with_progress(&format!("{}/x", server.uri()), &dest)
.await
.unwrap();
assert!(dest.exists());
assert!(!dest.with_extension("tmp").exists());
}
// ─────────────────────────────────────────────────────────────────────────────
// Parallel byte-range path — exercises the HEAD + 206 Partial Content code path
// in download_silent / download_with_progress for files >= 16 MiB.
// ─────────────────────────────────────────────────────────────────────────────
/// Wiremock responder for `GET` that honors `Range: bytes=A-B` with `206`.
/// Without a Range header it returns the full body with `200`.
#[derive(Clone)]
struct RangeResponder {
body: std::sync::Arc<Vec<u8>>,
}
impl Respond for RangeResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
let total = self.body.len();
let spec = request
.headers
.get("range")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("bytes=").map(|x| x.to_string()));
if let Some(spec) = spec
&& let Some((start_str, end_str)) = spec.split_once('-')
&& let (Ok(start), Ok(end)) = (start_str.parse::<usize>(), end_str.parse::<usize>())
{
let end = end.min(total - 1);
if start <= end {
let slice = self.body[start..=end].to_vec();
return ResponseTemplate::new(206)
.insert_header("content-range", format!("bytes {start}-{end}/{total}"))
.set_body_bytes(slice);
}
}
ResponseTemplate::new(200).set_body_bytes((*self.body).clone())
}
}
#[tokio::test]
async fn download_silent_parallel_path_reassembles_bytes() {
// 32 MiB body — clears the parallel threshold and yields 2 chunks
// (size_mb / 16 = 2, clamped to [1, 8]), so this actually exercises
// concurrent range fetches and the seek+write reassembly.
let body: Vec<u8> = (0u32..(32 * 1024 * 1024 / 4))
.flat_map(|n| n.to_le_bytes())
.collect();
assert_eq!(body.len(), 32 * 1024 * 1024);
let arc = std::sync::Arc::new(body.clone());
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/big"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-length", body.len().to_string())
.insert_header("accept-ranges", "bytes"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/big"))
.respond_with(RangeResponder { body: arc })
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("grok-binary");
download_silent(&format!("{}/big", server.uri()), &dest)
.await
.unwrap();
let written = std::fs::read(&dest).unwrap();
assert_eq!(written.len(), body.len());
assert_eq!(
written, body,
"reassembled file must match original byte-for-byte"
);
assert!(
!dest.with_extension("tmp").exists(),
"tmp file must be cleaned up"
);
}

View file

@ -0,0 +1,446 @@
//! Subprocess-based integration tests using fake `npm` / `gh` shell scripts
//! placed first on `PATH`.
//!
//! `auto_update::install_npm` and `version::fetch_npm_tag` spawn `npm` by
//! bare name (`Command::new("npm")`). To test them without touching the real
//! npm registry, we install a tempdir-resident shell script named `npm`
//! that logs its args and prints canned stdout, then prepend that tempdir
//! to `PATH` for the duration of the test.
//!
//! Same pattern for `gh` for the `gh-release` installer paths.
//!
//! All tests in this file mutate `PATH` (global), so they're serialized with
//! `#[serial]`.
#![cfg(unix)]
mod common;
use std::time::Duration;
use serial_test::serial;
use common::FakeBinGuard;
use xai_grok_update::auto_update::install_npm_for_test;
use xai_grok_update::version::{
fetch_gh_release_version, fetch_npm_tag_for_test, fetch_npm_version_for_test,
};
// ─────────────────────────────────────────────────────────────────────────────
// fetch_npm_tag — reads a single dist-tag from `npm view`.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn fetch_npm_tag_returns_string_response() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"\n");
let v = fetch_npm_tag_for_test("latest", None).await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_returns_array_response_picks_last() {
// npm view sometimes returns an array of versions for ambiguous specs.
// The implementation picks the LAST one (rev().find_map).
let g = FakeBinGuard::install_npm();
g.set_stdout(r#"["0.1.179", "0.1.180", "0.1.181"]"#);
let v = fetch_npm_tag_for_test("latest", None).await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_passes_pkg_and_tag_to_npm() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let _ = fetch_npm_tag_for_test("latest", None).await.unwrap();
let log = g.args_log();
assert_eq!(log.len(), 1, "exactly one npm invocation");
let args = &log[0];
assert!(args.contains("view"), "args: {args}");
// For "latest" tag, no `@latest` suffix is appended in pkg_spec.
assert!(args.contains("@xai-official/grok"), "args: {args}");
assert!(!args.contains("@latest"), "args: {args}");
assert!(args.contains("--json"), "args: {args}");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_alpha_appends_at_alpha_suffix() {
let g = FakeBinGuard::install_npm();
g.set_alpha_stdout("\"0.1.181-alpha.1\"");
let v = fetch_npm_tag_for_test("alpha", None).await.unwrap();
assert_eq!(v, "0.1.181-alpha.1");
let log = g.args_log();
assert!(
log[0].contains("@xai-official/grok@alpha"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_passes_registry_flag_when_set() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let _ = fetch_npm_tag_for_test("latest", Some("https://npm.example.com"))
.await
.unwrap();
let log = g.args_log();
assert!(
log[0].contains("--registry=https://npm.example.com"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_no_registry_flag_when_unset() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let _ = fetch_npm_tag_for_test("latest", None).await.unwrap();
let log = g.args_log();
assert!(!log[0].contains("--registry"), "args: {}", log[0]);
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_propagates_npm_failure() {
let g = FakeBinGuard::install_npm();
g.set_exit_code(1);
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("npm view"), "msg: {msg}");
assert!(msg.contains("failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_invalid_json_returns_err() {
let g = FakeBinGuard::install_npm();
g.set_stdout("not valid json {");
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
// serde_json should error on this.
let msg = format!("{err:#}");
assert!(!msg.is_empty());
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_unexpected_json_shape_returns_err() {
// npm view can return null, an object, etc. The function expects string
// or array of strings — anything else is an error.
let g = FakeBinGuard::install_npm();
g.set_stdout("42");
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("unexpected JSON"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_npm_tag_empty_array_returns_err() {
let g = FakeBinGuard::install_npm();
g.set_stdout("[]");
let err = fetch_npm_tag_for_test("latest", None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("empty"), "msg: {msg}");
}
// ─────────────────────────────────────────────────────────────────────────────
// fetch_npm_version — alpha channel calls both tags and returns the max.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn fetch_npm_version_stable_calls_only_latest() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\"");
let v = fetch_npm_version_for_test("stable", None).await.unwrap();
assert_eq!(v, "0.1.181");
assert_eq!(g.args_log().len(), 1, "stable should make one call");
}
#[tokio::test]
#[serial]
async fn fetch_npm_version_alpha_returns_max_of_alpha_and_latest_when_alpha_higher() {
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.181\""); // latest tag → stable
g.set_alpha_stdout("\"0.1.182-alpha.1\""); // alpha tag
let v = fetch_npm_version_for_test("alpha", None).await.unwrap();
assert_eq!(v, "0.1.182-alpha.1");
assert_eq!(g.args_log().len(), 2, "alpha should make two calls");
}
#[tokio::test]
#[serial]
async fn fetch_npm_version_alpha_returns_stable_when_higher() {
// Common case: stable shipped after a stale alpha tag — must not strand
// alpha users on the older alpha.
let g = FakeBinGuard::install_npm();
g.set_stdout("\"0.1.182\"");
g.set_alpha_stdout("\"0.1.181-alpha.1\"");
let v = fetch_npm_version_for_test("alpha", None).await.unwrap();
assert_eq!(v, "0.1.182");
}
// ─────────────────────────────────────────────────────────────────────────────
// install_npm — spawns `npm i -g @pkg@version`.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn install_npm_calls_npm_with_version_arg() {
let g = FakeBinGuard::install_npm();
// No stdout/exit setup → succeeds with empty stdout.
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert_eq!(log.len(), 1, "exactly one npm invocation");
let args = &log[0];
assert!(args.contains("i -g"), "args: {args}");
assert!(args.contains("@xai-official/grok@0.1.181"), "args: {args}");
}
#[tokio::test]
#[serial]
async fn install_npm_falls_back_to_dist_tag_on_no_target() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(None, "stable", None).unwrap();
let log = g.args_log();
assert!(
log[0].contains("@xai-official/grok@latest"),
"stable channel uses @latest dist-tag: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn install_npm_falls_back_to_alpha_dist_tag_on_alpha_channel() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(None, "alpha", None).unwrap();
let log = g.args_log();
assert!(
log[0].contains("@xai-official/grok@alpha"),
"alpha channel uses @alpha dist-tag: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn install_npm_passes_registry_flag_when_set() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", Some("https://npm.example.com")).unwrap();
let log = g.args_log();
assert!(
log[0].contains("--registry=https://npm.example.com"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn install_npm_no_registry_flag_when_unset() {
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert!(!log[0].contains("--registry"), "args: {}", log[0]);
}
#[tokio::test]
#[serial]
async fn install_npm_returns_err_on_npm_failure() {
let g = FakeBinGuard::install_npm();
g.set_exit_code(1);
let err = install_npm_for_test(Some("0.1.181"), "stable", None).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("npm install failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn install_npm_with_token_passes_userconfig() {
// SAFETY: serial_test ensures no other thread touches NPM_TOKEN.
unsafe { std::env::set_var("NPM_TOKEN", "secrettoken") };
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert!(
log[0].contains("--userconfig="),
"with NPM_TOKEN, must pass --userconfig: {}",
log[0]
);
// The userconfig path should be cleaned up afterwards.
let userconfig_arg = log[0]
.split_whitespace()
.find(|a| a.starts_with("--userconfig="))
.unwrap()
.trim_start_matches("--userconfig=");
assert!(
!std::path::Path::new(userconfig_arg).exists(),
"userconfig file should be cleaned up: {userconfig_arg}"
);
unsafe { std::env::remove_var("NPM_TOKEN") };
}
#[tokio::test]
#[serial]
async fn install_npm_no_token_no_userconfig() {
unsafe { std::env::remove_var("NPM_TOKEN") };
let g = FakeBinGuard::install_npm();
install_npm_for_test(Some("0.1.181"), "stable", None).unwrap();
let log = g.args_log();
assert!(!log[0].contains("--userconfig"), "args: {}", log[0]);
}
// ─────────────────────────────────────────────────────────────────────────────
// fetch_gh_release_version — exercises the `gh release list` shell-out.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
#[serial]
async fn fetch_gh_release_stable_returns_tag_stripped() {
let g = FakeBinGuard::install_gh();
// For stable channel, only the `--exclude-pre-releases` invocation is made.
g.set_stable_only_stdout("v0.1.181\n");
let v = fetch_gh_release_version("stable").await.unwrap();
assert_eq!(v, "0.1.181");
let log = g.args_log();
assert_eq!(log.len(), 1);
assert!(
log[0].contains("--exclude-pre-releases"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_stable_handles_tag_without_v_prefix() {
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("0.1.181");
let v = fetch_gh_release_version("stable").await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_alpha_returns_max_of_pre_and_stable() {
// Alpha channel makes two `gh release list` calls (with and without
// --exclude-pre-releases) and returns the semver-max.
let g = FakeBinGuard::install_gh();
g.set_with_pre_stdout("v0.1.182-alpha.1");
g.set_stable_only_stdout("v0.1.181");
let v = fetch_gh_release_version("alpha").await.unwrap();
assert_eq!(v, "0.1.182-alpha.1");
assert_eq!(g.args_log().len(), 2);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_alpha_returns_stable_when_higher() {
let g = FakeBinGuard::install_gh();
g.set_with_pre_stdout("v0.1.180-alpha.5");
g.set_stable_only_stdout("v0.1.181");
let v = fetch_gh_release_version("alpha").await.unwrap();
assert_eq!(v, "0.1.181");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_propagates_gh_failure() {
let g = FakeBinGuard::install_gh();
g.set_exit_code(1);
let err = fetch_gh_release_version("stable").await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("gh release list"), "msg: {msg}");
assert!(msg.contains("failed"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_empty_response_returns_err() {
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("");
let err = fetch_gh_release_version("stable").await.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("No releases found"), "msg: {msg}");
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_passes_repo_flag() {
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("v0.1.181");
let _ = fetch_gh_release_version("stable").await.unwrap();
let log = g.args_log();
assert!(log[0].contains("--repo"), "args: {}", log[0]);
assert!(
log[0].contains("xai-org-shared/grok-build"),
"args: {}",
log[0]
);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_uses_jq_to_extract_tag() {
// The function constructs `gh release list --json tagName --jq '.[0].tagName'`
// — we verify the args include the jq filter so a refactor doesn't accidentally
// drop it.
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("v0.1.181");
let _ = fetch_gh_release_version("stable").await.unwrap();
let log = g.args_log();
assert!(log[0].contains("--json"), "args: {}", log[0]);
assert!(log[0].contains("--jq"), "args: {}", log[0]);
}
#[tokio::test]
#[serial]
async fn fetch_gh_release_does_not_hang_on_quick_responses() {
// Sanity: every call should return well under our test timeout.
let g = FakeBinGuard::install_gh();
g.set_stable_only_stdout("v0.1.181");
let res =
tokio::time::timeout(Duration::from_secs(5), fetch_gh_release_version("stable")).await;
assert!(res.is_ok(), "should not hang");
}