rite_runtime/test_support.rs
1//! Test-only helpers for action implementors.
2//!
3//! Provides a [`ReporterHarness`] that owns the channels and an
4//! [`InMemorySink`](crate::InMemorySink) needed to construct a
5//! [`Reporter`] in unit and integration tests, so downstream crates can
6//! exercise their actions without dealing with channel plumbing.
7
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11use crossbeam_channel::{Receiver, Sender, unbounded};
12use rite_model::StepId;
13
14use crate::clock::{Clock, SystemClock};
15use crate::protocol::{ExecEvent, PromptId, Response, UiCommand};
16use crate::reporter::Reporter;
17use crate::transcript_sink::{InMemorySink, JsonlFileSink, TranscriptSink};
18use rite_model::{Sha256Digest, StepFact, TranscriptHeader};
19
20/// Owns the channels and sink needed to build a [`Reporter`] for tests.
21///
22/// The matching event receiver is kept alive for the harness's lifetime,
23/// so a reporter built from [`Self::reporter`] never sees a spurious
24/// disconnect while emitting facts. Its commands are the ones queued when
25/// it was built, and nothing more: a prompt with no answer left fails as a
26/// disconnect rather than waiting for one that cannot come.
27pub struct ReporterHarness {
28 sink: InMemorySink,
29 event_tx: Sender<ExecEvent>,
30 _event_rx: Receiver<ExecEvent>,
31 cmd_tx: Sender<UiCommand>,
32 cmd_rx: Receiver<UiCommand>,
33 /// The queued commands a reporter reads, from a channel whose sender
34 /// is already dropped.
35 step_rx: Receiver<UiCommand>,
36 next_response_id: u64,
37}
38
39impl ReporterHarness {
40 /// Build a harness with empty channels and an empty sink.
41 #[must_use]
42 pub fn new() -> Self {
43 let (event_tx, event_rx) = unbounded();
44 let (cmd_tx, cmd_rx) = unbounded();
45 Self {
46 sink: begun_sink(),
47 event_tx,
48 _event_rx: event_rx,
49 cmd_tx,
50 cmd_rx,
51 step_rx: unbounded().1,
52 next_response_id: 0,
53 }
54 }
55
56 /// Pre-queue a response to the next prompt the action under test will issue.
57 ///
58 /// Prompt ids start at 0 and increase by one per prompt, so queued
59 /// responses are matched in issue order: the first call answers the first
60 /// prompt, the second answers the second, and so on. The reporter reads
61 /// commands from an unbounded channel, so queuing before `execute` is
62 /// enough and no second thread is needed.
63 ///
64 /// Assumes a single reporter built from this harness (the id sequence is
65 /// not reset by [`Self::reporter`]); that matches every current test.
66 pub fn enqueue_response(&mut self, response: Response) {
67 let prompt_id = PromptId::new(self.next_response_id);
68 self.next_response_id = self.next_response_id.wrapping_add(1);
69 // The receiver is held by the harness for its lifetime, so this send
70 // cannot disconnect.
71 let _ = self.cmd_tx.send(UiCommand::PromptResponse {
72 prompt_id,
73 response,
74 });
75 }
76
77 /// Pre-queue a second answer to the prompt the last queued response
78 /// answered, for when that one is refused: a refused prompt is asked
79 /// again under the same id.
80 pub fn enqueue_retry(&mut self, response: Response) {
81 let prompt_id = PromptId::new(self.next_response_id.wrapping_sub(1));
82 let _ = self.cmd_tx.send(UiCommand::PromptResponse {
83 prompt_id,
84 response,
85 });
86 }
87
88 /// Build a reporter scoped to the given step. The reporter borrows
89 /// the harness for its lifetime.
90 ///
91 /// The entropy source is seeded with a fixed test seed, mirroring the
92 /// runner, so actions that draw values (serials, nonces) work out of the
93 /// box. Tests that need a specific seed can call
94 /// [`Reporter::seed_entropy`] again.
95 pub fn reporter(&mut self, step: StepId) -> Reporter<'_> {
96 // What an earlier reporter left, then what was queued since, in a
97 // channel that ends once they are read.
98 let (tx, rx) = unbounded();
99 for cmd in self.step_rx.try_iter().chain(self.cmd_rx.try_iter()) {
100 let _ = tx.send(cmd);
101 }
102 self.step_rx = rx;
103 let mut reporter = Reporter::new(
104 &self.event_tx,
105 &self.step_rx,
106 &mut self.sink,
107 Arc::new(SystemClock),
108 );
109 reporter.set_current_step(Some(step));
110 reporter.seed_entropy(b"rite-test-harness-seed");
111 reporter
112 }
113
114 /// Facts recorded by the harness's transcript sink, in order.
115 #[must_use]
116 pub fn facts(&self) -> &[StepFact] {
117 self.sink.facts()
118 }
119}
120
121impl Default for ReporterHarness {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127/// A header for tests: a fixed run id, not a dry run.
128#[must_use]
129pub fn test_header() -> TranscriptHeader {
130 TranscriptHeader::new("rite test", &"0".repeat(32), false)
131}
132
133/// An in-memory sink with [`test_header`] already written, ready to record
134/// facts.
135///
136/// # Panics
137///
138/// Never in practice: writing the first header to a fresh in-memory sink
139/// cannot fail.
140#[must_use]
141#[allow(clippy::expect_used)]
142pub fn begun_sink() -> InMemorySink {
143 let mut sink = InMemorySink::new();
144 sink.begin(&test_header())
145 .expect("a fresh in-memory sink takes a header");
146 sink
147}
148
149/// Write `transcript.jsonl` in `dir`: [`test_header`], then each fact at its
150/// default level and [`fixed_test_time`]. Returns the fingerprint.
151///
152/// # Errors
153///
154/// Returns the I/O error if the file cannot be created or written.
155pub fn write_transcript(
156 dir: &std::path::Path,
157 facts: &[StepFact],
158) -> std::io::Result<Sha256Digest> {
159 let mut sink = JsonlFileSink::create(dir)?;
160 sink.begin(&test_header())?;
161 for fact in facts {
162 sink.record(fixed_test_time(), fact.default_level(), fact)?;
163 }
164 sink.finalize()
165}
166
167/// A `CeremonyStarted` fact for tests, over a fixed template digest.
168#[must_use]
169pub fn ceremony_started(name: &str) -> StepFact {
170 StepFact::CeremonyStarted {
171 name: name.to_string(),
172 template: Sha256Digest::of(b"test ceremony"),
173 }
174}
175
176/// A fixed instant for tests that need a deterministic event time. Arbitrary
177/// but stable, so recorded `at` values and snapshots stay reproducible.
178#[must_use]
179pub fn fixed_test_time() -> DateTime<Utc> {
180 // `from_timestamp_nanos` is infallible, unlike the seconds-based
181 // constructor, so this needs no `expect` in non-test library code.
182 DateTime::from_timestamp_nanos(1_700_000_000_000_000_000)
183}
184
185/// Wrap a fact into an [`ExecEvent::Fact`] stamped with [`fixed_test_time`],
186/// for frontend tests that feed synthetic events into a driver.
187#[must_use]
188pub fn fact_event(fact: StepFact) -> ExecEvent {
189 ExecEvent::Fact {
190 at: fixed_test_time(),
191 fact,
192 }
193}
194
195/// A [`Clock`](crate::Clock) frozen at a caller-chosen instant, for asserting
196/// that event times come from the injected clock rather than the wall clock.
197pub struct FixedClock(pub DateTime<Utc>);
198
199impl Clock for FixedClock {
200 fn now(&self) -> DateTime<Utc> {
201 self.0
202 }
203}