26382a7ac6
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
JetBrains Plugin / Actionlint (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (rust) (push) Waiting to run
JetBrains Plugin / Validation (push) Waiting to run
JetBrains Plugin / Build (push) Waiting to run
JetBrains Plugin / Test (push) Blocked by required conditions
Security Check / Security Scan (push) Waiting to run
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
465 lines
15 KiB
Rust
465 lines
15 KiB
Rust
//! CLI characterization tests — spawn the real binary with each subcommand and
|
|
//! assert stable exit codes + output invariants. These freeze the existing
|
|
//! behavior so that future refactors of `cli/dispatch` can't silently regress.
|
|
|
|
use std::{
|
|
fs,
|
|
process::{Command, Output},
|
|
};
|
|
|
|
fn lean_ctx() -> Command {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_lean-ctx"));
|
|
cmd.env("LEAN_CTX_ACTIVE", "1");
|
|
cmd.env("HOME", "/tmp/lean-ctx-cli-test");
|
|
cmd.env("LEAN_CTX_DISABLED", "1");
|
|
cmd
|
|
}
|
|
|
|
fn run(args: &[&str]) -> Output {
|
|
lean_ctx()
|
|
.args(args)
|
|
.output()
|
|
.expect("failed to spawn lean-ctx binary")
|
|
}
|
|
|
|
fn stdout(out: &Output) -> String {
|
|
String::from_utf8_lossy(&out.stdout).to_string()
|
|
}
|
|
|
|
fn stderr(out: &Output) -> String {
|
|
String::from_utf8_lossy(&out.stderr).to_string()
|
|
}
|
|
|
|
fn exit_code(out: &Output) -> i32 {
|
|
out.status.code().unwrap_or(-1)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Basic entry points
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn version_flag_exits_zero_and_prints_version() {
|
|
let out = run(&["--version"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
let s = stdout(&out);
|
|
assert!(s.contains("lean-ctx"), "expected 'lean-ctx' in: {s}");
|
|
}
|
|
|
|
#[test]
|
|
fn version_short_flag() {
|
|
let out = run(&["-V"]);
|
|
assert_eq!(exit_code(&out), 0);
|
|
assert!(stdout(&out).contains("lean-ctx"));
|
|
}
|
|
|
|
#[test]
|
|
fn help_flag_exits_zero_and_lists_commands() {
|
|
let out = run(&["--help"]);
|
|
assert_eq!(exit_code(&out), 0);
|
|
let s = stdout(&out);
|
|
assert!(
|
|
s.contains("COMMANDS") || s.contains("Usage") || s.contains("lean-ctx"),
|
|
"help must show usage info; got: {}",
|
|
&s[..s.len().min(200)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_short_flag() {
|
|
let out = run(&["-h"]);
|
|
assert_eq!(exit_code(&out), 0);
|
|
assert!(!stdout(&out).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn sessions_delete_removes_saved_session_and_snapshot() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let sessions = tmp.path().join("sessions");
|
|
fs::create_dir_all(&sessions).expect("sessions dir");
|
|
let mut session = lean_ctx::core::session::SessionState::new();
|
|
session.id = "cli-delete-me".to_string();
|
|
fs::write(
|
|
sessions.join("cli-delete-me.json"),
|
|
serde_json::to_string_pretty(&session).unwrap(),
|
|
)
|
|
.unwrap();
|
|
fs::write(sessions.join("cli-delete-me_snapshot.txt"), "snapshot").unwrap();
|
|
fs::write(sessions.join("latest.json"), r#"{"id":"cli-delete-me"}"#).unwrap();
|
|
|
|
let out = lean_ctx()
|
|
.env("LEAN_CTX_DATA_DIR", tmp.path())
|
|
.args(["sessions", "delete", "cli-delete-me"])
|
|
.output()
|
|
.expect("failed to spawn lean-ctx binary");
|
|
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
assert!(stdout(&out).contains("Deleted session cli-delete-me."));
|
|
assert!(!sessions.join("cli-delete-me.json").exists());
|
|
assert!(!sessions.join("cli-delete-me_snapshot.txt").exists());
|
|
assert!(!sessions.join("latest.json").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_command_exits_nonzero() {
|
|
let out = run(&["this-command-does-not-exist-xyz"]);
|
|
assert_ne!(exit_code(&out), 0, "unknown command should fail");
|
|
let err = stderr(&out);
|
|
assert!(
|
|
err.contains("unknown command") || err.contains("this-command-does-not-exist"),
|
|
"stderr should mention the unknown command; got: {err}"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Info/read-only subcommands (no side effects)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn gain_exits_zero() {
|
|
let out = run(&["gain"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn gain_json_exits_zero() {
|
|
let out = run(&["gain", "--json"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
let s = stdout(&out);
|
|
assert!(
|
|
s.starts_with('{') || s.starts_with('[') || s.contains('"'),
|
|
"--json should produce JSON-like output; got: {}",
|
|
&s[..s.len().min(100)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gain_reset_exits_zero() {
|
|
let out = run(&["gain", "--reset"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn safety_levels_exits_zero() {
|
|
let out = run(&["safety-levels"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
assert!(
|
|
!stdout(&out).is_empty(),
|
|
"safety-levels should print a table"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cheatsheet_exits_zero() {
|
|
let out = run(&["cheatsheet"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
assert!(!stdout(&out).is_empty(), "cheatsheet must produce output");
|
|
}
|
|
|
|
#[test]
|
|
fn config_show_exits_zero() {
|
|
let out = run(&["config", "show"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn config_no_args_exits_zero() {
|
|
let out = run(&["config"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn stats_exits_zero() {
|
|
let out = run(&["stats"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn cep_exits_zero() {
|
|
let out = run(&["cep"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_exits_zero() {
|
|
// `wrapped` was folded into `gain --wrapped`; the standalone command now
|
|
// prints a removal hint and exits non-zero. Characterize the replacement.
|
|
let out = run(&["gain", "--wrapped"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Doctor
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn doctor_runs_without_panic() {
|
|
let out = run(&["doctor"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"doctor should exit 0 or 1, got {code}"
|
|
);
|
|
let s = stdout(&out);
|
|
assert!(
|
|
s.contains('✓') || s.contains('✗'),
|
|
"doctor output should contain check marks"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_compact_exits_cleanly() {
|
|
let out = run(&["doctor", "--compact"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"doctor --compact should exit 0 or 1, got {code}"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Subcommands that print usage on empty/wrong args
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn graph_no_subcommand_builds_without_crash() {
|
|
let out = run(&["graph", "status"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"graph status should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_unknown_sub_prints_usage() {
|
|
let out = run(&["graph", "nonexistent-sub"]);
|
|
assert_ne!(exit_code(&out), 0);
|
|
assert!(
|
|
stderr(&out).contains("Usage") || stderr(&out).contains("lean-ctx graph"),
|
|
"should print graph usage"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smells_exits_zero() {
|
|
let out = run(&["smells"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"smells should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_no_args_prints_usage() {
|
|
let out = run(&["hook"]);
|
|
assert_ne!(exit_code(&out), 0);
|
|
let err = stderr(&out);
|
|
assert!(
|
|
err.contains("Usage") || err.contains("lean-ctx hook"),
|
|
"hook without subcommand should show usage"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_unknown_prints_usage() {
|
|
let out = run(&["hook", "nonexistent"]);
|
|
assert_ne!(exit_code(&out), 0);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Shell exec (-c)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn exec_echo_exits_zero() {
|
|
let out = run(&["-c", "echo hello"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
assert!(
|
|
stdout(&out).contains("hello"),
|
|
"exec should pass through command output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_false_exits_nonzero() {
|
|
let out = run(&["-c", "false"]);
|
|
assert_ne!(
|
|
exit_code(&out),
|
|
0,
|
|
"exec of 'false' should propagate exit code"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_raw_flag_passes_through() {
|
|
let out = run(&["-c", "--raw", "echo raw_test"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
assert!(stdout(&out).contains("raw_test"));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Delegator commands (simple dispatch to sub-modules)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn session_no_args_exits_cleanly() {
|
|
let out = run(&["session"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"session should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_new_alias_resets_session() {
|
|
let out = run(&["session", "new"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
}
|
|
|
|
#[test]
|
|
fn knowledge_no_args_exits_cleanly() {
|
|
let out = run(&["knowledge"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"knowledge should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overview_exits_cleanly() {
|
|
let out = run(&["overview"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"overview should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compress_no_args_exits_cleanly() {
|
|
let out = run(&["compress"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"compress should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn heatmap_exits_cleanly() {
|
|
let out = run(&["heatmap"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"heatmap should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn terse_exits_cleanly() {
|
|
let out = run(&["terse"]);
|
|
let code = exit_code(&out);
|
|
assert!(code == 0 || code == 1, "terse should not crash, got {code}");
|
|
}
|
|
|
|
#[test]
|
|
fn slow_log_exits_cleanly() {
|
|
let out = run(&["slow-log"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"slow-log should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bypass_no_args_prints_usage() {
|
|
let out = run(&["bypass"]);
|
|
assert_ne!(exit_code(&out), 0);
|
|
let err = stderr(&out);
|
|
assert!(
|
|
err.contains("Usage") || err.contains("raw"),
|
|
"bypass alias without args should show usage"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn raw_no_args_prints_usage() {
|
|
// `raw` is the primary name for the former `bypass` subcommand (finding 5).
|
|
let out = run(&["raw"]);
|
|
assert_ne!(exit_code(&out), 0);
|
|
let err = stderr(&out);
|
|
assert!(
|
|
err.contains("Usage") && err.contains("raw"),
|
|
"raw without args should show usage: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn audit_exits_zero() {
|
|
let out = run(&["audit"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
assert!(!stdout(&out).is_empty(), "audit should produce output");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Token report
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn token_report_no_args_exits_cleanly() {
|
|
let out = run(&["token-report"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"token-report should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Proxy (read-only status check)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn proxy_status_exits_cleanly() {
|
|
let out = run(&["proxy", "status"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"proxy status should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Daemon (read-only status check)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn daemon_status_exits_cleanly() {
|
|
let out = run(&["daemon", "status"]);
|
|
let code = exit_code(&out);
|
|
assert!(
|
|
code == 0 || code == 1,
|
|
"daemon status should not crash, got {code}"
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// Uninstall (dry-run only — safe)
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn uninstall_dry_run_exits_zero() {
|
|
let out = run(&["uninstall", "--dry-run"]);
|
|
assert_eq!(exit_code(&out), 0, "stderr: {}", stderr(&out));
|
|
let s = stdout(&out);
|
|
assert!(
|
|
s.contains("dry") || s.contains("Would") || s.contains("lean-ctx") || !s.is_empty(),
|
|
"uninstall --dry-run should describe what it would do"
|
|
);
|
|
}
|