Files
OpenShell/e2e/rust/tests/cli_smoke.rs
krishicks 97e1051307 fix(tasks): format all Rust workspaces (#2268)
Run cargo fmt for both the root workspace and the standalone e2e Rust workspace
from the rust format and format-check tasks. Apply rustfmt to the e2e sources
so the expanded formatting check passes.

Signed-off-by: Kris Hicks <khicks@nvidia.com>
2026-07-14 17:37:38 +00:00

455 lines
14 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! CLI smoke tests that verify command structure and graceful error handling.
//!
//! These tests do NOT require a running gateway — they exercise the CLI binary
//! directly, validating that the restructured command tree parses correctly and
//! handles edge cases like missing gateway configuration.
use std::fs;
use std::path::Path;
use std::process::Stdio;
use openshell_e2e::harness::binary::openshell_cmd;
use openshell_e2e::harness::output::strip_ansi;
async fn run_with_config(
config_dir: &Path,
system_dir: Option<&Path>,
args: &[&str],
) -> (String, i32) {
let mut cmd = openshell_cmd();
cmd.args(args)
.env("XDG_CONFIG_HOME", config_dir)
.env("HOME", config_dir)
.env_remove("OPENSHELL_GATEWAY")
.env_remove("OPENSHELL_GATEWAY_ENDPOINT")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(system_dir) = system_dir {
cmd.env("OPENSHELL_SYSTEM_GATEWAY_DIR", system_dir);
} else {
cmd.env_remove("OPENSHELL_SYSTEM_GATEWAY_DIR");
}
let output = cmd.output().await.expect("spawn openshell");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = format!("{stdout}{stderr}");
let code = output.status.code().unwrap_or(-1);
(combined, code)
}
/// Run `openshell <args>` with an isolated (empty) config directory so it
/// cannot discover any real gateway.
async fn run_isolated(args: &[&str]) -> (String, i32) {
let tmpdir = tempfile::tempdir().expect("create isolated config dir");
let system_dir = tempfile::tempdir().expect("create isolated system config dir");
run_with_config(tmpdir.path(), Some(system_dir.path()), args).await
}
fn write_gateway_metadata(
root: &Path,
name: &str,
endpoint: &str,
gateway_port: u16,
is_remote: bool,
auth_mode: &str,
) {
let gateway_dir = root.join("gateways").join(name);
fs::create_dir_all(&gateway_dir).expect("create gateway dir");
let metadata = serde_json::json!({
"name": name,
"gateway_endpoint": endpoint,
"gateway_port": gateway_port,
"is_remote": is_remote,
"auth_mode": auth_mode,
});
fs::write(
gateway_dir.join("metadata.json"),
serde_json::to_vec_pretty(&metadata).expect("serialize gateway metadata"),
)
.expect("write gateway metadata");
}
fn write_user_gateway_metadata(
config_dir: &Path,
name: &str,
endpoint: &str,
gateway_port: u16,
is_remote: bool,
auth_mode: &str,
) {
write_gateway_metadata(
&config_dir.join("openshell"),
name,
endpoint,
gateway_port,
is_remote,
auth_mode,
);
}
fn write_system_gateway_metadata(
system_dir: &Path,
name: &str,
endpoint: &str,
gateway_port: u16,
is_remote: bool,
auth_mode: &str,
) {
write_gateway_metadata(
system_dir,
name,
endpoint,
gateway_port,
is_remote,
auth_mode,
);
}
fn write_active_gateway(config_dir: &Path, name: &str) {
let active_path = config_dir.join("openshell").join("active_gateway");
fs::create_dir_all(active_path.parent().expect("active gateway parent"))
.expect("create active gateway parent");
fs::write(active_path, format!("{name}\n")).expect("write active gateway");
}
fn seed_gateway_sources(config_dir: &Path, system_dir: &Path) {
write_user_gateway_metadata(
config_dir,
"alpha",
"https://alpha.example.com",
443,
true,
"cloudflare_jwt",
);
write_system_gateway_metadata(
system_dir,
"beta",
"http://127.0.0.1:17670",
17670,
false,
"plaintext",
);
}
// -------------------------------------------------------------------
// Top-level --help shows the restructured command tree
// -------------------------------------------------------------------
/// `openshell --help` must list the new top-level commands: gateway, status,
/// forward, logs, policy.
#[tokio::test]
async fn help_shows_restructured_commands() {
let (output, code) = run_isolated(&["--help"]).await;
assert_eq!(code, 0, "openshell --help should exit 0");
let clean = strip_ansi(&output);
for cmd in ["gateway", "status", "sandbox", "forward", "logs", "policy"] {
assert!(
clean.contains(cmd),
"expected '{cmd}' in --help output:\n{clean}"
);
}
}
/// `openshell gateway --help` must list registration/auth commands, not
/// service lifecycle commands.
#[tokio::test]
async fn gateway_help_shows_subcommands() {
let (output, code) = run_isolated(&["gateway", "--help"]).await;
assert_eq!(code, 0, "openshell gateway --help should exit 0");
let clean = strip_ansi(&output);
for sub in ["add", "remove", "login", "logout", "select", "info", "list"] {
assert!(
clean.contains(sub),
"expected '{sub}' in gateway --help output:\n{clean}"
);
}
for removed in ["start", "stop", "destroy"] {
assert!(
!clean.contains(removed),
"did not expect removed gateway lifecycle subcommand '{removed}' in help:\n{clean}"
);
}
}
/// `openshell sandbox --help` must list upload and download alongside create,
/// get, list, delete, connect.
#[tokio::test]
async fn sandbox_help_shows_upload_download() {
let (output, code) = run_isolated(&["sandbox", "--help"]).await;
assert_eq!(code, 0, "openshell sandbox --help should exit 0");
let clean = strip_ansi(&output);
for sub in [
"upload", "download", "create", "get", "list", "delete", "connect",
] {
assert!(
clean.contains(sub),
"expected '{sub}' in sandbox --help output:\n{clean}"
);
}
}
/// `openshell sandbox create --help` must show `--gpu`, `--upload`,
/// `--no-git-ignore`, `--editor`, and `--auto-providers`/`--no-auto-providers`.
#[tokio::test]
async fn sandbox_create_help_shows_new_flags() {
let (output, code) = run_isolated(&["sandbox", "create", "--help"]).await;
assert_eq!(code, 0, "openshell sandbox create --help should exit 0");
let clean = strip_ansi(&output);
for flag in [
"--gpu",
"--upload",
"--no-git-ignore",
"--editor",
"--auto-providers",
"--no-auto-providers",
] {
assert!(
clean.contains(flag),
"expected '{flag}' in sandbox create --help:\n{clean}"
);
}
}
/// `openshell sandbox connect --help` must show `--editor`.
#[tokio::test]
async fn sandbox_connect_help_shows_editor_flag() {
let (output, code) = run_isolated(&["sandbox", "connect", "--help"]).await;
assert_eq!(code, 0, "openshell sandbox connect --help should exit 0");
let clean = strip_ansi(&output);
assert!(
clean.contains("--editor"),
"expected '--editor' in sandbox connect --help:\n{clean}"
);
}
/// Removed gateway lifecycle subcommands should fail during parsing.
#[tokio::test]
async fn gateway_lifecycle_subcommands_are_removed() {
for subcommand in ["start", "stop", "destroy"] {
let (output, code) = run_isolated(&["gateway", subcommand, "--help"]).await;
assert!(
code != 0,
"openshell gateway {subcommand} should fail after lifecycle command removal"
);
let clean = strip_ansi(&output);
assert!(
clean.contains("unrecognized subcommand") || clean.contains("error:"),
"expected parser error for removed gateway subcommand '{subcommand}':\n{clean}"
);
}
}
// -------------------------------------------------------------------
// Graceful handling: `openshell status` without a gateway
// -------------------------------------------------------------------
/// `openshell status` with no gateway configured should exit 0 and print a
/// friendly message instead of erroring.
#[tokio::test]
async fn status_without_gateway_prints_friendly_message() {
let (output, code) = run_isolated(&["status"]).await;
assert_eq!(
code, 0,
"openshell status should exit 0 even without a gateway, got output:\n{output}"
);
let clean = strip_ansi(&output);
assert!(
clean.contains("No gateway configured"),
"expected 'No gateway configured' in status output:\n{clean}"
);
assert!(
clean.contains("openshell gateway add <endpoint>"),
"expected hint to register a gateway:\n{clean}"
);
}
// -------------------------------------------------------------------
// Gateway list source indicators
// -------------------------------------------------------------------
#[tokio::test]
async fn gateway_list_table_shows_user_and_system_sources() {
let config_dir = tempfile::tempdir().expect("create config dir");
let system_dir = tempfile::tempdir().expect("create system dir");
seed_gateway_sources(config_dir.path(), system_dir.path());
write_active_gateway(config_dir.path(), "alpha");
let (output, code) = run_with_config(
config_dir.path(),
Some(system_dir.path()),
&["gateway", "list"],
)
.await;
assert_eq!(code, 0, "gateway list should exit 0:\n{output}");
let clean = strip_ansi(&output);
assert!(clean.contains("SOURCE"), "expected SOURCE column:\n{clean}");
let alpha_line = clean
.lines()
.find(|line| line.contains("alpha"))
.expect("find alpha row");
assert!(
alpha_line.contains("user"),
"expected alpha row to show user source:\n{clean}"
);
let beta_line = clean
.lines()
.find(|line| line.contains("beta"))
.expect("find beta row");
assert!(
beta_line.contains("system"),
"expected beta row to show system source:\n{clean}"
);
}
#[tokio::test]
async fn gateway_list_json_includes_user_and_system_sources() {
let config_dir = tempfile::tempdir().expect("create config dir");
let system_dir = tempfile::tempdir().expect("create system dir");
seed_gateway_sources(config_dir.path(), system_dir.path());
let (output, code) = run_with_config(
config_dir.path(),
Some(system_dir.path()),
&["gateway", "list", "-o", "json"],
)
.await;
assert_eq!(code, 0, "gateway list -o json should exit 0:\n{output}");
let items: serde_json::Value = serde_json::from_str(&output).expect("parse gateway list json");
let items = items.as_array().expect("gateway list json array");
assert_eq!(items.len(), 2, "expected two gateways in json output");
let alpha = items
.iter()
.find(|item| item["name"] == "alpha")
.expect("find alpha entry");
assert_eq!(alpha["source"], "user");
let beta = items
.iter()
.find(|item| item["name"] == "beta")
.expect("find beta entry");
assert_eq!(beta["source"], "system");
}
#[tokio::test]
async fn gateway_add_can_shadow_system_gateway_with_user_registration() {
let config_dir = tempfile::tempdir().expect("create config dir");
let system_dir = tempfile::tempdir().expect("create system dir");
write_system_gateway_metadata(
system_dir.path(),
"beta",
"http://127.0.0.1:17670",
17670,
false,
"plaintext",
);
let (add_output, add_code) = run_with_config(
config_dir.path(),
Some(system_dir.path()),
&["gateway", "add", "http://127.0.0.1:17671", "--name", "beta"],
)
.await;
assert_eq!(
add_code, 0,
"gateway add should allow a user registration to shadow a system gateway:\n{add_output}"
);
let (list_output, list_code) = run_with_config(
config_dir.path(),
Some(system_dir.path()),
&["gateway", "list", "-o", "json"],
)
.await;
assert_eq!(
list_code, 0,
"gateway list -o json should exit 0:\n{list_output}"
);
let items: serde_json::Value =
serde_json::from_str(&list_output).expect("parse gateway list json");
let beta = items
.as_array()
.expect("gateway list json array")
.iter()
.find(|item| item["name"] == "beta")
.expect("find beta entry");
assert_eq!(beta["source"], "user");
assert_eq!(beta["endpoint"], "http://127.0.0.1:17671");
}
#[tokio::test]
async fn gateway_remove_rejects_system_only_registration_and_preserves_entry() {
let config_dir = tempfile::tempdir().expect("create config dir");
let system_dir = tempfile::tempdir().expect("create system dir");
write_system_gateway_metadata(
system_dir.path(),
"beta",
"http://127.0.0.1:17670",
17670,
false,
"plaintext",
);
let (remove_output, remove_code) = run_with_config(
config_dir.path(),
Some(system_dir.path()),
&["gateway", "remove", "beta"],
)
.await;
assert_ne!(
remove_code, 0,
"gateway remove should reject system-only registrations:\n{remove_output}"
);
let clean_remove = strip_ansi(&remove_output);
let normalized_remove = clean_remove
.replace(['│', '×'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
normalized_remove
.contains("installed by the system and cannot be removed from user config"),
"expected system-only removal guidance:\n{clean_remove}"
);
let (list_output, list_code) = run_with_config(
config_dir.path(),
Some(system_dir.path()),
&["gateway", "list", "-o", "json"],
)
.await;
assert_eq!(
list_code, 0,
"gateway list -o json should still succeed:\n{list_output}"
);
let items: serde_json::Value =
serde_json::from_str(&list_output).expect("parse gateway list json");
let beta = items
.as_array()
.expect("gateway list json array")
.iter()
.find(|item| item["name"] == "beta")
.expect("find beta entry after failed remove");
assert_eq!(beta["source"], "system");
assert_eq!(beta["endpoint"], "http://127.0.0.1:17670");
}