Files
cli/tests/cli.rs
zeekayandHanzo Dev 158183641a status: the whole cloud, not two laptops (1.9.21)
`hanzo status` was not a command. There was no `status` in the subcommand
enum, so it parsed as the flattened coding-session TASK and started a headless
coding agent — typing a read-only word launched a writer. It is now a real
command, and it shows the cloud rather than one corner of it: clusters, the
deployed applications and their health, and the machines on the fleet.

COMPOSED, never a new API. Three routes cloud already serves, read
concurrently through the one authenticated seam: GET /v1/k8s/clusters, GET
/v1/deploy/applications, GET /v1/fleet/workers. No fourth wire, no CLI-only
side channel. That seam is now a value (`product::Seam`) lifted out of the
product tree's own `call` — WHERE and WHO resolved once, then fanned out —
so this reads three surfaces without re-deriving the origin or the credential
and without a second HTTP client.

Two laws it exists to enforce:

  Most important first. Anything unhealthy leads the page: an application that
  is not Healthy+Synced, a cluster not running, a node not online. The rest is
  grouped and COUNTED, so a fleet of 336 healthy applications is three lines
  rather than 336 rows.

  A surface that did not answer is UNAVAILABLE, never zero. /v1/deploy/gitops
  answers 403 to a non-console identity while /v1/k8s/clusters legitimately
  answers an empty list — those are different facts, and rendering the refusal
  as "0" reads exactly like a healthy fleet. A refusal prints its status and
  the server's own words (collapsed to one clipped line, so an ingress error
  page cannot eat the page it was meant to explain); an empty 200 prints "none
  reported"; an answer with no list prints "unreadable". "all clear" is
  claimed only when every surface was actually read. One failing surface never
  fails the command; failing to read all of them does.

Nothing is invented: every field is read through one reader, and a value the
server did not state is omitted or reported unknown — never defaulted to a
number that looks like health.

1.9.20 was taken by the spec refresh already on main, so this is the next
patch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 11:53:41 -07:00

294 lines
10 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());
}
/// `--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", "fabric", "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"
);
}
}
// ── 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();
}