Skip to main content

spate_test/
logs.rs

1//! Assertions over what a component logged.
2//!
3//! [`LogCapture`] is a writer a `tracing` subscriber can be pointed at, and
4//! waits on what it has captured. [`capture_logs`] and [`show_logs`] install
5//! one around a call, scoped to the calling thread.
6
7use crate::run::poll_until;
8use std::sync::{Arc, LazyLock, Mutex};
9use std::time::Duration;
10use tracing::Level;
11use tracing_subscriber::filter::LevelFilter;
12use tracing_subscriber::fmt::writer::MakeWriterExt;
13use tracing_subscriber::fmt::{MakeWriter, TestWriter};
14use tracing_subscriber::layer::SubscriberExt;
15
16/// Everything the subscriber holding it has formatted.
17#[derive(Clone, Debug, Default)]
18pub struct LogCapture(Arc<Mutex<Vec<u8>>>);
19
20impl LogCapture {
21    /// An empty capture.
22    pub fn new() -> LogCapture {
23        LogCapture::default()
24    }
25
26    /// The lines formatted so far.
27    ///
28    /// Panics if a subscriber panicked mid-write and poisoned the buffer.
29    pub fn lines(&self) -> Vec<String> {
30        String::from_utf8_lossy(&self.0.lock().expect("capture"))
31            .lines()
32            .map(str::to_string)
33            .collect()
34    }
35
36    /// Poll `check` until it returns a value, on the cadence of
37    /// [`wait_until`](crate::wait_until).
38    ///
39    /// Panics once `timeout` elapses, naming `what` and carrying every line
40    /// captured so far.
41    pub fn wait_for<T>(
42        &self,
43        timeout: Duration,
44        what: &str,
45        check: impl FnMut() -> Option<T>,
46    ) -> T {
47        poll_until(timeout, check).unwrap_or_else(|| {
48            panic!(
49                "timed out after {timeout:?} waiting for: {what}\n--- captured ---\n{}",
50                self.lines().join("\n")
51            )
52        })
53    }
54
55    /// The first captured line containing `needle`, waiting up to `timeout`
56    /// for one to arrive.
57    ///
58    /// Panics on timeout as [`wait_for`](Self::wait_for) does.
59    pub fn wait_for_line(&self, timeout: Duration, needle: &str) -> String {
60        self.wait_for(timeout, &format!("a line containing {needle:?}"), || {
61            self.lines().into_iter().find(|l| l.contains(needle))
62        })
63    }
64}
65
66impl std::io::Write for LogCapture {
67    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
68        self.0.lock().expect("capture").extend_from_slice(buf);
69        Ok(buf.len())
70    }
71
72    fn flush(&mut self) -> std::io::Result<()> {
73        Ok(())
74    }
75}
76
77impl<'a> MakeWriter<'a> for LogCapture {
78    type Writer = LogCapture;
79
80    fn make_writer(&'a self) -> LogCapture {
81        self.clone()
82    }
83}
84
85/// Run `f` under a subscriber at `level` and return the lines it formatted.
86///
87/// The lines reach the test harness as well, so a panic inside `f` carries the
88/// log around it. The subscriber is scoped to the calling thread, because
89/// `cargo test` shares one process across a binary.
90pub fn capture_logs(level: Level, f: impl FnOnce()) -> Vec<String> {
91    let capture = LogCapture::new();
92    under_subscriber(level, capture.clone().and(TestWriter::default()), f);
93    capture.lines()
94}
95
96/// Run `f` under a subscriber at `level` writing to the test harness.
97///
98/// The harness prints those lines when the test fails, so a panic inside `f`
99/// carries the log around it. The subscriber is scoped to the calling thread,
100/// because `cargo test` shares one process across a binary.
101pub fn show_logs(level: Level, f: impl FnOnce()) {
102    under_subscriber(level, TestWriter::default(), f);
103}
104
105/// A second registered dispatcher, so that tracing-core asks every live
106/// subscriber about a new callsite.
107static BYSTANDER: LazyLock<tracing::Dispatch> =
108    LazyLock::new(|| tracing::Dispatch::new(tracing_subscriber::registry().with(LevelFilter::OFF)));
109
110fn under_subscriber<W>(level: Level, writer: W, f: impl FnOnce())
111where
112    W: for<'a> MakeWriter<'a> + Send + Sync + 'static,
113{
114    let subscriber = tracing_subscriber::fmt()
115        .with_writer(writer)
116        .with_max_level(level)
117        .without_time()
118        .finish();
119    LazyLock::force(&BYSTANDER);
120    tracing::subscriber::with_default(subscriber, f);
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::io::Write;
127
128    #[test]
129    fn wait_for_line_returns_the_matching_line() {
130        let mut capture = LogCapture::new();
131        capture.write_all(b"starting\nready at 1\n").unwrap();
132        assert_eq!(
133            capture.wait_for_line(Duration::from_secs(1), "ready"),
134            "ready at 1"
135        );
136    }
137
138    /// A timeout's panic carries every line captured so far.
139    #[test]
140    #[should_panic(expected = "--- captured ---\nstarting")]
141    fn a_timeout_panics_with_the_capture() {
142        let mut capture = LogCapture::new();
143        capture.write_all(b"starting\n").unwrap();
144        capture.wait_for_line(Duration::from_millis(20), "ready");
145    }
146
147    fn hit(from: &str) {
148        tracing::warn!(from, "callsite hit");
149    }
150
151    fn warn_enabled() -> bool {
152        tracing::enabled!(Level::WARN)
153    }
154
155    /// An event on the calling thread is captured after a thread with no
156    /// subscriber reached its callsite first. Regression for #838.
157    #[test]
158    fn an_event_is_captured_after_a_bare_thread_reached_its_callsite_first() {
159        let lines = capture_logs(Level::WARN, || {
160            std::thread::spawn(|| hit("other")).join().unwrap();
161            hit("caller");
162        });
163        assert_eq!(lines.len(), 1, "{lines:?}");
164        assert!(lines[0].contains("caller"), "{lines:?}");
165        assert!(!lines[0].contains("other"), "{lines:?}");
166    }
167
168    /// `show_logs` enables a callsite on the calling thread after a thread
169    /// with no subscriber reached it first. Regression for #838.
170    #[test]
171    fn show_logs_enables_a_callsite_a_bare_thread_reached_first() {
172        let mut seen = None;
173        show_logs(Level::WARN, || {
174            assert!(!std::thread::spawn(warn_enabled).join().unwrap());
175            seen = Some(warn_enabled());
176        });
177        assert_eq!(seen, Some(true));
178    }
179
180    /// The bystander dispatcher enables no level.
181    #[test]
182    fn the_bystander_enables_no_level() {
183        use tracing::Subscriber as _;
184        use tracing_subscriber::{Registry, layer::Layered};
185        let s = BYSTANDER
186            .downcast_ref::<Layered<LevelFilter, Registry>>()
187            .expect("bystander type");
188        assert_eq!(s.max_level_hint(), Some(LevelFilter::OFF));
189    }
190}