Files
Kigi-CLI/crates/codegen/kigi-update/tests/test_subprocess.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

447 lines
15 KiB
Rust

//! 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");
}