- install.ps1 ne trouvait que 'am.exe' dans le zip : les archives (CI et manuelles) utilisent le nom plateforme 'am-windows-x86_64.exe' -> repli systematique sur cargo install au lieu du binaire precompile - Le script accepte desormais les deux noms (copie vers am.exe) ; chemin du message cargo install corrige (.cargo\bin\am.exe au lieu de binam) - i18n : am ai utilisait tr()/tr_fmt() globaux (flaky selon le process) — passage a tr_in/tr_fmt_in(app.lang()) + helper tr_fmt_in ajoute a i18n.rs - Archive v1.0.6 : les deux entrees (am.exe + am-windows-x86_64.exe) pour la compatibilite maximale (installateurs et self-update) - Tests ai_test insensibles a la locale (FR/EN) - v1.0.6 ; ROADMAP maintenance note ajoutee
158 lines
5.5 KiB
Rust
158 lines
5.5 KiB
Rust
//! Integration tests for `am ai` (issues #96 #97): the command resolves
|
|
//! the `aichat` catalog agent, so a fake `aichat.cmd` shim is placed on
|
|
//! PATH and emits a canned command. The safety pipeline (dry-run default,
|
|
//! classification, confirmation, --yes) is exercised end to end through
|
|
//! the real command dispatch.
|
|
|
|
mod common;
|
|
|
|
use agent_manager::cli::Cli;
|
|
use clap::Parser;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::Mutex;
|
|
|
|
/// Serialize PATH mutation and process spawning between parallel tests.
|
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
|
|
|
const AICHAT_DEF: &str = r#"
|
|
agents:
|
|
- name: aichat
|
|
display_name: AIChat
|
|
description: fake shim for tests
|
|
category: shell-ai
|
|
install:
|
|
type: binary
|
|
repo: sigoden/aichat
|
|
binary: aichat
|
|
run: aichat
|
|
installable: false
|
|
"#;
|
|
|
|
fn fresh_dir(tag: &str) -> PathBuf {
|
|
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
let dir = std::env::temp_dir().join(format!("am-ai-{tag}-{}-{id}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
/// Write a fake `aichat.cmd` into `dir` and prepend `dir` to PATH.
|
|
fn fake_aichat(dir: &PathBuf, body: &str) {
|
|
std::fs::write(dir.join("aichat.cmd"), body).unwrap();
|
|
let mut paths = vec![dir.clone()];
|
|
if let Some(existing) = std::env::var_os("PATH") {
|
|
paths.extend(std::env::split_paths(&existing));
|
|
}
|
|
std::env::set_var("PATH", std::env::join_paths(paths).unwrap());
|
|
}
|
|
|
|
/// Build the App for an `am ai` invocation with the aichat shim on PATH.
|
|
fn app_for(shim_body: &str, settings: &str, args: &[&str]) -> (agent_manager::app::App, PathBuf) {
|
|
let dir = fresh_dir("app");
|
|
fake_aichat(&dir, shim_body);
|
|
let cfg = common::write_config(&dir, &format!("{settings}{AICHAT_DEF}"));
|
|
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
|
|
full.extend(args.iter().map(|s| s.to_string()));
|
|
let cli = Cli::parse_from(full);
|
|
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
|
|
common::isolate(&mut app, &dir);
|
|
(app, dir)
|
|
}
|
|
|
|
fn run(args: &[&str], shim_body: &str, settings: &str) -> (String, String, i32) {
|
|
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
let (app, dir) = app_for(shim_body, settings, args);
|
|
let cmd = app.cli.command.as_ref().expect("a command was parsed");
|
|
let code = agent_manager::commands::execute_command(&app, cmd).unwrap_or_else(|e| {
|
|
eprintln!("ERR: {e:#}");
|
|
1
|
|
});
|
|
let log = std::fs::read_to_string(app.paths.log_dir.join("agent-manager.log"))
|
|
.unwrap_or_default();
|
|
// Events journal lives next to state.json (isolated dir).
|
|
let mut events = String::new();
|
|
if let Ok(rd) = std::fs::read_dir(&dir) {
|
|
for e in rd.flatten() {
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
if name.starts_with("events-") && name.ends_with(".jsonl") {
|
|
events.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
|
|
}
|
|
}
|
|
}
|
|
(log, events, code)
|
|
}
|
|
|
|
#[test]
|
|
fn ai_exec_dry_run_is_the_default_and_never_executes() {
|
|
let (log, events, code) = run(
|
|
&["ai", "--exec", "supprime tout"],
|
|
"@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n",
|
|
"",
|
|
);
|
|
assert_eq!(code, 0);
|
|
assert!(
|
|
log.contains("non exécutée") || log.contains("not executed"),
|
|
"dry-run policy must abort, log: {log}"
|
|
);
|
|
assert!(!log.contains("exécution:"), "nothing must run, log: {log}");
|
|
assert!(
|
|
events.contains("shell_ai") && events.contains("executed=false"),
|
|
"journal must record the aborted exec, events: {events}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ai_exec_with_yes_executes_through_the_shell() {
|
|
// Shim emits a harmless command; --yes skips the policy gate.
|
|
let (log, events, code) = run(
|
|
&["ai", "--exec", "dis bonjour", "--yes"],
|
|
"@echo off\r\necho echo hello-from-shim\r\n",
|
|
"",
|
|
);
|
|
assert_eq!(code, 0);
|
|
assert!(
|
|
log.contains("exécution: echo hello-from-shim"),
|
|
"the generated command must run, log: {log}"
|
|
);
|
|
assert!(
|
|
events.contains("executed=true"),
|
|
"journal must record the execution, events: {events}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ai_conversational_passes_prompt_and_context_to_aichat() {
|
|
// Shim echoes its arguments; conversational mode passes -f . + prompt.
|
|
let (log, events, code) = run(
|
|
&["ai", "liste les fichiers"],
|
|
"@echo off\r\necho %*\r\n",
|
|
"",
|
|
);
|
|
assert_eq!(code, 0);
|
|
assert!(
|
|
log.contains("shell-ai:") || events.contains("mode=chat"),
|
|
"conversational mode must be journalized, log: {log} events: {events}"
|
|
);
|
|
assert!(events.contains("mode=chat"), "events: {events}");
|
|
}
|
|
|
|
#[test]
|
|
fn ai_exec_respects_configured_confirm_mode() {
|
|
// default_safety: confirm + risky command => the confirmation prompt is
|
|
// reached; without stdin input the prompt is declined (empty answer).
|
|
let (log, events, code) = run(
|
|
&["ai", "--exec", "supprime"],
|
|
"@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n",
|
|
" shell_ai:\n default_safety: confirm\n",
|
|
);
|
|
assert_eq!(code, 0);
|
|
assert!(
|
|
log.contains("annulé") || log.contains("cancelled") || log.contains("non exécutée")
|
|
|| log.contains("not executed"),
|
|
"declined confirmation must abort, log: {log}"
|
|
);
|
|
assert!(!log.contains("exécution:"), "log: {log}");
|
|
assert!(events.contains("executed=false"), "events: {events}");
|
|
}
|