Files
cli/tests/cli.rs
zeekay b2ec713051 A bare hanzo starts the coding agent, like hanzo code
`hanzo --help` and `hanzo help` keep the man page. A missing `dev` names the
installer. The generator-only `Ty::Union` variant no longer fails clippy.

Claude-Session: https://claude.ai/code/session_01LCxkTkYTJrFqZ27vDSsn6Y
2026-10-08 11:45:30 -07:00

370 lines
14 KiB
Rust

//! Native functionality tests for the SHIPPED `hanzo` binary — the real
//! executable, real argv, real exit codes. Hermetic tests isolate ALL persisted
//! state behind `--config <tempdir>` (the global flag, placed after the
//! subcommand); nothing here reads or writes the developer's own config.
//!
//! Live tests (real login against hanzo.id / api.hanzo.ai) are gated on
//! `HANZO_E2E_TOKEN` — a real hanzo.id bearer. Absent → the test SKIPS with the
//! env var named on stderr (honest skip, never a fake green). NOTE: a live
//! login stores a credential through the real vault (the OS keychain on a
//! desktop; the file vault in CI) for the token's OWN identity — the same slot
//! a developer's real login for that identity uses — so the suite never runs
//! `logout` and never removes credentials.
use assert_cmd::Command;
use predicates::prelude::*;
/// A `hanzo` invocation with clean, non-interactive output.
fn hanzo() -> Command {
let mut cmd = Command::cargo_bin("hanzo").expect("hanzo binary builds");
cmd.env("NO_COLOR", "1");
cmd.env("HANZO_NO_ANIMATION", "1");
cmd
}
/// The `--config <file>` tail every isolated invocation appends.
fn cfg_args(cfg: &tempfile::TempDir) -> [String; 2] {
[
"--config".into(),
cfg.path().join("config.toml").display().to_string(),
]
}
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
// ── Version + surface ────────────────────────────────────────────────────────
/// `--version` reports the ONE version (Cargo.toml via CARGO_PKG_VERSION).
#[test]
fn version_is_the_cargo_version() {
hanzo()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
/// THE DEFECT: `hanzo --version` printed clap's own line and exited, so it never
/// reached `commands::version::run` — the only thing that reports an install
/// skew — and it disagreed with `hanzo version` on format. All three spellings
/// must be ONE function, so their STDOUT must be byte-identical: exactly one
/// line, `hanzo <semver>`, which is what the Go control binary's delegate parser
/// reads. A skew report is a warning, so it may never land on stdout.
#[test]
fn every_version_spelling_prints_the_one_line() {
let out = |args: &[&str]| {
let a = hanzo().args(args).assert().success();
String::from_utf8_lossy(&a.get_output().stdout).to_string()
};
let one = format!("hanzo {}\n", env!("CARGO_PKG_VERSION"));
assert_eq!(out(&["--version"]), one);
assert_eq!(out(&["-V"]), one);
assert_eq!(out(&["version"]), one);
}
/// THE DEFECT: there was no `status` command, so `hanzo status` was parsed as the
/// flattened coding-session TASK and launched a headless agent. It must be a real
/// command with its own help.
#[test]
fn status_is_a_command_not_a_coding_session() {
hanzo()
.args(["status", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("unhealthy").and(predicate::str::contains("Usage: hanzo status")));
}
/// Signed out, `status` REFUSES and names the way in — it never renders an empty
/// cloud as though that were the truth.
#[test]
fn status_signed_out_refuses_and_never_shows_an_empty_fleet() {
let cfg = tmp();
hanzo()
.args(["status"])
.args(cfg_args(&cfg))
.assert()
.failure()
.stderr(predicate::str::contains("not signed in").and(predicate::str::contains("hanzo auth login")))
.stdout(predicate::str::contains("all clear").not());
}
/// A bare `hanzo` is the coding session, like `hanzo code`: it reaches for the
/// `dev` agent (here missing, so it says how to install it) instead of printing
/// the help page, which `hanzo --help` still prints.
#[test]
fn a_bare_hanzo_starts_the_coding_agent() {
let home = tmp();
let empty_path = tmp();
let out = hanzo()
.current_dir(home.path())
.env("HOME", home.path())
.env("PATH", empty_path.path())
.write_stdin("")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(stderr.contains("the `dev` coding agent is not installed"), "{stderr}");
assert!(stderr.contains("hanzo.sh"), "the error names the installer: {stderr}");
assert!(!stdout.contains("SYNOPSIS"), "a bare hanzo printed the help page: {stdout}");
}
/// `--help` names every major resource the CLI ships (the resource-noun tree),
/// and the old top-level verbs are GONE.
#[test]
fn help_lists_the_resource_nouns() {
let out = hanzo().arg("--help").assert().success();
let help = String::from_utf8_lossy(&out.get_output().stdout).to_string();
for surface in [
"agent", "auth", "cluster", "config", "model", "node", "runner", "secret",
"serve", "version", "chain", "network", "wallet", "billing", "connector", "usage",
] {
assert!(help.contains(surface), "`hanzo --help` must list the `{surface}` resource");
}
// The relocated verbs are not top-level names any more.
for gone in ["login", "whoami", "switch", "deploy"] {
assert!(
!help.contains(&format!(" {gone} ")),
"`hanzo --help` must not list `{gone}` as a top-level command"
);
}
}
/// Every graph operation is a command, and each one that asks the graph a
/// question at a point takes both of its times: `--as-of` (when it was so) and
/// `--as-known` (how much the graph had heard).
#[test]
fn graph_asks_at_both_times() {
let help = |args: &[&str]| {
let out = hanzo().args(args).arg("--help").assert().success();
String::from_utf8_lossy(&out.get_output().stdout).to_string()
};
let top = help(&["graph"]);
let listed = |verb: &str| top.lines().any(|l| l.split_whitespace().next() == Some(verb));
for verb in [
"create", "get", "search", "resolve", "neighbors", "path", "diff", "communities", "answer",
"vocabulary", "extract", "ingest", "erase",
] {
assert!(listed(verb), "`hanzo graph` does not list `{verb}`:\n{top}");
}
for verb in ["get", "search", "resolve", "neighbors", "path", "communities", "answer"] {
let h = help(&["graph", verb]);
assert!(h.contains("--as-of") && h.contains("--as-known"), "`hanzo graph {verb}` lacks a time:\n{h}");
}
let h = help(&["graph", "diff"]);
for flag in ["--from", "--to", "--from-known", "--to-known"] {
assert!(h.contains(flag), "`hanzo graph diff` lacks {flag}:\n{h}");
}
}
/// `hanzo vm` is a pure passthrough: a resolvable, current-enough `hanzo-vm`
/// runs verbatim — no reinstall, its stdout and exit are ours. (The bootstrap
/// that installs an absent or stale binary is unit-tested in commands/vm.rs;
/// this stays hermetic.)
#[test]
fn vm_passes_through_to_a_resolved_binary() {
let home = tmp();
let bin_dir = home.path().join(".local/bin");
std::fs::create_dir_all(&bin_dir).unwrap();
// First line is the version contract (last token); second proves the args
// arrived verbatim.
let fake = bin_dir.join("hanzo-vm");
std::fs::write(&fake, "#!/bin/sh\necho \"hanzo-vm 99.0.0\"\necho \"args: $@\"\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
hanzo()
.args(["vm", "--version"])
.env("PATH", "/nonexistent")
.env("HOME", home.path())
.assert()
.success()
.stdout(predicate::str::contains("hanzo-vm 99.0.0"))
.stdout(predicate::str::contains("args: --version"));
}
// ── Network model (hermetic, config-isolated) ────────────────────────────────
/// `network list` shows the four built-ins, mainnet active by default, and the
/// sovereign L1 law (network_id == chain_id) on each.
#[test]
fn network_list_shows_builtins_with_mainnet_active() {
let cfg = tmp();
hanzo()
.args(["network", "list"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(
predicate::str::contains("* mainnet")
.and(predicate::str::contains("testnet"))
.and(predicate::str::contains("devnet"))
.and(predicate::str::contains("local"))
.and(predicate::str::contains("36963"))
.and(predicate::str::contains("sovereign")),
);
}
/// `network use` switches and PERSISTS: a later invocation on the same config
/// reads the selection back. This proves the config write path end to end.
#[test]
fn network_use_persists_across_invocations() {
let cfg = tmp();
hanzo()
.args(["network", "use", "testnet"])
.args(cfg_args(&cfg))
.assert()
.success();
hanzo()
.args(["network", "current"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(predicate::str::contains("testnet").and(predicate::str::contains("36962")));
}
/// An unknown network is refused with a useful hint, non-zero.
#[test]
fn network_use_unknown_fails_with_hint() {
let cfg = tmp();
hanzo()
.args(["network", "use", "bogus"])
.args(cfg_args(&cfg))
.assert()
.failure()
.stderr(predicate::str::contains("unknown network").and(predicate::str::contains("network add")));
}
/// `network add` registers a sovereign L1 (chain-id defaults to network-id),
/// `--activate` selects it, and it round-trips through `current`.
#[test]
fn network_add_sovereign_roundtrip() {
let cfg = tmp();
hanzo()
.args([
"network", "add", "my-l1",
"--network-id", "424242",
"--rpc", "https://rpc.my-l1.example",
"--api", "https://api.my-l1.example",
"--activate",
])
.args(cfg_args(&cfg))
.assert()
.success();
hanzo()
.args(["network", "current"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(
predicate::str::contains("my-l1")
.and(predicate::str::contains("424242")) // chain_id defaulted == network_id
.and(predicate::str::contains("https://rpc.my-l1.example")),
);
}
// ── Identity + secrets law (hermetic) ────────────────────────────────────────
/// `auth show` with no identity is an honest refusal naming `hanzo auth login`.
#[test]
fn auth_show_signed_out_refuses_with_login_hint() {
let cfg = tmp();
hanzo()
.args(["auth", "show"])
.args(cfg_args(&cfg))
.assert()
.failure()
.stderr(
predicate::str::contains("not signed in").and(predicate::str::contains("hanzo auth login")),
);
}
/// The stdin-secret law: a literal token on argv is REFUSED (it would land in
/// `ps` and shell history) — the grammar itself demands stdin.
#[test]
fn login_refuses_a_literal_token_on_argv() {
let cfg = tmp();
hanzo()
.args(["auth", "login", "--provider", "hanzo", "--token", "not-a-real-token"])
.args(cfg_args(&cfg))
.assert()
.failure();
}
/// A garbage bearer on stdin fails CLEANLY: no identity can be derived from a
/// non-JWT, so nothing is stored and the exit is non-zero.
#[test]
fn login_rejects_a_garbage_token_from_stdin() {
let cfg = tmp();
hanzo()
.args(["auth", "login", "--provider", "hanzo", "--token", "-"])
.args(cfg_args(&cfg))
.write_stdin("garbage-not-a-jwt\n")
.assert()
.failure();
}
/// `wallet list` with no wallets is a clean empty state (exit 0) that names the
/// create path — never an error, never an invented wallet.
#[test]
fn wallet_list_empty_state() {
let cfg = tmp();
hanzo()
.args(["wallet", "list"])
.args(cfg_args(&cfg))
.assert()
.success()
.stdout(predicate::str::contains("no wallets").and(predicate::str::contains("hanzo wallet create")));
}
// ── Live auth flow (gated on HANZO_E2E_TOKEN) ────────────────────────────────
/// Skip helper: honest skip on stderr naming the exact env var.
fn e2e_token() -> Option<String> {
match std::env::var("HANZO_E2E_TOKEN") {
Ok(t) if !t.trim().is_empty() => Some(t.trim().to_string()),
_ => {
eprintln!("SKIP: set HANZO_E2E_TOKEN (a real hanzo.id bearer) to run the live auth flow");
None
}
}
}
/// The REAL auth flow with a token from env: `login --token -` (stdin) files the
/// identity, `whoami` reports it, and `usage` reads the live per-account
/// balances from api.hanzo.ai with that credential. One flow, live.
#[test]
fn live_login_whoami_usage_with_env_token() {
let Some(token) = e2e_token() else { return };
let cfg = tmp();
hanzo()
.args(["auth", "login", "--provider", "hanzo", "--token", "-"])
.args(cfg_args(&cfg))
.write_stdin(format!("{token}\n"))
.assert()
.success();
hanzo()
.args(["auth", "show"])
.args(cfg_args(&cfg))
.assert()
.success()
// auth show prints the active identity as owner/name — never "not signed in".
.stdout(predicate::str::contains("/"))
.stdout(predicate::str::contains("not signed in").not());
// `usage` fans out to api.hanzo.ai with each held account's own token. A
// stacked view always renders; a broke/expired account is a row state, not
// a crash — so success + at least one row is the honest assertion.
hanzo()
.arg("usage")
.args(cfg_args(&cfg))
.assert()
.success();
}