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