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 hypothesis;
42pub mod mcp_mock;
43pub mod overlay_fs;
44pub mod process_tape;
45pub mod tape;
46#[cfg(feature = "testbench-wasi")]
47pub mod wasi_process;
48
49use std::path::PathBuf;
50use std::sync::Arc;
51
52use crate::clock_mock::leak_audit::{self, ClockLeak};
53use crate::clock_mock::{install_override, ClockOverrideGuard, MockClock};
54use crate::egress::reset_egress_policy_for_host;
55
56use overlay_fs::{install_overlay, OverlayFs, OverlayFsGuard};
57use process_tape::{install_process_tape, ProcessTape, ProcessTapeGuard, ProcessTapeMode};
58use tape::{install_recorder, TapeHeader, TapeRecorder, TapeRecorderGuard};
59
60/// Declarative configuration for [`Testbench::activate`]. Every axis is
61/// optional so callers can compose only the surfaces they need.
62#[derive(Debug, Default, Clone)]
63pub struct Testbench {
64    pub clock: ClockConfig,
65    pub llm: LlmConfig,
66    pub filesystem: FilesystemConfig,
67    pub subprocess: SubprocessConfig,
68    pub network: NetworkConfig,
69    pub tape: TapeConfig,
70    pub hypothesis: Option<hypothesis::HypothesisScenario>,
71}
72
73/// Configures the unified mock clock. Defaults to the runtime's real
74/// clock so the testbench stays opt-in.
75#[derive(Debug, Default, Clone)]
76pub enum ClockConfig {
77    /// Leave the clock alone. Real wall-clock + monotonic time.
78    #[default]
79    Real,
80    /// Pin time to the given UNIX-epoch milliseconds. Honored by stdlib
81    /// `now_ms`/`sleep`, the trigger dispatcher, and cron.
82    Paused { starting_at_ms: i64 },
83}
84
85/// LLM provider configuration. Mirrors `harn run --llm-mock` /
86/// `--llm-mock-record` so the testbench is a strict superset of that
87/// flag pair. The testbench *does not* install LLM mocks itself — it
88/// stays declarative so [`crate::llm::install_cli_llm_mocks`] (or its
89/// `harn-cli` wrapper) remains the single mutator of LLM state.
90#[derive(Debug, Default, Clone)]
91pub enum LlmConfig {
92    /// No LLM substitution. Calls go through the configured provider.
93    #[default]
94    Real,
95    /// Replay scripted responses from a JSONL fixture.
96    Replay { fixture: PathBuf },
97    /// Capture executed responses into a JSONL fixture.
98    Record { fixture: PathBuf },
99}
100
101/// Filesystem overlay configuration.
102#[derive(Debug, Default, Clone)]
103pub enum FilesystemConfig {
104    /// No overlay. Reads and writes hit the real filesystem.
105    #[default]
106    Real,
107    /// Read-through, copy-on-write overlay rooted at `worktree`. Writes
108    /// stay in memory until the run ends, at which point the configured
109    /// emitter (CLI flag, in-process API) can read the diff.
110    Overlay { worktree: PathBuf },
111}
112
113/// Subprocess record/replay configuration.
114#[derive(Debug, Default, Clone)]
115pub enum SubprocessConfig {
116    /// No interception. Subprocesses spawn against the host OS.
117    #[default]
118    Real,
119    /// Record `(program, args, cwd)` tuples and their outputs into
120    /// `tape` so a follow-up run can replay them.
121    Record { tape: PathBuf },
122    /// Look every spawn up in `tape` and emit the recorded result. Errors
123    /// loudly when a tuple is not in the tape.
124    Replay { tape: PathBuf },
125    /// Resolve subprocess invocations against a directory of WASI
126    /// (`wasm32-wasi`) modules. Each `program` resolves to
127    /// `<dir>/<program>.wasm`; the module runs under wasmtime with the
128    /// testbench's mock clock virtualized into `clock_time_get` and
129    /// `poll_oneoff`. Calls whose program has no matching `.wasm` fall
130    /// through to the native spawn path. Requires the `testbench-wasi`
131    /// Cargo feature.
132    WasiToolchain { dir: PathBuf },
133}
134
135/// Network policy. Defaults to the production egress policy (no
136/// override). Testbench callers usually pick `DenyByDefault`.
137#[derive(Debug, Default, Clone)]
138pub enum NetworkConfig {
139    /// Use whatever egress policy the host has already installed.
140    #[default]
141    Real,
142    /// Deny outbound requests unless `allow` matches. Installs a typed
143    /// [`crate::egress`] policy that replaces any prior configuration —
144    /// including ambient `HARN_EGRESS_*` environment — for the session's
145    /// lifetime.
146    DenyByDefault {
147        /// Allow rules in the `HARN_EGRESS_ALLOW` syntax (e.g.
148        /// `"github.com"`, `"*.openai.com"`). Empty means deny everything.
149        allow: Vec<String>,
150    },
151}
152
153/// Unified-tape configuration. Recording is opt-in: `Off` (the default)
154/// installs nothing and pays nothing in production; `Emit { path }`
155/// installs a [`tape::TapeRecorder`] consulted by every host-capability
156/// axis, then persists the result to `path` (plus `path.cas/` for large
157/// payloads) when [`TestbenchSession::finalize`] runs.
158#[derive(Debug, Default, Clone)]
159pub enum TapeConfig {
160    #[default]
161    Off,
162    Emit {
163        path: PathBuf,
164        /// Argv forwarded to the script after `--`. Captured in the tape
165        /// header so two tapes that differ only in argv are
166        /// distinguishable.
167        argv: Vec<String>,
168        /// Path to the `.harn` script. Informational only; used to
169        /// populate the tape header so consumers can attribute records.
170        script_path: Option<String>,
171    },
172}
173
174impl Testbench {
175    /// Convenience: construct a builder.
176    pub fn builder() -> TestbenchBuilder {
177        TestbenchBuilder::default()
178    }
179
180    /// Activate every configured axis and return an RAII handle. Drop
181    /// the handle to restore the prior state.
182    pub fn activate(self) -> Result<TestbenchSession, TestbenchError> {
183        TestbenchSession::install(self)
184    }
185}
186
187/// Fluent constructor for [`Testbench`].
188#[derive(Debug, Default, Clone)]
189pub struct TestbenchBuilder {
190    bench: Testbench,
191}
192
193impl TestbenchBuilder {
194    pub fn paused_clock_at_ms(mut self, starting_at_ms: i64) -> Self {
195        self.bench.clock = ClockConfig::Paused { starting_at_ms };
196        self
197    }
198
199    pub fn replay_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
200        self.bench.llm = LlmConfig::Replay {
201            fixture: fixture.into(),
202        };
203        self
204    }
205
206    pub fn record_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
207        self.bench.llm = LlmConfig::Record {
208            fixture: fixture.into(),
209        };
210        self
211    }
212
213    pub fn fs_overlay(mut self, worktree: impl Into<PathBuf>) -> Self {
214        self.bench.filesystem = FilesystemConfig::Overlay {
215            worktree: worktree.into(),
216        };
217        self
218    }
219
220    pub fn record_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
221        self.bench.subprocess = SubprocessConfig::Record { tape: tape.into() };
222        self
223    }
224
225    pub fn replay_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
226        self.bench.subprocess = SubprocessConfig::Replay { tape: tape.into() };
227        self
228    }
229
230    /// Use a directory of WASI modules as the subprocess source. See
231    /// [`SubprocessConfig::WasiToolchain`].
232    pub fn wasi_toolchain(mut self, dir: impl Into<PathBuf>) -> Self {
233        self.bench.subprocess = SubprocessConfig::WasiToolchain { dir: dir.into() };
234        self
235    }
236
237    pub fn deny_network(mut self) -> Self {
238        self.bench.network = NetworkConfig::DenyByDefault { allow: Vec::new() };
239        self
240    }
241
242    pub fn allow_network(mut self, allow: impl IntoIterator<Item = String>) -> Self {
243        self.bench.network = NetworkConfig::DenyByDefault {
244            allow: allow.into_iter().collect(),
245        };
246        self
247    }
248
249    pub fn emit_tape(mut self, path: impl Into<PathBuf>) -> Self {
250        self.bench.tape = TapeConfig::Emit {
251            path: path.into(),
252            argv: Vec::new(),
253            script_path: None,
254        };
255        self
256    }
257
258    pub fn emit_tape_for(
259        mut self,
260        path: impl Into<PathBuf>,
261        script_path: Option<String>,
262        argv: Vec<String>,
263    ) -> Self {
264        self.bench.tape = TapeConfig::Emit {
265            path: path.into(),
266            argv,
267            script_path,
268        };
269        self
270    }
271
272    pub fn hypothesis_scenario(mut self, scenario: hypothesis::HypothesisScenario) -> Self {
273        self.bench.hypothesis = Some(scenario);
274        self
275    }
276
277    pub fn build(self) -> Testbench {
278        self.bench
279    }
280}
281
282/// RAII handle returned by [`Testbench::activate`]. Holds every guard
283/// for the active axes; dropping it tears them all down in order.
284#[must_use = "the testbench tears down on drop; bind the handle to a `_session` local"]
285pub struct TestbenchSession {
286    _hypothesis: Option<crate::HostCallBridgeGuard>,
287    _clock_leak_scope: Option<leak_audit::ClockLeakScopeGuard>,
288    _clock: Option<ClockOverrideGuard>,
289    _process: Option<ProcessTapeGuard>,
290    _overlay: Option<OverlayFsGuard>,
291    _recorder: Option<TapeRecorderGuard>,
292    process_tape: Option<Arc<ProcessTape>>,
293    overlay: Option<Arc<OverlayFs>>,
294    recorder: Option<Arc<TapeRecorder>>,
295    tape_path: Option<PathBuf>,
296    tape_started_at_unix_ms: Option<i64>,
297    tape_script_path: Option<String>,
298    tape_argv: Vec<String>,
299    subprocess_mode: ProcessTapeMode,
300    subprocess_tape_path: Option<PathBuf>,
301    #[cfg(feature = "testbench-wasi")]
302    _wasi_toolchain: Option<wasi_process::WasiToolchainGuard>,
303    /// Whether this session installed a deny-by-default egress policy that
304    /// must be torn down on drop.
305    egress_policy_installed: bool,
306}
307
308impl TestbenchSession {
309    fn install(bench: Testbench) -> Result<Self, TestbenchError> {
310        let hypothesis_guard = bench
311            .hypothesis
312            .map(|scenario| crate::install_host_call_bridge(hypothesis::bridge(scenario)));
313        let (clock_leak_scope, clock_guard, started_at_unix_ms) = match bench.clock {
314            ClockConfig::Real => (None, None, None),
315            ClockConfig::Paused { starting_at_ms } => (
316                Some(leak_audit::install_scope()),
317                Some(install_override(MockClock::at_wall_ms(starting_at_ms))),
318                Some(starting_at_ms),
319            ),
320        };
321
322        // LLM state is *not* installed here — the caller owns the
323        // CliLlmMockMode channel. Reading bench.llm just keeps the
324        // declarative config visible to test inspection.
325        #[allow(clippy::no_effect_underscore_binding)]
326        let _llm_config = bench.llm;
327
328        #[cfg(feature = "testbench-wasi")]
329        let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
330
331        let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
332            match bench.subprocess {
333                SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
334                SubprocessConfig::Record { tape } => {
335                    let active = Arc::new(ProcessTape::recording());
336                    let guard = install_process_tape(Arc::clone(&active));
337                    (
338                        Some(Arc::clone(&active)),
339                        Some(guard),
340                        ProcessTapeMode::Record,
341                        Some(tape),
342                    )
343                }
344                SubprocessConfig::Replay { tape } => {
345                    let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
346                    let active = Arc::new(loaded);
347                    let guard = install_process_tape(Arc::clone(&active));
348                    (
349                        Some(Arc::clone(&active)),
350                        Some(guard),
351                        ProcessTapeMode::Replay,
352                        Some(tape),
353                    )
354                }
355                #[cfg(feature = "testbench-wasi")]
356                SubprocessConfig::WasiToolchain { dir } => {
357                    if !dir.exists() {
358                        return Err(TestbenchError::Subprocess(format!(
359                            "wasi toolchain directory does not exist: {}",
360                            dir.display()
361                        )));
362                    }
363                    wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
364                    (None, None, ProcessTapeMode::Replay, None)
365                }
366                #[cfg(not(feature = "testbench-wasi"))]
367                SubprocessConfig::WasiToolchain { .. } => {
368                    return Err(TestbenchError::Subprocess(
369                        "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
370                    ));
371                }
372            };
373
374        let (overlay, overlay_guard) = match bench.filesystem {
375            FilesystemConfig::Real => (None, None),
376            FilesystemConfig::Overlay { worktree } => {
377                let overlay = Arc::new(OverlayFs::rooted_at(worktree));
378                let guard = install_overlay(Arc::clone(&overlay));
379                (Some(overlay), Some(guard))
380            }
381        };
382
383        let egress_policy_installed = match bench.network {
384            NetworkConfig::Real => false,
385            NetworkConfig::DenyByDefault { allow } => {
386                crate::egress::install_deny_by_default_policy(&allow)
387                    .map_err(|error| TestbenchError::Network(error.to_string()))?;
388                true
389            }
390        };
391
392        let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
393            TapeConfig::Off => (None, None, None, Vec::new(), None),
394            TapeConfig::Emit {
395                path,
396                argv,
397                script_path,
398            } => {
399                let recorder = Arc::new(TapeRecorder::new());
400                let guard = install_recorder(Arc::clone(&recorder));
401                (
402                    Some(Arc::clone(&recorder)),
403                    Some(guard),
404                    Some(path),
405                    argv,
406                    script_path,
407                )
408            }
409        };
410
411        Ok(Self {
412            _hypothesis: hypothesis_guard,
413            _clock_leak_scope: clock_leak_scope,
414            _clock: clock_guard,
415            _process: process_guard,
416            _overlay: overlay_guard,
417            _recorder: recorder_guard,
418            process_tape,
419            overlay,
420            recorder,
421            tape_path,
422            tape_started_at_unix_ms: started_at_unix_ms,
423            tape_script_path,
424            tape_argv,
425            subprocess_mode,
426            subprocess_tape_path,
427            #[cfg(feature = "testbench-wasi")]
428            _wasi_toolchain: wasi_guard,
429            egress_policy_installed,
430        })
431    }
432
433    /// Whether subprocess interception is recording new entries.
434    pub fn subprocess_mode(&self) -> ProcessTapeMode {
435        self.subprocess_mode
436    }
437
438    /// Path that recorded subprocess tape entries should land in, or
439    /// where replay loaded them from.
440    pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
441        self.subprocess_tape_path.as_deref()
442    }
443
444    /// Reference to the active filesystem overlay (if any).
445    pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
446        self.overlay.as_ref()
447    }
448
449    /// Reference to the active process tape (if any).
450    pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
451        self.process_tape.as_ref()
452    }
453
454    /// Reference to the active tape recorder (if any).
455    pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
456        self.recorder.as_ref()
457    }
458
459    /// Persist the recorded subprocess tape (if recording) and return
460    /// the filesystem diff (if an overlay is active). Tearing down the
461    /// session via [`Drop`] will not persist; call this explicitly to
462    /// flush.
463    pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
464        let diff = self
465            .overlay
466            .as_ref()
467            .map(|overlay| overlay.diff())
468            .unwrap_or_default();
469        let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
470            if let (Some(tape), Some(path)) = (
471                self.process_tape.as_ref(),
472                self.subprocess_tape_path.as_ref(),
473            ) {
474                tape.persist(path).map_err(TestbenchError::Subprocess)?;
475            }
476            self.process_tape
477                .as_ref()
478                .map(|tape| tape.recorded())
479                .unwrap_or_default()
480        } else {
481            Vec::new()
482        };
483        let mut emitted_tape = None;
484        if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
485            let header = TapeHeader::current(
486                self.tape_started_at_unix_ms,
487                self.tape_script_path.clone(),
488                self.tape_argv.clone(),
489            );
490            let tape = recorder.snapshot(header);
491            tape.persist(path).map_err(TestbenchError::Tape)?;
492            emitted_tape = Some(EmittedTape {
493                path: path.clone(),
494                records: tape.records.len(),
495            });
496        }
497        // Drain the leak audit last so anything emitted while we
498        // serialized other artifacts (e.g. tape persistence reading the
499        // wall clock for timestamps it shouldn't be reading) is still
500        // captured in this session's report.
501        let clock_leaks = leak_audit::drain();
502        // The Drop impl undoes mocks regardless of finalize success.
503        Ok(TestbenchFinalize {
504            fs_diff: diff,
505            recorded_subprocesses: recorded,
506            tape: emitted_tape,
507            clock_leaks,
508        })
509    }
510}
511
512impl Drop for TestbenchSession {
513    fn drop(&mut self) {
514        if self.egress_policy_installed {
515            reset_egress_policy_for_host();
516        }
517        // The remaining `_clock`/`_overlay`/`_process` guards drop in
518        // field-declared order, restoring the prior thread-local state.
519    }
520}
521
522/// Outcome of a finalized testbench session — the artifacts the operator
523/// inspects after a hermetic run.
524#[derive(Debug, Default, Clone)]
525pub struct TestbenchFinalize {
526    pub fs_diff: Vec<overlay_fs::DiffEntry>,
527    pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
528    pub tape: Option<EmittedTape>,
529    /// Capabilities that observed real wall-clock or monotonic time
530    /// during the session. Empty under a hermetic run; non-empty entries
531    /// are fidelity hazards the operator should investigate or migrate
532    /// off of direct host-clock reads.
533    pub clock_leaks: Vec<ClockLeak>,
534}
535
536/// Summary metadata for a unified tape that was emitted at finalize-time.
537#[derive(Debug, Clone)]
538pub struct EmittedTape {
539    pub path: PathBuf,
540    pub records: usize,
541}
542
543/// Errors surfaced when activating or finalizing a testbench session.
544#[derive(Debug)]
545pub enum TestbenchError {
546    Subprocess(String),
547    Tape(String),
548    Network(String),
549}
550
551impl std::fmt::Display for TestbenchError {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        match self {
554            Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
555            Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
556            Self::Network(msg) => write!(f, "testbench network: {msg}"),
557        }
558    }
559}
560
561impl std::error::Error for TestbenchError {}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use std::sync::Mutex;
567
568    static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
569
570    /// Some tests in this module mutate process-global env vars. Keep
571    /// those serialized without coupling them to the clock-leak audit,
572    /// which is session-scoped.
573    fn serial<F: FnOnce()>(body: F) {
574        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
575        body();
576    }
577
578    #[test]
579    fn paused_clock_pins_now_ms_for_session_lifetime() {
580        serial(|| {
581            let bench = Testbench::builder()
582                .paused_clock_at_ms(1_700_000_000_000)
583                .build();
584            let session = bench.activate().expect("activate");
585            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
586            crate::clock_mock::advance(std::time::Duration::from_mins(1));
587            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
588            drop(session);
589            // After drop the override is gone; no assertion on real time.
590            assert!(!crate::clock_mock::is_mocked());
591        });
592    }
593
594    #[test]
595    fn deny_by_default_blocks_egress_until_drop() {
596        serial(|| {
597            let _env = crate::egress::test_env_guard();
598            let bench = Testbench::builder().deny_network().build();
599            let session = bench.activate().expect("activate");
600            assert!(
601                crate::egress::check_url("testbench", "https://example.com/x")
602                    .expect("policy check")
603                    .is_some()
604            );
605            drop(session);
606            assert!(
607                crate::egress::check_url("testbench", "https://example.com/x")
608                    .expect("policy check")
609                    .is_none()
610            );
611        });
612    }
613
614    #[test]
615    fn finalize_surfaces_clock_leaks_for_contrived_capability() {
616        serial(|| {
617            let bench = Testbench::builder()
618                .paused_clock_at_ms(1_700_000_000_000)
619                .build();
620            let session = bench.activate().expect("activate");
621
622            // Contrived "leaky" capability: routes through the audit shim
623            // while a paused mock is installed. Production callers (e.g.
624            // `stdlib/date_iso`) follow the exact same pattern.
625            let _ = leak_audit::wall_now("test/contrived_leak");
626            let _ = leak_audit::instant_now("test/contrived_instant");
627            let _ = leak_audit::wall_now("test/contrived_leak");
628
629            let finalize = session.finalize().expect("finalize");
630            let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
631                .clock_leaks
632                .iter()
633                .map(|leak| (leak.capability_id.as_str(), leak))
634                .collect();
635            let wall = by_id
636                .get("test/contrived_leak")
637                .expect("wall leak surfaced");
638            assert_eq!(wall.count, 2);
639            let inst = by_id
640                .get("test/contrived_instant")
641                .expect("instant leak surfaced");
642            assert_eq!(inst.count, 1);
643
644            // Drain semantics: a fresh session sees no carry-over.
645            let next_session = Testbench::builder()
646                .paused_clock_at_ms(1_700_000_000_000)
647                .build()
648                .activate()
649                .expect("activate next");
650            let next = next_session.finalize().expect("finalize next");
651            assert!(next.clock_leaks.is_empty());
652        });
653    }
654
655    #[test]
656    fn audit_quiet_when_no_mock_is_active() {
657        serial(|| {
658            leak_audit::reset();
659            // No `Testbench` activated → no mock clock → no leak entries
660            // even when the helpers are called.
661            let _ = leak_audit::wall_now("test/no_mock");
662            let _ = leak_audit::instant_now("test/no_mock");
663            assert!(leak_audit::snapshot().is_empty());
664        });
665    }
666}