F8: distribution and GitHub-Releases self-update
- .github/workflows/release.yml: on tag v* build all 5 targets (macOS
arm64/x86_64, Linux arm64/x86_64 incl. free arm runners, Windows
x86_64) with the release-dist profile, archive kigi-<version>-<triple>
with LICENSE/NOTICE/THIRD-PARTY-NOTICES, generate SHA256SUMS, publish
the release (prerelease for tags containing '-'), with a tag↔workspace
version guard.
- install.sh / install.ps1 (repo root): platform detection, latest or
--version download from GitHub Releases, SHA-256 verification against
SHA256SUMS, install into the kigi home's downloads/ + bin/kigi symlink
(the same layout the self-updater manages), smoke test, PATH guidance.
- kigi-update rewritten onto the GitHub Releases API (documented wire
shape; stable=/latest, alpha=semver-max across the list, pinned=/tags):
SHA-256 gate before any binary swap, tar.gz/zip extraction per
platform, atomic bin/kigi symlink swap, channel/rollback semantics and
the KIGI_AUTO_UPDATE gate preserved verbatim; every x.ai/GCS/npm
endpoint deleted, npm/gh-release installers removed, legacy grok/agent
links retired on install. kigi-env owns the update base URL with a
KIGI_UPDATE_BASE_URL override (this is what the test artifact server
injects).
- .cargo/config.toml: removed the non-portable neoverse-v2 CPU pin on
Linux arm64 (fleet-specific); RELRO/NX hardening link-args now apply
to the gnu targets too, matching the release-dist profile's contract.
- THIRD-PARTY-NOTICES regenerated via cargo-about (about.toml +
template); the M0 hand-built file is dropped and README points at the
generated one. docs/RELEASE.md carries the release checklist.
- Deleted xAI-era leftovers: kigi-tui/scripts/install*.{sh,ps1} (x.ai
CDN) and the @xai-official/grok npm skeleton (PRD F8: no npm).
Gates: fmt clean; workspace check/clippy 0/0 (--locked, -D warnings);
kigi-update 58 lib + 86 integration tests green; deny ok;
release-dist build of kigi-bin succeeds and reports 'kigi 0.1.0'.
This commit is contained in:
@@ -12,6 +12,7 @@ reqwest = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
time = { workspace = true, features = ["serde"] }
|
||||
tokio = { workspace = true, features = ["fs", "process", "io-util", "macros", "time"] }
|
||||
@@ -21,10 +22,20 @@ kigi-env = { workspace = true }
|
||||
kigi-tools = { workspace = true }
|
||||
kigi-version = { workspace = true }
|
||||
|
||||
# Release archives: tar.gz on Unix, zip on Windows (PRD F8).
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
flate2 = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
time = { workspace = true, features = ["serde"] }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "test-util"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
//! Minimum-version enforcement.
|
||||
//!
|
||||
//! When `cli.minimum_version` is set in any config layer, Grok refuses to
|
||||
//! When `cli.minimum_version` is set in any config layer, Kigi refuses to
|
||||
//! start below that floor. With auto-update on, we install
|
||||
//! `max(latest, minimum)`; otherwise the user is asked to run `grok update`.
|
||||
//! `max(latest, minimum)`; otherwise the user is asked to run `kigi update`.
|
||||
//!
|
||||
//! Set `KIGI_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,
|
||||
UpdateConfig, fetch_latest_version, get_installed_kigi_version, write_version_cache,
|
||||
};
|
||||
use kigi_shell::util::config;
|
||||
use tracing::{info, warn};
|
||||
@@ -36,7 +36,7 @@ enum EnforcementOutcome {
|
||||
pub(crate) enum MinimumVersionError {
|
||||
/// `source` chains via `Error::source()`; omitted from `Display`.
|
||||
#[error(
|
||||
"The minimum version \"{value}\" in your Grok configuration \
|
||||
"The minimum version \"{value}\" in your Kigi configuration \
|
||||
isn't a valid version number. Update `cli.minimum_version` and try again."
|
||||
)]
|
||||
InvalidMinimum {
|
||||
@@ -45,22 +45,22 @@ pub(crate) enum MinimumVersionError {
|
||||
source: semver::Error,
|
||||
},
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Run `grok update` to install version {minimum} or later."
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Run `kigi update` to install version {minimum} or later."
|
||||
)]
|
||||
AutoUpdateDisabled { current: String, minimum: String },
|
||||
/// `npm` / `gh` / `internal` GCS — none detected.
|
||||
/// No installer backend detected.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Run `grok update` to install version {minimum} or later."
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Run `kigi 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, \
|
||||
"This version of Kigi ({current}) is no longer supported, \
|
||||
and the update to version {minimum} didn't complete.\n\n\
|
||||
Run `grok update` to try again."
|
||||
Run `kigi update` to try again."
|
||||
)]
|
||||
UpgradeFailed {
|
||||
current: String,
|
||||
@@ -70,7 +70,7 @@ pub(crate) enum MinimumVersionError {
|
||||
/// 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. \
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Version {minimum} or later is required, but the most recent release is {latest}. \
|
||||
Contact your administrator."
|
||||
)]
|
||||
@@ -81,15 +81,15 @@ pub(crate) enum MinimumVersionError {
|
||||
},
|
||||
/// Couldn't probe the registry — likely transient.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
"This version of Kigi ({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.
|
||||
/// `kigi 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."
|
||||
"Cannot install Kigi {target}: the configured minimum is {minimum}. \
|
||||
Run `kigi update` to install the latest allowed version."
|
||||
)]
|
||||
TargetBelowFloor { target: String, minimum: String },
|
||||
}
|
||||
@@ -133,7 +133,7 @@ fn evaluate_minimum_version(
|
||||
}
|
||||
|
||||
/// Refuse an explicit install target below the configured floor.
|
||||
/// Used by `grok update --version X`.
|
||||
/// Used by `kigi 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())
|
||||
@@ -154,7 +154,7 @@ fn check_install_target_inner(
|
||||
}
|
||||
|
||||
/// `max(target, configured_floor)`; passthrough when no floor is set.
|
||||
/// Used by `grok update` to keep the install target at or above the pin.
|
||||
/// Used by `kigi 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())
|
||||
@@ -193,7 +193,7 @@ async fn enforce_minimum_version(
|
||||
minimum_version: Option<&str>,
|
||||
update_config: &UpdateConfig,
|
||||
) -> Result<EnforcementOutcome, MinimumVersionError> {
|
||||
let current_version = get_installed_grok_version();
|
||||
let current_version = get_installed_kigi_version();
|
||||
let decision = evaluate_minimum_version(¤t_version, minimum_version)?;
|
||||
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
|
||||
info!(current = %current_version, "minimum_version: floor satisfied");
|
||||
@@ -214,12 +214,12 @@ async fn enforce_minimum_version(
|
||||
return Err(MinimumVersionError::NoInstaller { current, minimum });
|
||||
};
|
||||
|
||||
let latest = fetch_latest_version(installer, update_config).await.ok();
|
||||
let latest = fetch_latest_version(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. \
|
||||
"This version of Kigi ({current}) is no longer supported. \
|
||||
Updating to {target}…"
|
||||
);
|
||||
|
||||
@@ -276,12 +276,12 @@ pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) {
|
||||
match enforce_minimum_version(Some(&min), update_config).await {
|
||||
Ok(EnforcementOutcome::Allowed) => {}
|
||||
Ok(EnforcementOutcome::Upgraded) => {
|
||||
// TODO: restart_grok uses exec() which carries the same
|
||||
// TODO: restart_kigi 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.");
|
||||
eprintln!("Update installed. Run `kigi` to start.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
// SAFETY: #[serial] excludes other env-touching tests.
|
||||
unsafe { std::env::set_var("KIGI_TEST_VERSION", "0.1.50") };
|
||||
let decision =
|
||||
evaluate_minimum_version(&get_installed_grok_version(), Some("0.1.100")).unwrap();
|
||||
evaluate_minimum_version(&get_installed_kigi_version(), Some("0.1.100")).unwrap();
|
||||
assert!(matches!(
|
||||
decision,
|
||||
MinimumVersionDecision::BelowMinimum { .. }
|
||||
|
||||
@@ -2,28 +2,19 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
|
||||
use kigi_shell::util::kigi_home::kigi_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];
|
||||
/// Release channel: this repo's GitHub Releases (PRD F8). The API base is
|
||||
/// resolved through [`kigi_env::update_base_url`] — production default
|
||||
/// `https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases`,
|
||||
/// overridable via `KIGI_UPDATE_BASE_URL` for mirrors and tests.
|
||||
pub(crate) fn update_base_url() -> String {
|
||||
kigi_env::update_base_url()
|
||||
}
|
||||
|
||||
/// Minimal configuration the update system needs from the environment.
|
||||
///
|
||||
@@ -40,8 +31,6 @@ pub struct UpdateConfig {
|
||||
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 {
|
||||
@@ -52,20 +41,216 @@ impl UpdateConfig {
|
||||
deployment_key: None,
|
||||
alpha_test_key: None,
|
||||
channel: "stable".to_string(),
|
||||
npm_registry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GitHub Releases API wire shape
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One downloadable asset attached to a GitHub release.
|
||||
///
|
||||
/// Wire shape per the GitHub REST API
|
||||
/// (<https://docs.github.com/en/rest/releases/releases#get-the-latest-release>):
|
||||
/// `GET /repos/{owner}/{repo}/releases/latest` →
|
||||
/// `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`.
|
||||
/// Unknown fields are ignored.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
/// A GitHub release, reduced to the fields the updater consumes.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Release {
|
||||
/// Git tag, e.g. `v0.1.0`. [`Release::version`] strips the `v` prefix.
|
||||
pub tag_name: String,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
#[serde(default)]
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
impl Release {
|
||||
/// Semver version from `tag_name` (`v0.1.0` → `0.1.0`). Errors on a tag
|
||||
/// that is not a `v`-prefixed (or bare) semver string.
|
||||
pub fn version(&self) -> Result<String> {
|
||||
let v = self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name);
|
||||
semver::Version::parse(v)
|
||||
.map_err(|e| anyhow::anyhow!("release tag '{}' is not semver: {e}", self.tag_name))?;
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
/// Asset with exactly `name`, or an error listing what the release has.
|
||||
pub fn asset(&self, name: &str) -> Result<&ReleaseAsset> {
|
||||
self.assets.iter().find(|a| a.name == name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"release {} has no asset named '{}' (available: {})",
|
||||
self.tag_name,
|
||||
name,
|
||||
self.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared HTTP client for GitHub API metadata requests. GitHub rejects
|
||||
/// requests without a `User-Agent`, so one is always set.
|
||||
fn github_api_client(timeout: Duration) -> Result<reqwest::Client> {
|
||||
Ok(reqwest::Client::builder()
|
||||
.user_agent(concat!("kigi/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(timeout)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
/// GET `url` and decode the JSON body as `T`, retrying transient failures
|
||||
/// (network errors, HTTP 5xx) up to 3 times with 1s/2s/4s backoff.
|
||||
/// Non-5xx HTTP errors (404 missing release, 403 rate limit) fail fast.
|
||||
async fn fetch_github_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T> {
|
||||
let client = github_api_client(Duration::from_secs(15))?;
|
||||
let max_retries: u32 = 3;
|
||||
let mut last_err: Option<anyhow::Error> = 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)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"GitHub API request failed for {url}: {e:#}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.is_server_error() {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"GitHub API returned HTTP {status} for {url}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"GitHub API returned HTTP {} for {}: {}",
|
||||
status,
|
||||
url,
|
||||
body.chars().take(200).collect::<String>().trim()
|
||||
);
|
||||
}
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"GitHub API body read failed for {url}: {e:#}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
return serde_json::from_str(&body)
|
||||
.map_err(|e| anyhow::anyhow!("GitHub API returned unexpected JSON for {url}: {e}"));
|
||||
}
|
||||
Err(last_err.expect("loop ran at least once"))
|
||||
}
|
||||
|
||||
/// Latest release for `channel` from a GitHub-Releases-shaped API at `base`.
|
||||
///
|
||||
/// - `stable` / `enterprise`: `GET {base}/latest` — GitHub's "latest" already
|
||||
/// excludes drafts and pre-releases.
|
||||
/// - `alpha`: `GET {base}?per_page=30` (newest first) and take the semver-max
|
||||
/// non-draft entry. The list includes both pre-releases and stable
|
||||
/// releases, so this preserves the max(alpha, stable) channel semantics —
|
||||
/// alpha users are never stuck behind a newer stable.
|
||||
pub async fn fetch_latest_release_from_base(channel: &str, base: &str) -> Result<Release> {
|
||||
let base = base.trim_end_matches('/');
|
||||
if channel == "alpha" {
|
||||
let releases: Vec<Release> = fetch_github_json(&format!("{base}?per_page=30")).await?;
|
||||
return releases
|
||||
.into_iter()
|
||||
.filter(|r| !r.draft)
|
||||
.filter_map(|r| {
|
||||
let v = semver::Version::parse(r.tag_name.strip_prefix('v').unwrap_or(&r.tag_name))
|
||||
.ok()?;
|
||||
Some((v, r))
|
||||
})
|
||||
.max_by(|a, b| a.0.cmp(&b.0))
|
||||
.map(|(_, r)| r)
|
||||
.ok_or_else(|| anyhow::anyhow!("no releases with semver tags found at {base}"));
|
||||
}
|
||||
fetch_github_json(&format!("{base}/latest")).await
|
||||
}
|
||||
|
||||
/// Release for an exact version (`GET {base}/tags/v{version}`), used by
|
||||
/// pinned installs (`kigi update --version X`) and rollbacks.
|
||||
pub async fn fetch_release_for_version_from_base(version: &str, base: &str) -> Result<Release> {
|
||||
let base = base.trim_end_matches('/');
|
||||
fetch_github_json(&format!("{base}/tags/v{version}")).await
|
||||
}
|
||||
|
||||
/// Latest release for `channel` from the production base URL
|
||||
/// ([`kigi_env::update_base_url`]).
|
||||
pub(crate) async fn fetch_latest_release(channel: &str) -> Result<Release> {
|
||||
fetch_latest_release_from_base(channel, &update_base_url()).await
|
||||
}
|
||||
|
||||
/// Fetch the latest version for the configured channel 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(config: &UpdateConfig) -> Result<String> {
|
||||
fetch_latest_release(&config.channel).await?.version()
|
||||
}
|
||||
|
||||
/// Fetch the latest version for the configured channel and cache it.
|
||||
pub async fn get_latest_version(config: &UpdateConfig) -> Result<String> {
|
||||
let version = fetch_latest_version(config).await?;
|
||||
let stable_ptr = try_fetch_stable_version().await;
|
||||
write_version_cache(&version, stable_ptr.as_deref()).await;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// Fetch the latest stable version for caching alongside the version, so
|
||||
/// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O.
|
||||
///
|
||||
/// Best-effort and capped at 500 ms: the label is cosmetic, never required
|
||||
/// for correctness. On slow or unreachable networks the timeout fires and we
|
||||
/// return `None`; the label populates on the next successful TTL check
|
||||
/// (~30 min). This keeps startup and post-install paths fast.
|
||||
pub(crate) async fn try_fetch_stable_version() -> Option<String> {
|
||||
tokio::time::timeout(Duration::from_millis(500), async {
|
||||
fetch_latest_release("stable").await.ok()?.version().ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Version cache (~/.kigi/version.json)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, serde::Serialize, Deserialize)]
|
||||
struct GrokVersion {
|
||||
struct VersionCache {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
stable_version: Option<String>,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
impl GrokVersion {
|
||||
impl VersionCache {
|
||||
fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool {
|
||||
if let Ok(dt) = time::OffsetDateTime::parse(
|
||||
&self.checked_at,
|
||||
@@ -93,270 +278,16 @@ impl GrokVersion {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(®istry_flag);
|
||||
}
|
||||
let mut cmd = Command::new("npm");
|
||||
cmd.args(&args).stdin(std::process::Stdio::null());
|
||||
kigi_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(kigi_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());
|
||||
kigi_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(kigi_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
|
||||
/// `stable_version` records the current stable release 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 = kigi_home().join("version.json");
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let json = GrokVersion::new(
|
||||
let json = VersionCache::new(
|
||||
version.to_string(),
|
||||
stable_version.map(|s| s.to_string()),
|
||||
now,
|
||||
@@ -384,26 +315,12 @@ pub async fn write_version_cache(version: &str, stable_version: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = kigi_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)
|
||||
&& let Ok(version) = serde_json::from_str::<VersionCache>(&version_str)
|
||||
&& version.is_fresh(now, TTL_SECONDS_BEFORE_AUTO_UPDATE)
|
||||
{
|
||||
return true;
|
||||
@@ -411,14 +328,14 @@ pub async fn is_version_cache_fresh() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub use kigi_version::installed as get_installed_grok_version;
|
||||
pub use kigi_version::installed as get_installed_kigi_version;
|
||||
|
||||
/// Version of the managed grok binary currently on disk, read from the
|
||||
/// `~/.kigi/bin/grok` symlink target (`../downloads/grok-<version>-<platform>`)
|
||||
/// Version of the managed kigi binary currently on disk, read from the
|
||||
/// `~/.kigi/bin/kigi` symlink target (`../downloads/kigi-<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
|
||||
/// explicit `kigi 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.
|
||||
///
|
||||
@@ -427,11 +344,6 @@ pub use kigi_version::installed as get_installed_grok_version;
|
||||
/// link whose target binary was deleted (e.g. manual `~/.kigi/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)]
|
||||
{
|
||||
@@ -440,7 +352,7 @@ pub fn installed_on_disk_version() -> Option<String> {
|
||||
// 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")
|
||||
version_from_versioned_binary_name(target.file_name()?.to_str()?, "kigi")
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -450,18 +362,23 @@ pub fn installed_on_disk_version() -> Option<String> {
|
||||
|
||||
/// 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.
|
||||
/// Handles the managed layout (`kigi-0.1.0-macos-aarch64`, including
|
||||
/// pre-releases: `kigi-0.1.0-alpha.1-linux-x86_64` → `0.1.0-alpha.1`) and a
|
||||
/// bare versioned name without a platform suffix (`kigi-0.1.0`): everything
|
||||
/// between the `{bin_prefix}-` prefix and the first platform-OS component is
|
||||
/// the version, validated as semver so unknown layouts (`kigi-latest`)
|
||||
/// 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"];
|
||||
// Release archives (`kigi-<v>-<triple>.tar.gz|.zip`) are downloads, not
|
||||
// binaries — and their triple would otherwise parse as a semver
|
||||
// pre-release (`0.1.0-aarch64-apple-darwin.tar.gz` is valid semver).
|
||||
if name.ends_with(".tar.gz") || name.ends_with(".zip") {
|
||||
return None;
|
||||
}
|
||||
let suffix = name.strip_prefix(bin_prefix)?.strip_prefix('-')?;
|
||||
let parts: Vec<&str> = suffix.split('-').collect();
|
||||
let platform_start = parts
|
||||
@@ -473,31 +390,6 @@ pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -
|
||||
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 `~/.kigi/version.json` (sync, for display).
|
||||
///
|
||||
/// Returns `None` if the file doesn't exist, can't be parsed, or has no
|
||||
@@ -505,7 +397,7 @@ pub(crate) async fn try_fetch_stable_pointer() -> Option<String> {
|
||||
pub fn cached_stable_version() -> Option<String> {
|
||||
let version_path = kigi_home().join("version.json");
|
||||
let content = std::fs::read_to_string(&version_path).ok()?;
|
||||
let gv: GrokVersion = serde_json::from_str(&content).ok()?;
|
||||
let gv: VersionCache = serde_json::from_str(&content).ok()?;
|
||||
gv.stable_version
|
||||
}
|
||||
|
||||
@@ -575,7 +467,7 @@ mod tests {
|
||||
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);
|
||||
let v = VersionCache::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)."
|
||||
@@ -583,41 +475,140 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Disk-version probe: parsing the version out of the managed install's
|
||||
/// symlink-target file name (`grok-<version>-<platform>`).
|
||||
/// symlink-target file name (`kigi-<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")),
|
||||
("kigi-0.2.46-darwin-arm64", Some("0.2.46")),
|
||||
("kigi-0.1.220-linux-x86_64", Some("0.1.220")),
|
||||
("kigi-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
|
||||
("kigi-0.1.220-alpha.4-linux-x86_64", Some("0.1.220-alpha.4")),
|
||||
("kigi-0.1.220-alpha.4", Some("0.1.220-alpha.4")), // no platform suffix
|
||||
("kigi-garbage-darwin-arm64", None), // unparseable version
|
||||
("kigi-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
|
||||
("kigi-latest", None), // symlink alias, not a version
|
||||
("kigi", None), // bare name
|
||||
("", None),
|
||||
// Release archives must never parse as versioned binaries, or
|
||||
// cleanup would treat them as installable versions.
|
||||
("kigi-0.1.0-aarch64-apple-darwin.tar.gz", None),
|
||||
("kigi-0.1.0-x86_64-pc-windows-msvc.zip", None),
|
||||
];
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(
|
||||
version_from_versioned_binary_name(name, "grok").as_deref(),
|
||||
version_from_versioned_binary_name(name, "kigi").as_deref(),
|
||||
*expected,
|
||||
"version_from_versioned_binary_name({name:?})"
|
||||
);
|
||||
}
|
||||
|
||||
// bin_prefix discrimination: the pager binary parses under its own
|
||||
// prefix but not under "grok".
|
||||
// bin_prefix discrimination: a differently-prefixed binary parses
|
||||
// under its own prefix but not under "kigi".
|
||||
assert_eq!(
|
||||
version_from_versioned_binary_name("grok-pager-0.1.5-darwin-arm64", "grok-pager")
|
||||
version_from_versioned_binary_name("kigi-pager-0.1.5-darwin-arm64", "kigi-pager")
|
||||
.as_deref(),
|
||||
Some("0.1.5")
|
||||
);
|
||||
assert_eq!(
|
||||
version_from_versioned_binary_name("kigi-pager-0.1.5-darwin-arm64", "kigi"),
|
||||
None,
|
||||
"\"pager\" is not a version"
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GitHub release JSON — wire-shape invariants
|
||||
//
|
||||
// Fixtures mirror the real GitHub REST API response for
|
||||
// GET /repos/{owner}/{repo}/releases/latest:
|
||||
// https://docs.github.com/en/rest/releases/releases#get-the-latest-release
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_release_json_parses_github_wire_shape() {
|
||||
let json = r#"{
|
||||
"url": "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases/1",
|
||||
"tag_name": "v0.1.0",
|
||||
"name": "Kigi 0.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"size": 123,
|
||||
"content_type": "application/gzip"
|
||||
},
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let r: Release = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r.tag_name, "v0.1.0");
|
||||
assert_eq!(r.version().unwrap(), "0.1.0");
|
||||
assert!(!r.draft);
|
||||
assert!(!r.prerelease);
|
||||
assert_eq!(r.assets.len(), 2);
|
||||
let asset = r.asset("kigi-0.1.0-aarch64-apple-darwin.tar.gz").unwrap();
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
r.asset("SHA256SUMS").unwrap().name,
|
||||
"SHA256SUMS",
|
||||
"checksum asset resolved by exact name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_version_accepts_bare_and_v_prefixed_tags() {
|
||||
let mk = |tag: &str| Release {
|
||||
tag_name: tag.to_string(),
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: vec![],
|
||||
};
|
||||
assert_eq!(mk("v0.1.0").version().unwrap(), "0.1.0");
|
||||
assert_eq!(mk("0.1.0").version().unwrap(), "0.1.0");
|
||||
assert_eq!(mk("v0.2.0-alpha.3").version().unwrap(), "0.2.0-alpha.3");
|
||||
assert!(mk("release-1").version().is_err());
|
||||
assert!(mk("").version().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_missing_asset_error_lists_available() {
|
||||
let r = Release {
|
||||
tag_name: "v0.1.0".to_string(),
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: vec![ReleaseAsset {
|
||||
name: "SHA256SUMS".to_string(),
|
||||
browser_download_url: "https://example.test/SHA256SUMS".to_string(),
|
||||
}],
|
||||
};
|
||||
let err = r
|
||||
.asset("kigi-0.1.0-x86_64-unknown-linux-gnu.tar.gz")
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("no asset named"), "msg: {msg}");
|
||||
assert!(msg.contains("SHA256SUMS"), "must list available: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_defaults_for_absent_optional_fields() {
|
||||
// Minimal object: only tag_name. draft/prerelease default false,
|
||||
// assets default empty (serde(default)).
|
||||
let r: Release = serde_json::from_str(r#"{"tag_name":"v0.1.0"}"#).unwrap();
|
||||
assert!(!r.draft && !r.prerelease && r.assets.is_empty());
|
||||
// tag_name is required.
|
||||
assert!(serde_json::from_str::<Release>(r#"{"assets":[]}"#).is_err());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -663,59 +654,22 @@ mod tests {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// VersionCache 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();
|
||||
let v: VersionCache = 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 new = VersionCache::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();
|
||||
let parsed: VersionCache = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.version, "0.2.5");
|
||||
assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3"));
|
||||
|
||||
@@ -730,11 +684,11 @@ mod tests {
|
||||
|
||||
// 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());
|
||||
assert!(serde_json::from_str::<VersionCache>(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());
|
||||
assert!(serde_json::from_str::<VersionCache>(missing).is_err());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -744,7 +698,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_is_fresh_ttl_boundaries() {
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let v = GrokVersion::new("0.1.200".to_string(), None, now);
|
||||
let v = VersionCache::new("0.1.200".to_string(), None, now);
|
||||
|
||||
// Within TTL → fresh
|
||||
assert!(v.is_fresh(now, Duration::from_secs(60)));
|
||||
@@ -760,7 +714,7 @@ mod tests {
|
||||
assert!(!v.is_fresh(now, Duration::ZERO));
|
||||
|
||||
// Malformed timestamp → not fresh
|
||||
let bad = GrokVersion {
|
||||
let bad = VersionCache {
|
||||
version: "0.1.200".to_string(),
|
||||
stable_version: None,
|
||||
checked_at: "not-rfc3339".to_string(),
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
//! Controllable raw HTTP/1.1 artifact server shared by the blitz
|
||||
//! download/install tests and the concurrent-update convergence tests.
|
||||
//! Controllable raw HTTP/1.1 GitHub-Releases-shaped 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.
|
||||
//! Serves the three routes the updater consumes:
|
||||
//!
|
||||
//! - `GET /releases/latest` and `GET /releases/tags/v{version}` — release
|
||||
//! JSON in the real GitHub wire shape
|
||||
//! (<https://docs.github.com/en/rest/releases/releases>):
|
||||
//! `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`
|
||||
//! - `GET /dl/{version}/SHA256SUMS` — checksum manifest for the archive
|
||||
//! - `GET /dl/{version}/kigi-{version}-{triple}.tar.gz` — the archive itself
|
||||
//!
|
||||
//! The archive route can serve the real archive, truncate the body, serve a
|
||||
//! right-length-but-garbage body (defeated by the SHA-256 gate), serve a
|
||||
//! correctly-checksummed archive whose binary fails to run (defeated by the
|
||||
//! smoke test), or hang mid-transfer — for both the parallel byte-range path
|
||||
//! and the single-connection path. It also counts archive-serving GETs (HEAD
|
||||
//! probes and metadata routes 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::collections::HashMap;
|
||||
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.
|
||||
use super::{archive_name, make_release_archive, sha256_hex};
|
||||
|
||||
/// How the server corrupts (or doesn't) the next archive download.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Mode {
|
||||
/// Serve the real artifact correctly.
|
||||
/// Serve the real archive correctly.
|
||||
Full,
|
||||
/// Serve a right-length body that exits non-zero (fails the smoke-test).
|
||||
/// Serve a right-length body of wrong bytes — SHA256SUMS still lists the
|
||||
/// GOOD archive's hash, so the checksum gate must reject it.
|
||||
Garbage,
|
||||
/// Serve a correctly-checksummed archive whose `kigi` binary exits
|
||||
/// non-zero — only the smoke test can reject it.
|
||||
BadBinary,
|
||||
/// Advertise the full length but send only `k` bytes then close the socket
|
||||
/// (silent truncation: premature EOF / short range chunk).
|
||||
Truncate(usize),
|
||||
@@ -29,11 +46,41 @@ pub enum Mode {
|
||||
Hang(usize),
|
||||
}
|
||||
|
||||
/// Precomputed per-version fixtures.
|
||||
struct VersionFixture {
|
||||
/// Real archive: tar.gz containing `kigi` = the good binary body.
|
||||
good_archive: Arc<Vec<u8>>,
|
||||
/// Same-shape archive whose `kigi` exits 1; its hash is served in
|
||||
/// SHA256SUMS while `Mode::BadBinary` is active.
|
||||
bad_archive: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
struct ServerState {
|
||||
body: Arc<Vec<u8>>,
|
||||
versions: HashMap<String, VersionFixture>,
|
||||
/// The good binary body used to synthesize fixtures for versions
|
||||
/// requested but not yet registered.
|
||||
default_binary: Vec<u8>,
|
||||
latest: String,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
fn fixture(&mut self, version: &str) -> &VersionFixture {
|
||||
if !self.versions.contains_key(version) {
|
||||
let good = make_release_archive(&self.default_binary);
|
||||
let bad = make_release_archive(b"#!/bin/sh\nexit 1\n");
|
||||
self.versions.insert(
|
||||
version.to_string(),
|
||||
VersionFixture {
|
||||
good_archive: Arc::new(good),
|
||||
bad_archive: Arc::new(bad),
|
||||
},
|
||||
);
|
||||
}
|
||||
&self.versions[version]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArtifactServer {
|
||||
addr: std::net::SocketAddr,
|
||||
state: Arc<Mutex<ServerState>>,
|
||||
@@ -43,12 +90,17 @@ pub struct ArtifactServer {
|
||||
}
|
||||
|
||||
impl ArtifactServer {
|
||||
pub fn start(body: Vec<u8>) -> Self {
|
||||
/// Start a server whose release archives contain `binary_body` as the
|
||||
/// `kigi` binary. `latest` starts as `0.0.0`; set it with
|
||||
/// [`ArtifactServer::set_latest`] before exercising latest-based flows.
|
||||
pub fn start(binary_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),
|
||||
versions: HashMap::new(),
|
||||
default_binary: binary_body,
|
||||
latest: "0.0.0".to_string(),
|
||||
mode: Mode::Full,
|
||||
}));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
@@ -86,30 +138,49 @@ impl ArtifactServer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uri(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
/// Base URL to hand the updater (`…/releases`, mirroring the production
|
||||
/// `https://api.github.com/repos/{owner}/{repo}/releases`).
|
||||
pub fn base(&self) -> String {
|
||||
format!("http://{}/releases", self.addr)
|
||||
}
|
||||
|
||||
/// Version served by `GET /releases/latest`.
|
||||
pub fn set_latest(&self, version: &str) {
|
||||
self.state.lock().unwrap().latest = version.to_string();
|
||||
}
|
||||
|
||||
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).
|
||||
/// Length of the (good) archive for `version` — corruption offsets for
|
||||
/// [`Mode::Truncate`]/[`Mode::Hang`] are positions within this body.
|
||||
pub fn archive_len(&self, version: &str) -> usize {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.fixture(version)
|
||||
.good_archive
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Number of archive-serving GET requests handled so far (HEAD probes
|
||||
/// from the parallel-download path and metadata/SHA256SUMS routes 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.
|
||||
/// When enabled, hold each archive 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);
|
||||
}
|
||||
@@ -134,6 +205,39 @@ fn parse_range(request: &str) -> Option<(usize, usize)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The request path, without query string.
|
||||
fn parse_path(request: &str) -> String {
|
||||
request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.unwrap_or("/")
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or("/")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Release JSON for `version` with asset URLs rooted at this server.
|
||||
fn release_json(addr: &std::net::SocketAddr, version: &str) -> String {
|
||||
let name = archive_name(version);
|
||||
format!(
|
||||
r#"{{"tag_name":"v{version}","draft":false,"prerelease":false,"assets":[{{"name":"{name}","browser_download_url":"http://{addr}/dl/{version}/{name}"}},{{"name":"SHA256SUMS","browser_download_url":"http://{addr}/dl/{version}/SHA256SUMS"}}]}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn write_simple_response(stream: &mut TcpStream, status: &str, body: &[u8], is_head: bool) {
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
if !is_head {
|
||||
let _ = stream.write_all(body);
|
||||
}
|
||||
let _ = stream.flush();
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
mut stream: TcpStream,
|
||||
state: Arc<Mutex<ServerState>>,
|
||||
@@ -169,18 +273,74 @@ fn handle_connection(
|
||||
}
|
||||
let request = String::from_utf8_lossy(&buf).to_string();
|
||||
let is_head = request.starts_with("HEAD");
|
||||
let path = parse_path(&request);
|
||||
let range = parse_range(&request);
|
||||
let addr = stream.local_addr().unwrap();
|
||||
|
||||
// ── Metadata routes ─────────────────────────────────────────────────────
|
||||
if path == "/releases/latest" {
|
||||
let latest = state.lock().unwrap().latest.clone();
|
||||
let body = release_json(&addr, &latest);
|
||||
write_simple_response(&mut stream, "200 OK", body.as_bytes(), is_head);
|
||||
return;
|
||||
}
|
||||
if let Some(tag) = path.strip_prefix("/releases/tags/v") {
|
||||
let body = release_json(&addr, tag);
|
||||
write_simple_response(&mut stream, "200 OK", body.as_bytes(), is_head);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Asset routes: /dl/{version}/{name} ──────────────────────────────────
|
||||
let Some(rest) = path.strip_prefix("/dl/") else {
|
||||
write_simple_response(&mut stream, "404 Not Found", b"not found", is_head);
|
||||
return;
|
||||
};
|
||||
let Some((version, asset)) = rest.split_once('/') else {
|
||||
write_simple_response(&mut stream, "404 Not Found", b"not found", is_head);
|
||||
return;
|
||||
};
|
||||
|
||||
let (good, bad, mode) = {
|
||||
let mut st = state.lock().unwrap();
|
||||
let mode = st.mode;
|
||||
let fixture = st.fixture(version);
|
||||
(
|
||||
fixture.good_archive.clone(),
|
||||
fixture.bad_archive.clone(),
|
||||
mode,
|
||||
)
|
||||
};
|
||||
|
||||
if asset == "SHA256SUMS" {
|
||||
// BadBinary serves the bad archive WITH its correct hash (a release
|
||||
// whose binary is broken but whose checksums are fine); every other
|
||||
// mode lists the good archive's hash so in-transit corruption is
|
||||
// caught by the checksum gate.
|
||||
let hashed: &[u8] = match mode {
|
||||
Mode::BadBinary => &bad,
|
||||
_ => &good,
|
||||
};
|
||||
let body = format!("{} {}\n", sha256_hex(hashed), archive_name(version));
|
||||
write_simple_response(&mut stream, "200 OK", body.as_bytes(), is_head);
|
||||
return;
|
||||
}
|
||||
|
||||
if asset != archive_name(version) {
|
||||
write_simple_response(&mut stream, "404 Not Found", b"not found", is_head);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Archive body with corruption modes ──────────────────────────────────
|
||||
// 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 body: &[u8] = match mode {
|
||||
Mode::BadBinary => &bad,
|
||||
_ => &good,
|
||||
};
|
||||
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.
|
||||
@@ -190,7 +350,7 @@ fn handle_connection(
|
||||
};
|
||||
let claimed_len = slice_end_excl - slice_start;
|
||||
|
||||
// For truncation/hang, `k` is a GLOBAL cutoff across the whole artifact:
|
||||
// For truncation/hang, `k` is a GLOBAL cutoff across the whole archive:
|
||||
// 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 {
|
||||
@@ -201,9 +361,9 @@ fn handle_connection(
|
||||
// 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
|
||||
let mut bad_bytes = b"not the archive you checksummed".to_vec();
|
||||
bad_bytes.resize(claimed_len, b'\n');
|
||||
bad_bytes
|
||||
}
|
||||
_ => body[slice_start..send_end].to_vec(),
|
||||
};
|
||||
@@ -236,7 +396,7 @@ fn handle_connection(
|
||||
}
|
||||
|
||||
match mode {
|
||||
Mode::Full | Mode::Garbage => {
|
||||
Mode::Full | Mode::Garbage | Mode::BadBinary => {
|
||||
// Hold the connection open longer so concurrent installers
|
||||
// genuinely overlap mid-download (see `set_slow`).
|
||||
if slow.load(Ordering::Relaxed) {
|
||||
|
||||
@@ -40,8 +40,7 @@ use std::sync::OnceLock;
|
||||
/// 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`).
|
||||
/// values can't pollute the baseline.
|
||||
pub fn test_home() -> &'static PathBuf {
|
||||
static HOME: OnceLock<PathBuf> = OnceLock::new();
|
||||
HOME.get_or_init(|| {
|
||||
@@ -52,10 +51,9 @@ pub fn test_home() -> &'static PathBuf {
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_SHARE_DIR", &path);
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
std::env::remove_var("NPM_TOKEN");
|
||||
std::env::remove_var("KIGI_INSTALLER");
|
||||
std::env::remove_var("KIGI_MANAGED_BY_NPM");
|
||||
std::env::remove_var("KIGI_MANAGED_BY_INTERNAL");
|
||||
std::env::remove_var(kigi_env::UPDATE_BASE_URL_ENV);
|
||||
}
|
||||
path
|
||||
})
|
||||
@@ -74,24 +72,32 @@ pub fn reset_home() {
|
||||
// SAFETY: tests using this helper must be `#[serial]`.
|
||||
unsafe {
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
std::env::remove_var("NPM_TOKEN");
|
||||
std::env::remove_var("KIGI_INSTALLER");
|
||||
std::env::remove_var(kigi_env::UPDATE_BASE_URL_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the version reported by `get_installed_grok_version()` for the
|
||||
/// Override the version reported by `get_installed_kigi_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("KIGI_TEST_VERSION", v) };
|
||||
}
|
||||
|
||||
/// Point the production update flows (`check_update_status`,
|
||||
/// `ensure_latest_on_disk`, `run_update`) at a mock GitHub Releases API.
|
||||
/// Cleared by [`reset_home`].
|
||||
pub fn set_update_base(base: &str) {
|
||||
// SAFETY: tests using this helper must be `#[serial]`.
|
||||
unsafe { std::env::set_var(kigi_env::UPDATE_BASE_URL_ENV, base) };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Install-test fixtures (shared by the blitz + convergence suites)
|
||||
// Install-test fixtures
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Host `{os}-{arch}` string matching the versioned binary naming scheme
|
||||
/// (`grok-{version}-{platform}`).
|
||||
/// (`kigi-{version}-{platform}`).
|
||||
pub fn host_platform() -> String {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
@@ -110,6 +116,27 @@ pub fn host_platform() -> String {
|
||||
format!("{os}-{arch}")
|
||||
}
|
||||
|
||||
/// Host Rust target triple, matching `auto_update::target_triple()` and the
|
||||
/// release-asset naming in `.github/workflows/release.yml`.
|
||||
pub fn host_triple() -> &'static str {
|
||||
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
"aarch64-apple-darwin"
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
|
||||
"x86_64-apple-darwin"
|
||||
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
"aarch64-unknown-linux-gnu"
|
||||
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
|
||||
"x86_64-unknown-linux-gnu"
|
||||
} else {
|
||||
panic!("unsupported test platform");
|
||||
}
|
||||
}
|
||||
|
||||
/// Release-archive asset name for `version` on the host platform.
|
||||
pub fn archive_name(version: &str) -> String {
|
||||
format!("kigi-{version}-{}.tar.gz", host_triple())
|
||||
}
|
||||
|
||||
/// Minimal [`kigi_update::UpdateConfig`] for install tests.
|
||||
pub fn make_update_config(channel: &str) -> kigi_update::UpdateConfig {
|
||||
kigi_update::UpdateConfig {
|
||||
@@ -118,7 +145,6 @@ pub fn make_update_config(channel: &str) -> kigi_update::UpdateConfig {
|
||||
deployment_key: None,
|
||||
alpha_test_key: None,
|
||||
channel: channel.to_string(),
|
||||
npm_registry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +194,113 @@ pub fn backdate_downloads() {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PATH-override fake binary
|
||||
// GitHub Releases fixtures
|
||||
//
|
||||
// Wire shapes mirror the real GitHub REST API
|
||||
// (https://docs.github.com/en/rest/releases/releases):
|
||||
// GET /repos/{o}/{r}/releases/latest → release object
|
||||
// GET /repos/{o}/{r}/releases/tags/{tag} → release object
|
||||
// GET /repos/{o}/{r}/releases → array of release objects
|
||||
// Release object: {"tag_name":"v0.1.0","assets":[{"name":"...",
|
||||
// "browser_download_url":"..."}]}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a tar.gz archive from `(name, bytes)` entries.
|
||||
#[cfg(unix)]
|
||||
pub fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut builder = tar::Builder::new(gz);
|
||||
for (name, data) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, name, *data).unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
/// Release archive containing a single `kigi` entry with `binary` as its body.
|
||||
#[cfg(unix)]
|
||||
pub fn make_release_archive(binary: &[u8]) -> Vec<u8> {
|
||||
make_tar_gz(&[("kigi", binary)])
|
||||
}
|
||||
|
||||
/// Hex SHA-256 of `bytes` (as written into SHA256SUMS manifests).
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
/// GitHub release JSON for `version`, with asset download URLs rooted at
|
||||
/// `{server_uri}/dl/v{version}/…`.
|
||||
pub fn release_json(server_uri: &str, version: &str) -> serde_json::Value {
|
||||
let name = archive_name(version);
|
||||
serde_json::json!({
|
||||
"tag_name": format!("v{version}"),
|
||||
"draft": false,
|
||||
"prerelease": !semver::Version::parse(version).unwrap().pre.is_empty(),
|
||||
"assets": [
|
||||
{
|
||||
"name": name,
|
||||
"browser_download_url": format!("{server_uri}/dl/v{version}/{name}"),
|
||||
},
|
||||
{
|
||||
"name": "SHA256SUMS",
|
||||
"browser_download_url": format!("{server_uri}/dl/v{version}/SHA256SUMS"),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Mount the per-release endpoints for `version` on a wiremock server:
|
||||
/// `GET /releases/tags/v{version}` plus the archive and SHA256SUMS asset
|
||||
/// downloads. Callers that need `latest` also call [`mount_latest`].
|
||||
///
|
||||
/// The base URL to hand the updater is `format!("{}/releases", server.uri())`.
|
||||
#[cfg(unix)]
|
||||
pub async fn mount_release(server: &wiremock::MockServer, version: &str, binary: &[u8]) {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, ResponseTemplate};
|
||||
|
||||
let archive = make_release_archive(binary);
|
||||
let sums = format!("{} {}\n", sha256_hex(&archive), archive_name(version));
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/releases/tags/v{version}")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(release_json(&server.uri(), version)),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/dl/v{version}/{}", archive_name(version))))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/dl/v{version}/SHA256SUMS")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(sums))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mount `GET /releases/latest` returning `version`.
|
||||
pub async fn mount_latest(server: &wiremock::MockServer, version: &str) {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, ResponseTemplate};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/releases/latest"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(release_json(&server.uri(), version)),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PATH-override fake binary (used by the install.sh harness)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// RAII guard that places a sh-script with name `name` at the head of `PATH`.
|
||||
@@ -215,18 +347,8 @@ impl FakeBinGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// The tempdir backing this guard (where canned response files can be
|
||||
/// written by tests, and where `<name>-args.log` is appended).
|
||||
pub fn dir(&self) -> PathBuf {
|
||||
self.tmp.path().to_path_buf()
|
||||
}
|
||||
@@ -239,46 +361,6 @@ impl FakeBinGuard {
|
||||
.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 {
|
||||
@@ -287,67 +369,3 @@ impl Drop for FakeBinGuard {
|
||||
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"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
//! truncation / corruption / cancel at every point, and after every iteration
|
||||
//! assert the single invariant that makes the brick impossible:
|
||||
//!
|
||||
//! > `~/.kigi/bin/grok` resolves to a binary that passes the smoke-test, OR it
|
||||
//! > `~/.kigi/bin/kigi` 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.
|
||||
//! A controllable raw HTTP/1.1 GitHub-Releases-shaped server serves release
|
||||
//! JSON, SHA256SUMS, and the archive, and can truncate the archive body,
|
||||
//! close the connection early, serve a right-length-but-garbage body (caught
|
||||
//! by the SHA-256 gate), serve a correctly-checksummed archive whose binary
|
||||
//! fails to run (caught by the smoke test), or hang mid-transfer — for both
|
||||
//! the parallel byte-range path and the single-connection path.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
@@ -35,38 +37,44 @@ use kigi_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.
|
||||
/// A real executable whose ARCHIVE clears 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 padding — which is pseudo-random bytes
|
||||
/// so gzip cannot compress the archive below the threshold.
|
||||
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.reserve(34 * 1024 * 1024);
|
||||
// xorshift64* keeps the padding incompressible without an RNG dependency.
|
||||
let mut x: u64 = 0x243F6A8885A308D3;
|
||||
while v.len() < 34 * 1024 * 1024 {
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
v.extend_from_slice(&x.wrapping_mul(0x2545F4914F6CDD1D).to_le_bytes());
|
||||
}
|
||||
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.
|
||||
/// Seed a previous-good versioned binary + the managed `kigi` symlink.
|
||||
/// 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}"));
|
||||
let prev = downloads.join(format!("kigi-{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();
|
||||
}
|
||||
let rel = format!("../downloads/kigi-{version}-{platform}");
|
||||
let link = bin.join("kigi");
|
||||
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.
|
||||
/// What the active `kigi` should resolve to after an install attempt.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Expect {
|
||||
/// The new version was installed and activated.
|
||||
@@ -77,34 +85,21 @@ enum Expect {
|
||||
|
||||
/// 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.
|
||||
/// link is always runnable and is never a `.tmp` or a partial file.
|
||||
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");
|
||||
let link = home.join("bin").join("kigi");
|
||||
assert!(link.is_symlink(), "kigi 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}"));
|
||||
.unwrap_or_else(|e| panic!("active kigi 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}"
|
||||
"active kigi must not be a temp file: {resolved_name}"
|
||||
);
|
||||
|
||||
// Re-run the resolved binary from disk: the active link must always run.
|
||||
@@ -118,7 +113,7 @@ fn assert_link_invariant(
|
||||
.unwrap_or(false);
|
||||
assert!(
|
||||
ran_ok,
|
||||
"active {name} must pass the smoke-test, but {} did not run",
|
||||
"active kigi must pass the smoke-test, but {} did not run",
|
||||
resolved.display()
|
||||
);
|
||||
|
||||
@@ -126,11 +121,11 @@ fn assert_link_invariant(
|
||||
Expect::NewBinary => assert_eq!(
|
||||
resolved,
|
||||
dunce::canonicalize(new_binary).unwrap(),
|
||||
"expected the newly-installed binary to be active for {name}"
|
||||
"expected the newly-installed binary to be active"
|
||||
),
|
||||
Expect::PreviousGood => assert_eq!(
|
||||
resolved, prev_good,
|
||||
"expected the previous-good binary to stay active for {name} after a rejected install"
|
||||
"expected the previous-good binary to stay active after a rejected install"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -149,12 +144,12 @@ async fn run_one(
|
||||
let prev_good = seed_previous_good(home, "0.1.100", &platform);
|
||||
let new_binary = home
|
||||
.join("downloads")
|
||||
.join(format!("grok-{version}-{platform}"));
|
||||
.join(format!("kigi-{version}-{platform}"));
|
||||
let cfg = make_update_config("stable");
|
||||
|
||||
server.set_mode(mode);
|
||||
|
||||
let base = server.uri();
|
||||
let base = server.base();
|
||||
let install = install_internal_from_base(Some(version), &cfg, &base);
|
||||
let expect = match (mode, cancel_after) {
|
||||
(Mode::Full, None) => {
|
||||
@@ -180,7 +175,7 @@ async fn run_one(
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deterministic matrix — single-connection path (small artifact)
|
||||
// Deterministic matrix — single-connection path (small archive)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -191,15 +186,20 @@ async fn blitz_single_connection_matrix() {
|
||||
return;
|
||||
}
|
||||
let server = ArtifactServer::start(small_good_artifact());
|
||||
let len = small_good_artifact().len();
|
||||
let len = server.archive_len("0.1.181");
|
||||
|
||||
// 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).
|
||||
// Right-length garbage — caught by the SHA-256 gate.
|
||||
run_one(&server, Mode::Garbage, "0.1.181", None).await;
|
||||
|
||||
// Premature EOF at several offsets — caught by the length/transport checks.
|
||||
// Correctly-checksummed archive with a broken binary — caught by the
|
||||
// smoke test.
|
||||
run_one(&server, Mode::BadBinary, "0.1.181", None).await;
|
||||
|
||||
// Premature EOF at several offsets — caught by the length/transport
|
||||
// checks (and the checksum gate as belt-and-suspenders).
|
||||
for k in [0usize, 1, len / 2, len.saturating_sub(1)] {
|
||||
run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
|
||||
}
|
||||
@@ -220,12 +220,12 @@ async fn blitz_single_connection_matrix() {
|
||||
// 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.
|
||||
// smoke_and_checksum_failures_keep_previous_good_then_recover.
|
||||
run_one(&server, Mode::Full, "0.1.182", None).await;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deterministic matrix — parallel byte-range path (>= 16 MiB artifact)
|
||||
// Deterministic matrix — parallel byte-range path (>= 16 MiB archive)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -235,22 +235,23 @@ async fn blitz_parallel_path_matrix() {
|
||||
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
||||
return;
|
||||
}
|
||||
let body = large_good_artifact();
|
||||
let len = body.len();
|
||||
let server = ArtifactServer::start(body);
|
||||
let server = ArtifactServer::start(large_good_artifact());
|
||||
let len = server.archive_len("0.1.181");
|
||||
assert!(
|
||||
len >= 16 * 1024 * 1024,
|
||||
"archive must clear the parallel threshold (got {len} bytes)"
|
||||
);
|
||||
|
||||
// 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.
|
||||
// Right-length garbage reassembled from range chunks — checksum 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.
|
||||
// a reqwest stream error that rejects the chunk; the parallel path falls
|
||||
// back to single-connection, which hits the same truncation.
|
||||
for k in [0usize, 1024, len / 3, len - 4096] {
|
||||
run_one(&server, Mode::Truncate(k), "0.1.181", None).await;
|
||||
}
|
||||
@@ -269,12 +270,13 @@ async fn blitz_parallel_path_matrix() {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Smoke-test rejects garbage and keeps previous-good
|
||||
// Checksum + smoke-test rejections keep previous-good, then recover WITHOUT
|
||||
// a reset in between.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn smoke_test_rejects_garbage_and_keeps_previous_good() {
|
||||
async fn smoke_and_checksum_failures_keep_previous_good_then_recover() {
|
||||
if !can_exec_shell_scripts() {
|
||||
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
||||
return;
|
||||
@@ -285,23 +287,28 @@ async fn smoke_test_rejects_garbage_and_keeps_previous_good() {
|
||||
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 base = server.base();
|
||||
let new_binary = home
|
||||
.join("downloads")
|
||||
.join(format!("grok-0.1.181-{platform}"));
|
||||
.join(format!("kigi-0.1.181-{platform}"));
|
||||
|
||||
// Checksum failure (garbage body) keeps previous good.
|
||||
server.set_mode(Mode::Garbage);
|
||||
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
|
||||
assert!(result.is_err(), "garbage archive must not install");
|
||||
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
|
||||
|
||||
// A subsequent clean serve must succeed.
|
||||
// Smoke-test failure (valid checksum, broken binary) keeps previous good.
|
||||
server.set_mode(Mode::BadBinary);
|
||||
let result = install_internal_from_base(Some("0.1.181"), &cfg, &base).await;
|
||||
assert!(result.is_err(), "broken binary must not install");
|
||||
assert_invariant(home, &prev_good, &new_binary, Expect::PreviousGood);
|
||||
|
||||
// A subsequent clean serve must succeed over the SAME dirty state.
|
||||
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");
|
||||
.expect("clean serve after failures should succeed");
|
||||
assert_invariant(home, &prev_good, &new_binary, Expect::NewBinary);
|
||||
}
|
||||
|
||||
@@ -328,7 +335,7 @@ impl Rng {
|
||||
|
||||
async fn fuzz_loop(iterations: usize, seed: u64) {
|
||||
let server = ArtifactServer::start(small_good_artifact());
|
||||
let len = small_good_artifact().len();
|
||||
let len = server.archive_len("0.1.181");
|
||||
let mut rng = Rng(seed);
|
||||
|
||||
for i in 0..iterations {
|
||||
@@ -340,9 +347,10 @@ async fn fuzz_loop(iterations: usize, seed: u64) {
|
||||
run_one(&server, Mode::Full, version, None).await;
|
||||
continue;
|
||||
}
|
||||
match rng.below(3) {
|
||||
match rng.below(4) {
|
||||
0 => run_one(&server, Mode::Garbage, version, None).await,
|
||||
1 => {
|
||||
1 => run_one(&server, Mode::BadBinary, version, None).await,
|
||||
2 => {
|
||||
// k in [0, len): always strictly truncating (k == len would be
|
||||
// a complete transfer).
|
||||
let k = rng.below(len);
|
||||
@@ -381,11 +389,11 @@ async fn blitz_fuzz_bounded() {
|
||||
}
|
||||
|
||||
/// The "test it a million times, cancelling at every point" stress run. Gated
|
||||
/// behind `#[ignore]`; invoke via `just blitz-stress` or
|
||||
/// behind `#[ignore]`; invoke via
|
||||
/// `cargo nextest run -p kigi-update --run-ignored all`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
#[ignore = "stress: 100k iterations, run via `just blitz-stress`"]
|
||||
#[ignore = "stress: 100k iterations"]
|
||||
async fn blitz_fuzz_stress() {
|
||||
if !can_exec_shell_scripts() {
|
||||
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
//! 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 kigi_update::UpdateConfig;
|
||||
use kigi_update::auto_update::check_update_status;
|
||||
|
||||
/// Set up a fake `npm` on PATH, set `KIGI_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("KIGI_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());
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
//! 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 kigi_update::UpdateConfig;
|
||||
use kigi_update::auto_update::{
|
||||
auto_update_target, check_update_status, ensure_latest_on_disk, install_internal_from_base,
|
||||
};
|
||||
use kigi_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("KIGI_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("KIGI_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 KIGI_SHARE_DIR:
|
||||
/// `bin/{kigi,grok,agent} -> ../downloads/grok-<version>-<platform>` (what
|
||||
/// `install_internal_from_base` produces; `kigi` is the canonical link the
|
||||
/// disk-version probe reads, `grok` the legacy compat link).
|
||||
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();
|
||||
for link in ["kigi", "grok", "agent"] {
|
||||
std::os::unix::fs::symlink(
|
||||
std::path::Path::new("../downloads").join(&name),
|
||||
bin.join(link),
|
||||
)
|
||||
.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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,404 +1,305 @@
|
||||
//! 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:
|
||||
//! Harness for the bootstrap installer (`install.sh` at the repo root), the
|
||||
//! second client that can brick a machine. Runs the REAL shipped script
|
||||
//! against a fake `curl` that serves GitHub-Releases-shaped JSON, the
|
||||
//! archive, and SHA256SUMS from local fixtures, and asserts:
|
||||
//!
|
||||
//! > 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.
|
||||
//! > After any install attempt, `$KIGI_SHARE_DIR/bin/kigi` resolves to a
|
||||
//! > binary that runs, OR the install failed cleanly with nothing activated —
|
||||
//! > 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 `kigi-tui` 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.
|
||||
//! Each test uses its own tempdir home and passes PATH/KIGI_SHARE_DIR to the
|
||||
//! child process explicitly, so no process-global state is touched and no
|
||||
//! `#[serial]` is needed.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
mod common;
|
||||
|
||||
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!("../kigi-tui/scripts/{name}")),
|
||||
)
|
||||
.ok()
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
use common::{archive_name, host_platform, make_release_archive, sha256_hex, small_good_artifact};
|
||||
|
||||
fn install_sh_path() -> Option<PathBuf> {
|
||||
script_path("install.sh")
|
||||
// crates/codegen/kigi-update → repo root.
|
||||
dunce::canonicalize(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../install.sh"))
|
||||
.ok()
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
|
||||
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}")
|
||||
/// GitHub release JSON with download URLs whose suffixes the fake curl
|
||||
/// dispatches on (the host is irrelevant).
|
||||
fn release_json(version: &str) -> String {
|
||||
let name = archive_name(version);
|
||||
serde_json::json!({
|
||||
"tag_name": format!("v{version}"),
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [
|
||||
{ "name": name, "browser_download_url": format!("https://example.test/dl/v{version}/{name}") },
|
||||
{ "name": "SHA256SUMS", "browser_download_url": format!("https://example.test/dl/v{version}/SHA256SUMS") },
|
||||
],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
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();
|
||||
/// Fixture directory holding the fake curl + canned responses.
|
||||
struct Fixture {
|
||||
dir: tempfile::TempDir,
|
||||
home: tempfile::TempDir,
|
||||
}
|
||||
|
||||
/// Seed a valid previous-good binary + symlink in the isolated home.
|
||||
fn seed_previous_good(home: &Path, platform: &str) -> PathBuf {
|
||||
let downloads = home.join(".kigi").join("downloads");
|
||||
let bin = home.join(".kigi").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(".kigi").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("KIGI_BIN_DIR", home.join(".kigi").join("bin"))
|
||||
.env("KIGI_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 {
|
||||
impl Fixture {
|
||||
/// `binary` becomes the `kigi` entry of the served archive; `sums_hash`
|
||||
/// overrides the manifest hash when `Some` (to simulate corruption).
|
||||
fn new(version: &str, binary: &[u8], sums_hash: Option<&str>) -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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"
|
||||
let archive = make_release_archive(binary);
|
||||
let hash = match sums_hash {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_hex(&archive),
|
||||
};
|
||||
std::fs::write(dir.path().join("release.json"), release_json(version)).unwrap();
|
||||
std::fs::write(dir.path().join("archive.tar.gz"), &archive).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("SHA256SUMS"),
|
||||
format!("{hash} {}\n", archive_name(version)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let d = dir.path().to_string_lossy().replace('\'', "'\\''");
|
||||
let curl = format!(
|
||||
r#"#!/bin/sh
|
||||
echo "$@" >> '{d}/curl-args.log'
|
||||
out=""
|
||||
url=""
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
if [ "$prev" = "-o" ]; then out="$a"; fi
|
||||
case "$a" in
|
||||
-*) ;;
|
||||
*) url="$a" ;;
|
||||
esac
|
||||
prev="$a"
|
||||
done
|
||||
serve() {{
|
||||
if [ -n "$out" ]; then cat "$1" > "$out"; else cat "$1"; fi
|
||||
}}
|
||||
case "$url" in
|
||||
*/SHA256SUMS) serve '{d}/SHA256SUMS' ;;
|
||||
*.tar.gz) serve '{d}/archive.tar.gz' ;;
|
||||
*/latest|*/tags/v*) serve '{d}/release.json' ;;
|
||||
*) echo "fake curl: unmatched url: $url" >&2; exit 22 ;;
|
||||
esac
|
||||
"#
|
||||
);
|
||||
let curl_path = dir.path().join("curl");
|
||||
std::fs::write(&curl_path, curl).unwrap();
|
||||
std::fs::set_permissions(&curl_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
// 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());
|
||||
Self { dir, home }
|
||||
}
|
||||
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
let script = install_sh_path().expect("install.sh present at repo root");
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
self.dir.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
Command::new("sh")
|
||||
.arg(&script)
|
||||
.args(args)
|
||||
.env("PATH", path)
|
||||
.env("KIGI_SHARE_DIR", self.home.path())
|
||||
.env("HOME", self.home.path())
|
||||
.output()
|
||||
.expect("install.sh must spawn")
|
||||
}
|
||||
|
||||
fn curl_log(&self) -> String {
|
||||
std::fs::read_to_string(self.dir.path().join("curl-args.log")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn active_kigi(&self) -> PathBuf {
|
||||
self.home.path().join("bin").join("kigi")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-rc rewrite matrix: stow absolute/relative/`..`, plain, first-create, enterprise.
|
||||
fn stderr_of(out: &std::process::Output) -> String {
|
||||
String::from_utf8_lossy(&out.stderr).to_string()
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn install_sh_happy_path_installs_versioned_binary_and_symlink() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"install.sh must succeed: stderr={}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
|
||||
// Managed layout: versioned binary + relative symlink, same as the
|
||||
// self-updater produces.
|
||||
let versioned = fx
|
||||
.home
|
||||
.path()
|
||||
.join("downloads")
|
||||
.join(format!("kigi-0.1.5-{}", host_platform()));
|
||||
assert!(versioned.exists(), "versioned binary installed");
|
||||
assert_eq!(std::fs::read(&versioned).unwrap(), small_good_artifact());
|
||||
|
||||
let link = fx.active_kigi();
|
||||
assert!(link.is_symlink(), "bin/kigi is a symlink");
|
||||
assert_eq!(
|
||||
std::fs::read_link(&link).unwrap(),
|
||||
Path::new("..")
|
||||
.join("downloads")
|
||||
.join(format!("kigi-0.1.5-{}", host_platform())),
|
||||
"symlink must be relative (survives bind-mounted homes)"
|
||||
);
|
||||
|
||||
// The active link runs.
|
||||
let status = Command::new(&link).arg("--version").status().unwrap();
|
||||
assert!(status.success(), "installed kigi must run");
|
||||
|
||||
// Resolved the latest endpoint (no pinned version).
|
||||
assert!(
|
||||
fx.curl_log().contains("/latest"),
|
||||
"must resolve via /latest: {}",
|
||||
fx.curl_log()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_pinned_version_uses_tag_endpoint() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
|
||||
let out = fx.run(&["--version", "v0.1.5"]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"pinned install must succeed: stderr={}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
assert!(
|
||||
fx.curl_log().contains("/tags/v0.1.5"),
|
||||
"must resolve via /tags/v0.1.5: {}",
|
||||
fx.curl_log()
|
||||
);
|
||||
assert!(fx.active_kigi().is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_rejects_checksum_mismatch_and_activates_nothing() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), Some(&"0".repeat(64)));
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"checksum mismatch must fail the install"
|
||||
);
|
||||
assert!(
|
||||
stderr_of(&out).contains("SHA256 mismatch"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
let link = fx.active_kigi();
|
||||
assert!(
|
||||
!link.exists() && !link.is_symlink(),
|
||||
"nothing may be activated after a checksum failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_rejects_invalid_version_argument() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
|
||||
let out = fx.run(&["--version", "not-a-version"]);
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
stderr_of(&out).contains("invalid version"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
assert!(
|
||||
fx.curl_log().is_empty(),
|
||||
"invalid arguments must fail before any network access"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_fails_when_release_lacks_platform_asset() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
// Rewrite release.json without the platform archive asset.
|
||||
let json = serde_json::json!({
|
||||
"tag_name": "v0.1.5",
|
||||
"assets": [
|
||||
{ "name": "SHA256SUMS", "browser_download_url": "https://example.test/dl/v0.1.5/SHA256SUMS" },
|
||||
],
|
||||
});
|
||||
std::fs::write(fx.dir.path().join("release.json"), json.to_string()).unwrap();
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
stderr_of(&out).contains("no asset"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
let link = fx.active_kigi();
|
||||
assert!(!link.exists() && !link.is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_sh_fails_when_archive_lacks_kigi_binary() {
|
||||
if install_sh_path().is_none() {
|
||||
eprintln!("skipping: install.sh not found (vendored sandbox)");
|
||||
return;
|
||||
}
|
||||
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
|
||||
// Replace the archive with one that has no `kigi` entry; keep the
|
||||
// manifest consistent so the checksum gate passes and the extraction
|
||||
// check is what trips.
|
||||
let archive = common::make_tar_gz(&[("LICENSE", b"license only")]);
|
||||
std::fs::write(
|
||||
fx.dir.path().join("SHA256SUMS"),
|
||||
format!("{} {}\n", sha256_hex(&archive), archive_name("0.1.5")),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(fx.dir.path().join("archive.tar.gz"), &archive).unwrap();
|
||||
|
||||
let out = fx.run(&[]);
|
||||
assert!(!out.status.success());
|
||||
assert!(
|
||||
stderr_of(&out).contains("does not contain a 'kigi' binary"),
|
||||
"stderr: {}",
|
||||
stderr_of(&out)
|
||||
);
|
||||
let link = fx.active_kigi();
|
||||
assert!(!link.exists() && !link.is_symlink());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! I/O integration tests for the auto-update crate.
|
||||
//!
|
||||
//! These tests touch global process state — `KIGI_SHARE_DIR` (a `OnceLock` in
|
||||
//! `kigi-config`), `KIGI_TEST_VERSION`, and `NPM_TOKEN` — so they
|
||||
//! `kigi-config`), `KIGI_TEST_VERSION` — so they
|
||||
//! must run serially. Once `KIGI_SHARE_DIR` 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.
|
||||
@@ -277,13 +277,13 @@ async fn write_version_cache_idempotent_for_same_version() {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// get_installed_grok_version env override
|
||||
// get_installed_kigi_version env override
|
||||
//
|
||||
// The function honors `KIGI_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
|
||||
// Note: `get_installed_kigi_version` is not re-exported from `lib.rs`, but
|
||||
// it's `pub` from `version` module and accessible via `version::`.
|
||||
|
||||
#[tokio::test]
|
||||
@@ -295,7 +295,7 @@ async fn get_installed_version_uses_env_var_override() {
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_TEST_VERSION", "9.9.9");
|
||||
}
|
||||
let v = kigi_update::version::get_installed_grok_version();
|
||||
let v = kigi_update::version::get_installed_kigi_version();
|
||||
assert_eq!(v, "9.9.9");
|
||||
unsafe {
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
@@ -311,7 +311,7 @@ async fn get_installed_version_falls_back_to_cargo_pkg_version_when_env_unset()
|
||||
unsafe {
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
}
|
||||
let v = kigi_update::version::get_installed_grok_version();
|
||||
let v = kigi_update::version::get_installed_kigi_version();
|
||||
// The compile-time CARGO_PKG_VERSION must be a parseable semver string.
|
||||
let _: semver::Version = v
|
||||
.parse()
|
||||
@@ -328,13 +328,13 @@ async fn get_installed_version_with_env_var_takes_precedence() {
|
||||
unsafe {
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
}
|
||||
kigi_update::version::get_installed_grok_version()
|
||||
kigi_update::version::get_installed_kigi_version()
|
||||
};
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_TEST_VERSION", "0.0.0-test");
|
||||
}
|
||||
let overridden = kigi_update::version::get_installed_grok_version();
|
||||
let overridden = kigi_update::version::get_installed_kigi_version();
|
||||
assert_ne!(real, overridden);
|
||||
assert_eq!(overridden, "0.0.0-test");
|
||||
|
||||
@@ -352,7 +352,7 @@ async fn get_installed_version_handles_alpha_prerelease_in_env() {
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_TEST_VERSION", "0.1.200-alpha.5");
|
||||
}
|
||||
let v = kigi_update::version::get_installed_grok_version();
|
||||
let v = kigi_update::version::get_installed_kigi_version();
|
||||
assert_eq!(v, "0.1.200-alpha.5");
|
||||
unsafe {
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
@@ -370,7 +370,7 @@ async fn get_installed_version_does_not_validate_env_var_format() {
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_TEST_VERSION", "not-a-version");
|
||||
}
|
||||
let v = kigi_update::version::get_installed_grok_version();
|
||||
let v = kigi_update::version::get_installed_kigi_version();
|
||||
assert_eq!(v, "not-a-version");
|
||||
unsafe {
|
||||
std::env::remove_var("KIGI_TEST_VERSION");
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! directly. We don't need `serial_test` here because each `MockServer` binds
|
||||
//! to its own random port and tests don't touch global state.
|
||||
//!
|
||||
//! Release JSON fixtures mirror the real GitHub REST API
|
||||
//! (https://docs.github.com/en/rest/releases/releases#get-the-latest-release):
|
||||
//! `GET /repos/{owner}/{repo}/releases/latest` →
|
||||
//! `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`.
|
||||
//!
|
||||
//! 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
|
||||
@@ -15,299 +20,299 @@ use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
use kigi_update::auto_update::{download_silent, download_with_progress};
|
||||
use kigi_update::version::fetch_gcs_version_from_base;
|
||||
use kigi_update::version::{fetch_latest_release_from_base, fetch_release_for_version_from_base};
|
||||
|
||||
fn tag_json(tag: &str) -> serde_json::Value {
|
||||
serde_json::json!({ "tag_name": tag, "draft": false, "prerelease": false, "assets": [] })
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Happy-path tests (fast, no retries triggered).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_returns_version_on_success() {
|
||||
async fn latest_release_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"))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let v = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let release = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v, "0.1.181");
|
||||
assert_eq!(release.version().unwrap(), "0.1.181");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_trims_whitespace() {
|
||||
async fn latest_release_accepts_bare_semver_tag() {
|
||||
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 "))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("0.1.181")))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let v = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let release = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v, "0.1.181");
|
||||
assert_eq!(release.version().unwrap(), "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).
|
||||
async fn latest_release_rejects_non_semver_tag_without_retry() {
|
||||
// A non-semver tag is a repo data bug, not a transient failure — the
|
||||
// fetch succeeds in one request and version() reports the bad tag.
|
||||
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
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("release-one")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let release = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = release.version().unwrap_err();
|
||||
assert!(format!("{err}").contains("not semver"), "err: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alpha_channel_picks_semver_max_from_release_list() {
|
||||
// The list is ordered by publication date (newest first) — NOT semver.
|
||||
// Alpha must take the semver max, so a newer-published pre-release does
|
||||
// not shadow a semver-higher stable and vice versa.
|
||||
let server = MockServer::start().await;
|
||||
let list = serde_json::json!([
|
||||
{ "tag_name": "v0.1.180-alpha.5", "draft": false, "prerelease": true, "assets": [] },
|
||||
{ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
|
||||
{ "tag_name": "v0.1.179", "draft": false, "prerelease": false, "assets": [] },
|
||||
]);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(list))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let release = fetch_latest_release_from_base("alpha", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(release.version().unwrap(), "0.1.181");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alpha_channel_returns_prerelease_when_it_is_max() {
|
||||
let server = MockServer::start().await;
|
||||
let list = serde_json::json!([
|
||||
{ "tag_name": "v0.1.182-alpha.1", "draft": false, "prerelease": true, "assets": [] },
|
||||
{ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
|
||||
]);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(list))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let release = fetch_latest_release_from_base("alpha", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(release.version().unwrap(), "0.1.182-alpha.1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alpha_channel_skips_drafts_and_non_semver_tags() {
|
||||
let server = MockServer::start().await;
|
||||
let list = serde_json::json!([
|
||||
{ "tag_name": "v9.9.9", "draft": true, "prerelease": false, "assets": [] },
|
||||
{ "tag_name": "nightly", "draft": false, "prerelease": false, "assets": [] },
|
||||
{ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [] },
|
||||
]);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(list))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let release = fetch_latest_release_from_base("alpha", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
release.version().unwrap(),
|
||||
"0.1.181",
|
||||
"drafts and non-semver tags must not win"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alpha_channel_empty_list_is_an_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = fetch_latest_release_from_base("alpha", &server.uri())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("invalid semver"), "msg: {msg}");
|
||||
assert!(format!("{err:#}").contains("no releases"), "err: {err:#}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_alpha_channel_returns_max_of_alpha_and_stable_when_stable_higher() {
|
||||
async fn stable_channel_does_not_fetch_the_release_list() {
|
||||
// Stable users resolve /latest only; the list endpoint must not be hit.
|
||||
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"))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
|
||||
.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"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let v = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let release = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v, "0.1.181");
|
||||
assert_eq!(release.version().unwrap(), "0.1.181");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_with_long_pre_release_version() {
|
||||
async fn release_for_version_fetches_tag_endpoint() {
|
||||
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"))
|
||||
.and(path("/tags/v0.1.150"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.150")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let v = fetch_gcs_version_from_base("alpha", &server.uri())
|
||||
let release = fetch_release_for_version_from_base("0.1.150", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v, "0.1.190-alpha.42");
|
||||
assert_eq!(release.version().unwrap(), "0.1.150");
|
||||
}
|
||||
|
||||
#[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}`.
|
||||
async fn base_url_trailing_slash_is_tolerated() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/cli/stable"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("0.1.181"))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.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");
|
||||
let base = format!("{}/", server.uri());
|
||||
let release = fetch_latest_release_from_base("stable", &base)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(release.version().unwrap(), "0.1.181");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Retry behavior — these tests intentionally exercise the 1s+2s+4s backoff,
|
||||
// so each takes ~7 seconds. They run in parallel.
|
||||
// so each takes up to ~7 seconds. They run in parallel.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_retries_on_5xx_then_succeeds() {
|
||||
async fn latest_release_retries_on_5xx_then_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/stable"))
|
||||
.and(path("/latest"))
|
||||
.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"))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(tag_json("v0.1.181")))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let v = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let release = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v, "0.1.181");
|
||||
assert_eq!(release.version().unwrap(), "0.1.181");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_gives_up_after_max_retries() {
|
||||
async fn latest_release_gives_up_after_max_retries() {
|
||||
let server = MockServer::start().await;
|
||||
// 4 attempts total: initial + 3 retries.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/stable"))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(4)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let err = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("HTTP 500"), "msg: {msg}");
|
||||
assert!(msg.contains("/latest"), "url should be in error: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_retries_on_empty_body() {
|
||||
async fn latest_release_404_fails_fast_without_retry() {
|
||||
// 404 = release/repo missing — a data condition, not transient. Exactly
|
||||
// one request, and the GitHub error body is surfaced.
|
||||
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"))
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not Found"}"#))
|
||||
.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_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())
|
||||
let err = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("HTTP 404"), "msg: {msg}");
|
||||
assert!(msg.contains("Not Found"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_includes_url_in_error_message() {
|
||||
async fn latest_release_malformed_json_fails_without_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/stable"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(4)
|
||||
.and(path("/latest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<html>not json</html>"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = fetch_gcs_version_from_base("stable", &server.uri())
|
||||
let err = fetch_latest_release_from_base("stable", &server.uri())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("/stable"), "url should be in error: {msg}");
|
||||
assert!(msg.contains("unexpected JSON"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gcs_pointer_connection_refused_is_retried_and_returns_error() {
|
||||
async fn latest_release_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)
|
||||
let err = fetch_latest_release_from_base("stable", &url)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err:#}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("fetch failed")
|
||||
msg.contains("request failed")
|
||||
|| msg.contains("connection")
|
||||
|| msg.contains("error sending request")
|
||||
|| msg.contains("refused"),
|
||||
@@ -325,14 +330,14 @@ 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"))
|
||||
.and(path("/kigi-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());
|
||||
let dest = tmp.path().join("kigi");
|
||||
let url = format!("{}/kigi-0.1.181-macos-aarch64", server.uri());
|
||||
download_silent(&url, &dest).await.unwrap();
|
||||
|
||||
let written = std::fs::read(&dest).unwrap();
|
||||
@@ -371,7 +376,7 @@ async fn download_silent_atomically_renames_via_tmp_file() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
download_silent(&format!("{}/bin", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -399,7 +404,7 @@ async fn download_silent_publishes_executable() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok-0.1.181-linux-x86_64");
|
||||
let dest = tmp.path().join("kigi-0.1.181-linux-x86_64");
|
||||
download_silent(&format!("{}/bin", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -422,7 +427,7 @@ async fn download_silent_fails_on_4xx() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
let err = download_silent(&format!("{}/missing", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -443,7 +448,7 @@ async fn download_silent_fails_on_5xx() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
let err = download_silent(&format!("{}/x", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -460,7 +465,7 @@ async fn download_silent_overwrites_existing_dest() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
std::fs::write(&dest, "old content").unwrap();
|
||||
|
||||
download_silent(&format!("{}/x", server.uri()), &dest)
|
||||
@@ -481,7 +486,7 @@ async fn download_silent_handles_empty_body() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
download_silent(&format!("{}/x", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -503,7 +508,7 @@ async fn download_silent_streams_large_body() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
download_silent(&format!("{}/big", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -524,7 +529,7 @@ async fn download_silent_to_nonexistent_parent_dir_fails() {
|
||||
|
||||
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 dest = tmp.path().join("missing-subdir").join("kigi");
|
||||
let err = download_silent(&format!("{}/x", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -547,14 +552,14 @@ async fn download_with_progress_writes_body_with_content_length() {
|
||||
let server = MockServer::start().await;
|
||||
let body = b"binary content".to_vec();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/grok"))
|
||||
.and(path("/kigi"))
|
||||
.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)
|
||||
let dest = tmp.path().join("kigi");
|
||||
download_with_progress(&format!("{}/kigi", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -571,7 +576,7 @@ async fn download_with_progress_fails_on_http_error() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
let err = download_with_progress(&format!("{}/x", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -590,7 +595,7 @@ async fn download_with_progress_atomic_rename() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok");
|
||||
let dest = tmp.path().join("kigi");
|
||||
download_with_progress(&format!("{}/x", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -663,7 +668,7 @@ async fn download_silent_parallel_path_reassembles_bytes() {
|
||||
.await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path().join("grok-binary");
|
||||
let dest = tmp.path().join("kigi-binary");
|
||||
download_silent(&format!("{}/big", server.uri()), &dest)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
//! 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 kigi_update::auto_update::install_npm_for_test;
|
||||
use kigi_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");
|
||||
}
|
||||
+197
-199
@@ -1,32 +1,21 @@
|
||||
//! 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.
|
||||
//! End-to-end tests for the production update flows (`check_update_status`,
|
||||
//! `ensure_latest_on_disk`, `run_update`, `run_update_if_available`) against
|
||||
//! a GitHub-Releases-shaped [`common::artifact_server::ArtifactServer`],
|
||||
//! injected via the `KIGI_UPDATE_BASE_URL` override that
|
||||
//! `kigi_env::update_base_url()` honors.
|
||||
//!
|
||||
//! Production has three independent downloader paths that can race around a
|
||||
//! release:
|
||||
//! Three invariant families:
|
||||
//!
|
||||
//! 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.
|
||||
//! 1. **Convergence**: a binary already on disk (installed by another
|
||||
//! process) is never downloaded a second time, but stale runners still
|
||||
//! get the relaunch/report signal.
|
||||
//! 2. **Status**: `kigi update --check` reports upgrades only, surfaces
|
||||
//! fetch errors in the `error` field, and never advertises downgrades.
|
||||
//! 3. **Race integrity**: concurrent installers — even for different
|
||||
//! versions — never leave a corrupt active binary.
|
||||
//!
|
||||
//! 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.
|
||||
//! Everything here is `#[serial]`: KIGI_SHARE_DIR, KIGI_UPDATE_BASE_URL and
|
||||
//! KIGI_TEST_VERSION are process-global.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
@@ -39,12 +28,35 @@ 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,
|
||||
can_exec_shell_scripts, host_platform, make_update_config, reset_home, set_test_version,
|
||||
set_update_base, small_good_artifact, test_home,
|
||||
};
|
||||
use kigi_update::auto_update::{
|
||||
UpdateRunMode, check_update_status, ensure_latest_on_disk, install_internal_from_base,
|
||||
run_update, run_update_if_available,
|
||||
};
|
||||
use kigi_update::auto_update::{ensure_latest_on_disk, install_internal_from_base, run_update};
|
||||
use kigi_update::version::installed_on_disk_version;
|
||||
|
||||
/// Lay down a managed-install layout in the test KIGI_SHARE_DIR:
|
||||
/// `bin/kigi -> ../downloads/kigi-<version>-<platform>` (what the installer
|
||||
/// produces; the canonical link the disk-version probe reads).
|
||||
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!("kigi-{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();
|
||||
let _ = std::fs::remove_file(bin.join("kigi"));
|
||||
std::os::unix::fs::symlink(Path::new("../downloads").join(&name), bin.join("kigi")).unwrap();
|
||||
}
|
||||
|
||||
/// Assert the active `~/.kigi/bin/kigi` 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).
|
||||
@@ -55,8 +67,8 @@ fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_con
|
||||
.unwrap_or_else(|e| panic!("active kigi 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"
|
||||
format!("kigi-{version}-{platform}"),
|
||||
"active kigi must be the expected version"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&resolved).unwrap(),
|
||||
@@ -72,88 +84,20 @@ fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_con
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
assert!(ran_ok, "active grok must pass the smoke-test");
|
||||
assert!(ran_ok, "active kigi must pass the smoke-test");
|
||||
}
|
||||
|
||||
/// Lay down a managed-install layout in the test KIGI_SHARE_DIR:
|
||||
/// `bin/{kigi,grok,agent} -> ../downloads/grok-<version>-<platform>` (what
|
||||
/// `install_internal_from_base` produces; `kigi` is the canonical link the
|
||||
/// disk-version probe reads, `grok` the legacy compat link).
|
||||
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();
|
||||
for link in ["kigi", "grok", "agent"] {
|
||||
std::os::unix::fs::symlink(
|
||||
std::path::Path::new("../downloads").join(&name),
|
||||
bin.join(link),
|
||||
)
|
||||
.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 {
|
||||
fn setup(server: &ArtifactServer, latest: &str, running: &str) {
|
||||
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("KIGI_INSTALLER", "gh-release") };
|
||||
FakeBinGuard::install("gh", fake_gh_serving_releases)
|
||||
server.set_latest(latest);
|
||||
set_update_base(&server.base());
|
||||
set_test_version(running);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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]
|
||||
@@ -163,15 +107,15 @@ async fn ensure_latest_downloads_once_then_converges_without_redownload() {
|
||||
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 server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.7", "0.2.5");
|
||||
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!(server.request_count(), 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 →
|
||||
@@ -181,18 +125,12 @@ async fn ensure_latest_downloads_once_then_converges_without_redownload() {
|
||||
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),
|
||||
server.request_count(),
|
||||
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() {
|
||||
@@ -200,8 +138,8 @@ async fn run_update_skips_download_when_disk_already_current() {
|
||||
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 server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.7", "0.2.5");
|
||||
// Another process (TUI background download) already installed 0.2.7.
|
||||
fake_managed_install("0.2.7");
|
||||
let mut cfg = make_update_config("stable");
|
||||
@@ -215,7 +153,7 @@ async fn run_update_skips_download_when_disk_already_current() {
|
||||
signals stale leaders to relaunch"
|
||||
);
|
||||
assert_eq!(
|
||||
gh_download_count(&g),
|
||||
server.request_count(),
|
||||
0,
|
||||
"a binary someone else installed must not be downloaded again"
|
||||
);
|
||||
@@ -228,8 +166,8 @@ async fn run_update_force_still_redownloads_when_disk_current() {
|
||||
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");
|
||||
let server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.7", "0.2.7");
|
||||
fake_managed_install("0.2.7");
|
||||
let mut cfg = make_update_config("stable");
|
||||
|
||||
@@ -237,88 +175,41 @@ async fn run_update_force_still_redownloads_when_disk_current() {
|
||||
|
||||
assert_eq!(result.as_deref(), Some("0.2.7"));
|
||||
assert_eq!(
|
||||
gh_download_count(&g),
|
||||
server.request_count(),
|
||||
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 `~/.kigi/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("KIGI_INSTALLER", "npm") };
|
||||
FakeBinGuard::install_npm()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn npm_update_not_suppressed_by_leftover_newer_internal_symlink() {
|
||||
async fn run_update_rolls_back_when_latest_moved_backwards() {
|
||||
// Release rollback: the latest release points BELOW the on-disk install
|
||||
// (a bad release was deleted). The internal installer is authoritative,
|
||||
// so run_update must converge the disk down to it.
|
||||
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 server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.5", "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"),
|
||||
"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()
|
||||
Some("0.2.5"),
|
||||
"rollback target installed"
|
||||
);
|
||||
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.5"));
|
||||
assert_eq!(server.request_count(), 1);
|
||||
}
|
||||
|
||||
#[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()
|
||||
);
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Disk-version probe
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
@@ -349,7 +240,7 @@ async fn disk_probe_rejects_dangling_symlink() {
|
||||
|
||||
std::fs::remove_file(
|
||||
home.join("downloads")
|
||||
.join(format!("grok-0.2.7-{platform}")),
|
||||
.join(format!("kigi-0.2.7-{platform}")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -370,14 +261,14 @@ async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
|
||||
// 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 server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.7", "0.2.5");
|
||||
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}")),
|
||||
.join(format!("kigi-0.2.7-{platform}")),
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = make_update_config("stable");
|
||||
@@ -389,7 +280,7 @@ async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
|
||||
Some("0.2.7"),
|
||||
"dangling symlink must be repaired by an actual download"
|
||||
);
|
||||
assert_eq!(gh_download_count(&g), 1);
|
||||
assert_eq!(server.request_count(), 1);
|
||||
assert_eq!(
|
||||
installed_on_disk_version().as_deref(),
|
||||
Some("0.2.7"),
|
||||
@@ -397,19 +288,128 @@ async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// check_update_status (`kigi update --check`)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn check_status_reports_update_when_release_is_newer() {
|
||||
let server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.7", "0.2.5");
|
||||
let cfg = make_update_config("stable");
|
||||
|
||||
let status = check_update_status(&cfg).await;
|
||||
|
||||
assert_eq!(status.current_version, "0.2.5");
|
||||
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
|
||||
assert!(status.update_available);
|
||||
assert_eq!(status.installer.as_deref(), Some("internal"));
|
||||
assert_eq!(status.error, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn check_status_never_reports_downgrade_as_update() {
|
||||
// --check reports upgrades only; a rolled-back release is not advertised
|
||||
// (auto-update converges separately).
|
||||
let server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.5", "0.2.7");
|
||||
let cfg = make_update_config("stable");
|
||||
|
||||
let status = check_update_status(&cfg).await;
|
||||
|
||||
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
|
||||
assert!(!status.update_available, "downgrade must not be advertised");
|
||||
assert_eq!(status.error, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn check_status_surfaces_fetch_error_in_error_field() {
|
||||
// Point the updater at a dead endpoint (bound then dropped port →
|
||||
// connection refused). The status must carry the error rather than
|
||||
// pretending "up to date".
|
||||
let _ = test_home();
|
||||
reset_home();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
set_update_base(&format!("http://127.0.0.1:{port}/releases"));
|
||||
set_test_version("0.2.5");
|
||||
let cfg = make_update_config("stable");
|
||||
|
||||
let status = check_update_status(&cfg).await;
|
||||
|
||||
assert!(!status.update_available);
|
||||
assert_eq!(status.latest_version, None);
|
||||
let err = status.error.as_deref().expect("error must be surfaced");
|
||||
assert!(!err.is_empty());
|
||||
|
||||
// And it serializes into the --json contract.
|
||||
let v = serde_json::to_value(&status).unwrap();
|
||||
assert!(v["error"].is_string());
|
||||
assert_eq!(v["updateAvailable"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn check_status_unsupported_channel_reports_error() {
|
||||
let server = ArtifactServer::start(small_good_artifact());
|
||||
setup(&server, "0.2.7", "0.2.5");
|
||||
let cfg = make_update_config("beta");
|
||||
|
||||
let status = check_update_status(&cfg).await;
|
||||
|
||||
assert!(!status.update_available);
|
||||
let err = status.error.as_deref().expect("channel error surfaced");
|
||||
assert!(
|
||||
err.contains("Unsupported release channel 'beta'"),
|
||||
"err: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// run_update_if_available — the auto-update opt-out gate.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn run_update_if_available_respects_auto_update_false() {
|
||||
// With cli.auto_update = false persisted, the startup check must return
|
||||
// without ever touching the network (the update base points at a dead
|
||||
// port — any fetch would error, any download would install).
|
||||
let _ = test_home();
|
||||
reset_home();
|
||||
std::fs::write(
|
||||
test_home().join("config.toml"),
|
||||
"[cli]\nauto_update = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
set_update_base(&format!("http://127.0.0.1:{port}/releases"));
|
||||
set_test_version("0.1.0");
|
||||
let cfg = make_update_config("stable");
|
||||
|
||||
let ran = run_update_if_available(UpdateRunMode::Blocking, false, &cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ran, "auto_update=false must suppress the update entirely");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
// must never leave a corrupt active binary.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn run_concurrent_installs(
|
||||
server: &ArtifactServer,
|
||||
versions: &[&str],
|
||||
) -> Vec<anyhow::Result<()>> {
|
||||
let base = server.uri();
|
||||
let base = server.base();
|
||||
let mut tasks = Vec::new();
|
||||
for version in versions {
|
||||
let base = base.clone();
|
||||
@@ -466,9 +466,6 @@ async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
|
||||
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");
|
||||
@@ -478,7 +475,7 @@ async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
|
||||
for version in ["0.1.181", "0.1.182"] {
|
||||
let path = home
|
||||
.join("downloads")
|
||||
.join(format!("grok-{version}-{platform}"));
|
||||
.join(format!("kigi-{version}-{platform}"));
|
||||
assert_eq!(
|
||||
std::fs::read(&path).unwrap(),
|
||||
artifact,
|
||||
@@ -488,17 +485,18 @@ async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
|
||||
|
||||
// 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();
|
||||
let resolved = dunce::canonicalize(home.join("bin").join("kigi")).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}"
|
||||
"active kigi must never be a temp file: {name}"
|
||||
);
|
||||
|
||||
// No stray shared temp file left behind (the pre-fix collision name).
|
||||
// No stray shared temp file left behind (a with_extension-style
|
||||
// collision name).
|
||||
assert!(
|
||||
!home.join("downloads").join("grok-0.1.tmp").exists(),
|
||||
"the pre-fix shared temp name must not exist"
|
||||
!home.join("downloads").join("kigi-0.1.tmp").exists(),
|
||||
"the shared-temp-name collision must not exist"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user