Files
yvgude--lean-ctx/rust/tests/shell_and_agent_tests.rs
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

705 lines
22 KiB
Rust

//! E2E tests for shell detection, `LEAN_CTX_SHELL` override,
//! agent init (incl. antigravity alias), Windows path handling,
//! and pipe-guard (stdout not a terminal → bypass lean-ctx).
// noqa: SIZE_OK — single-responsibility E2E test module for the CLI binary.
use std::io::Write;
use std::process::{Command, Stdio};
fn lean_ctx_bin() -> String {
env!("CARGO_BIN_EXE_lean-ctx").to_string()
}
fn run_with_env(
args: &[&str],
env_vars: &[(&str, &str)],
stdin_data: Option<&str>,
) -> (String, String, i32) {
let mut cmd = Command::new(lean_ctx_bin());
cmd.args(args)
.env("LEAN_CTX_DISABLED", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in env_vars {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn lean-ctx");
if let Some(data) = stdin_data {
child
.stdin
.take()
.unwrap()
.write_all(data.as_bytes())
.unwrap();
}
let output = child.wait_with_output().expect("failed to wait");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let code = output.status.code().unwrap_or(1);
(stdout, stderr, code)
}
fn run_hook_test(
args: &[&str],
env_vars: &[(&str, &str)],
stdin_data: Option<&str>,
) -> (String, String, i32) {
let mut cmd = Command::new(lean_ctx_bin());
cmd.args(args)
.env_remove("LEAN_CTX_DISABLED")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in env_vars {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn lean-ctx");
if let Some(data) = stdin_data {
child
.stdin
.take()
.unwrap()
.write_all(data.as_bytes())
.unwrap();
}
let output = child.wait_with_output().expect("failed to wait");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let code = output.status.code().unwrap_or(1);
(stdout, stderr, code)
}
fn assert_hook_command_suffix(actual: Option<&str>, expected_suffix: &str) {
let actual = actual.expect("hook command should exist");
assert!(
actual.contains("lean-ctx"),
"expected hook command to reference lean-ctx, got {actual:?}"
);
assert!(
actual.ends_with(expected_suffix),
"expected hook command to end with {expected_suffix:?}, got {actual:?}"
);
}
#[test]
fn lean_ctx_shell_override_uses_specified_shell() {
if cfg!(windows) {
return; // /bin/sh not available on Windows
}
let (stdout, _stderr, code) = run_with_env(
&["-c", "echo lean_ctx_shell_works"],
&[("LEAN_CTX_SHELL", "/bin/sh")],
None,
);
assert_eq!(code, 0, "should succeed with /bin/sh");
assert!(
stdout.contains("lean_ctx_shell_works"),
"should see echo output: {stdout}"
);
}
#[test]
fn lean_ctx_shell_override_bash() {
if !std::path::Path::new("/bin/bash").exists() {
return;
}
let (stdout, _stderr, code) = run_with_env(
&["-c", "echo $BASH_VERSION"],
&[("LEAN_CTX_SHELL", "/bin/bash")],
None,
);
assert_eq!(code, 0, "should succeed with /bin/bash");
assert!(!stdout.trim().is_empty(), "BASH_VERSION should be set");
}
#[test]
fn lean_ctx_shell_override_invalid_shell_fails() {
let (_stdout, _stderr, code) = run_with_env(
&["-c", "echo hello"],
&[("LEAN_CTX_SHELL", "/nonexistent/shell")],
None,
);
assert_ne!(code, 0, "should fail with nonexistent shell");
}
#[test]
fn shell_exec_simple_command() {
let (stdout, _stderr, code) = run_with_env(&["-c", "echo hello_world"], &[], None);
assert_eq!(code, 0);
assert!(stdout.contains("hello_world"), "output: {stdout}");
}
#[test]
fn shell_exec_pipe_command() {
if cfg!(windows) {
return; // head -1 not available on Windows
}
let (stdout, _stderr, code) =
run_with_env(&["-c", "echo 'line1\nline2\nline3' | head -1"], &[], None);
assert_eq!(code, 0, "pipe should work");
assert!(!stdout.trim().is_empty(), "should have output: {stdout}");
}
#[test]
fn shell_exec_and_chain() {
let (stdout, _stderr, code) = run_with_env(&["-c", "echo first && echo second"], &[], None);
assert_eq!(code, 0, "&& chain should work");
assert!(stdout.contains("first"), "first: {stdout}");
assert!(stdout.contains("second"), "second: {stdout}");
}
#[test]
fn shell_exec_semicolon_chain() {
let (stdout, _stderr, code) = run_with_env(&["-c", "echo aaa; echo bbb"], &[], None);
assert_eq!(code, 0, "; chain should work");
assert!(stdout.contains("aaa"), "aaa: {stdout}");
assert!(stdout.contains("bbb"), "bbb: {stdout}");
}
#[test]
fn shell_exec_subshell() {
if cfg!(windows) {
return; // $(...) subshell syntax varies on Windows
}
let (stdout, _stderr, code) = run_with_env(&["-c", "echo $(echo subshell_output)"], &[], None);
assert_eq!(code, 0, "subshell should work");
assert!(stdout.contains("subshell_output"), "subshell: {stdout}");
}
#[test]
fn shell_exec_env_var_expansion() {
if cfg!(windows) {
return; // $VAR syntax is bash-only; PowerShell uses $env:VAR
}
let (stdout, _stderr, code) = run_with_env(
&["-c", "echo $TEST_LEAN_CTX_VAR"],
&[("TEST_LEAN_CTX_VAR", "expanded_value")],
None,
);
assert_eq!(code, 0);
assert!(
stdout.contains("expanded_value"),
"env var expansion: {stdout}"
);
}
#[test]
fn shell_exec_quoted_args() {
let (stdout, _stderr, code) =
run_with_env(&["-c", r#"echo "hello world with spaces""#], &[], None);
assert_eq!(code, 0);
assert!(
stdout.contains("hello world with spaces"),
"quoted args: {stdout}"
);
}
#[test]
fn agent_init_antigravity_alias() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let home = tmpdir.path();
let gemini_dir = home.join(".gemini");
std::fs::create_dir_all(&gemini_dir).unwrap();
let mut cmd = Command::new(lean_ctx_bin());
cmd.args(["init", "--agent", "antigravity", "--global"])
.env("HOME", home.to_str().unwrap())
.env("LEAN_CTX_DISABLED", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd.output().expect("failed to run init");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Unknown agent"),
"antigravity should be recognized: {stderr}"
);
let hooks_dir = gemini_dir.join("hooks");
if hooks_dir.exists() {
let rewrite = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
assert!(rewrite.exists(), "rewrite script should be created");
let content = std::fs::read_to_string(&rewrite).unwrap();
assert!(
content.contains("hookSpecificOutput"),
"rewrite script should contain hook output format"
);
}
}
#[test]
fn agent_init_unknown_agent_fails() {
let (_stdout, stderr, code) =
run_with_env(&["init", "--agent", "nonexistent_agent"], &[], None);
assert_ne!(code, 0, "unknown agent should fail");
assert!(
stderr.contains("Unknown agent"),
"should say unknown: {stderr}"
);
}
#[test]
fn codex_pretooluse_rewrites_rewritable_bash_with_updated_input() {
let input =
r#"{"tool_name":"Bash","tool_input":{"command":"git status"},"command":"git status"}"#;
let (stdout, stderr, code) = run_hook_test(&["hook", "codex-pretooluse"], &[], Some(input));
assert_eq!(code, 0, "hook should rewrite without blocking: {stderr}");
assert!(
stderr.trim().is_empty(),
"stderr should stay empty for non-blocking rewrites: {stderr}"
);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should contain hook JSON");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
let command = parsed["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.expect("updated command should be a string");
let expected_binary = if cfg!(windows) {
"lean-ctx.exe"
} else {
"lean-ctx"
};
assert!(
command.contains(expected_binary)
&& command.contains("-c")
&& command.contains("git status"),
"updated command should wrap git status with lean-ctx: {command}"
);
}
#[test]
fn agent_init_codex_installs_compatible_hooks_and_instructions() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let home = tmpdir.path();
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).unwrap();
let home_str = home.to_string_lossy().to_string();
#[cfg(not(windows))]
let envs = vec![("HOME", home_str.as_str())];
#[cfg(windows)]
let envs = vec![
("HOME", home_str.as_str()),
("USERPROFILE", home_str.as_str()),
];
let (_stdout, stderr, code) =
run_with_env(&["init", "--agent", "codex", "--global"], &envs, None);
assert_eq!(code, 0, "codex init should succeed: {stderr}");
assert!(
codex_dir.join("AGENTS.md").exists(),
"AGENTS.md should exist"
);
assert!(
codex_dir.join("LEAN-CTX.md").exists(),
"LEAN-CTX.md should exist"
);
let hooks: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
.expect("hooks.json should be valid");
assert_hook_command_suffix(
hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str(),
"hook codex-pretooluse",
);
assert_hook_command_suffix(
hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
"hook codex-session-start",
);
let config = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap_or_default();
assert!(
config.contains("hooks = true"),
"init should enable Codex hook support"
);
}
#[test]
fn agent_init_codex_migrates_legacy_lean_ctx_hook_but_keeps_other_hooks() {
let tmpdir = tempfile::tempdir().expect("create tempdir");
let home = tmpdir.path();
let codex_dir = home.join(".codex");
let hooks_dir = codex_dir.join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("lean-ctx-rewrite-codex.sh"),
"#!/bin/sh\nexit 0\n",
)
.unwrap();
std::fs::write(
codex_dir.join("hooks.json"),
serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "lean-ctx hook rewrite",
"timeout": 15
}]
},
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo keep-me",
"timeout": 5
}]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo keep-post",
"timeout": 5
}]
}
]
}
})
.to_string(),
)
.unwrap();
let home_str = home.to_string_lossy().to_string();
#[cfg(not(windows))]
let envs = vec![("HOME", home_str.as_str())];
#[cfg(windows)]
let envs = vec![
("HOME", home_str.as_str()),
("USERPROFILE", home_str.as_str()),
];
let (_stdout, stderr, code) =
run_with_env(&["init", "--agent", "codex", "--global"], &envs, None);
assert_eq!(code, 0, "codex init should succeed: {stderr}");
assert!(
!hooks_dir.join("lean-ctx-rewrite-codex.sh").exists(),
"legacy Codex hook script should be removed"
);
let hooks: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
.expect("hooks.json should stay valid");
let pre_tool_use = hooks["hooks"]["PreToolUse"]
.as_array()
.expect("PreToolUse should remain");
assert_eq!(
pre_tool_use.len(),
2,
"legacy hook should be replaced and custom hook preserved (deny is handled at runtime by codex-pretooluse)"
);
assert_eq!(
pre_tool_use[0]["hooks"][0]["command"].as_str(),
Some("echo keep-me")
);
assert_hook_command_suffix(
pre_tool_use[1]["hooks"][0]["command"].as_str(),
"hook codex-pretooluse",
);
assert_hook_command_suffix(
hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
"hook codex-session-start",
);
assert_eq!(
hooks["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
Some("echo keep-post")
);
}
#[test]
fn agent_init_gemini_installs_antigravity_cli_plugin_hooks() {
// GH #284: `gemini` setup also configures the Antigravity CLI MCP target, so
// it must install the `agy` plugin hooks. `agy` reads hooks only from its
// plugin dir (`~/.gemini/config/plugins/lean-ctx`), never from the legacy
// `~/.gemini/settings.json` — so the plugin (not just MCP) must be written.
let tmpdir = tempfile::tempdir().expect("create tempdir");
let home = tmpdir.path();
let home_str = home.to_string_lossy().to_string();
#[cfg(not(windows))]
let envs = vec![("HOME", home_str.as_str())];
#[cfg(windows)]
let envs = vec![
("HOME", home_str.as_str()),
("USERPROFILE", home_str.as_str()),
];
let (_stdout, stderr, code) =
run_with_env(&["init", "--agent", "gemini", "--global"], &envs, None);
assert_eq!(code, 0, "gemini init should succeed: {stderr}");
let plugin_hooks = home.join(".gemini/config/plugins/lean-ctx/hooks/hooks.json");
assert!(
plugin_hooks.exists(),
"gemini init must install the Antigravity CLI plugin hooks at {plugin_hooks:?}"
);
let manifest = std::fs::read_to_string(home.join(".gemini/config/import_manifest.json"))
.unwrap_or_default();
assert!(
manifest.contains("lean-ctx"),
"the plugin must be registered in import_manifest.json: {manifest}"
);
}
#[test]
fn agent_init_lists_antigravity_in_supported() {
let (_stdout, stderr, _code) =
run_with_env(&["init", "--agent", "nonexistent_agent"], &[], None);
assert!(
stderr.contains("antigravity"),
"supported list should include antigravity: {stderr}"
);
}
#[test]
fn hook_rewrite_works_with_shell_override() {
let input = r#"{"tool_name":"Bash","command":"git status"}"#;
let (stdout, _stderr, _code) = run_hook_test(
&["hook", "rewrite"],
&[("LEAN_CTX_SHELL", "/bin/sh")],
Some(input),
);
if !stdout.trim().is_empty() {
let v: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
assert!(
v["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.is_some(),
"should have command field"
);
}
}
#[test]
fn hook_rewrite_disabled_produces_allow_output() {
let input = r#"{"tool_name":"Bash","command":"git status"}"#;
let (stdout, _stderr, code) = run_hook_test(
&["hook", "rewrite"],
&[("LEAN_CTX_DISABLED", "1")],
Some(input),
);
let trimmed = stdout.trim();
assert!(
!trimmed.is_empty(),
"disabled hook should produce ALLOW output"
);
let v: serde_json::Value =
serde_json::from_str(trimmed).expect("disabled hook output should be valid JSON");
assert_eq!(
v["permission"].as_str().unwrap_or(""),
"allow",
"disabled hook must output allow, got: {stdout}"
);
assert_eq!(code, 0, "disabled hook should exit cleanly");
}
#[test]
fn codex_pretooluse_disabled_exits_cleanly() {
let input =
r#"{"tool_name":"Bash","tool_input":{"command":"git status"},"command":"git status"}"#;
let (stdout, _stderr, code) = run_hook_test(
&["hook", "codex-pretooluse"],
&[("LEAN_CTX_DISABLED", "1")],
Some(input),
);
assert_eq!(code, 0, "disabled codex hook should not exit(2)");
assert!(
stdout.trim().is_empty(),
"disabled codex hook should produce no output"
);
}
#[test]
fn generated_script_handles_windows_path() {
let script = lean_ctx::hooks::generate_rewrite_script("/c/Users/Jaina/bin/lean-ctx.exe");
assert!(
script.contains("LEAN_CTX_BIN=\"/c/Users/Jaina/bin/lean-ctx.exe\""),
"Windows bash path should be properly quoted in script"
);
}
#[test]
fn generated_script_handles_path_with_spaces() {
let script = lean_ctx::hooks::generate_rewrite_script("/c/Program Files/lean-ctx/lean-ctx.exe");
assert!(
script.contains("LEAN_CTX_BIN=\"/c/Program Files/lean-ctx/lean-ctx.exe\""),
"path with spaces should be quoted"
);
}
#[test]
fn generated_compact_script_handles_windows_path() {
let script =
lean_ctx::hooks::generate_compact_rewrite_script("/c/Users/Jaina/bin/lean-ctx.exe");
assert!(
script.contains("LEAN_CTX_BIN=\"/c/Users/Jaina/bin/lean-ctx.exe\""),
"compact script should handle Windows path"
);
}
#[test]
fn generated_script_skips_own_binary() {
let script = lean_ctx::hooks::generate_rewrite_script("lean-ctx");
assert!(
script.contains("lean-ctx ") || script.contains("$LEAN_CTX_BIN "),
"script should reference lean-ctx for self-skip check"
);
}
#[test]
fn bash_script_with_windows_binary_path_produces_valid_json() {
if cfg!(windows) {
return; // bash not available on Windows CI
}
let script =
lean_ctx::hooks::generate_compact_rewrite_script("/c/Users/Jaina/bin/lean-ctx.exe");
let script_path =
std::env::temp_dir().join(format!("lean_ctx_winpath_test_{}.sh", std::process::id()));
std::fs::write(&script_path, &script).expect("write script");
let input = r#"{"tool_name":"Bash","command":"git status"}"#;
let mut child = Command::new("bash")
.arg(&script_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn bash");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let output = child.wait_with_output().expect("failed to wait");
let _ = std::fs::remove_file(&script_path);
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("invalid JSON from Windows path script: {e}\nraw: {stdout}")
});
let cmd = v["hookSpecificOutput"]["updatedInput"]["command"]
.as_str()
.expect("should have command");
assert!(
cmd.contains("/c/Users/Jaina/bin/lean-ctx.exe"),
"rewritten command should use the Windows bash path: {cmd}"
);
assert!(
cmd.contains("git status"),
"original command should be preserved: {cmd}"
);
}
}
#[test]
fn piped_output_is_not_compressed() {
if cfg!(windows) {
return;
}
let bin = lean_ctx_bin();
let script = r#"echo "line one"; echo "line two"; echo "line three""#.to_string();
let output = Command::new(&bin)
.args(["-c", &script])
.env("LEAN_CTX_DISABLED", "0")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("line one"),
"piped output must contain original content: {stdout}"
);
}
#[test]
fn bash_hook_contains_pipe_guard() {
if cfg!(windows) {
return;
}
let bin = lean_ctx_bin();
let _output = Command::new(&bin)
.args(["init", "--dry-run"])
.env("LEAN_CTX_DISABLED", "1")
.env("SHELL", "/bin/bash")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run init --dry-run");
// This is tested more directly in cli.rs unit tests
}
#[test]
fn generated_bash_hook_has_tty_check() {
let script = lean_ctx::hooks::generate_rewrite_script("lean-ctx");
// The rewrite hook is for Claude Code / Gemini, not the shell alias.
// The shell alias pipe guard is in cli.rs.
assert!(
!script.is_empty(),
"generated rewrite script should not be empty"
);
}
#[test]
fn lean_ctx_c_preserves_output_when_piped() {
if cfg!(windows) {
return;
}
let bin = lean_ctx_bin();
let output = Command::new(&bin)
.args(["-c", "echo MARKER_STRING_12345"])
.env_remove("LEAN_CTX_DISABLED")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run lean-ctx -c echo");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("MARKER_STRING_12345"),
"lean-ctx -c must preserve output content when piped: {stdout}"
);
}
#[test]
fn lean_ctx_c_multiline_preserves_all_lines_when_piped() {
if cfg!(windows) {
return;
}
let bin = lean_ctx_bin();
let cmd = "echo LINE_A && echo LINE_B && echo LINE_C";
let output = Command::new(&bin)
.args(["-c", cmd])
.env_remove("LEAN_CTX_DISABLED")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("LINE_A"), "LINE_A: {stdout}");
assert!(stdout.contains("LINE_B"), "LINE_B: {stdout}");
assert!(stdout.contains("LINE_C"), "LINE_C: {stdout}");
}