Câble la détection de limite de session dans l'adapter Claude : - claude.rs : parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4. - mod.rs : 26 nouveaux tests QA (#[cfg(test)]) + 2 tests existants alignés sur le nouveau contrat. - conformance.rs : RateLimited ajouté aux événements non terminaux autorisés. `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
9.4 KiB
Rust
243 lines
9.4 KiB
Rust
//! 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<dyn AgentSession>,
|
|
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<ReplyEvent> = 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");
|
|
}
|
|
}
|