//! Fake CLI scriptable + **harnais de conformité de port (Liskov)** pour les //! adapters structurés (ARCHITECTURE §17.2). Permet de tester la **machinerie** //! (spawn, lecture ligne-à-ligne, drain jusqu'au `Final`, capture d'id de session, //! shutdown, timeout) **sans réseau ni vraie CLI**, et d'asserter le **contrat //! [`AgentSession`]** de façon réutilisable pour Claude ET Codex. //! //! Disponible hors `cfg(test)` (mais sous une porte `pub`) pour que QA puisse //! réutiliser le harnais et le fake CLI dans des tests d'intégration ultérieurs. //! //! > Les *scripts* de lignes JSON fournis aux tests reproduisent le **format RÉEL //! > vérifié 2026-06-09** (spikes S1/S2 résolus), documenté dans `claude::parse_event` //! > / `codex::parse_event`. La machinerie et le harnais sont indépendants du format. use std::io::Write; use std::path::PathBuf; use super::process::SpawnLine; /// Un **fake CLI** : un script exécutable qui **rejoue un script de lignes** sur /// stdout (en ignorant ses arguments), puis se termine. Substitué au vrai /// `claude`/`codex` pour rendre la machinerie déterministe et hors-réseau. /// /// Le binaire est matérialisé dans un fichier temporaire ; il est supprimé au drop. pub struct FakeCli { /// Chemin du script exécutable généré. path: PathBuf, } impl FakeCli { /// Crée un fake CLI qui imprimera exactement `lines` (une par ligne de stdout), /// dans l'ordre, puis sortira avec le code 0. /// /// # Panics /// Panique si le fichier temporaire ne peut être écrit (environnement de test /// cassé) — acceptable dans un utilitaire de test. #[must_use] pub fn printing(lines: &[&str]) -> Self { let mut path = std::env::temp_dir(); // Nom unique : pid + compteur atomique pour éviter toute collision entre // tests parallèles. use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); path.push(format!("idea-fake-cli-{}-{n}", std::process::id())); let mut script = String::from("#!/bin/sh\n"); for line in lines { // `printf '%s\n'` imprime la ligne littéralement (pas d'interprétation // d'échappements), en isolant la donnée de toute injection shell via // l'unique argument `--`. script.push_str("printf '%s\\n' "); script.push_str(&shell_single_quote(line)); script.push('\n'); } let mut file = std::fs::File::create(&path).expect("création du fake CLI"); file.write_all(script.as_bytes()) .expect("écriture du fake CLI"); // `sync_all` force la fermeture/flush du descripteur en écriture AVANT toute // tentative d'exécution : sans cela, `execve` sur un binaire encore ouvert en // écriture par un autre thread (suite parallèle) retourne `ETXTBSY` // (« Text file busy », os error 26) — défaut de fixture intermittent. file.sync_all().expect("sync du fake CLI"); drop(file); set_executable(&path); wait_until_executable(&path); Self { path } } /// Le binaire à passer en `command` d'un [`SpawnLine`] / d'un adapter. #[must_use] pub fn command(&self) -> String { self.path.to_string_lossy().into_owned() } /// Construit un [`SpawnLine`] minimal lançant ce fake CLI (utile pour tester la /// machinerie [`super::process::run_turn`] directement). #[must_use] pub fn spawn_line(&self) -> SpawnLine { SpawnLine { command: self.command(), args: Vec::new(), cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: None, } } } impl Drop for FakeCli { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } /// Échappe une chaîne pour l'insérer en argument shell entre quotes simples. fn shell_single_quote(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('\''); for c in s.chars() { if c == '\'' { out.push_str("'\\''"); } else { out.push(c); } } out.push('\''); out } /// Attend que `path` soit réellement exécutable (probe `execve` qui ne retourne /// plus `ETXTBSY`). Sur Linux, exécuter un fichier encore ouvert en écriture par un /// autre thread échoue avec « Text file busy » : on boucle un court instant jusqu'à /// ce que la condition se lève, garantissant qu'un `spawn` ultérieur ne *race* pas. #[cfg(unix)] pub(crate) fn wait_until_executable(path: &std::path::Path) { use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; let deadline = Instant::now() + Duration::from_secs(5); loop { // Probe la plus légère possible : on tente le spawn ; ETXTBSY ⇒ on réessaie. match Command::new(path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() { Ok(mut child) => { let _ = child.wait(); return; } Err(e) if e.raw_os_error() == Some(26) && Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(2)); } // Toute autre erreur (ou dépassement de délai) : on rend la main, le test // appelant remontera l'échec réel s'il subsiste. Err(_) => return, } } } #[cfg(not(unix))] pub(crate) fn wait_until_executable(_path: &std::path::Path) {} #[cfg(unix)] fn set_executable(path: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(path) .expect("metadata fake CLI") .permissions(); perms.set_mode(0o755); std::fs::set_permissions(path, perms).expect("chmod fake CLI"); } #[cfg(not(unix))] fn set_executable(_path: &std::path::Path) { // Sur les plateformes non-Unix le harnais s'appuiera sur un fake CLI adapté // (ex. `.cmd`) ; non requis pour la CI Linux actuelle. } // --------------------------------------------------------------------------- // Harnais de conformité de port (Liskov) — réutilisable Claude ET Codex // --------------------------------------------------------------------------- #[cfg(test)] pub(crate) mod harness { use std::sync::Arc; use domain::ports::{AgentSession, ReplyEvent}; /// Asserte le **contrat [`AgentSession`]** sur une session déjà construite /// derrière un fake CLI dont le script produit ≥0 deltas/activités puis **un** /// `Final` portant `expected_final`, et dont l'init assigne `expected_conv_id`. /// /// Vérifie (substituabilité Liskov, §17.2) : /// 1. `send` émet une séquence de deltas/activités **puis exactement un** `Final` ; /// 2. après le `Final` le flux est **clos** (plus aucun événement) ; /// 3. le `Final` porte bien `expected_final` ; /// 4. `conversation_id()` devient `Some(expected_conv_id)` après le tour assignant ; /// 5. `shutdown()` réussit et est **idempotent** (deux appels OK). pub async fn assert_agent_session_contract( session: Arc, expected_conv_id: &str, expected_final: &str, ) { // Avant tout tour : aucun id de conversation assigné. assert_eq!( session.conversation_id(), None, "conversation_id doit être None avant le premier tour" ); let stream = session.send("salut").await.expect("send doit réussir"); let events: Vec = stream.collect(); // (1)+(2) : exactement un Final, en dernière position. let final_count = events .iter() .filter(|e| matches!(e, ReplyEvent::Final { .. })) .count(); assert_eq!(final_count, 1, "le flux doit porter EXACTEMENT un Final"); match events.last() { Some(ReplyEvent::Final { content }) => { // (3) assert_eq!(content, expected_final, "contenu Final inattendu"); } other => panic!("le dernier événement doit être Final, vu: {other:?}"), } // Les événements avant le Final ne sont que des deltas / activités / heartbeats // / rate-limited (tous **non terminaux** ; le heartbeat est une preuve de // vivacité, lot 1 ; le RateLimited s'intercale comme un heartbeat, §21-T4). for e in &events[..events.len() - 1] { assert!( matches!( e, ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. } | ReplyEvent::Heartbeat | ReplyEvent::RateLimited { .. } ), "avant le Final, seuls deltas/activités/heartbeats/rate-limited sont permis, vu: {e:?}" ); } // (4) : id de conversation capté après le tour assignant. assert_eq!( session.conversation_id().as_deref(), Some(expected_conv_id), "conversation_id doit être assigné après le premier tour" ); // (5) : shutdown réussit et est idempotent. session.shutdown().await.expect("shutdown doit réussir"); session .shutdown() .await .expect("shutdown doit être idempotent"); } }