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
705 lines
22 KiB
Rust
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}");
|
|
}
|