Skip to main content

harn_vm/testbench/
mod.rs

1//! Testbench: hermetic-execution composition primitive.
2//!
3//! Wires the four pluggable axes Harn already had — virtual time, mocked
4//! LLM, filesystem overlay, recorded subprocess — behind a single
5//! [`Testbench`] handle. Production wires real impls; tests/demos pick a
6//! config and get an audit trail of everything that crossed the host
7//! boundary.
8//!
9//! # Axes
10//!
11//! - **Clock** ([`crate::clock_mock`]). Pinned wall-clock + monotonic time
12//!   honored by stdlib `now_ms`/`sleep`/`monotonic_ms`, the trigger
13//!   dispatcher, and the cron scheduler. Tests advance with
14//!   [`crate::clock_mock::advance`] or the script-side `advance_time(...)`.
15//!
16//! - **LLM** ([`crate::llm`]). The CLI replay/record path
17//!   (`install_cli_llm_mocks` / `enable_cli_llm_mock_recording`) is the
18//!   workhorse; [`crate::llm::FakeLlmProvider`] adds streaming/error
19//!   fidelity for tests that care about per-token order.
20//!
21//! - **Filesystem** ([`overlay_fs`]). Copy-on-write overlay rooted at a
22//!   real worktree: reads pass through, writes land in an in-memory
23//!   layer, and [`overlay_fs::OverlayFs::diff`] surfaces a unified-style
24//!   diff that can be applied back or discarded.
25//!
26//! - **Subprocess** ([`process_tape`]). Records `(program, args, cwd) →
27//!   (stdout, stderr, exit, virtual Δt)` tuples in record mode and
28//!   replays them deterministically in replay mode. Env-var matching
29//!   is documented as future work — the JSON tape carries an `env`
30//!   field reserved for it.
31//!
32//! # Network
33//!
34//! Network egress is deny-by-default in testbench mode — outbound HTTP
35//! and connector requests fail fast unless an explicit allowlist names
36//! the destination. The deny pass routes through [`crate::egress`], the
37//! same policy engine production uses.
38
39pub mod annotations;
40pub mod fidelity;
41pub mod overlay_fs;
42pub mod process_tape;
43pub mod tape;
44#[cfg(feature = "testbench-wasi")]
45pub mod wasi_process;
46
47use std::path::PathBuf;
48use std::sync::Arc;
49
50use crate::clock_mock::leak_audit::{self, ClockLeak};
51use crate::clock_mock::{install_override, ClockOverrideGuard, MockClock};
52use crate::egress::reset_egress_policy_for_host;
53
54use overlay_fs::{install_overlay, OverlayFs, OverlayFsGuard};
55use process_tape::{install_process_tape, ProcessTape, ProcessTapeGuard, ProcessTapeMode};
56use tape::{install_recorder, TapeHeader, TapeRecorder, TapeRecorderGuard};
57
58/// Declarative configuration for [`Testbench::activate`]. Every axis is
59/// optional so callers can compose only the surfaces they need.
60#[derive(Debug, Default, Clone)]
61pub struct Testbench {
62    pub clock: ClockConfig,
63    pub llm: LlmConfig,
64    pub filesystem: FilesystemConfig,
65    pub subprocess: SubprocessConfig,
66    pub network: NetworkConfig,
67    pub tape: TapeConfig,
68}
69
70/// Configures the unified mock clock. Defaults to the runtime's real
71/// clock so the testbench stays opt-in.
72#[derive(Debug, Default, Clone)]
73pub enum ClockConfig {
74    /// Leave the clock alone. Real wall-clock + monotonic time.
75    #[default]
76    Real,
77    /// Pin time to the given UNIX-epoch milliseconds. Honored by stdlib
78    /// `now_ms`/`sleep`, the trigger dispatcher, and cron.
79    Paused { starting_at_ms: i64 },
80}
81
82/// LLM provider configuration. Mirrors `harn run --llm-mock` /
83/// `--llm-mock-record` so the testbench is a strict superset of that
84/// flag pair. The testbench *does not* install LLM mocks itself — it
85/// stays declarative so [`crate::llm::install_cli_llm_mocks`] (or its
86/// `harn-cli` wrapper) remains the single mutator of LLM state.
87#[derive(Debug, Default, Clone)]
88pub enum LlmConfig {
89    /// No LLM substitution. Calls go through the configured provider.
90    #[default]
91    Real,
92    /// Replay scripted responses from a JSONL fixture.
93    Replay { fixture: PathBuf },
94    /// Capture executed responses into a JSONL fixture.
95    Record { fixture: PathBuf },
96}
97
98/// Filesystem overlay configuration.
99#[derive(Debug, Default, Clone)]
100pub enum FilesystemConfig {
101    /// No overlay. Reads and writes hit the real filesystem.
102    #[default]
103    Real,
104    /// Read-through, copy-on-write overlay rooted at `worktree`. Writes
105    /// stay in memory until the run ends, at which point the configured
106    /// emitter (CLI flag, in-process API) can read the diff.
107    Overlay { worktree: PathBuf },
108}
109
110/// Subprocess record/replay configuration.
111#[derive(Debug, Default, Clone)]
112pub enum SubprocessConfig {
113    /// No interception. Subprocesses spawn against the host OS.
114    #[default]
115    Real,
116    /// Record `(program, args, cwd)` tuples and their outputs into
117    /// `tape` so a follow-up run can replay them.
118    Record { tape: PathBuf },
119    /// Look every spawn up in `tape` and emit the recorded result. Errors
120    /// loudly when a tuple is not in the tape.
121    Replay { tape: PathBuf },
122    /// Resolve subprocess invocations against a directory of WASI
123    /// (`wasm32-wasi`) modules. Each `program` resolves to
124    /// `<dir>/<program>.wasm`; the module runs under wasmtime with the
125    /// testbench's mock clock virtualized into `clock_time_get` and
126    /// `poll_oneoff`. Calls whose program has no matching `.wasm` fall
127    /// through to the native spawn path. Requires the `testbench-wasi`
128    /// Cargo feature.
129    WasiToolchain { dir: PathBuf },
130}
131
132/// Network policy. Defaults to the production egress policy (no
133/// override). Testbench callers usually pick `DenyByDefault`.
134#[derive(Debug, Default, Clone)]
135pub enum NetworkConfig {
136    /// Use whatever egress policy the host has already installed.
137    #[default]
138    Real,
139    /// Deny outbound requests unless `allow` matches. Routes through
140    /// [`crate::egress`] using the same env-var format that
141    /// `HARN_EGRESS_*` accepts.
142    DenyByDefault {
143        /// Comma-separated allow rules (e.g. `"github.com,*.openai.com"`).
144        /// Empty means deny everything.
145        allow: Vec<String>,
146    },
147}
148
149/// Unified-tape configuration. Recording is opt-in: `Off` (the default)
150/// installs nothing and pays nothing in production; `Emit { path }`
151/// installs a [`tape::TapeRecorder`] consulted by every host-capability
152/// axis, then persists the result to `path` (plus `path.cas/` for large
153/// payloads) when [`TestbenchSession::finalize`] runs.
154#[derive(Debug, Default, Clone)]
155pub enum TapeConfig {
156    #[default]
157    Off,
158    Emit {
159        path: PathBuf,
160        /// Argv forwarded to the script after `--`. Captured in the tape
161        /// header so two tapes that differ only in argv are
162        /// distinguishable.
163        argv: Vec<String>,
164        /// Path to the `.harn` script. Informational only; used to
165        /// populate the tape header so consumers can attribute records.
166        script_path: Option<String>,
167    },
168}
169
170impl Testbench {
171    /// Convenience: construct a builder.
172    pub fn builder() -> TestbenchBuilder {
173        TestbenchBuilder::default()
174    }
175
176    /// Activate every configured axis and return an RAII handle. Drop
177    /// the handle to restore the prior state.
178    pub fn activate(self) -> Result<TestbenchSession, TestbenchError> {
179        TestbenchSession::install(self)
180    }
181}
182
183/// Fluent constructor for [`Testbench`].
184#[derive(Debug, Default, Clone)]
185pub struct TestbenchBuilder {
186    bench: Testbench,
187}
188
189impl TestbenchBuilder {
190    pub fn paused_clock_at_ms(mut self, starting_at_ms: i64) -> Self {
191        self.bench.clock = ClockConfig::Paused { starting_at_ms };
192        self
193    }
194
195    pub fn replay_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
196        self.bench.llm = LlmConfig::Replay {
197            fixture: fixture.into(),
198        };
199        self
200    }
201
202    pub fn record_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
203        self.bench.llm = LlmConfig::Record {
204            fixture: fixture.into(),
205        };
206        self
207    }
208
209    pub fn fs_overlay(mut self, worktree: impl Into<PathBuf>) -> Self {
210        self.bench.filesystem = FilesystemConfig::Overlay {
211            worktree: worktree.into(),
212        };
213        self
214    }
215
216    pub fn record_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
217        self.bench.subprocess = SubprocessConfig::Record { tape: tape.into() };
218        self
219    }
220
221    pub fn replay_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
222        self.bench.subprocess = SubprocessConfig::Replay { tape: tape.into() };
223        self
224    }
225
226    /// Use a directory of WASI modules as the subprocess source. See
227    /// [`SubprocessConfig::WasiToolchain`].
228    pub fn wasi_toolchain(mut self, dir: impl Into<PathBuf>) -> Self {
229        self.bench.subprocess = SubprocessConfig::WasiToolchain { dir: dir.into() };
230        self
231    }
232
233    pub fn deny_network(mut self) -> Self {
234        self.bench.network = NetworkConfig::DenyByDefault { allow: Vec::new() };
235        self
236    }
237
238    pub fn allow_network(mut self, allow: impl IntoIterator<Item = String>) -> Self {
239        self.bench.network = NetworkConfig::DenyByDefault {
240            allow: allow.into_iter().collect(),
241        };
242        self
243    }
244
245    pub fn emit_tape(mut self, path: impl Into<PathBuf>) -> Self {
246        self.bench.tape = TapeConfig::Emit {
247            path: path.into(),
248            argv: Vec::new(),
249            script_path: None,
250        };
251        self
252    }
253
254    pub fn emit_tape_for(
255        mut self,
256        path: impl Into<PathBuf>,
257        script_path: Option<String>,
258        argv: Vec<String>,
259    ) -> Self {
260        self.bench.tape = TapeConfig::Emit {
261            path: path.into(),
262            argv,
263            script_path,
264        };
265        self
266    }
267
268    pub fn build(self) -> Testbench {
269        self.bench
270    }
271}
272
273/// RAII handle returned by [`Testbench::activate`]. Holds every guard
274/// for the active axes; dropping it tears them all down in order.
275#[must_use = "the testbench tears down on drop; bind the handle to a `_session` local"]
276pub struct TestbenchSession {
277    _clock: Option<ClockOverrideGuard>,
278    _process: Option<ProcessTapeGuard>,
279    _overlay: Option<OverlayFsGuard>,
280    _recorder: Option<TapeRecorderGuard>,
281    process_tape: Option<Arc<ProcessTape>>,
282    overlay: Option<Arc<OverlayFs>>,
283    recorder: Option<Arc<TapeRecorder>>,
284    tape_path: Option<PathBuf>,
285    tape_started_at_unix_ms: Option<i64>,
286    tape_script_path: Option<String>,
287    tape_argv: Vec<String>,
288    subprocess_mode: ProcessTapeMode,
289    subprocess_tape_path: Option<PathBuf>,
290    #[cfg(feature = "testbench-wasi")]
291    _wasi_toolchain: Option<wasi_process::WasiToolchainGuard>,
292    /// Saved env state (`HARN_EGRESS_DEFAULT`, `_ALLOW`, `_DENY`) for
293    /// restoration on drop. `None` means the testbench did not override
294    /// network policy this run.
295    saved_egress_env: Option<SavedEgressEnv>,
296}
297
298#[derive(Debug, Clone)]
299struct SavedEgressEnv {
300    default: Option<String>,
301    allow: Option<String>,
302    deny: Option<String>,
303}
304
305impl TestbenchSession {
306    fn install(bench: Testbench) -> Result<Self, TestbenchError> {
307        // Clear the leak audit so the session reports leaks it observed
308        // rather than entries left behind by an earlier session that
309        // never called `finalize` (e.g. a panicking test).
310        leak_audit::reset();
311
312        let (clock_guard, started_at_unix_ms) = match bench.clock {
313            ClockConfig::Real => (None, None),
314            ClockConfig::Paused { starting_at_ms } => (
315                Some(install_override(MockClock::at_wall_ms(starting_at_ms))),
316                Some(starting_at_ms),
317            ),
318        };
319
320        // LLM state is *not* installed here — the caller owns the
321        // CliLlmMockMode channel. Reading bench.llm just keeps the
322        // declarative config visible to test inspection.
323        let _llm_config = bench.llm;
324
325        #[cfg(feature = "testbench-wasi")]
326        let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
327
328        let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
329            match bench.subprocess {
330                SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
331                SubprocessConfig::Record { tape } => {
332                    let active = Arc::new(ProcessTape::recording());
333                    let guard = install_process_tape(Arc::clone(&active));
334                    (
335                        Some(Arc::clone(&active)),
336                        Some(guard),
337                        ProcessTapeMode::Record,
338                        Some(tape),
339                    )
340                }
341                SubprocessConfig::Replay { tape } => {
342                    let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
343                    let active = Arc::new(loaded);
344                    let guard = install_process_tape(Arc::clone(&active));
345                    (
346                        Some(Arc::clone(&active)),
347                        Some(guard),
348                        ProcessTapeMode::Replay,
349                        Some(tape),
350                    )
351                }
352                #[cfg(feature = "testbench-wasi")]
353                SubprocessConfig::WasiToolchain { dir } => {
354                    if !dir.exists() {
355                        return Err(TestbenchError::Subprocess(format!(
356                            "wasi toolchain directory does not exist: {}",
357                            dir.display()
358                        )));
359                    }
360                    wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
361                    (None, None, ProcessTapeMode::Replay, None)
362                }
363                #[cfg(not(feature = "testbench-wasi"))]
364                SubprocessConfig::WasiToolchain { .. } => {
365                    return Err(TestbenchError::Subprocess(
366                        "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
367                    ));
368                }
369            };
370
371        let (overlay, overlay_guard) = match bench.filesystem {
372            FilesystemConfig::Real => (None, None),
373            FilesystemConfig::Overlay { worktree } => {
374                let overlay = Arc::new(OverlayFs::rooted_at(worktree));
375                let guard = install_overlay(Arc::clone(&overlay));
376                (Some(overlay), Some(guard))
377            }
378        };
379
380        let saved_egress_env = match bench.network {
381            NetworkConfig::Real => None,
382            NetworkConfig::DenyByDefault { allow } => {
383                let saved = SavedEgressEnv {
384                    default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
385                    allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
386                    deny: std::env::var("HARN_EGRESS_DENY").ok(),
387                };
388                // Reset any prior policy so install_policy doesn't trip the
389                // "policy already configured" guard, then install via env-var
390                // so the host_policy and stdlib paths see the same view.
391                reset_egress_policy_for_host();
392                std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
393                if allow.is_empty() {
394                    std::env::remove_var("HARN_EGRESS_ALLOW");
395                } else {
396                    std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
397                }
398                std::env::remove_var("HARN_EGRESS_DENY");
399                Some(saved)
400            }
401        };
402
403        let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
404            TapeConfig::Off => (None, None, None, Vec::new(), None),
405            TapeConfig::Emit {
406                path,
407                argv,
408                script_path,
409            } => {
410                let recorder = Arc::new(TapeRecorder::new());
411                let guard = install_recorder(Arc::clone(&recorder));
412                (
413                    Some(Arc::clone(&recorder)),
414                    Some(guard),
415                    Some(path),
416                    argv,
417                    script_path,
418                )
419            }
420        };
421
422        Ok(Self {
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    /// Whether subprocess interception is recording new entries.
443    pub fn subprocess_mode(&self) -> ProcessTapeMode {
444        self.subprocess_mode
445    }
446
447    /// Path that recorded subprocess tape entries should land in, or
448    /// where replay loaded them from.
449    pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
450        self.subprocess_tape_path.as_deref()
451    }
452
453    /// Reference to the active filesystem overlay (if any).
454    pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
455        self.overlay.as_ref()
456    }
457
458    /// Reference to the active process tape (if any).
459    pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
460        self.process_tape.as_ref()
461    }
462
463    /// Reference to the active tape recorder (if any).
464    pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
465        self.recorder.as_ref()
466    }
467
468    /// Persist the recorded subprocess tape (if recording) and return
469    /// the filesystem diff (if an overlay is active). Tearing down the
470    /// session via [`Drop`] will not persist; call this explicitly to
471    /// flush.
472    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        // Drain the leak audit last so anything emitted while we
507        // serialized other artifacts (e.g. tape persistence reading the
508        // wall clock for timestamps it shouldn't be reading) is still
509        // captured in this session's report.
510        let clock_leaks = leak_audit::drain();
511        // The Drop impl undoes mocks regardless of finalize success.
512        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        // The remaining `_clock`/`_overlay`/`_process` guards drop in
530        // field-declared order, restoring the prior thread-local state.
531    }
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/// Outcome of a finalized testbench session — the artifacts the operator
542/// inspects after a hermetic run.
543#[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    /// Capabilities that observed real wall-clock or monotonic time
549    /// during the session. Empty under a hermetic run; non-empty entries
550    /// are fidelity hazards the operator should investigate or migrate
551    /// off of direct host-clock reads.
552    pub clock_leaks: Vec<ClockLeak>,
553}
554
555/// Summary metadata for a unified tape that was emitted at finalize-time.
556#[derive(Debug, Clone)]
557pub struct EmittedTape {
558    pub path: PathBuf,
559    pub records: usize,
560}
561
562/// Errors surfaced when activating or finalizing a testbench session.
563#[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
584    /// Tests in this module mutate process-global state (env vars, the
585    /// leak audit registry) and must run one at a time even though
586    /// `cargo test` defaults to parallel execution. We share
587    /// [`leak_audit::TEST_LOCK`] so the audit module's own tests
588    /// serialize with the testbench's tests against the same registry.
589    fn serial<F: FnOnce()>(body: F) {
590        let _guard = leak_audit::TEST_LOCK
591            .lock()
592            .unwrap_or_else(|p| p.into_inner());
593        body();
594    }
595
596    #[test]
597    fn paused_clock_pins_now_ms_for_session_lifetime() {
598        serial(|| {
599            let bench = Testbench::builder()
600                .paused_clock_at_ms(1_700_000_000_000)
601                .build();
602            let session = bench.activate().expect("activate");
603            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
604            crate::clock_mock::advance(std::time::Duration::from_secs(60));
605            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
606            drop(session);
607            // After drop the override is gone; no assertion on real time.
608            assert!(!crate::clock_mock::is_mocked());
609        });
610    }
611
612    #[test]
613    fn deny_by_default_blocks_egress_until_drop() {
614        serial(|| {
615            let bench = Testbench::builder().deny_network().build();
616            let session = bench.activate().expect("activate");
617            assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
618            drop(session);
619            assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
620        });
621    }
622
623    #[test]
624    fn finalize_surfaces_clock_leaks_for_contrived_capability() {
625        serial(|| {
626            let bench = Testbench::builder()
627                .paused_clock_at_ms(1_700_000_000_000)
628                .build();
629            let session = bench.activate().expect("activate");
630
631            // Contrived "leaky" capability: routes through the audit shim
632            // while a paused mock is installed. Production callers (e.g.
633            // `stdlib/date_iso`) follow the exact same pattern.
634            let _ = leak_audit::wall_now("test/contrived_leak");
635            let _ = leak_audit::instant_now("test/contrived_instant");
636            let _ = leak_audit::wall_now("test/contrived_leak");
637
638            let finalize = session.finalize().expect("finalize");
639            let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
640                .clock_leaks
641                .iter()
642                .map(|leak| (leak.capability_id.as_str(), leak))
643                .collect();
644            let wall = by_id
645                .get("test/contrived_leak")
646                .expect("wall leak surfaced");
647            assert_eq!(wall.count, 2);
648            let inst = by_id
649                .get("test/contrived_instant")
650                .expect("instant leak surfaced");
651            assert_eq!(inst.count, 1);
652
653            // Drain semantics: a fresh session sees no carry-over.
654            let next_session = Testbench::builder()
655                .paused_clock_at_ms(1_700_000_000_000)
656                .build()
657                .activate()
658                .expect("activate next");
659            let next = next_session.finalize().expect("finalize next");
660            assert!(next.clock_leaks.is_empty());
661        });
662    }
663
664    #[test]
665    fn audit_quiet_when_no_mock_is_active() {
666        serial(|| {
667            leak_audit::reset();
668            // No `Testbench` activated → no mock clock → no leak entries
669            // even when the helpers are called.
670            let _ = leak_audit::wall_now("test/no_mock");
671            let _ = leak_audit::instant_now("test/no_mock");
672            assert!(leak_audit::snapshot().is_empty());
673        });
674    }
675}