Files
yvgude--lean-ctx/rust/tests/setup_ci_smoke.rs
T
wehub-resource-sync 26382a7ac6
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
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

592 lines
22 KiB
Rust

use std::process::Command;
use lean_ctx::core::setup_report::SetupReport;
use lean_ctx::status::StatusReport;
use lean_ctx::token_report::TokenReport;
fn run_json(bin: &str, args: &[&str], envs: &[(&str, &str)]) -> (i32, String) {
let mut cmd = Command::new(bin);
cmd.args(args);
for (k, v) in envs {
cmd.env(k, v);
}
let out = cmd.output().expect("process start");
let code = out.status.code().unwrap_or(1);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let json_str = extract_json(&stdout).unwrap_or_else(|| {
eprintln!(
"--- run_json debug ({} {}) ---\nexit={code}\nstdout[{}]={stdout}\nstderr[{}]={stderr}\n---",
bin,
args.join(" "),
out.stdout.len(),
out.stderr.len(),
);
stdout.clone()
});
(code, json_str)
}
fn extract_json(s: &str) -> Option<String> {
let start = s.find('{')?;
let end = s.rfind('}')?;
if end >= start {
Some(s[start..=end].to_string())
} else {
None
}
}
fn write_exe(path: &std::path::Path, content: &str) {
std::fs::write(path, content).expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
}
#[test]
#[cfg_attr(
windows,
ignore = "Windows handle inheritance causes subprocess hangs in CI"
)]
fn setup_bootstrap_doctor_status_json_smoke() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let home_str = home.to_string_lossy().to_string();
let data_str = data_dir.to_string_lossy().to_string();
// Fake claude binary so we can verify `claude mcp add-json` integration.
// It writes stdin JSON to $HOME/claude-mcp.json and exits 0.
let claude_path = bin_dir.join(if cfg!(windows) {
"claude.cmd"
} else {
"claude"
});
if cfg!(windows) {
write_exe(
&claude_path,
"@echo off\r\nsetlocal\r\nset \"OUT=%USERPROFILE%\\claude-mcp.json\"\r\npowershell -NoProfile -Command \"[Console]::In.ReadToEnd() | Set-Content -Path $env:OUT -NoNewline\"\r\nexit /b 0\r\n",
);
} else {
write_exe(
&claude_path,
"#!/bin/sh\nset -eu\nOUT=\"$HOME/claude-mcp.json\"\ncat > \"$OUT\"\nexit 0\n",
);
}
let mut envs = vec![
("HOME", home_str.as_str()),
("LEAN_CTX_DATA_DIR", data_str.as_str()),
("LEAN_CTX_ACTIVE", "1"),
("LEAN_CTX_DISABLED", "1"),
];
#[cfg(not(windows))]
{
envs.push(("SHELL", "/bin/bash"));
}
#[cfg(windows)]
{
envs.push(("USERPROFILE", home_str.as_str()));
}
// Prefer our fake claude first in PATH.
let old_path = std::env::var("PATH").unwrap_or_default();
let sep = if cfg!(windows) { ";" } else { ":" };
let new_path = format!("{}{sep}{}", bin_dir.to_string_lossy(), old_path);
envs.push(("PATH", new_path.as_str()));
envs.push(("LEAN_CTX_TRUST_CLAUDE_PATH", "1"));
// bootstrap --json returns clean JSON (SetupReport)
let (code, out) = run_json(bin, &["bootstrap", "--json"], &envs);
assert_eq!(code, 0, "bootstrap exit code");
let setup: SetupReport = serde_json::from_str(&out).unwrap_or_else(|e| {
panic!(
"bootstrap JSON parse: {e}\nstdout[{}]=<<<{out}>>>",
out.len()
)
});
assert_eq!(setup.schema_version, 1);
// bootstrap should create env.sh in LEAN_CTX_DATA_DIR for Docker/CI shells.
// env.sh is Unix-only (shell script); skip assertion on Windows.
#[cfg(not(windows))]
{
let env_sh = data_dir.join("env.sh");
let env_sh_content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
assert!(
env_sh_content.contains("lean-ctx docker self-heal"),
"env.sh missing docker self-heal snippet"
);
}
// init --agent claude --mode mcp should prefer `claude mcp add-json` when available.
let out = Command::new(bin)
.args(["init", "--agent", "claude", "--global", "--mode", "mcp"])
.envs(envs.iter().copied())
.output()
.expect("init --agent claude --mode mcp");
assert!(out.status.success(), "init --agent claude --mode mcp exit");
let saved = std::fs::read_to_string(home.join("claude-mcp.json")).expect("claude-mcp.json");
let v: serde_json::Value = serde_json::from_str(&saved).expect("claude json parse");
assert!(
v.get("command").is_some(),
"claude input should be server entry json"
);
// doctor --fix --json returns clean JSON (SetupReport shape)
// Exit code may be 1 if doctor finds unfixable issues (e.g. no real shell profile in CI)
let (_code, out) = run_json(bin, &["doctor", "--fix", "--json"], &envs);
let doctor_report: SetupReport = serde_json::from_str(&out).expect("doctor JSON parse");
assert_eq!(doctor_report.schema_version, 1);
// status --json returns clean JSON
let (code, out) = run_json(bin, &["status", "--json"], &envs);
assert_eq!(code, 0, "status exit code");
let status: StatusReport = serde_json::from_str(&out).expect("status JSON parse");
assert_eq!(status.schema_version, 1);
// token-report --json returns clean JSON
let (code, out) = run_json(bin, &["token-report", "--json"], &envs);
assert_eq!(code, 0, "token-report exit code");
let report: TokenReport = serde_json::from_str(&out).expect("token-report JSON parse");
assert_eq!(report.schema_version, 1);
}
#[test]
fn claude_config_dir_fallback_writes_dot_claude_json() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let claude_cfg = tmp.path().join("claude-cfg");
std::fs::create_dir_all(&claude_cfg).unwrap();
let home_str = home.to_string_lossy().to_string();
let data_str = data_dir.to_string_lossy().to_string();
let claude_cfg_str = claude_cfg.to_string_lossy().to_string();
// Fake claude that fails (forces lean-ctx to fallback to file merge/write).
let claude_path = bin_dir.join(if cfg!(windows) {
"claude.cmd"
} else {
"claude"
});
if cfg!(windows) {
write_exe(&claude_path, "@echo off\r\nexit /b 1\r\n");
} else {
write_exe(&claude_path, "#!/bin/sh\nexit 1\n");
}
let mut envs = vec![
("HOME", home_str.as_str()),
("LEAN_CTX_DATA_DIR", data_str.as_str()),
("LEAN_CTX_ACTIVE", "1"),
("LEAN_CTX_DISABLED", "1"),
("CLAUDE_CONFIG_DIR", claude_cfg_str.as_str()),
];
#[cfg(not(windows))]
{
envs.push(("SHELL", "/bin/bash"));
}
#[cfg(windows)]
{
envs.push(("USERPROFILE", home_str.as_str()));
}
let old_path = std::env::var("PATH").unwrap_or_default();
let sep = if cfg!(windows) { ";" } else { ":" };
let new_path = format!("{}{sep}{}", bin_dir.to_string_lossy(), old_path);
envs.push(("PATH", new_path.as_str()));
let out = Command::new(bin)
.args(["init", "--agent", "claude", "--global", "--mode", "mcp"])
.envs(envs.iter().copied())
.output()
.expect("init --agent claude --mode mcp");
assert!(out.status.success(), "init --agent claude --mode mcp exit");
let cfg_path = claude_cfg.join(".claude.json");
let content = std::fs::read_to_string(&cfg_path).expect(".claude.json exists");
assert!(
content.contains("\"mcpServers\""),
"must contain mcpServers"
);
assert!(content.contains("lean-ctx"), "must contain lean-ctx entry");
// `doctor` is a health gate (#1046): it exits non-zero whenever *any* check
// needs attention, and this sandbox legitimately fails several unrelated
// ones (no lean-ctx on PATH, no shell profile, a fake `claude` that exits 1)
// — the same reason the bootstrap smoke test tolerates a non-zero code. The
// contract under test is the `.claude.json` fallback *detection*, which is
// printed to stdout regardless of the gate, so assert on the report and only
// guard the exit code against a hard error (panic/crash → not 0/1).
let out = Command::new(bin)
.args(["doctor"])
.envs(envs.iter().copied())
.output()
.expect("doctor");
let code = out.status.code().unwrap_or(-1);
assert!(
code == 0 || code == 1,
"doctor must run as a health gate (exit 0 or 1), got {code}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("MCP config") && stdout.contains("lean-ctx found"),
"doctor should report lean-ctx found in MCP config; got:\n{stdout}"
);
}
#[test]
fn init_agent_preserves_agents_md_and_is_idempotent() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
// Create a git repo so project files are generated.
std::fs::create_dir_all(project.join(".git")).unwrap();
// Existing user AGENTS.md should be preserved.
let agents_path = project.join("AGENTS.md");
std::fs::write(&agents_path, "# My Agents\n\nDo not overwrite.\n").unwrap();
let home_str = home.to_string_lossy().to_string();
let data_str = data_dir.to_string_lossy().to_string();
// Fake claude (success) so init --agent claude prefers `claude mcp add-json`.
let claude_path = bin_dir.join(if cfg!(windows) {
"claude.cmd"
} else {
"claude"
});
if cfg!(windows) {
write_exe(&claude_path, "@echo off\r\nrem succeed\r\nexit /b 0\r\n");
} else {
write_exe(&claude_path, "#!/bin/sh\nexit 0\n");
}
let mut envs = vec![
("HOME", home_str.as_str()),
("LEAN_CTX_DATA_DIR", data_str.as_str()),
("LEAN_CTX_ACTIVE", "1"),
("LEAN_CTX_DISABLED", "1"),
];
#[cfg(not(windows))]
{
envs.push(("SHELL", "/bin/bash"));
}
#[cfg(windows)]
{
envs.push(("USERPROFILE", home_str.as_str()));
}
let old_path = std::env::var("PATH").unwrap_or_default();
let sep = if cfg!(windows) { ";" } else { ":" };
let new_path = format!("{}{sep}{}", bin_dir.to_string_lossy(), old_path);
envs.push(("PATH", new_path.as_str()));
for _ in 0..2 {
let out = Command::new(bin)
.args(["init", "--agent", "claude"])
.current_dir(&project)
.envs(envs.iter().copied())
.output()
.expect("init --agent claude");
assert!(out.status.success(), "init --agent claude exit");
}
let agents = std::fs::read_to_string(&agents_path).unwrap();
assert!(agents.contains("# My Agents"), "must preserve user header");
assert!(
agents.contains("Do not overwrite."),
"must preserve user content"
);
// v3 (GL #555): the injected block is a compact pointer. It references
// LEAN-CTX.md *without* an `@` prefix on purpose — agents expand `@`
// imports inline at session start, which would defeat the footprint cut
// ("open on demand — do not auto-load").
assert!(
agents.contains("<!-- lean-ctx -->") && agents.contains("LEAN-CTX.md"),
"must add compact lean-ctx pointer block"
);
assert!(
!agents.contains("@LEAN-CTX.md"),
"pointer must not use @-import (inline expansion defeats #555)"
);
assert_eq!(
agents.matches("<!-- lean-ctx -->").count(),
1,
"must not duplicate marker block"
);
let lean_ctx_md = project.join("LEAN-CTX.md");
let lean_ctx_content = std::fs::read_to_string(&lean_ctx_md).expect("LEAN-CTX.md exists");
assert!(
lean_ctx_content.contains("<!-- lean-ctx-rules -->")
&& lean_ctx_content.contains("Tool selection by intent"),
"LEAN-CTX.md must contain the canonical lean-ctx ruleset"
);
}
/// On Windows, `dirs::home_dir()` uses the Win32 API (`SHGetKnownFolderPath`)
/// rather than `USERPROFILE`, so env-var overrides in the subprocess don't
/// control where files land. We can only verify file creation on Unix.
#[test]
#[cfg_attr(windows, ignore)]
fn init_claude_installs_dedicated_rules_file_without_claude_md() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let home_str = home.to_string_lossy().to_string();
let data_str = data_dir.to_string_lossy().to_string();
let mut envs = vec![
("HOME", home_str.as_str()),
("LEAN_CTX_DATA_DIR", data_str.as_str()),
("LEAN_CTX_ACTIVE", "1"),
("LEAN_CTX_DISABLED", "1"),
];
#[cfg(not(windows))]
{
envs.push(("SHELL", "/bin/bash"));
}
#[cfg(windows)]
{
envs.push(("USERPROFILE", home_str.as_str()));
}
let out = Command::new(bin)
.args(["init", "--agent", "claude", "--global", "--mode", "mcp"])
.current_dir(&project)
.envs(envs.iter().copied())
.output()
.expect("init --agent claude --global --mode mcp");
assert!(
out.status.success(),
"init --agent claude --global --mode mcp exit"
);
let claude_md_path = home.join(".claude/CLAUDE.md");
assert!(
claude_md_path.exists(),
"must create ~/.claude/CLAUDE.md with lean-ctx block"
);
let claude_md = std::fs::read_to_string(&claude_md_path).expect("CLAUDE.md readable");
assert!(
claude_md.contains("<!-- lean-ctx -->"),
"CLAUDE.md must contain lean-ctx marker block"
);
// v3 (GL #555): the block is self-contained — Claude Code expands `@`
// imports inline at launch, so the old `@rules/lean-ctx.md` pointer
// silently multiplied the per-session footprint. Detail docs moved to
// the on-demand skill; the CLAUDE.md block itself must stay compact.
// v4 (GH #637 / GL #1138): MCP-aware — ctx_* guidance is conditional on
// the tools actually existing in the session, and native Read → Edit is
// documented as a guard-safe editing path (read-before-write gate).
// v5 (#1008 / GL #1144): edits route through anchored ctx_patch, ctx_edit
// is a legacy power-profile mention; v4's guard semantics stay documented.
// v6: block content updated; version bump propagated here.
assert!(
claude_md.contains("lean-ctx-claude-v6"),
"CLAUDE.md must carry the v6 block version"
);
assert!(
claude_md.contains("ctx_patch"),
"v6 block must route edits to the anchored editor (#1008)"
);
assert!(
claude_md.contains("prior native Read"),
"v6 block must document Claude Code's path-keyed read-before-write gate"
);
assert!(
!claude_md.contains("@rules/"),
"v6 block must not @-import rules (inline expansion defeats #555)"
);
assert!(
claude_md.len() < 2048,
"compact block contract: CLAUDE.md footprint must stay <2 KB, got {}",
claude_md.len()
);
assert!(
!project.join("CLAUDE.md").exists(),
"must not create project CLAUDE.md"
);
let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
assert!(
skill_path.exists(),
"must install the on-demand lean-ctx skill (detail docs live there)"
);
}
// End-to-end: `lean-ctx init --agent augment` must drive setup.rs's
// `"augment" =>` match arm through configure_agent_mcp → the McpJson writer,
// landing at ~/.augment/settings.json with the standard mcpServers shape.
//
// Same Unix-only caveat as init_claude_installs_dedicated_rules_file_without_claude_md:
// on Windows `dirs::home_dir()` uses SHGetKnownFolderPath, not %USERPROFILE%,
// so env-var overrides don't reach the subprocess's HOME resolution.
#[test]
#[cfg_attr(windows, ignore)]
fn init_augment_installs_lean_ctx_mcp_into_dot_augment_settings() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let home_str = home.to_string_lossy().to_string();
let data_str = data_dir.to_string_lossy().to_string();
let envs = [
("HOME", home_str.as_str()),
("LEAN_CTX_DATA_DIR", data_str.as_str()),
("LEAN_CTX_ACTIVE", "1"),
("LEAN_CTX_DISABLED", "1"),
("SHELL", "/bin/bash"),
];
let out = Command::new(bin)
.args(["init", "--agent", "augment", "--global", "--mode", "mcp"])
.current_dir(&project)
.envs(envs.iter().copied())
.output()
.expect("init --agent augment --global --mode mcp");
assert!(
out.status.success(),
"init --agent augment exit; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let settings_path = home.join(".augment/settings.json");
assert!(
settings_path.exists(),
"must create ~/.augment/settings.json"
);
let raw = std::fs::read_to_string(&settings_path).expect("settings.json readable");
let json: serde_json::Value = serde_json::from_str(&raw).expect("settings.json is JSON");
let lean_ctx = json
.get("mcpServers")
.and_then(|m| m.get("lean-ctx"))
.expect("mcpServers.lean-ctx must be present");
assert!(
lean_ctx.get("command").and_then(|c| c.as_str()).is_some(),
"lean-ctx entry must carry a command string"
);
// Rules injection (ticket #3): the per-agent rules file must land at
// ~/.augment/rules/lean-ctx.md so Auggie actually knows lean-ctx exists.
let rules_path = home.join(".augment/rules/lean-ctx.md");
assert!(
rules_path.exists(),
"must create ~/.augment/rules/lean-ctx.md"
);
let rules = std::fs::read_to_string(&rules_path).expect("rules readable");
assert!(
rules.contains("<!-- lean-ctx-rules -->") && rules.contains("<!-- version: "),
"rules file must carry the canonical marker + version comment"
);
// Ticket #2: the VS Code extension surface (augment.vscode-augment) keeps
// its MCP server list as a top-level JSON array in globalStorage. The
// exact path is OS-specific; we only assert on Linux/macOS here because
// those are the platforms this test runs on (Windows is #[ignore]'d).
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let vscode_path = {
#[cfg(target_os = "linux")]
{
let user_dirs = [
home.join(".config/Code/User"),
home.join(".config/Code - Insiders/User"),
home.join(".vscode-server/data/User"),
];
let user_dir = user_dirs
.iter()
.find(|p| p.exists())
.cloned()
.unwrap_or_else(|| user_dirs[0].clone());
user_dir.join(
"globalStorage/augment.vscode-augment/augment-global-state/mcpServers.json",
)
}
#[cfg(target_os = "macos")]
{
home.join("Library/Application Support/Code/User/globalStorage/augment.vscode-augment/augment-global-state/mcpServers.json")
}
};
assert!(
vscode_path.exists(),
"must create augment vscode mcpServers.json at {}",
vscode_path.display()
);
let raw = std::fs::read_to_string(&vscode_path).expect("vscode mcpServers.json readable");
let arr: serde_json::Value =
serde_json::from_str(&raw).expect("vscode mcpServers.json is JSON");
let entries = arr.as_array().expect("vscode mcpServers.json is an array");
let lean_ctx = entries
.iter()
.find(|e| e.get("name").and_then(|n| n.as_str()) == Some("lean-ctx"))
.expect("array must contain a lean-ctx entry");
assert_eq!(
lean_ctx.get("type").and_then(|t| t.as_str()),
Some("stdio"),
"augment vscode entry must declare stdio transport"
);
assert!(
lean_ctx.get("id").and_then(|i| i.as_str()).is_some(),
"augment vscode entry must carry a stable id"
);
assert!(
lean_ctx.get("command").and_then(|c| c.as_str()).is_some(),
"augment vscode entry must carry a command string"
);
assert!(
lean_ctx.get("env").is_none(),
"data dir is auto-detected at runtime, not pinned into the config (GH #408)"
);
}
}