//! 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}"); }