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