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        #[allow(clippy::no_effect_underscore_binding)]
324        let _llm_config = bench.llm;
325
326        #[cfg(feature = "testbench-wasi")]
327        let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
328
329        let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
330            match bench.subprocess {
331                SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
332                SubprocessConfig::Record { tape } => {
333                    let active = Arc::new(ProcessTape::recording());
334                    let guard = install_process_tape(Arc::clone(&active));
335                    (
336                        Some(Arc::clone(&active)),
337                        Some(guard),
338                        ProcessTapeMode::Record,
339                        Some(tape),
340                    )
341                }
342                SubprocessConfig::Replay { tape } => {
343                    let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
344                    let active = Arc::new(loaded);
345                    let guard = install_process_tape(Arc::clone(&active));
346                    (
347                        Some(Arc::clone(&active)),
348                        Some(guard),
349                        ProcessTapeMode::Replay,
350                        Some(tape),
351                    )
352                }
353                #[cfg(feature = "testbench-wasi")]
354                SubprocessConfig::WasiToolchain { dir } => {
355                    if !dir.exists() {
356                        return Err(TestbenchError::Subprocess(format!(
357                            "wasi toolchain directory does not exist: {}",
358                            dir.display()
359                        )));
360                    }
361                    wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
362                    (None, None, ProcessTapeMode::Replay, None)
363                }
364                #[cfg(not(feature = "testbench-wasi"))]
365                SubprocessConfig::WasiToolchain { .. } => {
366                    return Err(TestbenchError::Subprocess(
367                        "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
368                    ));
369                }
370            };
371
372        let (overlay, overlay_guard) = match bench.filesystem {
373            FilesystemConfig::Real => (None, None),
374            FilesystemConfig::Overlay { worktree } => {
375                let overlay = Arc::new(OverlayFs::rooted_at(worktree));
376                let guard = install_overlay(Arc::clone(&overlay));
377                (Some(overlay), Some(guard))
378            }
379        };
380
381        let saved_egress_env = match bench.network {
382            NetworkConfig::Real => None,
383            NetworkConfig::DenyByDefault { allow } => {
384                let saved = SavedEgressEnv {
385                    default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
386                    allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
387                    deny: std::env::var("HARN_EGRESS_DENY").ok(),
388                };
389                // Reset any prior policy so install_policy doesn't trip the
390                // "policy already configured" guard, then install via env-var
391                // so the host_policy and stdlib paths see the same view.
392                reset_egress_policy_for_host();
393                std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
394                if allow.is_empty() {
395                    std::env::remove_var("HARN_EGRESS_ALLOW");
396                } else {
397                    std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
398                }
399                std::env::remove_var("HARN_EGRESS_DENY");
400                Some(saved)
401            }
402        };
403
404        let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
405            TapeConfig::Off => (None, None, None, Vec::new(), None),
406            TapeConfig::Emit {
407                path,
408                argv,
409                script_path,
410            } => {
411                let recorder = Arc::new(TapeRecorder::new());
412                let guard = install_recorder(Arc::clone(&recorder));
413                (
414                    Some(Arc::clone(&recorder)),
415                    Some(guard),
416                    Some(path),
417                    argv,
418                    script_path,
419                )
420            }
421        };
422
423        Ok(Self {
424            _clock: clock_guard,
425            _process: process_guard,
426            _overlay: overlay_guard,
427            _recorder: recorder_guard,
428            process_tape,
429            overlay,
430            recorder,
431            tape_path,
432            tape_started_at_unix_ms: started_at_unix_ms,
433            tape_script_path,
434            tape_argv,
435            subprocess_mode,
436            subprocess_tape_path,
437            #[cfg(feature = "testbench-wasi")]
438            _wasi_toolchain: wasi_guard,
439            saved_egress_env,
440        })
441    }
442
443    /// Whether subprocess interception is recording new entries.
444    pub fn subprocess_mode(&self) -> ProcessTapeMode {
445        self.subprocess_mode
446    }
447
448    /// Path that recorded subprocess tape entries should land in, or
449    /// where replay loaded them from.
450    pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
451        self.subprocess_tape_path.as_deref()
452    }
453
454    /// Reference to the active filesystem overlay (if any).
455    pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
456        self.overlay.as_ref()
457    }
458
459    /// Reference to the active process tape (if any).
460    pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
461        self.process_tape.as_ref()
462    }
463
464    /// Reference to the active tape recorder (if any).
465    pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
466        self.recorder.as_ref()
467    }
468
469    /// Persist the recorded subprocess tape (if recording) and return
470    /// the filesystem diff (if an overlay is active). Tearing down the
471    /// session via [`Drop`] will not persist; call this explicitly to
472    /// flush.
473    pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
474        let diff = self
475            .overlay
476            .as_ref()
477            .map(|overlay| overlay.diff())
478            .unwrap_or_default();
479        let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
480            if let (Some(tape), Some(path)) = (
481                self.process_tape.as_ref(),
482                self.subprocess_tape_path.as_ref(),
483            ) {
484                tape.persist(path).map_err(TestbenchError::Subprocess)?;
485            }
486            self.process_tape
487                .as_ref()
488                .map(|tape| tape.recorded())
489                .unwrap_or_default()
490        } else {
491            Vec::new()
492        };
493        let mut emitted_tape = None;
494        if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
495            let header = TapeHeader::current(
496                self.tape_started_at_unix_ms,
497                self.tape_script_path.clone(),
498                self.tape_argv.clone(),
499            );
500            let tape = recorder.snapshot(header);
501            tape.persist(path).map_err(TestbenchError::Tape)?;
502            emitted_tape = Some(EmittedTape {
503                path: path.clone(),
504                records: tape.records.len(),
505            });
506        }
507        // Drain the leak audit last so anything emitted while we
508        // serialized other artifacts (e.g. tape persistence reading the
509        // wall clock for timestamps it shouldn't be reading) is still
510        // captured in this session's report.
511        let clock_leaks = leak_audit::drain();
512        // The Drop impl undoes mocks regardless of finalize success.
513        Ok(TestbenchFinalize {
514            fs_diff: diff,
515            recorded_subprocesses: recorded,
516            tape: emitted_tape,
517            clock_leaks,
518        })
519    }
520}
521
522impl Drop for TestbenchSession {
523    fn drop(&mut self) {
524        if let Some(saved) = self.saved_egress_env.take() {
525            restore_env("HARN_EGRESS_DEFAULT", saved.default);
526            restore_env("HARN_EGRESS_ALLOW", saved.allow);
527            restore_env("HARN_EGRESS_DENY", saved.deny);
528            reset_egress_policy_for_host();
529        }
530        // The remaining `_clock`/`_overlay`/`_process` guards drop in
531        // field-declared order, restoring the prior thread-local state.
532    }
533}
534
535fn restore_env(key: &str, prior: Option<String>) {
536    match prior {
537        Some(value) => std::env::set_var(key, value),
538        None => std::env::remove_var(key),
539    }
540}
541
542/// Outcome of a finalized testbench session — the artifacts the operator
543/// inspects after a hermetic run.
544#[derive(Debug, Default, Clone)]
545pub struct TestbenchFinalize {
546    pub fs_diff: Vec<overlay_fs::DiffEntry>,
547    pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
548    pub tape: Option<EmittedTape>,
549    /// Capabilities that observed real wall-clock or monotonic time
550    /// during the session. Empty under a hermetic run; non-empty entries
551    /// are fidelity hazards the operator should investigate or migrate
552    /// off of direct host-clock reads.
553    pub clock_leaks: Vec<ClockLeak>,
554}
555
556/// Summary metadata for a unified tape that was emitted at finalize-time.
557#[derive(Debug, Clone)]
558pub struct EmittedTape {
559    pub path: PathBuf,
560    pub records: usize,
561}
562
563/// Errors surfaced when activating or finalizing a testbench session.
564#[derive(Debug)]
565pub enum TestbenchError {
566    Subprocess(String),
567    Tape(String),
568}
569
570impl std::fmt::Display for TestbenchError {
571    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572        match self {
573            Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
574            Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
575        }
576    }
577}
578
579impl std::error::Error for TestbenchError {}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    /// Tests in this module mutate process-global state (env vars, the
586    /// leak audit registry) and must run one at a time even though
587    /// `cargo test` defaults to parallel execution. We share
588    /// [`leak_audit::TEST_LOCK`] so the audit module's own tests
589    /// serialize with the testbench's tests against the same registry.
590    fn serial<F: FnOnce()>(body: F) {
591        let _guard = leak_audit::TEST_LOCK
592            .lock()
593            .unwrap_or_else(|p| p.into_inner());
594        body();
595    }
596
597    #[test]
598    fn paused_clock_pins_now_ms_for_session_lifetime() {
599        serial(|| {
600            let bench = Testbench::builder()
601                .paused_clock_at_ms(1_700_000_000_000)
602                .build();
603            let session = bench.activate().expect("activate");
604            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
605            crate::clock_mock::advance(std::time::Duration::from_mins(1));
606            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
607            drop(session);
608            // After drop the override is gone; no assertion on real time.
609            assert!(!crate::clock_mock::is_mocked());
610        });
611    }
612
613    #[test]
614    fn deny_by_default_blocks_egress_until_drop() {
615        serial(|| {
616            let bench = Testbench::builder().deny_network().build();
617            let session = bench.activate().expect("activate");
618            assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
619            drop(session);
620            assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
621        });
622    }
623
624    #[test]
625    fn finalize_surfaces_clock_leaks_for_contrived_capability() {
626        serial(|| {
627            let bench = Testbench::builder()
628                .paused_clock_at_ms(1_700_000_000_000)
629                .build();
630            let session = bench.activate().expect("activate");
631
632            // Contrived "leaky" capability: routes through the audit shim
633            // while a paused mock is installed. Production callers (e.g.
634            // `stdlib/date_iso`) follow the exact same pattern.
635            let _ = leak_audit::wall_now("test/contrived_leak");
636            let _ = leak_audit::instant_now("test/contrived_instant");
637            let _ = leak_audit::wall_now("test/contrived_leak");
638
639            let finalize = session.finalize().expect("finalize");
640            let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
641                .clock_leaks
642                .iter()
643                .map(|leak| (leak.capability_id.as_str(), leak))
644                .collect();
645            let wall = by_id
646                .get("test/contrived_leak")
647                .expect("wall leak surfaced");
648            assert_eq!(wall.count, 2);
649            let inst = by_id
650                .get("test/contrived_instant")
651                .expect("instant leak surfaced");
652            assert_eq!(inst.count, 1);
653
654            // Drain semantics: a fresh session sees no carry-over.
655            let next_session = Testbench::builder()
656                .paused_clock_at_ms(1_700_000_000_000)
657                .build()
658                .activate()
659                .expect("activate next");
660            let next = next_session.finalize().expect("finalize next");
661            assert!(next.clock_leaks.is_empty());
662        });
663    }
664
665    #[test]
666    fn audit_quiet_when_no_mock_is_active() {
667        serial(|| {
668            leak_audit::reset();
669            // No `Testbench` activated → no mock clock → no leak entries
670            // even when the helpers are called.
671            let _ = leak_audit::wall_now("test/no_mock");
672            let _ = leak_audit::instant_now("test/no_mock");
673            assert!(leak_audit::snapshot().is_empty());
674        });
675    }
676}