//! E2E del cliente del bus (Fase 6) contra un servidor stub. //! //! No depende de `hammerd` ni del lab: un mini-servidor JSON-líneas que conoce el //! handshake y responde a `Compile` con `BuildReady` sintético. Demuestra que el cliente //! síncrono multiplexa correctamente respuestas (BuildReady) y eventos asíncronos (Modified). use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::thread; use std::time::Duration; use takana_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION}; fn start_stub_bus(sock: PathBuf) -> std::sync::mpsc::Sender { let (tx_async, rx_async) = std::sync::mpsc::channel::(); let _ = std::fs::remove_file(&sock); let listener = UnixListener::bind(&sock).expect("bind stub"); thread::Builder::new() .name("stub-bus".into()) .spawn(move || { // Sólo aceptamos una conexión: el Receiver no es Clone, se mueve al handler. if let Some(Ok(stream)) = listener.incoming().next() { thread::spawn(move || stub_serve(stream, rx_async)); } }) .unwrap(); tx_async } fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver) { let reader_stream = stream.try_clone().unwrap(); let mut writer = stream; let writer_clone = writer.try_clone().unwrap(); // Hilo que reenvía los eventos asíncronos al cliente. Mover el writer impide que // se mezclen los bytes con las respuestas (cada Sender los serializa atómicamente). let async_writer = std::sync::Arc::new(std::sync::Mutex::new(writer_clone)); let aw_for_async = async_writer.clone(); thread::spawn(move || { for ev in rx_async { let line = serde_json::to_string(&ev).unwrap(); let mut g = aw_for_async.lock().unwrap(); let _ = g.write_all(line.as_bytes()); let _ = g.write_all(b"\n"); let _ = g.flush(); } }); let mut reader = BufReader::new(reader_stream); let mut first = String::new(); reader.read_line(&mut first).unwrap(); let cmd: Command = serde_json::from_str(first.trim()).unwrap(); let Command::Hello { ver: _, client: _ } = cmd else { return; }; // welcome let w = Event::Welcome { ver: PROTOCOL_VERSION, caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init], peer: Peer { uid: 1000, gid: 1000, pid: 1 }, }; let mut g = async_writer.lock().unwrap(); g.write_all(serde_json::to_string(&w).unwrap().as_bytes()).unwrap(); g.write_all(b"\n").unwrap(); g.flush().unwrap(); drop(g); // Loop de comandos. loop { let mut line = String::new(); match reader.read_line(&mut line) { Ok(0) => break, Ok(_) => {} Err(_) => break, } if line.trim().is_empty() { continue; } let cmd: Command = match serde_json::from_str(line.trim()) { Ok(c) => c, Err(_) => continue, }; let response = match cmd { Command::Compile { recipe } => { // El stub refleja la evidencia declarada como un veredicto "todo pasa" para // ejercitar el camino H1c del cliente (sin lab real que la ejecute). let verdict = (!recipe.evidence.is_empty()).then(|| { takana_core::proto::EvidenceVerdict { all_passed: true, max_level: recipe.evidence.checks.iter().map(|c| c.kind).max(), checks: recipe .evidence .checks .iter() .map(|c| takana_core::proto::EvidenceCheckVerdict { kind: c.kind, cmd: c.cmd.clone(), passed: true, detail: "stub ok".into(), }) .collect(), } }); Event::BuildReady { recipe: recipe.name.clone(), artifact: format!( "b3:stub-{}", recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc") ), verdict, } } Command::Query { what, .. } => Event::QueryResult { what, value: serde_json::json!({"stub": true}), }, Command::Init { cmd } => Event::InitAck { cmd }, Command::Inject { artifact, target, .. } => Event::Injected { artifact, target, files: 1, }, Command::Hello { .. } => Event::Error { code: "duplicate_hello".into(), msg: "ya hicimos hello".into(), }, }; let mut g = async_writer.lock().unwrap(); g.write_all(serde_json::to_string(&response).unwrap().as_bytes()) .unwrap(); g.write_all(b"\n").unwrap(); g.flush().unwrap(); drop(g); // Cierra solo si el cliente cierra; aquí seguimos. if !writer.flush().is_ok() { break; } } } #[test] fn client_handshake_compile_and_async_modified() { let d = tempfile::tempdir().unwrap(); let sock = d.path().join("stub.sock"); let async_tx = start_stub_bus(sock.clone()); // Pequeño wait para que el listener bindee. for _ in 0..50 { if sock.exists() { break } thread::sleep(Duration::from_millis(10)); } let mut client = takana_agent::AgentClient::connect(&sock).expect("connect"); assert_eq!(client.welcome.ver, PROTOCOL_VERSION); assert!(client.welcome.caps.contains(&Cap::Compile)); let recipe = RecipeInline { name: "grep".into(), repo: Some("git://example/grep.git".into()), commit: Some("abc123".into()), tarball: None, sha256: None, patch: None, compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], evidence: takana_core::Evidence { checks: vec![takana_core::EvidenceCheck { kind: takana_core::EvidenceKind::CmdExit, cmd: "grep --version".into(), expected_exit: 0, expected_output: None, }], }, }; let outcome = client .compile(recipe, Duration::from_secs(2)) .expect("BuildReady"); assert_eq!(outcome.artifact, "b3:stub-abc123"); // [H1c] el veredicto de la evidencia llega por BuildReady y el cliente lo expone. let verdict = outcome.verdict.expect("verdict presente (declaramos evidencia)"); assert!(verdict.all_passed); assert_eq!(verdict.checks.len(), 1); assert_eq!(verdict.max_level, Some(takana_core::EvidenceKind::CmdExit)); // El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos // que el cliente lo entrega por drain_async()/next_async(). async_tx .send(Event::Modified { path: "/bin/grep".into(), op: "replace".into(), ts: "2026-06-09T00:00:00Z".into(), }) .unwrap(); let ev = client.next_async(Duration::from_secs(2)).expect("async"); match ev { Event::Modified { path, .. } => assert_eq!(path, "/bin/grep"), other => panic!("esperaba Modified, llegó {other:?}"), } }