1pub mod annotations;
40pub mod fidelity;
41pub mod mcp_mock;
42pub mod overlay_fs;
43pub mod process_tape;
44pub mod tape;
45#[cfg(feature = "testbench-wasi")]
46pub mod wasi_process;
47
48use std::path::PathBuf;
49use std::sync::Arc;
50
51use crate::clock_mock::leak_audit::{self, ClockLeak};
52use crate::clock_mock::{install_override, ClockOverrideGuard, MockClock};
53use crate::egress::reset_egress_policy_for_host;
54
55use overlay_fs::{install_overlay, OverlayFs, OverlayFsGuard};
56use process_tape::{install_process_tape, ProcessTape, ProcessTapeGuard, ProcessTapeMode};
57use tape::{install_recorder, TapeHeader, TapeRecorder, TapeRecorderGuard};
58
59#[derive(Debug, Default, Clone)]
62pub struct Testbench {
63 pub clock: ClockConfig,
64 pub llm: LlmConfig,
65 pub filesystem: FilesystemConfig,
66 pub subprocess: SubprocessConfig,
67 pub network: NetworkConfig,
68 pub tape: TapeConfig,
69}
70
71#[derive(Debug, Default, Clone)]
74pub enum ClockConfig {
75 #[default]
77 Real,
78 Paused { starting_at_ms: i64 },
81}
82
83#[derive(Debug, Default, Clone)]
89pub enum LlmConfig {
90 #[default]
92 Real,
93 Replay { fixture: PathBuf },
95 Record { fixture: PathBuf },
97}
98
99#[derive(Debug, Default, Clone)]
101pub enum FilesystemConfig {
102 #[default]
104 Real,
105 Overlay { worktree: PathBuf },
109}
110
111#[derive(Debug, Default, Clone)]
113pub enum SubprocessConfig {
114 #[default]
116 Real,
117 Record { tape: PathBuf },
120 Replay { tape: PathBuf },
123 WasiToolchain { dir: PathBuf },
131}
132
133#[derive(Debug, Default, Clone)]
136pub enum NetworkConfig {
137 #[default]
139 Real,
140 DenyByDefault {
144 allow: Vec<String>,
147 },
148}
149
150#[derive(Debug, Default, Clone)]
156pub enum TapeConfig {
157 #[default]
158 Off,
159 Emit {
160 path: PathBuf,
161 argv: Vec<String>,
165 script_path: Option<String>,
168 },
169}
170
171impl Testbench {
172 pub fn builder() -> TestbenchBuilder {
174 TestbenchBuilder::default()
175 }
176
177 pub fn activate(self) -> Result<TestbenchSession, TestbenchError> {
180 TestbenchSession::install(self)
181 }
182}
183
184#[derive(Debug, Default, Clone)]
186pub struct TestbenchBuilder {
187 bench: Testbench,
188}
189
190impl TestbenchBuilder {
191 pub fn paused_clock_at_ms(mut self, starting_at_ms: i64) -> Self {
192 self.bench.clock = ClockConfig::Paused { starting_at_ms };
193 self
194 }
195
196 pub fn replay_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
197 self.bench.llm = LlmConfig::Replay {
198 fixture: fixture.into(),
199 };
200 self
201 }
202
203 pub fn record_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
204 self.bench.llm = LlmConfig::Record {
205 fixture: fixture.into(),
206 };
207 self
208 }
209
210 pub fn fs_overlay(mut self, worktree: impl Into<PathBuf>) -> Self {
211 self.bench.filesystem = FilesystemConfig::Overlay {
212 worktree: worktree.into(),
213 };
214 self
215 }
216
217 pub fn record_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
218 self.bench.subprocess = SubprocessConfig::Record { tape: tape.into() };
219 self
220 }
221
222 pub fn replay_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
223 self.bench.subprocess = SubprocessConfig::Replay { tape: tape.into() };
224 self
225 }
226
227 pub fn wasi_toolchain(mut self, dir: impl Into<PathBuf>) -> Self {
230 self.bench.subprocess = SubprocessConfig::WasiToolchain { dir: dir.into() };
231 self
232 }
233
234 pub fn deny_network(mut self) -> Self {
235 self.bench.network = NetworkConfig::DenyByDefault { allow: Vec::new() };
236 self
237 }
238
239 pub fn allow_network(mut self, allow: impl IntoIterator<Item = String>) -> Self {
240 self.bench.network = NetworkConfig::DenyByDefault {
241 allow: allow.into_iter().collect(),
242 };
243 self
244 }
245
246 pub fn emit_tape(mut self, path: impl Into<PathBuf>) -> Self {
247 self.bench.tape = TapeConfig::Emit {
248 path: path.into(),
249 argv: Vec::new(),
250 script_path: None,
251 };
252 self
253 }
254
255 pub fn emit_tape_for(
256 mut self,
257 path: impl Into<PathBuf>,
258 script_path: Option<String>,
259 argv: Vec<String>,
260 ) -> Self {
261 self.bench.tape = TapeConfig::Emit {
262 path: path.into(),
263 argv,
264 script_path,
265 };
266 self
267 }
268
269 pub fn build(self) -> Testbench {
270 self.bench
271 }
272}
273
274#[must_use = "the testbench tears down on drop; bind the handle to a `_session` local"]
277pub struct TestbenchSession {
278 _clock_leak_scope: Option<leak_audit::ClockLeakScopeGuard>,
279 _clock: Option<ClockOverrideGuard>,
280 _process: Option<ProcessTapeGuard>,
281 _overlay: Option<OverlayFsGuard>,
282 _recorder: Option<TapeRecorderGuard>,
283 process_tape: Option<Arc<ProcessTape>>,
284 overlay: Option<Arc<OverlayFs>>,
285 recorder: Option<Arc<TapeRecorder>>,
286 tape_path: Option<PathBuf>,
287 tape_started_at_unix_ms: Option<i64>,
288 tape_script_path: Option<String>,
289 tape_argv: Vec<String>,
290 subprocess_mode: ProcessTapeMode,
291 subprocess_tape_path: Option<PathBuf>,
292 #[cfg(feature = "testbench-wasi")]
293 _wasi_toolchain: Option<wasi_process::WasiToolchainGuard>,
294 saved_egress_env: Option<SavedEgressEnv>,
298}
299
300#[derive(Debug, Clone)]
301struct SavedEgressEnv {
302 default: Option<String>,
303 allow: Option<String>,
304 deny: Option<String>,
305}
306
307impl TestbenchSession {
308 fn install(bench: Testbench) -> Result<Self, TestbenchError> {
309 let (clock_leak_scope, clock_guard, started_at_unix_ms) = match bench.clock {
310 ClockConfig::Real => (None, None, None),
311 ClockConfig::Paused { starting_at_ms } => (
312 Some(leak_audit::install_scope()),
313 Some(install_override(MockClock::at_wall_ms(starting_at_ms))),
314 Some(starting_at_ms),
315 ),
316 };
317
318 #[allow(clippy::no_effect_underscore_binding)]
322 let _llm_config = bench.llm;
323
324 #[cfg(feature = "testbench-wasi")]
325 let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
326
327 let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
328 match bench.subprocess {
329 SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
330 SubprocessConfig::Record { tape } => {
331 let active = Arc::new(ProcessTape::recording());
332 let guard = install_process_tape(Arc::clone(&active));
333 (
334 Some(Arc::clone(&active)),
335 Some(guard),
336 ProcessTapeMode::Record,
337 Some(tape),
338 )
339 }
340 SubprocessConfig::Replay { tape } => {
341 let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
342 let active = Arc::new(loaded);
343 let guard = install_process_tape(Arc::clone(&active));
344 (
345 Some(Arc::clone(&active)),
346 Some(guard),
347 ProcessTapeMode::Replay,
348 Some(tape),
349 )
350 }
351 #[cfg(feature = "testbench-wasi")]
352 SubprocessConfig::WasiToolchain { dir } => {
353 if !dir.exists() {
354 return Err(TestbenchError::Subprocess(format!(
355 "wasi toolchain directory does not exist: {}",
356 dir.display()
357 )));
358 }
359 wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
360 (None, None, ProcessTapeMode::Replay, None)
361 }
362 #[cfg(not(feature = "testbench-wasi"))]
363 SubprocessConfig::WasiToolchain { .. } => {
364 return Err(TestbenchError::Subprocess(
365 "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
366 ));
367 }
368 };
369
370 let (overlay, overlay_guard) = match bench.filesystem {
371 FilesystemConfig::Real => (None, None),
372 FilesystemConfig::Overlay { worktree } => {
373 let overlay = Arc::new(OverlayFs::rooted_at(worktree));
374 let guard = install_overlay(Arc::clone(&overlay));
375 (Some(overlay), Some(guard))
376 }
377 };
378
379 let saved_egress_env = match bench.network {
380 NetworkConfig::Real => None,
381 NetworkConfig::DenyByDefault { allow } => {
382 let saved = SavedEgressEnv {
383 default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
384 allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
385 deny: std::env::var("HARN_EGRESS_DENY").ok(),
386 };
387 reset_egress_policy_for_host();
391 std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
392 if allow.is_empty() {
393 std::env::remove_var("HARN_EGRESS_ALLOW");
394 } else {
395 std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
396 }
397 std::env::remove_var("HARN_EGRESS_DENY");
398 Some(saved)
399 }
400 };
401
402 let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
403 TapeConfig::Off => (None, None, None, Vec::new(), None),
404 TapeConfig::Emit {
405 path,
406 argv,
407 script_path,
408 } => {
409 let recorder = Arc::new(TapeRecorder::new());
410 let guard = install_recorder(Arc::clone(&recorder));
411 (
412 Some(Arc::clone(&recorder)),
413 Some(guard),
414 Some(path),
415 argv,
416 script_path,
417 )
418 }
419 };
420
421 Ok(Self {
422 _clock_leak_scope: clock_leak_scope,
423 _clock: clock_guard,
424 _process: process_guard,
425 _overlay: overlay_guard,
426 _recorder: recorder_guard,
427 process_tape,
428 overlay,
429 recorder,
430 tape_path,
431 tape_started_at_unix_ms: started_at_unix_ms,
432 tape_script_path,
433 tape_argv,
434 subprocess_mode,
435 subprocess_tape_path,
436 #[cfg(feature = "testbench-wasi")]
437 _wasi_toolchain: wasi_guard,
438 saved_egress_env,
439 })
440 }
441
442 pub fn subprocess_mode(&self) -> ProcessTapeMode {
444 self.subprocess_mode
445 }
446
447 pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
450 self.subprocess_tape_path.as_deref()
451 }
452
453 pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
455 self.overlay.as_ref()
456 }
457
458 pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
460 self.process_tape.as_ref()
461 }
462
463 pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
465 self.recorder.as_ref()
466 }
467
468 pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
473 let diff = self
474 .overlay
475 .as_ref()
476 .map(|overlay| overlay.diff())
477 .unwrap_or_default();
478 let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
479 if let (Some(tape), Some(path)) = (
480 self.process_tape.as_ref(),
481 self.subprocess_tape_path.as_ref(),
482 ) {
483 tape.persist(path).map_err(TestbenchError::Subprocess)?;
484 }
485 self.process_tape
486 .as_ref()
487 .map(|tape| tape.recorded())
488 .unwrap_or_default()
489 } else {
490 Vec::new()
491 };
492 let mut emitted_tape = None;
493 if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
494 let header = TapeHeader::current(
495 self.tape_started_at_unix_ms,
496 self.tape_script_path.clone(),
497 self.tape_argv.clone(),
498 );
499 let tape = recorder.snapshot(header);
500 tape.persist(path).map_err(TestbenchError::Tape)?;
501 emitted_tape = Some(EmittedTape {
502 path: path.clone(),
503 records: tape.records.len(),
504 });
505 }
506 let clock_leaks = leak_audit::drain();
511 Ok(TestbenchFinalize {
513 fs_diff: diff,
514 recorded_subprocesses: recorded,
515 tape: emitted_tape,
516 clock_leaks,
517 })
518 }
519}
520
521impl Drop for TestbenchSession {
522 fn drop(&mut self) {
523 if let Some(saved) = self.saved_egress_env.take() {
524 restore_env("HARN_EGRESS_DEFAULT", saved.default);
525 restore_env("HARN_EGRESS_ALLOW", saved.allow);
526 restore_env("HARN_EGRESS_DENY", saved.deny);
527 reset_egress_policy_for_host();
528 }
529 }
532}
533
534fn restore_env(key: &str, prior: Option<String>) {
535 match prior {
536 Some(value) => std::env::set_var(key, value),
537 None => std::env::remove_var(key),
538 }
539}
540
541#[derive(Debug, Default, Clone)]
544pub struct TestbenchFinalize {
545 pub fs_diff: Vec<overlay_fs::DiffEntry>,
546 pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
547 pub tape: Option<EmittedTape>,
548 pub clock_leaks: Vec<ClockLeak>,
553}
554
555#[derive(Debug, Clone)]
557pub struct EmittedTape {
558 pub path: PathBuf,
559 pub records: usize,
560}
561
562#[derive(Debug)]
564pub enum TestbenchError {
565 Subprocess(String),
566 Tape(String),
567}
568
569impl std::fmt::Display for TestbenchError {
570 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571 match self {
572 Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
573 Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
574 }
575 }
576}
577
578impl std::error::Error for TestbenchError {}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use std::sync::Mutex;
584
585 static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
586
587 fn serial<F: FnOnce()>(body: F) {
591 let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
592 body();
593 }
594
595 #[test]
596 fn paused_clock_pins_now_ms_for_session_lifetime() {
597 serial(|| {
598 let bench = Testbench::builder()
599 .paused_clock_at_ms(1_700_000_000_000)
600 .build();
601 let session = bench.activate().expect("activate");
602 assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
603 crate::clock_mock::advance(std::time::Duration::from_mins(1));
604 assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
605 drop(session);
606 assert!(!crate::clock_mock::is_mocked());
608 });
609 }
610
611 #[test]
612 fn deny_by_default_blocks_egress_until_drop() {
613 serial(|| {
614 let bench = Testbench::builder().deny_network().build();
615 let session = bench.activate().expect("activate");
616 assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
617 drop(session);
618 assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
619 });
620 }
621
622 #[test]
623 fn finalize_surfaces_clock_leaks_for_contrived_capability() {
624 serial(|| {
625 let bench = Testbench::builder()
626 .paused_clock_at_ms(1_700_000_000_000)
627 .build();
628 let session = bench.activate().expect("activate");
629
630 let _ = leak_audit::wall_now("test/contrived_leak");
634 let _ = leak_audit::instant_now("test/contrived_instant");
635 let _ = leak_audit::wall_now("test/contrived_leak");
636
637 let finalize = session.finalize().expect("finalize");
638 let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
639 .clock_leaks
640 .iter()
641 .map(|leak| (leak.capability_id.as_str(), leak))
642 .collect();
643 let wall = by_id
644 .get("test/contrived_leak")
645 .expect("wall leak surfaced");
646 assert_eq!(wall.count, 2);
647 let inst = by_id
648 .get("test/contrived_instant")
649 .expect("instant leak surfaced");
650 assert_eq!(inst.count, 1);
651
652 let next_session = Testbench::builder()
654 .paused_clock_at_ms(1_700_000_000_000)
655 .build()
656 .activate()
657 .expect("activate next");
658 let next = next_session.finalize().expect("finalize next");
659 assert!(next.clock_leaks.is_empty());
660 });
661 }
662
663 #[test]
664 fn audit_quiet_when_no_mock_is_active() {
665 serial(|| {
666 leak_audit::reset();
667 let _ = leak_audit::wall_now("test/no_mock");
670 let _ = leak_audit::instant_now("test/no_mock");
671 assert!(leak_audit::snapshot().is_empty());
672 });
673 }
674}