diff --git a/Cargo.toml b/Cargo.toml index 50a07dc3..8a39ba28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -203,6 +203,8 @@ cc = "1.2.55" # Testing and benchmarking utilities tokio-test = "0.4.5" +assert_cmd = "2.0.16" +predicates = "3.1.3" chrono = { version = "0.4.43", features = ["serde"] } # Additional common dependencies diff --git a/crates/flight-cli/Cargo.toml b/crates/flight-cli/Cargo.toml index 5b8aa73b..781f3fa4 100644 --- a/crates/flight-cli/Cargo.toml +++ b/crates/flight-cli/Cargo.toml @@ -40,3 +40,9 @@ serde_json.workspace = true serde = { workspace = true } chrono = { workspace = true, features = ["serde"] } dirs.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/flight-cli/tests/common/mod.rs b/crates/flight-cli/tests/common/mod.rs new file mode 100644 index 00000000..54b9bcc1 --- /dev/null +++ b/crates/flight-cli/tests/common/mod.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Shared test helpers for flight-cli integration tests + +use serde_json::Value; + +/// Build an `assert_cmd::Command` pointing at the `flightctl` binary. +pub fn cli() -> assert_cmd::Command { + assert_cmd::Command::new(assert_cmd::cargo_bin!("flightctl")) +} + +/// Find the first JSON object line in `text` and parse it, or panic. +pub fn parse_json_from(text: &str) -> Value { + text.lines() + .find(|l| l.trim().starts_with('{')) + .and_then(|l| serde_json::from_str(l).ok()) + .unwrap_or_else(|| panic!("No valid JSON line found in:\n{}", text)) +} + +/// Try to find and parse the first JSON object line in `text`. +#[allow(dead_code)] +pub fn try_parse_json_from(text: &str) -> Option { + text.lines() + .find(|l| l.trim().starts_with('{')) + .and_then(|l| serde_json::from_str(l).ok()) +} diff --git a/crates/flight-cli/tests/device_cmd.rs b/crates/flight-cli/tests/device_cmd.rs new file mode 100644 index 00000000..1de333bc --- /dev/null +++ b/crates/flight-cli/tests/device_cmd.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests for `flightctl devices` subcommands + +mod common; + +use common::{cli, parse_json_from}; +use serde_json::Value; + +// ── devices list ────────────────────────────────────────────────────────── + +#[test] +fn devices_list_does_not_panic() { + let output = cli().args(["devices", "list"]).output().unwrap(); + // Must not panic (exit code 101); both success and failure are acceptable + assert_ne!(output.status.code(), Some(101)); +} + +#[test] +fn devices_list_json_has_stable_fields() { + let output = cli().args(["--json", "devices", "list"]).output().unwrap(); + // Must not panic + assert_ne!(output.status.code(), Some(101)); + + if output.status.success() { + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + } else { + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + + assert_eq!(json["success"], false); + assert!(json["error"].is_string()); + assert!(json["error_code"].is_string()); + + let error_code = json["error_code"].as_str().unwrap(); + let valid_codes = [ + "CONNECTION_FAILED", + "VERSION_MISMATCH", + "UNSUPPORTED_FEATURE", + "TRANSPORT_ERROR", + "SERIALIZATION_ERROR", + "GRPC_ERROR", + "UNKNOWN_ERROR", + ]; + assert!( + valid_codes.contains(&error_code), + "error_code '{}' should be a known code", + error_code + ); + } +} + +#[test] +fn devices_list_with_include_disconnected_flag_accepted() { + let output = cli() + .args(["devices", "list", "--include-disconnected"]) + .output() + .unwrap(); + // Flag should be accepted; must not panic + assert_ne!(output.status.code(), Some(101)); +} + +#[test] +fn devices_list_with_filter_types_flag_accepted() { + let output = cli() + .args(["devices", "list", "--filter-types", "joystick,throttle"]) + .output() + .unwrap(); + // Flag should be accepted; must not panic + assert_ne!(output.status.code(), Some(101)); +} + +// ── devices info ────────────────────────────────────────────────────────── + +#[test] +fn devices_info_requires_device_id() { + cli() + .args(["devices", "info"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +#[test] +fn devices_info_fails_gracefully_without_daemon() { + let output = cli() + .args(["devices", "info", "test-device-123"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +// ── devices dump ────────────────────────────────────────────────────────── + +#[test] +fn devices_dump_requires_device_id() { + cli() + .args(["devices", "dump"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +// ── devices calibrate ───────────────────────────────────────────────────── + +#[test] +fn devices_calibrate_requires_device_id() { + cli() + .args(["devices", "calibrate"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +#[test] +fn devices_calibrate_fails_gracefully_without_daemon() { + let output = cli() + .args(["devices", "calibrate", "test-device"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +// ── devices test ────────────────────────────────────────────────────────── + +#[test] +fn devices_test_requires_device_id() { + cli() + .args(["devices", "test"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +#[test] +fn devices_test_fails_gracefully_without_daemon() { + let output = cli() + .args(["devices", "test", "test-device"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +#[test] +fn devices_test_accepts_interval_and_count_flags() { + let output = cli() + .args([ + "devices", + "test", + "test-device", + "--interval-ms", + "50", + "--count", + "10", + ]) + .output() + .unwrap(); + // Fails because no daemon, but flags should be accepted (no parse errors) + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} diff --git a/crates/flight-cli/tests/diag_cmd.rs b/crates/flight-cli/tests/diag_cmd.rs new file mode 100644 index 00000000..139088c3 --- /dev/null +++ b/crates/flight-cli/tests/diag_cmd.rs @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests for `flightctl diag` and `flightctl diagnostics` subcommands + +mod common; + +use common::{cli, parse_json_from, try_parse_json_from}; +use serde_json::Value; +use std::fs; +use tempfile::TempDir; + +// ── diag bundle ─────────────────────────────────────────────────────────── + +#[test] +fn diag_bundle_does_not_panic_without_daemon() { + let output = cli().args(["diag", "bundle"]).output().unwrap(); + // Bundle may succeed (collects local info) or fail, but must not panic + assert_ne!( + output.status.code(), + Some(101), + "diag bundle should not panic" + ); +} + +#[test] +fn diag_bundle_json_output_has_bundle_path() { + let output = cli().args(["--json", "diag", "bundle"]).output().unwrap(); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if let Some(json) = try_parse_json_from(&combined) { + // If we got JSON output, check it has expected fields + if json.get("success").is_some() && json["success"] == true { + assert!( + json["data"]["bundle_path"].is_string() || json["data"]["contents"].is_array(), + "bundle JSON should have bundle_path or contents" + ); + } + } +} + +// ── diag health ─────────────────────────────────────────────────────────── + +#[test] +fn diag_health_fails_without_daemon() { + let output = cli().args(["diag", "health"]).output().unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +#[test] +fn diag_health_json_error_format() { + let output = cli().args(["--json", "diag", "health"]).output().unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].is_string()); +} + +// ── diag metrics ────────────────────────────────────────────────────────── + +#[test] +fn diag_metrics_fails_without_daemon() { + let output = cli().args(["diag", "metrics"]).output().unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +// ── diag trace ──────────────────────────────────────────────────────────── + +#[test] +fn diag_trace_requires_duration_arg() { + cli() + .args(["diag", "trace"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +#[test] +fn diag_trace_fails_without_daemon() { + let output = cli().args(["diag", "trace", "30"]).output().unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +// ── diag record ─────────────────────────────────────────────────────────── + +#[test] +fn diag_record_requires_output_arg() { + cli() + .args(["diag", "record"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +#[test] +fn diag_record_rejects_non_fbb_extension() { + let tmp = TempDir::new().unwrap(); + let bad_ext = tmp.path().join("recording.txt"); + + let output = cli() + .args(["--json", "diag", "record", "-o", bad_ext.to_str().unwrap()]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains(".fbb")); +} + +#[test] +fn diag_record_accepts_fbb_extension() { + let tmp = TempDir::new().unwrap(); + let valid_path = tmp.path().join("recording.fbb"); + + let output = cli() + .args([ + "--json", + "diag", + "record", + "-o", + valid_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + + // Should succeed (simulated recording start) since the command + // doesn't actually require the daemon for the start acknowledgment + if output.status.success() { + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + assert_eq!(json["data"]["recording_started"], true); + } +} + +// ── diag replay ─────────────────────────────────────────────────────────── + +#[test] +fn diag_replay_nonexistent_file_fails() { + let output = cli() + .args(["--json", "diag", "replay", "nonexistent.fbb"]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("does not exist")); +} + +#[test] +fn diag_replay_wrong_extension_fails() { + let tmp = TempDir::new().unwrap(); + let wrong_ext = tmp.path().join("data.txt"); + fs::write(&wrong_ext, "fake data").unwrap(); + + let output = cli() + .args(["--json", "diag", "replay", wrong_ext.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains(".fbb")); +} + +// ── diag export ─────────────────────────────────────────────────────────── + +#[test] +fn diag_export_nonexistent_file_fails() { + let output = cli() + .args(["--json", "diag", "export", "nonexistent.fbb"]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("does not exist")); +} + +// ── diagnostics shorthand ───────────────────────────────────────────────── + +#[test] +fn diagnostics_shorthand_fails_without_daemon() { + let output = cli().args(["diagnostics"]).output().unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(101)); +} + +#[test] +fn diagnostics_shorthand_json_error_format() { + let output = cli().args(["--json", "diagnostics"]).output().unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error_code"].is_string()); +} + +// ── diag status and stop (simulated) ────────────────────────────────────── + +#[test] +fn diag_status_succeeds_with_simulated_data() { + let output = cli().args(["--json", "diag", "status"]).output().unwrap(); + + if output.status.success() { + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + // Should have recording_active field + assert!(json["data"].get("recording_active").is_some()); + } +} + +#[test] +fn diag_stop_succeeds_with_simulated_data() { + let output = cli().args(["--json", "diag", "stop"]).output().unwrap(); + + if output.status.success() { + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + assert_eq!(json["data"]["recording_stopped"], true); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/crates/flight-cli/tests/error_handling.rs b/crates/flight-cli/tests/error_handling.rs new file mode 100644 index 00000000..1420715a --- /dev/null +++ b/crates/flight-cli/tests/error_handling.rs @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests for CLI error handling: invalid arguments, exit codes, error messages + +mod common; + +use common::{cli, parse_json_from}; +use predicates::prelude::*; +use serde_json::Value; + +// ── Invalid subcommand ──────────────────────────────────────────────────── + +#[test] +fn invalid_subcommand_fails() { + cli() + .arg("nonexistent-command") + .assert() + .failure() + .stderr(predicate::str::contains("error")); +} + +#[test] +fn no_subcommand_fails() { + cli().assert().failure(); +} + +// ── Invalid output format ───────────────────────────────────────────────── + +#[test] +fn invalid_output_format_fails() { + cli() + .args(["--output", "xml", "status"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn invalid_output_format_yaml_fails() { + cli() + .args(["--output", "yaml", "status"]) + .assert() + .failure(); +} + +// ── Invalid timeout ─────────────────────────────────────────────────────── + +#[test] +fn invalid_timeout_not_a_number_fails() { + cli() + .args(["--timeout", "not-a-number", "status"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn negative_timeout_fails() { + cli().args(["--timeout", "-1", "status"]).assert().failure(); +} + +// ── Missing required arguments ──────────────────────────────────────────── + +#[test] +fn devices_info_missing_device_id_fails() { + cli() + .args(["devices", "info"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn devices_dump_missing_device_id_fails() { + cli() + .args(["devices", "dump"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn devices_calibrate_missing_device_id_fails() { + cli() + .args(["devices", "calibrate"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn devices_test_missing_device_id_fails() { + cli() + .args(["devices", "test"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn profile_activate_missing_name_fails() { + cli() + .args(["profile", "activate"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn profile_validate_missing_path_fails() { + cli() + .args(["profile", "validate"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn profile_apply_missing_path_fails() { + cli() + .args(["profile", "apply"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn diag_trace_missing_duration_fails() { + cli() + .args(["diag", "trace"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn diag_record_missing_output_fails() { + cli() + .args(["diag", "record"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn torque_unlock_missing_device_id_fails() { + cli() + .args(["torque", "unlock"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn torque_set_mode_missing_mode_fails() { + cli() + .args(["torque", "set-mode"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +// ── Extra/unknown flags rejected ────────────────────────────────────────── + +#[test] +fn unknown_global_flag_rejected() { + cli() + .args(["--nonexistent-flag", "status"]) + .assert() + .failure(); +} + +#[test] +fn unknown_subcommand_flag_rejected() { + cli() + .args(["status", "--nonexistent-flag"]) + .assert() + .failure(); +} + +// ── Exit codes ──────────────────────────────────────────────────────────── + +#[test] +fn connection_error_exit_code_in_valid_range() { + let output = cli().args(["info"]).output().unwrap(); + + if !output.status.success() { + let code = output.status.code().unwrap(); + // Exit codes 1-7 are the defined error range + assert!( + (1..=7).contains(&code), + "exit code should be in mapped range 1-7, got {}", + code + ); + } +} + +#[test] +fn connection_error_json_exit_code_matches_error_code() { + let output = cli().args(["--json", "info"]).output().unwrap(); + + if !output.status.success() { + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + + let error_code = json["error_code"].as_str().unwrap(); + let exit_code = output.status.code().unwrap(); + + // Verify the exit code maps to a known error code + match error_code { + "CONNECTION_FAILED" => assert_eq!(exit_code, 2), + "VERSION_MISMATCH" => assert_eq!(exit_code, 3), + "UNSUPPORTED_FEATURE" => assert_eq!(exit_code, 4), + "TRANSPORT_ERROR" => assert_eq!(exit_code, 5), + "SERIALIZATION_ERROR" => assert_eq!(exit_code, 6), + "GRPC_ERROR" => assert_eq!(exit_code, 7), + "UNKNOWN_ERROR" => assert_eq!(exit_code, 1), + _ => panic!("Unknown error code: {}", error_code), + } + } +} + +#[test] +fn parse_error_exit_code_is_nonzero() { + let output = cli() + .args(["--output", "invalid", "status"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_ne!(output.status.code(), Some(0)); +} + +// ── Overlay invalid severity ────────────────────────────────────────────── + +#[test] +fn overlay_notify_invalid_severity_fails() { + let output = cli() + .args([ + "--json", + "overlay", + "notify", + "test", + "--severity", + "invalid", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("severity")); +} + +// ── Graceful handling (no panics) ───────────────────────────────────────── + +#[test] +fn daemon_dependent_commands_do_not_panic() { + let commands: Vec> = vec![ + vec!["status"], + vec!["info"], + vec!["devices", "list"], + vec!["diag", "health"], + vec!["safe-mode"], + vec!["diagnostics"], + vec!["adapters", "status"], + vec!["adapters", "enable", "msfs"], + vec!["adapters", "disable", "xplane"], + vec!["adapters", "reconnect", "dcs"], + vec!["devices", "calibrate", "test-dev"], + vec!["devices", "test", "test-dev"], + vec!["torque", "status"], + ]; + + for args in &commands { + let output = cli().args(args).output().unwrap(); + assert_ne!( + output.status.code(), + Some(101), + "'flightctl {}' panicked", + args.join(" ") + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/crates/flight-cli/tests/help_completeness.rs b/crates/flight-cli/tests/help_completeness.rs new file mode 100644 index 00000000..78f1ac8c --- /dev/null +++ b/crates/flight-cli/tests/help_completeness.rs @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests verifying all subcommands have help text and descriptions + +mod common; + +use common::cli; +use predicates::prelude::*; + +// ── Top-level help ──────────────────────────────────────────────────────── + +#[test] +fn top_level_help_lists_all_subcommands() { + let assert = cli().arg("--help").assert().success(); + + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + + let expected_subcommands = [ + "devices", + "profile", + "sim", + "panels", + "torque", + "diag", + "metrics", + "dcs", + "xplane", + "ac7", + "update", + "cloud-profiles", + "adapters", + "overlay", + "status", + "info", + "version", + "safe-mode", + "diagnostics", + ]; + + for cmd in &expected_subcommands { + assert!( + stdout.contains(cmd), + "Top-level help should list '{}' subcommand.\nActual output:\n{}", + cmd, + stdout + ); + } +} + +#[test] +fn top_level_help_contains_description() { + cli() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains( + "Flight Hub command line interface", + )); +} + +// ── Subcommand help descriptions ────────────────────────────────────────── + +#[test] +fn devices_help_has_description() { + cli() + .args(["devices", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Device management commands")); +} + +#[test] +fn devices_help_lists_subcommands() { + let output = cli().args(["devices", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + for subcmd in &["list", "info", "dump", "calibrate", "test"] { + assert!( + stdout.contains(subcmd), + "devices help missing '{}': {}", + subcmd, + stdout + ); + } +} + +#[test] +fn profile_help_has_description() { + cli() + .args(["profile", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Profile management commands")); +} + +#[test] +fn profile_help_lists_subcommands() { + let output = cli().args(["profile", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + for subcmd in &["list", "apply", "show", "activate", "validate", "export"] { + assert!( + stdout.contains(subcmd), + "profile help missing '{}': {}", + subcmd, + stdout + ); + } +} + +#[test] +fn sim_help_has_description() { + cli() + .args(["sim", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Simulator configuration commands")); +} + +#[test] +fn panels_help_has_description() { + cli() + .args(["panels", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Panel management commands")); +} + +#[test] +fn torque_help_has_description() { + cli() + .args(["torque", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Force feedback and torque commands", + )); +} + +#[test] +fn diag_help_has_description() { + cli() + .args(["diag", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Diagnostics and recording commands", + )); +} + +#[test] +fn diag_help_lists_subcommands() { + let output = cli().args(["diag", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + for subcmd in &[ + "bundle", "health", "metrics", "trace", "record", "replay", "status", "stop", "export", + ] { + assert!( + stdout.contains(subcmd), + "diag help missing '{}': {}", + subcmd, + stdout + ); + } +} + +#[test] +fn metrics_help_has_description() { + cli() + .args(["metrics", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("System-wide metrics")); +} + +#[test] +fn dcs_help_has_description() { + cli() + .args(["dcs", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("DCS World integration commands")); +} + +#[test] +fn xplane_help_has_description() { + cli() + .args(["xplane", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("X-Plane integration commands")); +} + +#[test] +fn ac7_help_has_description() { + cli() + .args(["ac7", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Ace Combat 7 integration commands", + )); +} + +#[test] +fn adapters_help_has_description() { + cli() + .args(["adapters", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Simulator adapter management")); +} + +#[test] +fn adapters_help_lists_subcommands() { + let output = cli().args(["adapters", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + for subcmd in &["status", "enable", "disable", "reconnect"] { + assert!( + stdout.contains(subcmd), + "adapters help missing '{}': {}", + subcmd, + stdout + ); + } +} + +#[test] +fn overlay_help_has_description() { + cli() + .args(["overlay", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("VR overlay management")); +} + +#[test] +fn overlay_help_lists_subcommands() { + let output = cli().args(["overlay", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + for subcmd in &["status", "show", "hide", "toggle", "notify", "backends"] { + assert!( + stdout.contains(subcmd), + "overlay help missing '{}': {}", + subcmd, + stdout + ); + } +} + +#[test] +fn update_help_has_description() { + cli() + .args(["update", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Update channel management")); +} + +#[test] +fn cloud_profiles_help_has_description() { + cli() + .args(["cloud-profiles", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Community cloud profile")); +} + +// ── Leaf command help texts ─────────────────────────────────────────────── + +#[test] +fn devices_list_help_shows_flags() { + let output = cli().args(["devices", "list", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains("--include-disconnected")); + assert!(stdout.contains("--filter-types")); +} + +#[test] +fn profile_validate_help_describes_command() { + cli() + .args(["profile", "validate", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Validate a profile file")); +} + +#[test] +fn profile_export_help_describes_command() { + cli() + .args(["profile", "export", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Export a profile")); +} + +#[test] +fn diag_record_help_shows_flags() { + let output = cli().args(["diag", "record", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains("Start recording diagnostics")); + assert!(stdout.contains("--output")); + assert!(stdout.contains("--duration")); +} + +#[test] +fn diag_trace_help_shows_flags() { + let output = cli().args(["diag", "trace", "--help"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains("Record a trace")); +} diff --git a/crates/flight-cli/tests/output_format.rs b/crates/flight-cli/tests/output_format.rs new file mode 100644 index 00000000..9d19fbfa --- /dev/null +++ b/crates/flight-cli/tests/output_format.rs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests for CLI output formatting: --json flag, --output json/human, and JSON structure + +mod common; + +use common::{cli, parse_json_from}; +use serde_json::Value; + +// ── JSON output via --output json ───────────────────────────────────────── + +#[test] +fn output_json_flag_produces_valid_json_on_success() { + let output = cli().args(["--output", "json", "status"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); +} + +#[test] +fn output_json_flag_produces_valid_json_on_error() { + let output = cli().args(["--output", "json", "info"]).output().unwrap(); + + if output.status.success() { + // Daemon is reachable — validate success JSON on stdout + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + } else { + // Daemon is unreachable — validate error JSON on stderr + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].is_string()); + assert!(json["error_code"].is_string()); + } +} + +#[test] +fn output_json_list_response_has_data_array() { + let output = cli() + .args(["--output", "json", "profile", "list"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + assert!(json["data"].is_array()); + assert!(json["total_count"].is_number()); +} + +// ── --json shorthand ────────────────────────────────────────────────────── + +#[test] +fn json_shorthand_flag_equivalent_to_output_json() { + let output_long = cli().args(["--output", "json", "status"]).output().unwrap(); + let output_short = cli().args(["--json", "status"]).output().unwrap(); + + let json_long: Value = parse_json_from(&String::from_utf8(output_long.stdout.clone()).unwrap()); + let json_short: Value = + parse_json_from(&String::from_utf8(output_short.stdout.clone()).unwrap()); + + // Both should have the same structure + assert_eq!(json_long["success"], json_short["success"]); + assert_eq!( + json_long["data"]["service_status"], + json_short["data"]["service_status"] + ); +} + +// ── Human output ────────────────────────────────────────────────────────── + +#[test] +fn human_output_is_default_format() { + let output = cli().args(["status"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + // Human format should NOT start with '{' + let first_non_empty = stdout.lines().find(|l| !l.trim().is_empty()); + if let Some(line) = first_non_empty { + assert!( + !line.trim().starts_with('{'), + "Human output should not be JSON: {}", + line + ); + } +} + +#[test] +fn human_error_output_starts_with_error_prefix() { + let output = cli().args(["--output", "human", "info"]).output().unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.lines().any(|l| l.starts_with("Error:")), + "Human error output should start with 'Error:': {}", + stderr + ); +} + +// ── Overlay commands work locally (no daemon needed) ────────────────────── + +#[test] +fn overlay_show_json_output_is_valid() { + let output = cli().args(["--json", "overlay", "show"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(json["action"], "show"); + assert_eq!(json["queued"], true); +} + +#[test] +fn overlay_hide_json_output_is_valid() { + let output = cli().args(["--json", "overlay", "hide"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(json["action"], "hide"); +} + +#[test] +fn overlay_toggle_json_output_is_valid() { + let output = cli() + .args(["--json", "overlay", "toggle"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(json["action"], "toggle"); + assert_eq!(json["queued"], true); +} + +#[test] +fn overlay_notify_json_output_is_valid() { + let output = cli() + .args(["--json", "overlay", "notify", "test message"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(json["action"], "notify"); + assert_eq!(json["message"], "test message"); + assert_eq!(json["severity"], "info"); + assert_eq!(json["queued"], true); +} + +#[test] +fn overlay_backends_json_output_is_array() { + let output = cli() + .args(["--json", "overlay", "backends"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert!(json.is_array()); + assert!(!json.as_array().unwrap().is_empty()); +} + +// ── JSON contract stability ─────────────────────────────────────────────── + +#[test] +fn success_json_always_has_success_field() { + let output = cli().args(["--json", "status"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert!( + json.get("success").is_some(), + "Success responses must always have a 'success' field" + ); +} + +#[test] +fn error_json_always_has_error_and_error_code_fields() { + let output = cli().args(["--json", "info"]).output().unwrap(); + + if output.status.success() { + // Daemon is reachable — validate success JSON on stdout + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert!( + json.get("success").is_some(), + "Success responses must have 'success' field" + ); + } else { + // Daemon is unreachable — validate error JSON on stderr + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert!( + json.get("error").is_some(), + "Error responses must have 'error' field" + ); + assert!( + json.get("error_code").is_some(), + "Error responses must have 'error_code' field" + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/crates/flight-cli/tests/profile_cmd.rs b/crates/flight-cli/tests/profile_cmd.rs new file mode 100644 index 00000000..1033f50c --- /dev/null +++ b/crates/flight-cli/tests/profile_cmd.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests for `flightctl profile` subcommands + +mod common; + +use common::{cli, parse_json_from}; +use serde_json::Value; +use std::fs; +use tempfile::TempDir; + +// ── profile list ────────────────────────────────────────────────────────── + +#[test] +fn profile_list_succeeds_without_daemon() { + cli().args(["profile", "list"]).assert().success(); +} + +#[test] +fn profile_list_json_returns_array_data() { + let output = cli().args(["--json", "profile", "list"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + + assert_eq!(json["success"], true); + assert!(json["data"].is_array()); + assert!(json["total_count"].is_number()); +} + +#[test] +fn profile_list_with_builtin_includes_default() { + let output = cli() + .args(["--json", "profile", "list", "--include-builtin"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + + let profiles = json["data"].as_array().unwrap(); + let has_builtin = profiles.iter().any(|p| p["source"] == "builtin"); + assert!( + has_builtin, + "Should include builtin profiles when --include-builtin is set" + ); +} + +// ── profile validate ────────────────────────────────────────────────────── + +#[test] +fn profile_validate_nonexistent_file_fails() { + let output = cli() + .args([ + "--json", + "profile", + "validate", + "nonexistent_file_12345.json", + ]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("Failed to read")); +} + +#[test] +fn profile_validate_invalid_json_fails() { + let tmp = TempDir::new().unwrap(); + let bad_json = tmp.path().join("bad.json"); + fs::write(&bad_json, "{ not valid json }").unwrap(); + + let output = cli() + .args(["--json", "profile", "validate", bad_json.to_str().unwrap()]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("Invalid JSON")); +} + +// ── profile apply ───────────────────────────────────────────────────────── + +#[test] +fn profile_apply_nonexistent_file_fails() { + let output = cli() + .args(["--json", "profile", "apply", "nonexistent_file_12345.json"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("Failed to read")); +} + +#[test] +fn profile_apply_invalid_json_fails() { + let tmp = TempDir::new().unwrap(); + let bad_json = tmp.path().join("bad.json"); + fs::write(&bad_json, "not json at all").unwrap(); + + let output = cli() + .args(["--json", "profile", "apply", bad_json.to_str().unwrap()]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + let json: Value = parse_json_from(&stderr); + assert_eq!(json["success"], false); + assert!(json["error"].as_str().unwrap().contains("Invalid JSON")); +} + +#[test] +fn profile_apply_missing_path_arg_fails() { + cli() + .args(["profile", "apply"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +// ── profile export ──────────────────────────────────────────────────────── + +#[test] +fn profile_export_missing_name_arg_fails() { + cli() + .args(["profile", "export"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +#[test] +fn profile_export_missing_path_arg_fails() { + cli() + .args(["profile", "export", "somename"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +// ── profile activate ────────────────────────────────────────────────────── + +#[test] +fn profile_activate_missing_name_fails() { + cli() + .args(["profile", "activate"]) + .assert() + .failure() + .stderr(predicates::str::contains("required")); +} + +// ── profile show ────────────────────────────────────────────────────────── + +#[test] +fn profile_show_without_name_returns_message() { + // When no profile name given, returns a local stub with a message (no daemon needed) + let output = cli().args(["--json", "profile", "show"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + assert_eq!(json["success"], true); + assert!(json["data"]["message"].is_string()); +} + +// ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/crates/flight-cli/tests/version_cmd.rs b/crates/flight-cli/tests/version_cmd.rs new file mode 100644 index 00000000..bdf1c0bb --- /dev/null +++ b/crates/flight-cli/tests/version_cmd.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024 Flight Hub Team + +//! Tests for the `flightctl version` and `flightctl --version` commands + +mod common; + +use common::{cli, parse_json_from}; +use predicates::prelude::*; +use serde_json::Value; + +// ── --version flag ──────────────────────────────────────────────────────── + +#[test] +fn version_flag_succeeds() { + cli().arg("--version").assert().success(); +} + +#[test] +fn version_flag_prints_semver() { + let output = cli().arg("--version").output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + let version_str = stdout.trim(); + + // Should contain X.Y.Z semver pattern + let has_semver = version_str.split_whitespace().any(|word| { + let parts: Vec<&str> = word.split('.').collect(); + parts.len() == 3 && parts.iter().all(|p| p.parse::().is_ok()) + }); + assert!(has_semver, "Should contain semver: {}", version_str); +} + +#[test] +fn version_flag_contains_package_name() { + cli() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("flightctl")); +} + +// ── version subcommand ──────────────────────────────────────────────────── + +#[test] +fn version_subcommand_succeeds() { + cli().arg("version").assert().success(); +} + +#[test] +fn version_subcommand_json_has_all_required_fields() { + let output = cli().args(["--json", "version"]).output().unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + + assert_eq!(json["success"], true); + let data = &json["data"]; + assert!(data["cli_version"].is_string(), "must have cli_version"); + assert!(data["build_profile"].is_string(), "must have build_profile"); + assert!(data["build_target"].is_string(), "must have build_target"); + assert!(data["build_os"].is_string(), "must have build_os"); + assert!(data["rust_version"].is_string(), "must have rust_version"); + // service_status is optional — may be absent when daemon probe is skipped + if let Some(status) = data.get("service_status") { + assert!(status.is_string(), "service_status should be a string"); + } +} + +#[test] +fn version_subcommand_json_cli_version_matches_cargo_version() { + let output = cli().args(["--json", "version"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + + let cli_version = json["data"]["cli_version"].as_str().unwrap(); + // Should be a valid semver + let parts: Vec<&str> = cli_version.split('.').collect(); + assert_eq!( + parts.len(), + 3, + "cli_version should be semver: {}", + cli_version + ); + assert!( + parts.iter().all(|p| p.parse::().is_ok()), + "semver parts should be numbers: {}", + cli_version + ); +} + +#[test] +fn version_subcommand_service_status_is_valid_string() { + let output = cli().args(["--json", "version"]).output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + + if let Some(status) = json["data"]["service_status"].as_str() { + // Accept both reachable and unreachable states; just validate non-empty string + assert!( + !status.is_empty(), + "service_status should be a non-empty string" + ); + } +} + +#[test] +fn version_subcommand_verbose_includes_package_info() { + let output = cli() + .args(["--verbose", "--json", "version"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: Value = parse_json_from(&stdout); + + let data = &json["data"]; + assert!( + data["package_name"].is_string(), + "verbose should include package_name" + ); +} + +#[test] +fn version_subcommand_human_output_contains_version() { + let output = cli().arg("version").output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!( + stdout.contains("cli_version") || stdout.contains("0."), + "human version output should contain version info: {}", + stdout + ); +} + +// ── Helpers ───────────────────────────────────────────────────────────────