Skip to main content

proef_core/
report.rs

1//! Reporters (ADR-0008): composable consumers of the event spine.
2//!
3//! The JSONL run record **is** the appended event stream — no second record
4//! format. The console reporter buffers per scenario (a natural `Normalize`:
5//! parallel scenarios never interleave their lines) and prints a BDD tree with
6//! attempts and engine-measured timings on completion.
7//!
8//! Secret values never enter events by construction (capture *names* only,
9//! engine-redacted details); [`Redactions`] is the defense-in-depth applied to
10//! every rendered string, property-tested in this module.
11
12use std::io::Write;
13use std::sync::{Arc, Mutex};
14
15use crate::event::{Event, EventSink};
16use crate::step::Status;
17
18/// Known secret values, replaced by `***` in every rendered string.
19#[derive(Debug, Clone, Default)]
20pub struct Redactions(Vec<String>);
21
22impl Redactions {
23    /// Redact these values (empty values are ignored — nothing to leak).
24    pub fn new(values: impl IntoIterator<Item = String>) -> Self {
25        Self(values.into_iter().filter(|v| !v.is_empty()).collect())
26    }
27
28    /// `text` with every known secret value replaced.
29    pub fn apply(&self, text: &str) -> String {
30        let mut out = text.to_owned();
31        for value in &self.0 {
32            out = out.replace(value, "***");
33        }
34        out
35    }
36
37    /// No values to redact.
38    pub fn is_empty(&self) -> bool {
39        self.0.is_empty()
40    }
41
42    /// The event with every string field redacted. The match is exhaustive on
43    /// purpose: adding an event variant forces a redaction decision here, so
44    /// the invariant (ADR-0005: secrets reach **no** sink) cannot silently
45    /// erode as the schema grows.
46    pub fn apply_event(&self, event: &Event) -> Event {
47        let s = |text: &Arc<str>| -> Arc<str> { Arc::from(self.apply(text)) };
48        match event {
49            Event::RunStarted { schema, run_id } => Event::RunStarted {
50                schema: *schema,
51                run_id: s(run_id),
52            },
53            Event::ScenarioStarted {
54                scenario,
55                file,
56                timestamp_ms,
57                worker,
58            } => Event::ScenarioStarted {
59                scenario: s(scenario),
60                file: s(file),
61                timestamp_ms: *timestamp_ms,
62                worker: *worker,
63            },
64            Event::BatchStarted {
65                scenario,
66                engine,
67                steps,
68            } => Event::BatchStarted {
69                scenario: s(scenario),
70                engine: s(engine),
71                steps: *steps,
72            },
73            Event::EntryRunning {
74                scenario,
75                engine,
76                entry,
77                retry,
78            } => Event::EntryRunning {
79                scenario: s(scenario),
80                engine: s(engine),
81                entry: *entry,
82                retry: *retry,
83            },
84            Event::StepFinished {
85                scenario,
86                engine,
87                step,
88                status,
89                attempts,
90                duration_ms,
91                captures,
92                detail,
93                attempt_details,
94            } => Event::StepFinished {
95                scenario: s(scenario),
96                engine: s(engine),
97                step: crate::step::StepRef {
98                    file: s(&step.file),
99                    line: step.line,
100                    text: s(&step.text),
101                },
102                status: *status,
103                attempts: *attempts,
104                duration_ms: *duration_ms,
105                captures: captures.iter().map(|name| self.apply(name)).collect(),
106                detail: detail.as_deref().map(|text| self.apply(text)),
107                attempt_details: attempt_details
108                    .iter()
109                    .map(|text| self.apply(text))
110                    .collect(),
111            },
112            Event::ScenarioFinished {
113                scenario,
114                file,
115                status,
116                timestamp_ms,
117                worker,
118            } => Event::ScenarioFinished {
119                scenario: s(scenario),
120                file: s(file),
121                status: *status,
122                timestamp_ms: *timestamp_ms,
123                worker: *worker,
124            },
125            Event::RunFinished { .. } => event.clone(),
126        }
127    }
128}
129
130/// One reporter in the stack.
131pub trait Reporter: Send {
132    /// Consume one event.
133    fn on_event(&mut self, event: &Event);
134}
135
136/// Fan a reporter stack out as an [`EventSink`] (thread-safe: scenario threads
137/// share the sink). Redaction happens **here**, once, before fan-out — every
138/// reporter (console, JSONL record, future sinks) sees only redacted events,
139/// so the invariant does not depend on each leaf remembering to redact.
140pub fn sink(reporters: Vec<Box<dyn Reporter>>, redactions: Redactions) -> EventSink {
141    let stack = Arc::new(Mutex::new(reporters));
142    EventSink::new(move |event| {
143        // Poison recovery: fan-out holds no cross-event invariant, and
144        // dropping events after one reporter panic would truncate the run
145        // record and lose its terminal event (ADR-0008 — the JSONL stream IS
146        // the record).
147        let mut stack = stack
148            .lock()
149            .unwrap_or_else(std::sync::PoisonError::into_inner);
150        if redactions.is_empty() {
151            for reporter in stack.iter_mut() {
152                reporter.on_event(event);
153            }
154        } else {
155            let redacted = redactions.apply_event(event);
156            for reporter in stack.iter_mut() {
157                reporter.on_event(&redacted);
158            }
159        }
160    })
161}
162
163/// Buffer key: `(feature file, scenario name)` — the run-wide scenario
164/// identity (names are unique only within one file).
165type ScenarioKey = (Arc<str>, Arc<str>);
166
167/// Console BDD tree, buffered per scenario — keyed by `(file, scenario)`
168/// (the run-wide identity), since two same-named scenarios under `--jobs > 1`
169/// must never share a buffer.
170pub struct ConsoleReporter<W: Write + Send> {
171    out: W,
172    redactions: Redactions,
173    buffers: Vec<(ScenarioKey, Vec<String>)>,
174}
175
176impl<W: Write + Send> ConsoleReporter<W> {
177    /// A console reporter writing to `out`.
178    pub fn new(out: W, redactions: Redactions) -> Self {
179        Self {
180            out,
181            redactions,
182            buffers: Vec::new(),
183        }
184    }
185
186    fn buffer_for(&mut self, file: &Arc<str>, scenario: &Arc<str>) -> &mut Vec<String> {
187        if let Some(position) = self
188            .buffers
189            .iter()
190            .position(|((f, name), _)| f == file && name == scenario)
191        {
192            &mut self.buffers[position].1
193        } else {
194            self.buffers
195                .push(((Arc::clone(file), Arc::clone(scenario)), Vec::new()));
196            &mut self
197                .buffers
198                .last_mut()
199                .unwrap_or_else(|| unreachable!("buffer just pushed"))
200                .1
201        }
202    }
203}
204
205fn glyph(status: Status) -> &'static str {
206    match status {
207        Status::Passed => "✓",
208        Status::Failed => "✗",
209        Status::Skipped => "∅",
210        Status::Warned => "⚠",
211    }
212}
213
214impl<W: Write + Send> Reporter for ConsoleReporter<W> {
215    fn on_event(&mut self, event: &Event) {
216        match event {
217            Event::RunStarted { run_id, .. } => {
218                let _ = writeln!(self.out, "proef run {run_id}");
219            }
220            Event::ScenarioStarted { scenario, file, .. } => {
221                let header = format!("\n  Scenario: {scenario} ({file})");
222                self.buffer_for(file, scenario).push(header);
223            }
224            // Buffered console renders on completion; the live progress
225            // signal is for streaming consumers (JSONL record, future TTY).
226            Event::BatchStarted { .. } | Event::EntryRunning { .. } => {}
227            Event::StepFinished {
228                scenario,
229                step,
230                status,
231                attempts,
232                duration_ms,
233                detail,
234                ..
235            } => {
236                let attempts_note = if *attempts > 1 {
237                    format!(", {attempts} attempts")
238                } else {
239                    String::new()
240                };
241                let line = format!(
242                    "    {} {}:{} — {} ({duration_ms}ms{attempts_note})",
243                    glyph(*status),
244                    step.file,
245                    step.line,
246                    step.text
247                );
248                let line = self.redactions.apply(&line);
249                // A warning with no reason is unusable — say why. (Failures
250                // get the richer end-of-run list instead.)
251                let warn_detail = (*status == Status::Warned)
252                    .then_some(detail.as_deref())
253                    .flatten()
254                    .map(|d| self.redactions.apply(&format!("      ↳ {d}")));
255                let buffer = self.buffer_for(&step.file, scenario);
256                buffer.push(line);
257                if let Some(warn_detail) = warn_detail {
258                    buffer.push(warn_detail);
259                }
260            }
261            Event::ScenarioFinished {
262                scenario,
263                file,
264                status,
265                ..
266            } => {
267                let lines = self
268                    .buffers
269                    .iter()
270                    .position(|((f, name), _)| f == file && name == scenario)
271                    .map(|position| self.buffers.remove(position).1)
272                    .unwrap_or_default();
273                for line in lines {
274                    let _ = writeln!(self.out, "{line}");
275                }
276                let _ = writeln!(self.out, "    {} scenario {scenario}", glyph(*status));
277            }
278            Event::RunFinished {
279                passed,
280                failed,
281                skipped,
282                cancelled,
283            } => {
284                let note = if *cancelled { " · cancelled" } else { "" };
285                let _ = writeln!(
286                    self.out,
287                    "\nsummary: {passed} passed · {failed} failed · {skipped} skipped{note}"
288                );
289                let _ = self.out.flush();
290            }
291        }
292    }
293}
294
295/// Run totals derived from the event stream — the `Summarize` leg of the
296/// decorator stack (ADR-0008): leaves (GitHub summary, `--output json`, …)
297/// consume the totals at `RunFinished` instead of re-deriving them.
298#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
299pub struct RunTotals {
300    /// Scenarios that passed.
301    pub passed: usize,
302    /// Scenarios that failed.
303    pub failed: usize,
304    /// Scenarios skipped.
305    pub skipped: usize,
306    /// Steps finished (all statuses).
307    pub steps: usize,
308    /// Total attempts across steps (retries included).
309    pub attempts: u64,
310}
311
312impl RunTotals {
313    /// Fold one event into the totals.
314    pub fn observe(&mut self, event: &Event) {
315        match event {
316            Event::StepFinished { attempts, .. } => {
317                self.steps += 1;
318                self.attempts += u64::from(*attempts);
319            }
320            Event::RunFinished {
321                passed,
322                failed,
323                skipped,
324                ..
325            } => {
326                self.passed = *passed;
327                self.failed = *failed;
328                self.skipped = *skipped;
329            }
330            _ => {}
331        }
332    }
333}
334
335/// JSONL appender: the run record is the raw event stream, in arrival order
336/// (replays and tests normalize; TESTING-STRATEGY flake rule).
337pub struct JsonlReporter<W: Write + Send> {
338    out: W,
339}
340
341impl<W: Write + Send> JsonlReporter<W> {
342    /// A JSONL reporter writing to `out`.
343    pub fn new(out: W) -> Self {
344        Self { out }
345    }
346}
347
348impl<W: Write + Send> Reporter for JsonlReporter<W> {
349    fn on_event(&mut self, event: &Event) {
350        if let Ok(json) = serde_json::to_string(event) {
351            let _ = writeln!(self.out, "{json}");
352        }
353        if matches!(event, Event::RunFinished { .. }) {
354            let _ = self.out.flush();
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    #![allow(clippy::unwrap_used)]
362
363    use super::*;
364    use crate::step::StepRef;
365
366    fn sample_events() -> Vec<Event> {
367        vec![
368            Event::RunStarted {
369                schema: 1,
370                run_id: Arc::from("run-1"),
371            },
372            Event::ScenarioStarted {
373                scenario: Arc::from("S"),
374                file: Arc::from("f.feature"),
375                timestamp_ms: None,
376                worker: None,
377            },
378            Event::StepFinished {
379                scenario: Arc::from("S"),
380                engine: Arc::from("hurl"),
381                step: StepRef {
382                    file: Arc::from("f.feature"),
383                    line: 3,
384                    text: Arc::from("I log in"),
385                },
386                status: Status::Passed,
387                attempts: 2,
388                duration_ms: 12,
389                captures: vec!["token".to_owned()],
390                detail: None,
391                attempt_details: Vec::new(),
392            },
393            Event::ScenarioFinished {
394                scenario: Arc::from("S"),
395                file: Arc::from("f.feature"),
396                status: Status::Passed,
397                timestamp_ms: None,
398                worker: None,
399            },
400            Event::RunFinished {
401                passed: 1,
402                failed: 0,
403                skipped: 0,
404                cancelled: false,
405            },
406        ]
407    }
408
409    #[test]
410    fn console_buffers_per_scenario_and_prints_on_finish() {
411        let mut out = Vec::new();
412        {
413            let mut console = ConsoleReporter::new(&mut out, Redactions::default());
414            for event in sample_events() {
415                console.on_event(&event);
416            }
417        }
418        let text = String::from_utf8(out).unwrap();
419        assert!(text.contains("Scenario: S (f.feature)"), "{text}");
420        assert!(
421            text.contains("✓ f.feature:3 — I log in (12ms, 2 attempts)"),
422            "{text}"
423        );
424        assert!(
425            text.contains("summary: 1 passed · 0 failed · 0 skipped"),
426            "{text}"
427        );
428    }
429
430    #[test]
431    fn jsonl_is_the_event_stream() {
432        let mut out = Vec::new();
433        {
434            let mut jsonl = JsonlReporter::new(&mut out);
435            for event in sample_events() {
436                jsonl.on_event(&event);
437            }
438        }
439        let text = String::from_utf8(out).unwrap();
440        let parsed: Vec<Event> = text
441            .lines()
442            .map(|line| serde_json::from_str(line).unwrap())
443            .collect();
444        assert_eq!(parsed, sample_events());
445    }
446
447    #[test]
448    fn totals_fold_steps_and_run_counts() {
449        let mut totals = RunTotals::default();
450        for event in sample_events() {
451            totals.observe(&event);
452        }
453        assert_eq!(
454            totals,
455            RunTotals {
456                passed: 1,
457                failed: 0,
458                skipped: 0,
459                steps: 1,
460                attempts: 2,
461            }
462        );
463    }
464
465    mod properties {
466        #![allow(clippy::ignored_unit_patterns)]
467
468        use super::*;
469        use proptest::prelude::*;
470
471        proptest! {
472            /// The secret-mask invariant (ADR-0005, TESTING-STRATEGY): for any
473            /// rendered text containing a known secret value, the redacted
474            /// output never contains that value.
475            #[test]
476            fn redaction_removes_known_values(
477                secret in "[a-zA-Z0-9]{4,24}",
478                prefix in ".{0,30}",
479                suffix in ".{0,30}",
480            ) {
481                let redactions = Redactions::new([secret.clone()]);
482                let rendered = format!("{prefix}{secret}{suffix}");
483                let redacted = redactions.apply(&rendered);
484                prop_assert!(!redacted.contains(&secret));
485            }
486
487            /// The sink boundary redacts before fan-out: the JSONL run record
488            /// (and therefore every reporter) never contains a known secret,
489            /// wherever it appears in an event's string fields.
490            #[test]
491            fn sink_redacts_before_any_reporter(secret in "[a-zA-Z0-9]{6,20}") {
492                #[derive(Clone)]
493                struct Shared(Arc<Mutex<Vec<u8>>>);
494                impl Write for Shared {
495                    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
496                        if let Ok(mut out) = self.0.lock() {
497                            out.extend_from_slice(buf);
498                        }
499                        Ok(buf.len())
500                    }
501                    fn flush(&mut self) -> std::io::Result<()> {
502                        Ok(())
503                    }
504                }
505                let out = Shared(Arc::new(Mutex::new(Vec::new())));
506                let sink = sink(
507                    vec![Box::new(JsonlReporter::new(out.clone()))],
508                    Redactions::new([secret.clone()]),
509                );
510                sink.emit(&Event::ScenarioStarted {
511                    scenario: Arc::from(format!("uses {secret}")),
512                    file: Arc::from(format!("{secret}.feature")),
513                    timestamp_ms: None,
514                    worker: None,
515                });
516                sink.emit(&Event::StepFinished {
517                    scenario: Arc::from(format!("uses {secret}")),
518                    engine: Arc::from("hurl"),
519                    step: crate::step::StepRef {
520                        file: Arc::from(format!("{secret}.feature")),
521                        line: 1,
522                        text: Arc::from(format!("token is {secret}")),
523                    },
524                    status: Status::Failed,
525                    attempts: 2,
526                    duration_ms: 1,
527                    captures: vec![format!("cap-{secret}")],
528                    detail: Some(format!("boom {secret}")),
529                    attempt_details: vec![format!("earlier boom {secret}")],
530                });
531                sink.emit(&Event::RunFinished {
532                    passed: 0,
533                    failed: 1,
534                    skipped: 0,
535                    cancelled: false,
536                });
537                let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
538                prop_assert!(!text.is_empty());
539                prop_assert!(!text.contains(&secret), "{text}");
540            }
541
542            /// Console output never leaks a secret embedded in step text.
543            #[test]
544            fn console_never_prints_known_secrets(secret in "[a-zA-Z0-9]{6,20}") {
545                let mut out = Vec::new();
546                {
547                    let mut console = ConsoleReporter::new(
548                        &mut out,
549                        Redactions::new([secret.clone()]),
550                    );
551                    console.on_event(&Event::ScenarioStarted {
552                        scenario: Arc::from("S"),
553                        file: Arc::from("f"),
554                        timestamp_ms: None,
555                        worker: None,
556                    });
557                    console.on_event(&Event::StepFinished {
558                        scenario: Arc::from("S"),
559                        engine: Arc::from("hurl"),
560                        step: StepRef {
561                            file: Arc::from("f"),
562                            line: 1,
563                            text: Arc::from(format!("token is {secret}")),
564                        },
565                        status: Status::Failed,
566                        attempts: 1,
567                        duration_ms: 1,
568                        captures: Vec::new(),
569                        detail: None,
570                        attempt_details: Vec::new(),
571                    });
572                    console.on_event(&Event::ScenarioFinished {
573                        scenario: Arc::from("S"),
574                        file: Arc::from("f"),
575                        status: Status::Failed,
576                        timestamp_ms: None,
577                        worker: None,
578                    });
579                }
580                let text = String::from_utf8(out).unwrap();
581                prop_assert!(!text.contains(&secret), "{text}");
582            }
583        }
584    }
585}